Skip to content

Commit aeeb9d2

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

File tree

3 files changed

+109
-2
lines changed

3 files changed

+109
-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: 16 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,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+
legacySlugger
30+
) => {
31+
const legacySlug = legacySlugger.getLegacySlug(data.text, apiStem);
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,8 @@ 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+
const legacySlugger = createLegacySlugger();
234+
223235
// Creates the root node for the content
224236
const parsedNodes = createTree(
225237
'root',
@@ -229,7 +241,9 @@ export default (headNodes, metadataEntries, remark) => {
229241
const content = structuredClone(entry.content);
230242

231243
// Parses the Heading nodes into Heading elements
232-
visit(content, UNIST.isHeading, buildHeading);
244+
visit(content, UNIST.isHeading, (node, index, parent) =>
245+
buildHeading(node, index, parent, entry.api, legacySlugger)
246+
);
233247

234248
// Parses the Blockquotes into Stability elements
235249
// 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)