Skip to content

Commit 7c1a67c

Browse files
heiskrCopilot
andauthored
Actually write the link fixes the codemod computes for YAML files (#62759)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 026fdb7b-a47f-4af1-bf87-caa90fbbdf7f Copilot-Session: a12f7880-548d-4f87-a578-21fd6ad0f3da
1 parent 1bad1b4 commit 7c1a67c

3 files changed

Lines changed: 340 additions & 17 deletions

File tree

‎src/links/lib/update-internal-links.ts‎

Lines changed: 135 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import fs from 'fs'
33
import { visit, Test } from 'unist-util-visit'
44
import { fromMarkdown } from 'mdast-util-from-markdown'
55
import { toMarkdown } from 'mdast-util-to-markdown'
6+
import { dump } from 'js-yaml'
67
import { loadYaml } from '@/frame/lib/load-yaml'
78
import { type Node, type Nodes, type Definition, type Link } from 'mdast'
89

@@ -29,7 +30,7 @@ const logger = createLogger(import.meta.url)
2930
// we, at runtime, render out the links
3031
const AUTOTITLE = 'AUTOTITLE'
3132

32-
type LinkContext = {
33+
export type LinkContext = {
3334
pages: Record<string, Page>
3435
redirects: NonNullable<Context['redirects']>
3536
currentLanguage: string
@@ -74,6 +75,12 @@ type PendingReplacement = {
7475
baseHref: string
7576
makeMarkdown: (href: string) => string
7677
fragment?: CarriedFragment
78+
/**
79+
* Byte range of this link in the source, when the node's position could be mapped back
80+
* and the slice matches `asMarkdown` exactly. Replacing by range instead of by string
81+
* search keeps identical text elsewhere in the file untouched.
82+
*/
83+
span?: [number, number]
7784
}
7885

7986
const Options = {
@@ -120,7 +127,11 @@ export async function updateInternalLinks(files: string[], options = {}) {
120127
return results
121128
}
122129

123-
async function updateFile(file: string, context: LinkContext, opts: typeof Options) {
130+
/**
131+
* Exported so tests can drive a single file with a hand-built context. Loading the real
132+
* page tree takes tens of seconds, which is too slow to cover the rewrite branches.
133+
*/
134+
export async function updateFile(file: string, context: LinkContext, opts: typeof Options) {
124135
const rawContent = fs.readFileSync(file, 'utf8')
125136
let { data, content } = frontmatter(rawContent)
126137
data = data || {}
@@ -132,14 +143,58 @@ async function updateFile(file: string, context: LinkContext, opts: typeof Optio
132143
// the `frontmatter(rawContent).data` always becomes `{}`.
133144
// And since the Yaml file might contain arrays of internal linked
134145
// pathnames, we have to re-read it fully.
135-
if (file.endsWith('.yml')) {
146+
const isYaml = file.endsWith('.yml')
147+
if (isYaml) {
136148
Object.assign(data, loadYaml(content))
137149
}
138150

139151
let newContent = content
140-
const ast = fromMarkdown(newContent)
152+
153+
// Captured so the closure below sees a non-reassignable string.
154+
const source = content
155+
156+
// A YAML file is parsed as Markdown to find its links, and that parse is
157+
// indentation-sensitive: a value indented four or more spaces reads as a code block,
158+
// so the AST holds fewer link nodes than the text has occurrences. Stripping the
159+
// leading whitespace from every line exposes all of them. Line numbers are unaffected,
160+
// and `sourceSpan` maps each node's columns back onto the original text so the
161+
// rewrite still lands on the real bytes.
162+
const parseSource = isYaml ? dedentLines(source) : source
163+
const lineStarts = buildLineStarts(source)
164+
const indents = isYaml ? source.split('\n').map((line) => /^[ \t]*/.exec(line)![0].length) : null
165+
166+
/**
167+
* Column of a node in the original text. The YAML parse runs on dedented lines, so the
168+
* indent has to go back on before the column is reported to a human.
169+
*/
170+
function sourceColumn(node: Nodes): number | undefined {
171+
const pos = node.position
172+
if (!pos?.start.column) return undefined
173+
const indent = indents ? (indents[pos.start.line - 1] ?? 0) : 0
174+
return pos.start.column + indent
175+
}
176+
177+
/**
178+
* Byte range of a node in the source, or undefined when the range can't be trusted:
179+
* a node spanning several lines, or a serialization that doesn't match the source.
180+
*/
181+
function sourceSpan(node: Nodes, asMarkdown: string): [number, number] | undefined {
182+
const pos = node.position
183+
if (!pos?.start.line || !pos.end.line || pos.start.line !== pos.end.line) return undefined
184+
const lineIndex = pos.start.line - 1
185+
const lineStart = lineStarts[lineIndex]
186+
if (lineStart === undefined) return undefined
187+
const indent = indents ? indents[lineIndex] : 0
188+
const start = lineStart + indent + (pos.start.column - 1)
189+
const end = lineStart + indent + (pos.end.column - 1)
190+
return source.slice(start, end) === asMarkdown ? [start, end] : undefined
191+
}
192+
193+
const ast = fromMarkdown(parseSource)
141194

142195
const replacements: Replacement[] = []
196+
const spanEdits: { start: number; end: number; text: string }[] = []
197+
const stringEdits: { find: string; text: string }[] = []
143198
const warnings: Warning[] = []
144199

145200
const newData = structuredClone(data)
@@ -213,7 +268,7 @@ async function updateFile(file: string, context: LinkContext, opts: typeof Optio
213268
// getNewHref() might return a deliberate `undefined` if the
214269
// new href value could not be computed for some reason.
215270
const baseHref = result === undefined ? node.url : result.href
216-
const column = node.position?.start.column
271+
const column = sourceColumn(node)
217272
const line = (node.position?.start.line ?? 0) + lineOffset
218273
pending.push({
219274
asMarkdown,
@@ -222,6 +277,7 @@ async function updateFile(file: string, context: LinkContext, opts: typeof Optio
222277
baseHref,
223278
makeMarkdown: (href) => `[${label}]: ${href}`,
224279
fragment: result?.fragment,
280+
span: sourceSpan(node, asMarkdown),
225281
})
226282
}
227283
})
@@ -281,7 +337,7 @@ async function updateFile(file: string, context: LinkContext, opts: typeof Optio
281337
*/
282338
if (xValue) {
283339
if (singleStartingQuote(xValue)) {
284-
const column = node.position?.start.column
340+
const column = sourceColumn(node)
285341
const line = (node.position?.start.line ?? 0) + lineOffset
286342
warnings.push({
287343
warning: 'Starts with a single " inside the text',
@@ -290,7 +346,7 @@ async function updateFile(file: string, context: LinkContext, opts: typeof Optio
290346
column,
291347
})
292348
} else if (isSimpleQuote(xValue)) {
293-
const column = node.position?.start.column
349+
const column = sourceColumn(node)
294350
const line = (node.position?.start.line ?? 0) + lineOffset
295351
warnings.push({
296352
warning: 'Starts and ends with a " inside the text',
@@ -311,7 +367,7 @@ async function updateFile(file: string, context: LinkContext, opts: typeof Optio
311367
fragment = result.fragment
312368
}
313369
}
314-
const column = node.position?.start.column
370+
const column = sourceColumn(node)
315371
const line = (node.position?.start.line ?? 0) + lineOffset
316372
pending.push({
317373
asMarkdown,
@@ -320,6 +376,7 @@ async function updateFile(file: string, context: LinkContext, opts: typeof Optio
320376
baseHref,
321377
makeMarkdown: (href) => `[${newTitle}](${href})`,
322378
fragment,
379+
span: sourceSpan(node, asMarkdown),
323380
})
324381
} else if (opts.verbose) {
325382
logger.warn('Unable to find link as Markdown in the source content', {
@@ -376,14 +433,33 @@ async function updateFile(file: string, context: LinkContext, opts: typeof Optio
376433
}
377434
}
378435
const newAsMarkdown = item.makeMarkdown(finalHref)
379-
if (item.asMarkdown !== newAsMarkdown) {
436+
if (item.asMarkdown !== newAsMarkdown && content.includes(item.asMarkdown)) {
380437
replacements.push({
381438
asMarkdown: item.asMarkdown,
382439
newAsMarkdown,
383440
line: item.line,
384441
column: item.column,
385442
})
386-
newContent = newContent.replace(item.asMarkdown, newAsMarkdown)
443+
if (item.span) {
444+
spanEdits.push({ start: item.span[0], end: item.span[1], text: newAsMarkdown })
445+
} else {
446+
// No trustworthy range for this node, so fall back to a string search. Left for
447+
// the second pass, after the ranged edits, since a search can't be offset-aware.
448+
stringEdits.push({ find: item.asMarkdown, text: newAsMarkdown })
449+
}
450+
}
451+
}
452+
453+
// Ranged edits go in descending order so earlier offsets stay valid, and each one
454+
// touches exactly the bytes the parser identified as a link. That's what keeps an
455+
// identical string in a comment or a code example from being rewritten too.
456+
spanEdits.sort((a, b) => b.start - a.start)
457+
for (const edit of spanEdits) {
458+
newContent = newContent.slice(0, edit.start) + edit.text + newContent.slice(edit.end)
459+
}
460+
for (const edit of stringEdits) {
461+
if (newContent.includes(edit.find)) {
462+
newContent = newContent.replace(edit.find, edit.text)
387463
}
388464
}
389465

@@ -398,6 +474,23 @@ async function updateFile(file: string, context: LinkContext, opts: typeof Optio
398474
}
399475
}
400476

477+
/** Strip leading whitespace from every line, preserving the line count. */
478+
function dedentLines(content: string): string {
479+
return content
480+
.split('\n')
481+
.map((line) => line.replace(/^[ \t]+/, ''))
482+
.join('\n')
483+
}
484+
485+
/** Byte offset where each line begins, so a line/column pair can become an offset. */
486+
function buildLineStarts(content: string): number[] {
487+
const starts = [0]
488+
for (let i = 0; i < content.length; i++) {
489+
if (content[i] === '\n') starts.push(i + 1)
490+
}
491+
return starts
492+
}
493+
401494
function isDefinition(node: Node): node is Definition {
402495
return node.type === 'definition'
403496
}
@@ -701,6 +794,38 @@ function singleStartingQuote(text: string) {
701794
function isSimpleQuote(text: string) {
702795
return text.startsWith('"') && text.endsWith('"') && text.split('"').length === 3
703796
}
797+
798+
/**
799+
* Write a YAML data file back out.
800+
*
801+
* For `.yml` files every link fix lands in `newContent`, the file's own text, because
802+
* `updateFile` finds Markdown links by parsing that text and rewrites them in place.
803+
* `newData` is only mutated for the structured link keys (`featuredLinks` and
804+
* `introLinks`), which no file under `data/` currently uses.
805+
*
806+
* Writing `dump(newData)` therefore threw away every fix and reserialized the untouched
807+
* data instead: pure churn, no change. Prefer the surgically edited text, and only fall
808+
* back to reserializing when the structured data genuinely changed.
809+
*/
810+
export function serializeYaml(
811+
newContent: string,
812+
newData: Record<string, unknown> | undefined,
813+
differentContent: boolean,
814+
differentData: boolean,
815+
): string {
816+
if (!differentData) return newContent
817+
if (differentContent) {
818+
// The two kinds of change live in different representations and there is no
819+
// format-preserving way to merge them, so `dump` would silently drop the text
820+
// fixes. No file hits this today. Fail loudly rather than lose edits quietly.
821+
throw new Error(
822+
'Cannot serialize a YAML file that has both text and structured data changes ' +
823+
'without losing one of them. This needs a format-preserving merge.',
824+
)
825+
}
826+
return dump(newData || {})
827+
}
828+
704829
/**
705830
* Write a Markdown page back out, preserving the original frontmatter text verbatim
706831
* whenever the frontmatter data itself didn't change.

‎src/links/scripts/update-internal-links.ts‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@ import path from 'path'
1212

1313
import { program } from 'commander'
1414
import chalk from 'chalk'
15-
import { dump } from 'js-yaml'
1615

17-
import { updateInternalLinks, serializeMarkdown } from '@/links/lib/update-internal-links'
16+
import {
17+
updateInternalLinks,
18+
serializeMarkdown,
19+
serializeYaml,
20+
} from '@/links/lib/update-internal-links'
1821
import walkFiles from '@/workflows/walk-files'
1922

2023
program
@@ -116,6 +119,10 @@ async function main(files: string[], opts: Options) {
116119
const results = await updateInternalLinks(actualFiles, options)
117120

118121
let exitCheck = 0
122+
// Serializing can throw, and a throw halfway through the loop would leave a
123+
// half-updated checkout. Every output is computed first so a failure on the last
124+
// file means nothing was written at all, which is what the comment above promises.
125+
const pendingWrites: { file: string; output: string }[] = []
119126
for (const {
120127
file,
121128
rawContent,
@@ -153,15 +160,17 @@ async function main(files: string[], opts: Options) {
153160
}
154161
if (!opts.dryRun) {
155162
if (file.endsWith('.yml')) {
156-
fs.writeFileSync(file, dump(newData), 'utf-8')
163+
pendingWrites.push({
164+
file,
165+
output: serializeYaml(newContent, newData, differentContent, differentData),
166+
})
157167
} else {
158168
// Remember the `content` and `newContent` is the "meat" of the
159169
// Markdown page. To save it you need the frontmatter data too.
160-
fs.writeFileSync(
170+
pendingWrites.push({
161171
file,
162-
serializeMarkdown(rawContent, content, newContent, newData, differentData),
163-
'utf-8',
164-
)
172+
output: serializeMarkdown(rawContent, content, newContent, newData, differentData),
173+
})
165174
}
166175
}
167176
}
@@ -175,6 +184,11 @@ async function main(files: string[], opts: Options) {
175184
}
176185
}
177186

187+
// Every serializer succeeded, so the writes can't be interrupted by one of them.
188+
for (const { file, output } of pendingWrites) {
189+
fs.writeFileSync(file, output, 'utf-8')
190+
}
191+
178192
if (opts.aggregateStats) {
179193
const countFiles = results.length
180194
const countChangedFiles = new Set(results.filter((result) => result.replacements.length > 0))

0 commit comments

Comments
 (0)