Skip to content

Commit 1eec08d

Browse files
committed
feat: restore legacy anchor alias to preserve old links
Fixes: #697
1 parent d9697c7 commit 1eec08d

File tree

6 files changed

+88
-9
lines changed

6 files changed

+88
-9
lines changed

src/generators/jsx-ast/utils/buildContent.mjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,12 +128,14 @@ export const extractHeadingContent = content => {
128128
* @param {import('unist').Node|null} changeElement - The change history element, if available
129129
*/
130130
export const createHeadingElement = (content, changeElement) => {
131-
const { type, slug } = content.data;
131+
const { type, slug, legacySlug } = content.data;
132132

133133
let headingContent = extractHeadingContent(content);
134134

135135
// Build heading with anchor link
136136
const headingWrapper = createElement('div', [
137+
// Legacy anchor alias to preserve old external links
138+
createElement('a', { id: legacySlug }),
137139
createElement(
138140
`h${content.depth}`,
139141
{ id: slug },

src/generators/legacy-html/utils/buildContent.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ const buildHeading = ({ data, children, depth }, index, parent) => {
2626
// The inner Heading markdown content is still using Remark nodes, and they need
2727
// to be converted into Rehype nodes
2828
...children,
29+
// Legacy anchor alias to preserve old external links
30+
createElement('span', createElement(`a#${data.legacySlug}`)),
2931
// Creates the element that references the link to the heading
3032
// (The `#` anchor on the right of each Heading section)
3133
createElement(

src/generators/metadata/types.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ export interface HeadingData extends Data {
100100
name: string;
101101
/** URL-safe slug derived from heading text */
102102
slug: string;
103+
/** Legacy slug for backward-compatible anchor links */
104+
legacySlug: string;
103105
/** Optional type classification */
104106
type?: HeadingType;
105107
}

src/generators/metadata/utils/__tests__/slugger.test.mjs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import assert from 'node:assert/strict';
44
import { describe, it } from 'node:test';
55

6-
import createNodeSlugger, { slug } from '../slugger.mjs';
6+
import createNodeSlugger, { slug, getLegacySlug } from '../slugger.mjs';
77

88
const identity = str => str;
99

@@ -116,3 +116,39 @@ describe('createNodeSlugger', () => {
116116
assert.strictEqual(slugger2.slug('Hello'), 'hello');
117117
});
118118
});
119+
120+
describe('getLegacySlug', () => {
121+
it('prefixes with api stem and uses underscores', () => {
122+
assert.strictEqual(getLegacySlug('File System', 'fs'), 'fs_file_system');
123+
});
124+
125+
it('replaces special characters with underscores', () => {
126+
assert.strictEqual(
127+
getLegacySlug('fs.readFile(path)', 'fs'),
128+
'fs_fs_readfile_path'
129+
);
130+
});
131+
132+
it('strips leading and trailing underscores', () => {
133+
assert.strictEqual(getLegacySlug('Hello', 'fs'), 'fs_hello');
134+
});
135+
136+
it('prefixes with underscore when result starts with non-alpha', () => {
137+
assert.strictEqual(getLegacySlug('123 test', '0num'), '_0num_123_test');
138+
});
139+
});
140+
141+
describe('createNodeSlugger legacySlug', () => {
142+
it('deduplicates repeated legacy slugs with a counter', () => {
143+
const slugger = createNodeSlugger();
144+
assert.strictEqual(slugger.legacySlug('Hello', 'fs'), 'fs_hello');
145+
assert.strictEqual(slugger.legacySlug('Hello', 'fs'), 'fs_hello_1');
146+
assert.strictEqual(slugger.legacySlug('Hello', 'fs'), 'fs_hello_2');
147+
});
148+
149+
it('does not deduplicate different titles', () => {
150+
const slugger = createNodeSlugger();
151+
assert.strictEqual(slugger.legacySlug('First', 'fs'), 'fs_first');
152+
assert.strictEqual(slugger.legacySlug('Second', 'fs'), 'fs_second');
153+
});
154+
});

src/generators/metadata/utils/parse.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ export const parseApiDoc = ({ path, tree }, typeMap) => {
9292

9393
// Generate slug and update heading data
9494
metadata.heading.data.slug = nodeSlugger.slug(metadata.heading.data.text);
95+
metadata.heading.data.legacySlug = nodeSlugger.legacySlug(
96+
metadata.heading.data.text,
97+
api
98+
);
9599

96100
// Find the next heading to determine section boundaries
97101
const nextHeadingNode =

src/generators/metadata/utils/slugger.mjs

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,24 @@ const createNodeSlugger = () => {
1313
const slugger = new GitHubSlugger();
1414
const slugFn = slugger.slug.bind(slugger);
1515

16+
// Legacy slug counter tracking (mirrors old Node.js deduplication)
17+
const legacyIdCounters = { __proto__: null };
18+
19+
/** Deduplicates legacy slugs by appending an incremented counter */
20+
const legacyDeduplicateFn = id => {
21+
if (legacyIdCounters[id] !== undefined) {
22+
return `${id}_${++legacyIdCounters[id]}`;
23+
}
24+
legacyIdCounters[id] = 0;
25+
return id;
26+
};
27+
1628
return {
1729
...slugger,
18-
/**
19-
* Creates a new slug based on the provided string
20-
*
21-
* @param {string} title The title to be parsed into a slug
22-
*/
23-
// Applies certain string replacements that are specific
24-
// to the way how Node.js generates slugs/anchor IDs
30+
/** Creates a new slug with Node.js-specific replacements applied */
2531
slug: title => slug(title, slugFn),
32+
/** Creates a legacy-style slug to preserve old anchor links */
33+
legacySlug: (title, api) => getLegacySlug(title, api, legacyDeduplicateFn),
2634
};
2735
};
2836

@@ -36,4 +44,29 @@ export const slug = (title, slugFn = defaultSlugFn) =>
3644
slugFn(title)
3745
);
3846

47+
// Legacy slug algorithm regex patterns (from old Node.js doc generator)
48+
const notAlphaNumerics = /[^a-z0-9]+/g;
49+
const edgeUnderscores = /^_+|_+$/g;
50+
const notAlphaStart = /^[^a-z]/;
51+
52+
/**
53+
* Generates a legacy-style slug to preserve old anchor links.
54+
* The old Node.js doc generator used this format: filename_headingtext
55+
* with non-alphanumerics replaced by underscores.
56+
*
57+
* @param {string} title The heading text
58+
* @param {string} api The API file identifier (e.g. 'fs', 'http')
59+
* @param {(id: string) => string} [deduplicateFn] Optional function for counter-based deduplication
60+
* @returns {string} The legacy slug
61+
*/
62+
export const getLegacySlug = (title, api, deduplicateFn) => {
63+
let id = `${api}_${title}`
64+
.toLowerCase()
65+
.replace(notAlphaNumerics, '_')
66+
.replace(edgeUnderscores, '')
67+
.replace(notAlphaStart, '_$&');
68+
69+
return deduplicateFn ? deduplicateFn(id) : id;
70+
};
71+
3972
export default createNodeSlugger;

0 commit comments

Comments
 (0)