Skip to content

Commit e105236

Browse files
committed
feat(docs): add dynamic markdown anchors generation
1 parent 4d7f4f7 commit e105236

3 files changed

Lines changed: 330 additions & 4 deletions

File tree

docs-source/src/.vuepress/config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { defaultTheme } from 'vuepress';
22
import { shikiPlugin } from '@vuepress/plugin-shiki';
33
import { searchPlugin } from '@vuepress/plugin-search';
4+
import type MarkdownIt from 'markdown-it';
45
import { navBarItems, sideBarItems, configs, pageLinkRefs } from './configs/template';
6+
import { alignI18nAnchors } from './configs/anchors';
57
import { env, markdown } from './configs/utils';
68

79
export default {
@@ -48,7 +50,8 @@ export default {
4850
}
4951
},
5052
}),
51-
extendsMarkdown: (md: markdownit) => {
53+
extendsMarkdown: (md: MarkdownIt) => {
54+
md.use(alignI18nAnchors);
5255
markdown.injectLinks(md, env.dev ? pageLinkRefs.dev : pageLinkRefs.prod);
5356
},
5457
plugins: [
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
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(/\.md$/, '.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(/\.html$/, '.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(/\.html$/, '.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+
};

docs-source/src/.vuepress/configs/utils.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import type MarkdownIt from 'markdown-it';
2+
import { resolveI18nLink } from './anchors';
3+
14
export const env = {
25
dev: process.env.NODE_ENV === 'development'
36
};
@@ -17,22 +20,30 @@ export const i18n = {
1720
};
1821

1922
export const markdown = {
20-
injectLinks: (md: markdownit, maps: Record<string, string>[]) => {
23+
injectLinks: (md: MarkdownIt, maps: Record<string, string>[]) => {
2124
const defaultRender = md.renderer.rules.link_open || function (tokens, idx, options, _env, self) {
2225
return self.renderToken(tokens, idx, options);
2326
};
2427
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
2528
const hrefIndex = tokens[idx].attrIndex('href');
26-
let current = tokens[idx].attrs!![hrefIndex][1];
29+
if (hrefIndex < 0 || !tokens[idx].attrs) {
30+
return defaultRender(tokens, idx, options, env, self);
31+
}
32+
let current = tokens[idx].attrs[hrefIndex][1];
33+
current = resolveI18nLink({
34+
base: env.base,
35+
filePathRelative: env.filePathRelative
36+
}, current);
2737
for (const map of maps) {
2838
for (const [search, replace] of Object.entries(map)) {
2939
if (current.startsWith(search)) {
3040
current = current.replace(search, replace);
31-
tokens[idx].attrs!![hrefIndex][1] = current;
41+
tokens[idx].attrs[hrefIndex][1] = current;
3242
break;
3343
}
3444
}
3545
}
46+
tokens[idx].attrs[hrefIndex][1] = current;
3647
return defaultRender(tokens, idx, options, env, self);
3748
};
3849
}

0 commit comments

Comments
 (0)