diff --git a/docs/development/agent-workflow.md b/docs/development/agent-workflow.md index 2d40e1948..a9779cfbd 100644 --- a/docs/development/agent-workflow.md +++ b/docs/development/agent-workflow.md @@ -40,11 +40,12 @@ prefix or Markdown link for other ambiguous filenames that resemble code symbols Multi-part dotfiles are path candidates too. Link paths and fragments are decoded separately so encoded filename delimiters stay in the filename. Fenced and indented examples do not count as root guidance imports or satisfy the required Claude import. -Heading anchors decode HTML character references in text. Explicit HTML anchors +Heading anchors decode HTML character references in text and use `github-slugger` +for GitHub-compatible character filtering and duplicate suffixes. Explicit HTML anchors use `parse5`, excluding comments, scripts, styles and template contents. Rendered HTML anchor hrefs and image sources use the same local-reference checks as Markdown links, including decoded attributes and fragment validation. -The Nx test hash includes `marked` and `parse5` so dependency changes invalidate parser coverage. +The Nx test hash includes `marked`, `parse5` and `github-slugger` so dependency changes invalidate parser coverage. It cannot prove semantic equivalence; review changed contracts as well. ## Protected Markdown edits diff --git a/package.json b/package.json index 56c00254a..58811896b 100644 --- a/package.json +++ b/package.json @@ -215,6 +215,7 @@ "eslint-plugin-import": "2.32.0", "eslint-plugin-playwright": "^1.6.2", "express": "5.2.1", + "github-slugger": "2.0.0", "globals": "15.9.0", "html-escaper": "3.0.3", "istanbul-lib-coverage": "3.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 890e6fe30..f0d13254c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -404,6 +404,9 @@ importers: express: specifier: 5.2.1 version: 5.2.1 + github-slugger: + specifier: 2.0.0 + version: 2.0.0 globals: specifier: 15.9.0 version: 15.9.0 diff --git a/tools/skills/agent-guidance-markdown.mjs b/tools/skills/agent-guidance-markdown.mjs index ad715066c..a1af4b278 100644 --- a/tools/skills/agent-guidance-markdown.mjs +++ b/tools/skills/agent-guidance-markdown.mjs @@ -1,3 +1,4 @@ +import GithubSlugger from 'github-slugger'; import { Marked, Tokenizer } from 'marked'; import { parseFragment } from 'parse5'; @@ -83,18 +84,12 @@ export function guidanceProse(markdown) { } export function guidanceAnchors(markdown) { + const slugger = new GithubSlugger(); const found = new Set(); const html = []; markdownLexer.walkTokens(markdownLexer.lexer(markdown), (token) => { if (token.type === 'heading') { - const slug = inlineText(token.tokens) - .toLowerCase() - .replace(/[^\p{L}\p{M}\p{N}_\-\s]/gu, '') - .replace(/\s/gu, '-'); - let unique = slug; - let suffix = 0; - while (found.has(unique)) unique = `${slug}-${++suffix}`; - found.add(unique); + found.add(slugger.slug(inlineText(token.tokens))); } if (token.type === 'html') html.push(token.raw); }); diff --git a/tools/skills/project.json b/tools/skills/project.json index 0a614e4a7..e3d0c63a1 100644 --- a/tools/skills/project.json +++ b/tools/skills/project.json @@ -12,7 +12,7 @@ "{projectRoot}/*.mjs", "{projectRoot}/project.json", { - "externalDependencies": ["marked", "parse5"] + "externalDependencies": ["marked", "parse5", "github-slugger"] } ], "options": { diff --git a/tools/skills/validate-agent-guidance.test.mjs b/tools/skills/validate-agent-guidance.test.mjs index f730517fe..d02c7bcac 100644 --- a/tools/skills/validate-agent-guidance.test.mjs +++ b/tools/skills/validate-agent-guidance.test.mjs @@ -537,3 +537,24 @@ test('non-rendered HTML navigation is ignored', async (t) => { [] ); }); + +test('GitHub heading slugs remove non-ASCII whitespace', async (t) => { + for (const heading of ['A B', 'A\u00a0B', 'A\u2003B']) { + assert.deepEqual( + await diagnostics(t, { + 'AGENTS.md': '[Heading](docs/example.md#ab)', + 'docs/example.md': '# ' + heading, + }), + [] + ); + assert.match( + ( + await diagnostics(t, { + 'AGENTS.md': '[Heading](docs/example.md#a-b)', + 'docs/example.md': '# ' + heading, + }) + ).join('\n'), + /missing anchor/ + ); + } +});