1+ import { existsSync , readFileSync , statSync } from 'node:fs' ;
2+ import path from 'node:path' ;
3+ import MarkdownIt from 'markdown-it' ;
4+ import type StateCore from 'markdown-it/lib/rules_core/state_core' ;
5+ import type Token from 'markdown-it/lib/token' ;
6+ import { slugify } from '@mdit-vue/shared' ;
7+
8+ interface HeadingAnchor {
9+ alignedId : string ;
10+ localizedSlug : string ;
11+ pathKey : string ;
12+ }
13+
14+ interface HeadingLookup {
15+ byPathKey : Map < string , HeadingAnchor > ;
16+ bySlug : Map < string , string > ;
17+ }
18+
19+ interface CachedHeadingLookup {
20+ lookup : HeadingLookup ;
21+ mtimeMs : number ;
22+ }
23+
24+ interface HeadingToken {
25+ inlineToken : Token ;
26+ pathKey : string ;
27+ token : Token ;
28+ }
29+
30+ interface LinkResolveContext {
31+ base ?: string ;
32+ filePathRelative ?: string | null ;
33+ }
34+
35+ const sourceRoot = path . resolve ( process . cwd ( ) , 'src' ) ;
36+ const englishRoot = path . join ( sourceRoot , 'en' ) ;
37+ const headingParser = new MarkdownIt ( { html : true } ) ;
38+ const headingLookupCache = new Map < string , CachedHeadingLookup > ( ) ;
39+
40+ const createHeadingPathTracker = ( ) : ( ( level : number ) => string ) => {
41+ const indexes : number [ ] = [ ] ;
42+ return ( level : number ) : string => {
43+ const slot = Math . max ( level - 1 , 0 ) ;
44+ indexes . length = slot + 1 ;
45+ for ( let index = 0 ; index < slot ; index += 1 ) {
46+ if ( typeof indexes [ index ] !== 'number' ) {
47+ indexes [ index ] = 0 ;
48+ }
49+ }
50+ indexes [ slot ] = ( indexes [ slot ] ?? - 1 ) + 1 ;
51+ return indexes . join ( '.' ) ;
52+ } ;
53+ } ;
54+
55+ const getHeadingLevel = ( token : Token ) : number => Number . parseInt ( token . tag . slice ( 1 ) , 10 ) ;
56+
57+ const getHeadingText = ( inlineToken : Token ) : string =>
58+ ( inlineToken . children ?? [ ] )
59+ . filter ( ( child ) => child . type === 'text' || child . type === 'code_inline' )
60+ . map ( ( child ) => child . content )
61+ . join ( '' )
62+ . trim ( ) ;
63+
64+ const createUniqueSlug = ( candidate : string , usedSlugs : Set < string > ) : string => {
65+ let resolvedSlug = candidate ;
66+ let suffix = 1 ;
67+ while ( usedSlugs . has ( resolvedSlug ) ) {
68+ resolvedSlug = `${ candidate } -${ suffix } ` ;
69+ suffix += 1 ;
70+ }
71+ usedSlugs . add ( resolvedSlug ) ;
72+ return resolvedSlug ;
73+ } ;
74+
75+ const collectHeadings = ( tokens : Token [ ] ) : HeadingAnchor [ ] => {
76+ const nextPathKey = createHeadingPathTracker ( ) ;
77+ const usedSlugs = new Set < string > ( ) ;
78+ const headings : HeadingAnchor [ ] = [ ] ;
79+ for ( let index = 0 ; index < tokens . length ; index += 1 ) {
80+ const token = tokens [ index ] ;
81+ if ( token ?. type !== 'heading_open' ) {
82+ continue ;
83+ }
84+ const inlineToken = tokens [ index + 1 ] ;
85+ if ( ! inlineToken ) {
86+ continue ;
87+ }
88+ const level = getHeadingLevel ( token ) ;
89+ const title = getHeadingText ( inlineToken ) ;
90+ const localizedSlug = createUniqueSlug ( slugify ( title ) , usedSlugs ) ;
91+ headings . push ( {
92+ alignedId : localizedSlug ,
93+ localizedSlug,
94+ pathKey : nextPathKey ( level )
95+ } ) ;
96+ }
97+ return headings ;
98+ } ;
99+
100+ const resolveFileHeadings = ( filePath : string ) : HeadingAnchor [ ] => {
101+ const tokens = headingParser . parse ( readFileSync ( filePath , 'utf-8' ) , { } ) ;
102+ return collectHeadings ( tokens ) ;
103+ } ;
104+
105+ const buildHeadingLookup = ( localizedFilePath : string , englishFilePath : string ) : HeadingLookup => {
106+ const localizedHeadings = resolveFileHeadings ( localizedFilePath ) ;
107+ const englishHeadings = resolveFileHeadings ( englishFilePath ) ;
108+ const englishByPathKey = new Map ( englishHeadings . map ( ( heading ) => [ heading . pathKey , heading . alignedId ] ) ) ;
109+ const byPathKey = new Map < string , HeadingAnchor > ( ) ;
110+ const bySlug = new Map < string , string > ( ) ;
111+ for ( const localizedHeading of localizedHeadings ) {
112+ const alignedId = englishByPathKey . get ( localizedHeading . pathKey ) ?? localizedHeading . localizedSlug ;
113+ const heading = {
114+ ...localizedHeading ,
115+ alignedId
116+ } ;
117+ byPathKey . set ( localizedHeading . pathKey , heading ) ;
118+ bySlug . set ( localizedHeading . localizedSlug , alignedId ) ;
119+ }
120+ return { byPathKey, bySlug } ;
121+ } ;
122+
123+ const resolveLocalizedFilePath = ( filePathRelative : string ) : string =>
124+ path . join ( sourceRoot , ...filePathRelative . replace ( / \\ / g, '/' ) . split ( '/' ) ) ;
125+
126+ const resolveEnglishFilePath = ( filePathRelative : string ) : string =>
127+ path . join ( englishRoot , ...filePathRelative . replace ( / \\ / g, '/' ) . split ( '/' ) . slice ( 1 ) ) ;
128+
129+ const ensureMarkdownFilePath = ( filePath : string ) : string | null => {
130+ if ( existsSync ( filePath ) && statSync ( filePath ) . isFile ( ) ) {
131+ return filePath ;
132+ }
133+ const indexFilePath = path . join ( filePath , 'index.md' ) ;
134+ if ( existsSync ( indexFilePath ) && statSync ( indexFilePath ) . isFile ( ) ) {
135+ return indexFilePath ;
136+ }
137+ return null ;
138+ } ;
139+
140+ const isEnglishLocalePath = ( filePathRelative : string ) : boolean =>
141+ filePathRelative . replace ( / \\ / g, '/' ) . split ( '/' ) [ 0 ] === 'en' ;
142+
143+ const resolveHeadingLookup = ( filePathRelative : string | null | undefined ) : HeadingLookup | null => {
144+ if ( ! filePathRelative || isEnglishLocalePath ( filePathRelative ) ) {
145+ return null ;
146+ }
147+ const localizedFilePath = ensureMarkdownFilePath ( resolveLocalizedFilePath ( filePathRelative ) ) ;
148+ const englishFilePath = ensureMarkdownFilePath ( resolveEnglishFilePath ( filePathRelative ) ) ;
149+ if ( ! localizedFilePath || ! englishFilePath ) {
150+ return null ;
151+ }
152+ const cacheKey = `${ localizedFilePath } ::${ englishFilePath } ` ;
153+ const localizedMtimeMs = statSync ( localizedFilePath ) . mtimeMs ;
154+ const englishMtimeMs = statSync ( englishFilePath ) . mtimeMs ;
155+ const currentMtimeMs = Math . max ( localizedMtimeMs , englishMtimeMs ) ;
156+ const cached = headingLookupCache . get ( cacheKey ) ;
157+ if ( cached ?. mtimeMs === currentMtimeMs ) {
158+ return cached . lookup ;
159+ }
160+ const lookup = buildHeadingLookup ( localizedFilePath , englishFilePath ) ;
161+ headingLookupCache . set ( cacheKey , {
162+ lookup,
163+ mtimeMs : currentMtimeMs
164+ } ) ;
165+ return lookup ;
166+ } ;
167+
168+ const collectHeadingTokens = ( tokens : Token [ ] ) : HeadingToken [ ] => {
169+ const nextPathKey = createHeadingPathTracker ( ) ;
170+ const headings : HeadingToken [ ] = [ ] ;
171+ for ( let index = 0 ; index < tokens . length ; index += 1 ) {
172+ const token = tokens [ index ] ;
173+ if ( token ?. type !== 'heading_open' ) {
174+ continue ;
175+ }
176+ const inlineToken = tokens [ index + 1 ] ;
177+ if ( ! inlineToken ) {
178+ continue ;
179+ }
180+ headings . push ( {
181+ token,
182+ inlineToken,
183+ pathKey : nextPathKey ( getHeadingLevel ( token ) )
184+ } ) ;
185+ }
186+ return headings ;
187+ } ;
188+
189+ const syncPermalinkHref = ( inlineToken : Token , id : string ) : void => {
190+ for ( const child of inlineToken . children ?? [ ] ) {
191+ if ( child . type !== 'link_open' ) {
192+ continue ;
193+ }
194+ const className = child . attrGet ( 'class' ) ?? '' ;
195+ if ( ! className . split ( / \s + / ) . includes ( 'header-anchor' ) ) {
196+ continue ;
197+ }
198+ child . attrSet ( 'href' , `#${ id } ` ) ;
199+ break ;
200+ }
201+ } ;
202+
203+ const rewriteLocalHash = ( rawHref : string , lookup : HeadingLookup ) : string => {
204+ const hashIndex = rawHref . indexOf ( '#' ) ;
205+ if ( hashIndex < 0 ) {
206+ return rawHref ;
207+ }
208+ const hash = decodeURIComponent ( rawHref . slice ( hashIndex + 1 ) ) ;
209+ const resolvedHash = lookup . bySlug . get ( hash ) ;
210+ if ( ! resolvedHash || resolvedHash === hash ) {
211+ return rawHref ;
212+ }
213+ return `${ rawHref . slice ( 0 , hashIndex + 1 ) } ${ resolvedHash } ` ;
214+ } ;
215+
216+ const hasUriScheme = ( value : string ) : boolean => / ^ [ a - z ] [ a - z \d + . - ] * : / i. test ( value ) ;
217+
218+ const normalizeFilePathRelative = ( filePathRelative : string ) : string =>
219+ filePathRelative . replace ( / \\ / g, '/' ) ;
220+
221+ const normalizeBase = ( base = '/' ) : string => {
222+ const trimmed = base . trim ( ) ;
223+ if ( trimmed === '' || trimmed === '/' ) {
224+ return '/' ;
225+ }
226+ return `/${ trimmed . replace ( / ^ \/ + | \/ + $ / g, '' ) } /` ;
227+ } ;
228+
229+ const toSiteRoutePath = ( filePathRelative : string , base = '/' ) : string => {
230+ const normalizedFilePath = normalizeFilePathRelative ( filePathRelative ) ;
231+ const routePath = normalizedFilePath . endsWith ( '/index.md' )
232+ ? normalizedFilePath . slice ( 0 , - 'index.md' . length )
233+ : normalizedFilePath . replace ( / \. m d $ / , '.html' ) ;
234+ return `${ normalizeBase ( base ) } ${ routePath } ` . replace ( / \/ { 2 , } / g, '/' ) ;
235+ } ;
236+
237+ const resolveTargetFilePathRelative = ( currentFilePathRelative : string , rawPath : string , base = '/' ) : string | null => {
238+ const normalizedCurrentPath = normalizeFilePathRelative ( currentFilePathRelative ) ;
239+ if ( rawPath . length === 0 ) {
240+ return normalizedCurrentPath ;
241+ }
242+ if ( hasUriScheme ( rawPath ) || rawPath . startsWith ( '//' ) ) {
243+ return null ;
244+ }
245+ if ( rawPath . startsWith ( '/' ) ) {
246+ const normalizedBase = base === '/' ? '/' : `${ base . replace ( / \/ + $ / , '' ) } /` ;
247+ const trimmedPath = rawPath . startsWith ( normalizedBase )
248+ ? rawPath . slice ( normalizedBase . length )
249+ : rawPath . replace ( / ^ \/ + / , '' ) ;
250+ if ( trimmedPath . endsWith ( '.html' ) ) {
251+ return trimmedPath . replace ( / \. h t m l $ / , '.md' ) ;
252+ }
253+ if ( trimmedPath . endsWith ( '.md' ) ) {
254+ return trimmedPath ;
255+ }
256+ return `${ trimmedPath } .md` ;
257+ }
258+ if ( rawPath . endsWith ( '.html' ) ) {
259+ return path . posix . join ( path . posix . dirname ( normalizedCurrentPath ) , rawPath ) . replace ( / \. h t m l $ / , '.md' ) ;
260+ }
261+ if ( rawPath . endsWith ( '.md' ) ) {
262+ return path . posix . join ( path . posix . dirname ( normalizedCurrentPath ) , rawPath ) ;
263+ }
264+ return `${ path . posix . join ( path . posix . dirname ( normalizedCurrentPath ) , rawPath ) } .md` ;
265+ } ;
266+
267+ export const alignI18nAnchors = ( md : MarkdownIt ) : void => {
268+ md . core . ruler . after ( 'anchor' , 'align-i18n-anchors' , ( state : StateCore ) => {
269+ const lookup = resolveHeadingLookup ( state . env . filePathRelative ) ;
270+ if ( ! lookup ) {
271+ return ;
272+ }
273+ const localizedHeadings = collectHeadingTokens ( state . tokens ) ;
274+ if ( localizedHeadings . length === 0 ) {
275+ return ;
276+ }
277+ for ( const localizedHeading of localizedHeadings ) {
278+ const alignedHeading = lookup . byPathKey . get ( localizedHeading . pathKey ) ;
279+ if ( ! alignedHeading ) {
280+ continue ;
281+ }
282+ localizedHeading . token . attrSet ( 'id' , alignedHeading . alignedId ) ;
283+ syncPermalinkHref ( localizedHeading . inlineToken , alignedHeading . alignedId ) ;
284+ }
285+ } ) ;
286+ } ;
287+
288+ export const resolveI18nLink = ( context : LinkResolveContext , rawHref : string ) : string => {
289+ if ( ! context . filePathRelative || ! rawHref . includes ( '#' ) ) {
290+ return rawHref ;
291+ }
292+ const hashIndex = rawHref . indexOf ( '#' ) ;
293+ const rawPath = rawHref . slice ( 0 , hashIndex ) ;
294+ const targetFilePathRelative = resolveTargetFilePathRelative ( context . filePathRelative , rawPath , context . base ) ;
295+ if ( ! targetFilePathRelative ) {
296+ return rawHref ;
297+ }
298+ const lookup = resolveHeadingLookup ( targetFilePathRelative ) ;
299+ if ( ! lookup ) {
300+ return rawHref ;
301+ }
302+ const rewrittenHref = rewriteLocalHash ( rawHref , lookup ) ;
303+ if ( rawPath . length === 0 ) {
304+ return rewrittenHref ;
305+ }
306+ const resolvedHash = rewrittenHref . slice ( rewrittenHref . indexOf ( '#' ) + 1 ) ;
307+ const currentFilePathRelative = normalizeFilePathRelative ( context . filePathRelative ) ;
308+ if ( normalizeFilePathRelative ( targetFilePathRelative ) === currentFilePathRelative ) {
309+ return `#${ resolvedHash } ` ;
310+ }
311+ return `${ toSiteRoutePath ( targetFilePathRelative , context . base ) } #${ resolvedHash } ` ;
312+ } ;
0 commit comments