Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions src/generators/legacy-html/utils/__tests__/slugger.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
'use strict';

import assert from 'node:assert/strict';
import { describe, it } from 'node:test';

import { createLegacySlugger } from '../slugger.mjs';

describe('createLegacySlugger', () => {
it('prefixes with api stem and uses underscores', () => {
const slugger = createLegacySlugger();
assert.strictEqual(
slugger.getLegacySlug('File System', 'fs'),
'fs_file_system'
);
});

it('replaces special characters with underscores', () => {
const slugger = createLegacySlugger();
assert.strictEqual(
slugger.getLegacySlug('fs.readFile(path)', 'fs'),
'fs_fs_readfile_path'
);
});

it('strips leading and trailing underscores', () => {
const slugger = createLegacySlugger();
assert.strictEqual(slugger.getLegacySlug('Hello', 'fs'), 'fs_hello');
});

it('prefixes with underscore when result starts with non-alpha', () => {
const slugger = createLegacySlugger();
assert.strictEqual(
slugger.getLegacySlug('123 test', '0num'),
'_0num_123_test'
);
});

it('deduplicates with a counter for identical titles', () => {
const slugger = createLegacySlugger();
assert.strictEqual(slugger.getLegacySlug('Hello', 'fs'), 'fs_hello');
assert.strictEqual(slugger.getLegacySlug('Hello', 'fs'), 'fs_hello_1');
assert.strictEqual(slugger.getLegacySlug('Hello', 'fs'), 'fs_hello_2');
assert.strictEqual(slugger.getLegacySlug('World', 'fs'), 'fs_world');
});
});
15 changes: 13 additions & 2 deletions src/generators/legacy-html/utils/buildContent.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { u as createTree } from 'unist-builder';
import { SKIP, visit } from 'unist-util-visit';

import buildExtraContent from './buildExtraContent.mjs';
import { createLegacySlugger } from './slugger.mjs';
import getConfig from '../../../utils/configuration/index.mjs';
import {
GITHUB_BLOB_URL,
Expand All @@ -20,12 +21,14 @@ import { QUERIES, UNIST } from '../../../utils/queries/index.mjs';
* @param {import('unist').Parent} parent The parent node of the current node
* @returns {import('hast').Element} The HTML AST tree of the heading content
*/
const buildHeading = ({ data, children, depth }, index, parent) => {
const buildHeading = ({ data, children, depth }, index, parent, legacySlug) => {
// Creates the heading element with the heading text and the link to the heading
const headingElement = createElement(`h${depth + 1}`, [
// The inner Heading markdown content is still using Remark nodes, and they need
// to be converted into Rehype nodes
...children,
// Legacy anchor alias to preserve old external links
createElement('span', createElement(`a#${legacySlug}`)),
// Creates the element that references the link to the heading
// (The `#` anchor on the right of each Heading section)
createElement(
Expand Down Expand Up @@ -220,6 +223,8 @@ const buildMetadataElement = (node, remark) => {
* @param {import('unified').Processor} remark The Remark instance to be used to process
*/
export default (headNodes, metadataEntries, remark) => {
const legacySlugger = createLegacySlugger();

// Creates the root node for the content
const parsedNodes = createTree(
'root',
Expand All @@ -229,7 +234,13 @@ export default (headNodes, metadataEntries, remark) => {
const content = structuredClone(entry.content);

// Parses the Heading nodes into Heading elements
visit(content, UNIST.isHeading, buildHeading);
visit(content, UNIST.isHeading, (node, index, parent) => {
const legacySlug = legacySlugger.getLegacySlug(
node.data.text,
entry.api
);
buildHeading(node, index, parent, legacySlug);
});

// Parses the Blockquotes into Stability elements
// This is treated differently as we want to preserve the position of a Stability Index
Expand Down
48 changes: 48 additions & 0 deletions src/generators/legacy-html/utils/slugger.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
'use strict';

const notAlphaNumerics = /[^a-z0-9]+/g;
const edgeUnderscores = /^_+|_+$/g;
const notAlphaStart = /^[^a-z]/;

/**
* Deduplicates legacy slugs by appending an incremented counter.
* Adapted from maintainer suggestion to preserve `id` on first occurrence.
*
* @param {Record<string, number>} counters
* @returns {(id: string) => string}
*/
export const legacyDeduplicator =
(counters = { __proto__: null }) =>
id => {
counters[id] ??= -1;
const count = ++counters[id];
return count > 0 ? `${id}_${count}` : id;
};

/**
* Creates a stateful slugger for legacy anchor links.
*
* @returns {{ getLegacySlug: (text: string, apiStem: string) => string }}
*/
export const createLegacySlugger = () => {
const deduplicate = legacyDeduplicator();

return {
/**
* Generates a legacy-style slug to preserve old anchor links.
*
* @param {string} text The heading text
* @param {string} apiStem The API file identifier (e.g. 'fs', 'http')
* @returns {string} The legacy slug
*/
getLegacySlug: (text, apiStem) => {
const id = `${apiStem}_${text}`
.toLowerCase()
.replace(notAlphaNumerics, '_')
.replace(edgeUnderscores, '')
.replace(notAlphaStart, '_$&');

return deduplicate(id);
},
};
};
Loading