Skip to content

Commit 8e5017f

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

File tree

3 files changed

+106
-2
lines changed

3 files changed

+106
-2
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
'use strict';
2+
3+
import assert from 'node:assert/strict';
4+
import { describe, it } from 'node:test';
5+
6+
import { createLegacySlugger } from '../slugger.mjs';
7+
8+
describe('createLegacySlugger', () => {
9+
it('prefixes with api stem and uses underscores', () => {
10+
const slugger = createLegacySlugger();
11+
assert.strictEqual(
12+
slugger.getLegacySlug('File System', 'fs'),
13+
'fs_file_system'
14+
);
15+
});
16+
17+
it('replaces special characters with underscores', () => {
18+
const slugger = createLegacySlugger();
19+
assert.strictEqual(
20+
slugger.getLegacySlug('fs.readFile(path)', 'fs'),
21+
'fs_fs_readfile_path'
22+
);
23+
});
24+
25+
it('strips leading and trailing underscores', () => {
26+
const slugger = createLegacySlugger();
27+
assert.strictEqual(slugger.getLegacySlug('Hello', 'fs'), 'fs_hello');
28+
});
29+
30+
it('prefixes with underscore when result starts with non-alpha', () => {
31+
const slugger = createLegacySlugger();
32+
assert.strictEqual(
33+
slugger.getLegacySlug('123 test', '0num'),
34+
'_0num_123_test'
35+
);
36+
});
37+
38+
it('deduplicates with a counter for identical titles', () => {
39+
const slugger = createLegacySlugger();
40+
assert.strictEqual(slugger.getLegacySlug('Hello', 'fs'), 'fs_hello');
41+
assert.strictEqual(slugger.getLegacySlug('Hello', 'fs'), 'fs_hello_1');
42+
assert.strictEqual(slugger.getLegacySlug('Hello', 'fs'), 'fs_hello_2');
43+
assert.strictEqual(slugger.getLegacySlug('World', 'fs'), 'fs_world');
44+
});
45+
});

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { u as createTree } from 'unist-builder';
55
import { SKIP, visit } from 'unist-util-visit';
66

77
import buildExtraContent from './buildExtraContent.mjs';
8+
import { createLegacySlugger } from './slugger.mjs';
89
import getConfig from '../../../utils/configuration/index.mjs';
910
import {
1011
GITHUB_BLOB_URL,
@@ -20,12 +21,14 @@ import { QUERIES, UNIST } from '../../../utils/queries/index.mjs';
2021
* @param {import('unist').Parent} parent The parent node of the current node
2122
* @returns {import('hast').Element} The HTML AST tree of the heading content
2223
*/
23-
const buildHeading = ({ data, children, depth }, index, parent) => {
24+
const buildHeading = ({ data, children, depth }, index, parent, legacySlug) => {
2425
// Creates the heading element with the heading text and the link to the heading
2526
const headingElement = createElement(`h${depth + 1}`, [
2627
// The inner Heading markdown content is still using Remark nodes, and they need
2728
// to be converted into Rehype nodes
2829
...children,
30+
// Legacy anchor alias to preserve old external links
31+
createElement('span', createElement(`a#${legacySlug}`)),
2932
// Creates the element that references the link to the heading
3033
// (The `#` anchor on the right of each Heading section)
3134
createElement(
@@ -220,6 +223,8 @@ const buildMetadataElement = (node, remark) => {
220223
* @param {import('unified').Processor} remark The Remark instance to be used to process
221224
*/
222225
export default (headNodes, metadataEntries, remark) => {
226+
const legacySlugger = createLegacySlugger();
227+
223228
// Creates the root node for the content
224229
const parsedNodes = createTree(
225230
'root',
@@ -229,7 +234,13 @@ export default (headNodes, metadataEntries, remark) => {
229234
const content = structuredClone(entry.content);
230235

231236
// Parses the Heading nodes into Heading elements
232-
visit(content, UNIST.isHeading, buildHeading);
237+
visit(content, UNIST.isHeading, (node, index, parent) => {
238+
const legacySlug = legacySlugger.getLegacySlug(
239+
node.data.text,
240+
entry.api
241+
);
242+
buildHeading(node, index, parent, legacySlug);
243+
});
233244

234245
// Parses the Blockquotes into Stability elements
235246
// This is treated differently as we want to preserve the position of a Stability Index
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
'use strict';
2+
3+
const notAlphaNumerics = /[^a-z0-9]+/g;
4+
const edgeUnderscores = /^_+|_+$/g;
5+
const notAlphaStart = /^[^a-z]/;
6+
7+
/**
8+
* Deduplicates legacy slugs by appending an incremented counter.
9+
* Adapted from maintainer suggestion to preserve `id` on first occurrence.
10+
*
11+
* @param {Record<string, number>} counters
12+
* @returns {(id: string) => string}
13+
*/
14+
export const legacyDeduplicator =
15+
(counters = { __proto__: null }) =>
16+
id => {
17+
counters[id] ??= -1;
18+
const count = ++counters[id];
19+
return count > 0 ? `${id}_${count}` : id;
20+
};
21+
22+
/**
23+
* Creates a stateful slugger for legacy anchor links.
24+
*
25+
* @returns {{ getLegacySlug: (text: string, apiStem: string) => string }}
26+
*/
27+
export const createLegacySlugger = () => {
28+
const deduplicate = legacyDeduplicator();
29+
30+
return {
31+
/**
32+
* Generates a legacy-style slug to preserve old anchor links.
33+
*
34+
* @param {string} text The heading text
35+
* @param {string} apiStem The API file identifier (e.g. 'fs', 'http')
36+
* @returns {string} The legacy slug
37+
*/
38+
getLegacySlug: (text, apiStem) => {
39+
const id = `${apiStem}_${text}`
40+
.toLowerCase()
41+
.replace(notAlphaNumerics, '_')
42+
.replace(edgeUnderscores, '')
43+
.replace(notAlphaStart, '_$&');
44+
45+
return deduplicate(id);
46+
},
47+
};
48+
};

0 commit comments

Comments
 (0)