From 6c11061ab20c04bf81b74142e24acd2173d59469 Mon Sep 17 00:00:00 2001 From: Derek Lewis Date: Thu, 8 Oct 2026 22:23:58 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=F0=9F=94=A7=EF=BC=9Agive?= =?UTF-8?q?=20headings=20the=20anchors=20links=20point=20to?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit markdown-it-anchor keeps a heading's punctuation and percent-encodes it, so "logLevel?" became #loglevel%3F and "fetchContent()" became #fetchcontent(). TypeDoc links to those as #loglevel and #fetchcontent, which is GitHub's rule as well, so once the SDK reference is imported three of its links reach the top of the page instead of the heading, and every optional property or method heading has an anchor nothing can guess. The handbook had three such headings too. Headings now take GitHub's anchor: lowercased, with punctuation other than hyphens and underscores dropped and spaces turned into hyphens. A build of the site with the 3.0.0 reference imported has no link left pointing at a missing anchor. Signed-off-by: Derek Lewis Assisted-by: Claude-Code:claude-opus-5 --- build/shared/heading-slug.mts | 29 +++++++++++++++++++++ build/shared/heading-slug.test.mts | 42 ++++++++++++++++++++++++++++++ eleventy.config.mjs | 3 ++- package.json | 1 + 4 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 build/shared/heading-slug.mts create mode 100644 build/shared/heading-slug.test.mts 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",