@@ -3,6 +3,7 @@ import fs from 'fs'
33import { visit , Test } from 'unist-util-visit'
44import { fromMarkdown } from 'mdast-util-from-markdown'
55import { toMarkdown } from 'mdast-util-to-markdown'
6+ import { dump } from 'js-yaml'
67import { loadYaml } from '@/frame/lib/load-yaml'
78import { 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
3031const 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
7986const 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+
401494function isDefinition ( node : Node ) : node is Definition {
402495 return node . type === 'definition'
403496}
@@ -701,6 +794,38 @@ function singleStartingQuote(text: string) {
701794function 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.
0 commit comments