Skip to content

Commit 9f06c83

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

File tree

3 files changed

+96
-2
lines changed

3 files changed

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

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

Lines changed: 24 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 { getLegacySlug } from './slugger.mjs';
89
import getConfig from '../../../utils/configuration/index.mjs';
910
import {
1011
GITHUB_BLOB_URL,
@@ -20,12 +21,21 @@ 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 = (
25+
{ data, children, depth },
26+
index,
27+
parent,
28+
apiStem,
29+
deduplicateFn
30+
) => {
31+
const legacySlug = getLegacySlug(data.text, apiStem, deduplicateFn);
2432
// Creates the heading element with the heading text and the link to the heading
2533
const headingElement = createElement(`h${depth + 1}`, [
2634
// The inner Heading markdown content is still using Remark nodes, and they need
2735
// to be converted into Rehype nodes
2836
...children,
37+
// Legacy anchor alias to preserve old external links
38+
createElement('span', createElement(`a#${legacySlug}`)),
2939
// Creates the element that references the link to the heading
3040
// (The `#` anchor on the right of each Heading section)
3141
createElement(
@@ -220,6 +230,16 @@ const buildMetadataElement = (node, remark) => {
220230
* @param {import('unified').Processor} remark The Remark instance to be used to process
221231
*/
222232
export default (headNodes, metadataEntries, remark) => {
233+
// Legacy slug counter tracking for deduplication
234+
const legacyIdCounters = { __proto__: null };
235+
/** Deduplicates legacy slugs by appending an incremented counter */
236+
const legacyDeduplicateFn = id => {
237+
if (legacyIdCounters[id] !== undefined) {
238+
return `${id}_${++legacyIdCounters[id]}`;
239+
}
240+
legacyIdCounters[id] = 0;
241+
return id;
242+
};
223243
// Creates the root node for the content
224244
const parsedNodes = createTree(
225245
'root',
@@ -229,7 +249,9 @@ export default (headNodes, metadataEntries, remark) => {
229249
const content = structuredClone(entry.content);
230250

231251
// Parses the Heading nodes into Heading elements
232-
visit(content, UNIST.isHeading, buildHeading);
252+
visit(content, UNIST.isHeading, (node, index, parent) =>
253+
buildHeading(node, index, parent, entry.api, legacyDeduplicateFn)
254+
);
233255

234256
// Parses the Blockquotes into Stability elements
235257
// This is treated differently as we want to preserve the position of a Stability Index
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
'use strict';
2+
3+
const notAlphaNumerics = /[^a-z0-9]+/g;
4+
const edgeUnderscores = /^_+|_+$/g;
5+
const notAlphaStart = /^[^a-z]/;
6+
7+
/**
8+
* Generates a legacy-style slug to preserve old anchor links.
9+
*
10+
* @param {string} text The heading text
11+
* @param {string} apiStem The API file identifier (e.g. 'fs', 'http')
12+
* @param {(id: string) => string} [deduplicateFn] Optional function for counter-based deduplication
13+
* @returns {string} The legacy slug
14+
*/
15+
export const getLegacySlug = (text, apiStem, deduplicateFn) => {
16+
let id = `${apiStem}_${text}`
17+
.toLowerCase()
18+
.replace(notAlphaNumerics, '_')
19+
.replace(edgeUnderscores, '')
20+
.replace(notAlphaStart, '_$&');
21+
22+
return deduplicateFn ? deduplicateFn(id) : id;
23+
};

0 commit comments

Comments
 (0)