diff --git a/build/shared/heading-slug.mts b/build/shared/heading-slug.mts new file mode 100644 index 000000000..e831162fe --- /dev/null +++ b/build/shared/heading-slug.mts @@ -0,0 +1,29 @@ +/** + * @file The anchor a heading gets on the built site. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/shared/heading-slug + */ + +// cspell:ignore fetchcontent + +/** + * Turns a heading's text into its anchor the way GitHub does: lowercased, + * punctuation other than `-` and `_` dropped, and each space a `-`. + * + * The links that point at a heading are written to that rule, by people used + * to GitHub and by TypeDoc, whose reference links to `logLevel?` as + * `#loglevel` and to `fetchContent()` as `#fetchcontent`. markdown-it-anchor + * keeps the punctuation instead and percent-encodes it, so those headings were + * `#loglevel%3F` and `#fetchcontent()`, and the links reached the top of the + * page rather than the heading. + * @param {string} text The heading's text content. + * @returns {string} The anchor, without the `#`. + */ +export function headingSlug(text: string): string { + return text + .trim() + .toLowerCase() + .replace(/[^\p{L}\p{M}\p{N}\p{Pc}\- ]/gu, '') + .replace(/ /g, '-'); +} diff --git a/build/shared/heading-slug.test.mts b/build/shared/heading-slug.test.mts new file mode 100644 index 000000000..e1ef452e3 --- /dev/null +++ b/build/shared/heading-slug.test.mts @@ -0,0 +1,42 @@ +/** + * @file Tests for the heading anchor rule. + * @author The OpenINF Authors & Friends + * @license MIT OR Apache-2.0 OR BlueOak-1.0.0 + * @module {type ES6Module} build/shared/heading-slug.test + */ + +// cspell:ignore fetchcontent + +import { strictEqual } from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { headingSlug } from '@openinf/portal/build/heading-slug'; + +describe('headingSlug', () => { + test('matches the anchors TypeDoc links to', () => { + strictEqual(headingSlug('logLevel?'), 'loglevel'); + strictEqual(headingSlug('fetchContent()'), 'fetchcontent'); + strictEqual(headingSlug('set()?'), 'set'); + strictEqual(headingSlug('submodule_git_url?'), 'submodule_git_url'); + }); + + test('drops punctuation as GitHub does, keeping hyphens', () => { + strictEqual( + headingSlug('Show the command, not the ceremony'), + 'show-the-command-not-the-ceremony' + ); + strictEqual( + headingSlug('Required items (commands, arguments, etc.)'), + 'required-items-commands-arguments-etc' + ); + strictEqual(headingSlug('Questions or thoughts?'), 'questions-or-thoughts'); + strictEqual(headingSlug('Before-and-after'), 'before-and-after'); + }); + + test('keeps letters and digits from any script', () => { + strictEqual(headingSlug('Café naïve 2026'), 'café-naïve-2026'); + }); + + test('turns every space into a hyphen', () => { + strictEqual(headingSlug(' a b '), 'a--b'); + }); +}); diff --git a/eleventy.config.mjs b/eleventy.config.mjs index 2fb266bff..57da3b621 100644 --- a/eleventy.config.mjs +++ b/eleventy.config.mjs @@ -3,6 +3,7 @@ import { readdir, readFile, unlink, writeFile } from 'node:fs/promises'; import { extname, join, parse as pathParse } from 'node:path'; import { EleventyI18nPlugin } from '@11ty/eleventy'; import { PATHS } from '@openinf/portal/build/constants'; +import { headingSlug } from '@openinf/portal/build/heading-slug'; import { hasViewBox, replaceInlineSvg } from '@openinf/portal/build/inline-svg'; import { sanitizeSdkHtml } from '@openinf/portal/build/sdk-docs'; import autoprefixer from 'autoprefixer'; @@ -76,7 +77,7 @@ export default async function (eleventyConfig) { eleventyConfig.amendLibrary('md', (md) => { md.set({ highlight }); - md.use(markdownItAnchor); + md.use(markdownItAnchor, { slugify: headingSlug }); md.use(markdownItFootnote); // `> [!NOTE]` and the rest become a titled callout rather than a // blockquote opening on the literal marker. The icons it ships with come diff --git a/package.json b/package.json index 378da8f47..1dbaff8db 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "exports": { "./build/commit-message": "./build/shared/commit-message.mts", "./build/constants": "./build/shared/constants.mts", + "./build/heading-slug": "./build/shared/heading-slug.mts", "./build/inline-svg": "./build/shared/inline-svg.mts", "./build/landing": "./build/shared/landing.mts", "./build/pull-request": "./build/shared/pull-request.mts",