fix(doc-gates): review findings — GitHub-slugger parity and the surviving old-rule prose

ds-review-bot round: slugs now come from RENDERED heading text (links,
inline code, emphasis) with underscores kept and GitHub's occupied-set
repeat suffixes; explicit <a id> anchors register only from real HTML flow
(fences, inline code, and comments no longer produce phantoms); fragment
matching is exact-case since element ids are. anchorCache is exported and
the spec reuses it. The contradicting 'gate checks file existence, not
#anchor validity' sentence in docs/AGENTS.md is gone; the dsh-doc-standards
residual caveat names the real TS-string anchor homes; the 2026-06-18
cross-link note is updated to shipped behavior and cross-linked with the
fragment-gate note.
This commit is contained in:
Tianyi Cui
2026-08-09 10:39:11 +08:00
parent 10ef3d4924
commit dad5963484
10 changed files with 97 additions and 60 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.md
2026-06-18-markdown-cross-link-lint.md: b8b1337e9d758da6a4cc0bb46a6b37906357f877
2026-06-18-markdown-cross-link-lint.zh.md: 9b627ebb17a0567424ca0caaeac8edd9b36917f2
2026-06-18-markdown-cross-link-lint.md: 21c6884d3fd891794a11125a9aa51ac2bcb29059
2026-06-18-markdown-cross-link-lint.zh.md: 444cf6eb97d95049d4e6b7bb5f44138051f6577a

View File

@@ -20,11 +20,11 @@ A fourth `doc-sync` gate, `verify-md-links` (`scripts/verify-md-links.ts`), mirr
Scope matches the other gates plus the AGENTS.md pair and the repo-authored agent-skill Markdown under `.agents/skills/` (those skill files cross-link into the docs tree, so this reorg rewrote links in them too): `README.md`, `docs/**/*.md`, `packages/*/README.md`, `AGENTS.md`, `packages/AGENTS.md`, `.agents/skills/**/*.md`, deduped by real path (the `CLAUDE.md` symlinks resolve onto the AGENTS.md files). It is wired into `doc-sync`, so relevant documentation changes and CI exercise the same broken-link check.
This gate checks *existence*, not anchor validity: a link to a real file with a `#wrong-heading` fragment still passes (the file resolves; the fragment is stripped).
The gate now also checks `#fragment` anchors on Markdown targets — same-file anchors included — against heading slugs and explicit `<a id>`; the [fragment-anchor decision](2026-08-09-md-fragment-anchor-gate.md) owns that mechanism and the slug rules.
## Alternatives considered
**Anchor-level validity checking** — heavier and lower-value; file-level dead links are the failure that actually bit. The scope cut is deliberate: authors verify `#fragment` anchors themselves when linking to one.
**Anchor-level validity checking** deferred here as heavier and lower-value (file-level dead links were the failure that had actually bit), leaving authors to verify `#fragment` anchors themselves. That manual rule did not hold; the [fragment-anchor decision](2026-08-09-md-fragment-anchor-gate.md) later added the check.
## Consequences

View File

@@ -20,11 +20,11 @@ Status: implemented
检查范围与其他门禁一致,并额外包含 AGENTS.md 文件对以及 `.agents/skills/` 下仓库自有的 agent skill技能Markdown这些 skill 文件会交叉链接到 docs 目录树,因此本次重组也改写了其中的链接):`README.md``docs/**/*.md``packages/*/README.md``AGENTS.md``packages/AGENTS.md``.agents/skills/**/*.md`。系统按真实路径去重(`CLAUDE.md` symlink 会解析到 AGENTS.md 文件)。该检查接入 `doc-sync`,因此相关文档变更与 CI 执行同一套断链检查。
本门禁检查的是*文件存在性*,而非锚点有效性:指向一个真实文件但带有 `#wrong-heading` 片段的链接仍会通过(文件路径可解析;片段被剥除)
本门禁现在也检查 Markdown 目标上的 `#fragment` 锚点——包括同文件锚点——对照标题 slug 与显式 `<a id>`;该机制与 slug 规则归 [fragment 锚点决定](2026-08-09-md-fragment-anchor-gate.md)所有
## 曾考虑的替代方案
**锚点级有效性检查**:更重且价值更低;实际造成问题的是文件级死链。这一范围裁剪是有意为之:作者在链接到某个锚点时自行验证 `#fragment`
**锚点级有效性检查**当时以更重且价值更低为由推迟(实际咬过人的是文件级死链),把 `#fragment` 验证留给作者人工完成。该人工规则没有守住;[fragment 锚点决定](2026-08-09-md-fragment-anchor-gate.md)后来补上了这项检查
## 后果

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.md
2026-08-09-md-fragment-anchor-gate.md: 190ecb0ed07152761377961ca6221c25908082b0
2026-08-09-md-fragment-anchor-gate.zh.md: ab08609d8b1410ccf0f36a6ced68729ac20c7658
2026-08-09-md-fragment-anchor-gate.md: e02a917bedd9649a2326e3fb1f53072ac05c88a8
2026-08-09-md-fragment-anchor-gate.zh.md: dcc0644ae064c161ef5232654993f0e6c872064c

View File

@@ -10,23 +10,23 @@ English | [中文](2026-08-09-md-fragment-anchor-gate.zh.md)
## Decision
`verify-md-links` now resolves fragments too. For every relative link whose target is a Markdown file — same-file `#anchor` links included, which the old checker skipped entirely — the fragment must name a real anchor in the target: a heading's GitHub slug (repeated headings get the renderer's `-1`, `-2`, … suffixes) or an explicit `<a id>`. Fragments onto non-Markdown targets (`file.ts#L10`) carry renderer-owned semantics and stay out of scope, as do external and root-absolute URLs. Anchor sets are collected lazily for any existing target, so links INTO archived notes and vendor documents are validated without making those files sources.
`verify-md-links` now resolves fragments too (superseding the deferred scope cut in the [cross-link decision](2026-06-18-markdown-cross-link-lint.md)). For every relative link whose target is a Markdown file — same-file `#anchor` links included, which the old checker skipped entirely — the fragment must name a real anchor in the target: a heading's GitHub slug or an explicit `<a id>` in real HTML flow (code samples and commented-out anchors register nothing). Slugs are computed from the RENDERED heading text via the repository's own `markdownHeadingLines`, so links, inline code, and emphasis inside a heading slug as GitHub renders them; underscores survive (`#showcase-web_fetch`); repeated slugs get GitHub's occupied-set `-1`, `-2`, … suffixes; matching is exact-case, since element ids are case-sensitive. Fragments onto non-Markdown targets (`file.ts#L10`) carry renderer-owned semantics and stay out of scope, as do external and root-absolute URLs. Anchor sets are collected lazily for any existing target (`anchorCache`), so links INTO archived notes and vendor documents are validated without making those files sources.
The slug function is GitHub's algorithm, duplicated from `gen-cordis-catalog`'s region-anchor renderer rather than shared across the script/package boundary; the corpus passing this gate is what keeps the two in sync. Chinese pair sides follow the existing repository convention (`docs/glossary.zh.md`, `docs/cordis-primer.zh.md`): keep the English fragment in the link and place an explicit `<a id>` before the Chinese heading, so both language sides expose identical anchors.
The slug function differs from `gen-cordis-catalog`'s region-anchor slugger (which drops underscores): the generator's headings are always reachable through its explicit `<a id>` anchors, so the two need not share one rule. Chinese pair sides follow the existing repository convention (`docs/glossary.zh.md`, `docs/cordis-primer.zh.md`): keep the English fragment in the link and place an explicit `<a id>` before the Chinese heading, so both language sides expose identical anchors.
The 15 broken fragments are fixed in the same change: stale slugs retargeted to the current headings, the relocated no-timeout contract now linked at its owning group README, and four zh documents given explicit anchors. `docs/AGENTS.md` and the `dsh-doc-standards` skill no longer prescribe the manual anchor grep for Markdown links; anchors cited from TypeScript strings still need one.
The 15 broken fragments are fixed in the same change: stale slugs retargeted to the current headings, the relocated no-timeout contract now linked at its owning group README, and four zh documents given explicit anchors. `docs/AGENTS.md` and the `dsh-doc-standards` skill no longer prescribe the manual anchor grep for Markdown links; it survives only for anchors cited from TypeScript strings whose output never reaches gate-scanned Markdown (today's three all render into scanned pages, so the gate covers them through the committed output).
## Verification
`scripts/verify-md-links.spec.ts` proves the acceptance paths: slugging (backticks, punctuation, repeat suffixes, explicit `<a id>`), a resolving mixed-link document, a dead same-file fragment, a dead cross-file fragment, and a missing target still reported as `target` rather than `anchor`. The gate runs over the full corpus in doc-sync (`verify-md-links`, 1613 files) and passes only after the 15 fixes — the corpus itself is the red-to-green evidence for each decay mode.
`scripts/verify-md-links.spec.ts` proves the acceptance paths: rendered-text slugging (backticks, punctuation, a linked heading, kept underscores), occupied-set repeat suffixes, `<a id>` ignored inside fences/inline code/comments, a resolving mixed-link document, dead same-file and cross-file fragments, a case-variant fragment, and a missing target still reported as `target` rather than `anchor`. The gate runs over the full corpus in doc-sync (`verify-md-links`) and passes only after the 15 fixes — the corpus itself is the red-to-green evidence for each decay mode.
## Alternatives considered
- **Keep the manual-grep rule.** It demonstrably did not hold: the 15 fragments decayed under a gate-driven maintenance culture, because heading rewrites happen in PRs that never look at inbound links. A mechanical invariant belongs in an executed gate.
- **Point zh links at Chinese-slug anchors.** GitHub slugs CJK headings fine, but the corpus convention is already explicit `<a id>` + English fragments (glossary, primer), which also survives renderers that strip non-ASCII; adopting a second convention would split the corpus.
- **Share `githubSlug` with the typert generator.** A one-function import would couple a doc gate to a package build; the algorithm is three lines and the corpus gate itself detects divergence (a generator anchor the gate cannot resolve fails the build the moment a page links it).
- **Share `githubSlug` with the typert generator.** A one-function import would couple a doc gate to a package build, and the two rules genuinely differ (the generator strips underscores; its anchors are explicit `<a id>`s the gate reads directly), so divergence is by design, not drift.
- **Validate VitePress slugs as well.** The published site's dead-link check already runs in `website:build`; generated regions carry explicit anchors precisely so the two renderers agree, and hand headings that diverge would fail there.
## Consequences
Renaming a heading now breaks the build wherever a Markdown link cites its anchor, instead of stranding readers; authors fix the inbound links in the same change, exactly as they already must for file renames. Same-file anchors are no longer a blind spot, so zh pages must anchor any English fragment they use. The manual pre-rename grep survives only for anchors cited from TypeScript string literals.
Renaming a heading now breaks the build wherever a Markdown link cites its anchor, instead of stranding readers; authors fix the inbound links in the same change, exactly as they already must for file renames. Same-file anchors are no longer a blind spot, so zh pages must anchor any English fragment they use. The manual pre-rename grep survives only for anchors cited from TypeScript strings whose output never reaches gate-scanned Markdown.

View File

@@ -10,23 +10,23 @@ Status: implemented
## Decision
`verify-md-links` 现在也解析 fragment。对每条目标为 Markdown 文件的相对链接——包括旧检查器完全跳过的同文件 `#anchor` 链接——fragment 必须命名目标中的真实锚点:标题的 GitHub slug(重复标题获得渲染器的 `-1``-2`……后缀)或显式 `<a id>`。指向非 Markdown 目标的 fragment`file.ts#L10`)语义归渲染器所有,不在范围内;外部与根绝对 URL 同样不检查。锚点集合对任意存在的目标惰性收集,因此链入归档 note 与 vendor 文档的链接照常校验,而这些文件不会因此成为扫描源。
`verify-md-links` 现在也解析 fragment(取代[跨链接决定](2026-06-18-markdown-cross-link-lint.md)中被推迟的范围裁剪)。对每条目标为 Markdown 文件的相对链接——包括旧检查器完全跳过的同文件 `#anchor` 链接——fragment 必须命名目标中的真实锚点:标题的 GitHub slug,或真实 HTML 流中的显式 `<a id>`代码示例与注释掉的锚点不注册任何东西。slug 由仓库自有的 `markdownHeadingLines` 从**渲染后**的标题文本计算,因此标题内的链接、行内代码与强调都按 GitHub 的渲染结果 slug下划线保留`#showcase-web_fetch`);重复 slug 获得 GitHub 的占用集 `-1``-2`……后缀;匹配区分大小写,因为元素 id 本就区分大小写。指向非 Markdown 目标的 fragment`file.ts#L10`)语义归渲染器所有,不在范围内;外部与根绝对 URL 同样不检查。锚点集合对任意存在的目标惰性收集`anchorCache`,因此链入归档 note 与 vendor 文档的链接照常校验,而这些文件不会因此成为扫描源。
slug 函数是 GitHub 的算法,从 `gen-cordis-catalog` 的区块锚点渲染器复制而来,而非跨脚本/包边界共享;语料通过本 gate 即是两者保持同步的机制。中文侧沿用既有语料惯例(`docs/glossary.zh.md``docs/cordis-primer.zh.md`):链接保留英文 fragment在中文标题前放置显式 `<a id>`,使两个语言侧暴露相同的锚点。
slug 函数 `gen-cordis-catalog` 的区块锚点 slugger 不同(后者丢弃下划线):生成器的标题总能通过其显式 `<a id>` 锚点到达,两者无需共享一条规则。中文侧沿用既有语料惯例(`docs/glossary.zh.md``docs/cordis-primer.zh.md`):链接保留英文 fragment在中文标题前放置显式 `<a id>`,使两个语言侧暴露相同的锚点。
15 条坏 fragment 在同一变更中修复:陈旧 slug 重定向到当前标题,搬迁的无超时契约改链其属主 group README四份中文文档补上显式锚点。`docs/AGENTS.md``dsh-doc-standards` skill 不再为 Markdown 链接开人工 grep 锚点的处方;从 TypeScript 字符串引用的锚点仍需人工 grep
15 条坏 fragment 在同一变更中修复:陈旧 slug 重定向到当前标题,搬迁的无超时契约改链其属主 group README四份中文文档补上显式锚点。`docs/AGENTS.md``dsh-doc-standards` skill 不再为 Markdown 链接开人工 grep 锚点的处方;人工 grep 只对输出从不进入受检 Markdown 的 TypeScript 字符串锚点保留当下三处全部渲染进受检页面gate 经由提交的产物覆盖它们)
## Verification
`scripts/verify-md-links.spec.ts` 证明各验收路径slug 化(反引号、标点、重复后缀、显式 `<a id>`、全部可解析的混合链接文档、死的同文件 fragment、死的跨文件 fragment、以及缺失目标仍报 `target` 而非 `anchor`。gate 在 doc-sync 中跑完整语料(`verify-md-links`1613 个文件),且只有在 15 条修复之后才通过——语料本身就是每种衰变模式由红转绿的证据。
`scripts/verify-md-links.spec.ts` 证明各验收路径:渲染文本 slug 化(反引号、标点、含链接标题、保留下划线)、占用集重复后缀、围栏/行内代码/注释中的 `<a id>` 不注册、全部可解析的混合链接文档、死的同文件与跨文件 fragment、大小写变体 fragment、以及缺失目标仍报 `target` 而非 `anchor`。gate 在 doc-sync 中跑完整语料(`verify-md-links`),且只有在 15 条修复之后才通过——语料本身就是每种衰变模式由红转绿的证据。
## Alternatives considered
- **保留人工 grep 规则。** 它被证明守不住15 条 fragment 在 gate 驱动的维护文化下仍然衰变,因为改写标题的 PR 从不会去看入链。可机械检查的不变式应进入被执行的 gate。
- **让中文链接指向中文 slug 锚点。** GitHub 对 CJK 标题的 slug 没问题,但语料惯例已是显式 `<a id>` + 英文 fragmentglossary、primer且它在剥离非 ASCII 的渲染器下也存活;引入第二种惯例会割裂语料。
- **与 typert 生成器共享 `githubSlug`。** 为一个函数引入包构建耦合不值得;算法只有三行,语料 gate 本身能探测分歧(生成器产出而 gate 无法解析的锚点,在任何页面链接它的那一刻即失败)
- **与 typert 生成器共享 `githubSlug`。** 为一个函数引入包构建耦合不值得,且两条规则确实不同(生成器剥离下划线;其锚点是 gate 直接读取的显式 `<a id>`),分歧是设计使然而非漂移
- **同时校验 VitePress slug。** 发布站点的死链检查已在 `website:build` 中运行;生成区块正是为两种渲染器一致而携带显式锚点,手写标题若有分歧会在那里失败。
## Consequences
重命名标题现在会在任何 Markdown 链接引用其锚点处使构建失败,而非把读者丢在页顶;作者须在同一变更中修复入链,与文件重命名的既有义务完全一致。同文件锚点不再是盲区,中文页面使用英文 fragment 时必须补锚点。人工的重命名前 grep 只对 TypeScript 字符串字面量中的锚点保留。
重命名标题现在会在任何 Markdown 链接引用其锚点处使构建失败,而非把读者丢在页顶;作者须在同一变更中修复入链,与文件重命名的既有义务完全一致。同文件锚点不再是盲区,中文页面使用英文 fragment 时必须补锚点。人工的重命名前 grep 只对输出从不进入受检 Markdown 的 TypeScript 字符串锚点保留。

View File

@@ -29,7 +29,7 @@ Then check constraints that make placement expensive or wrong:
- Paired docs (`pnpm run verify-translation-pairing --list`) cost a zh counterpart update and a `--write` re-record on every edit — prefer an unpaired home for content that will churn.
- Generated catalogs are never hand-edited; if the fact belongs there, change the generator's source.
- Before renaming or moving any doc, grep for inbound references: `verify-md-links` catches Markdown link targets AND `#fragment` anchors onto Markdown files (heading slugs and explicit `<a id>`), and `verify-doc-refs` catches `docs/*.md` citations in TypeScript comments; anchors cited from TypeScript strings (one lives in `scripts/gen-cordis-catalog.ts`) still need a manual grep.
- Before renaming or moving any doc, grep for inbound references: `verify-md-links` catches Markdown link targets AND `#fragment` anchors onto Markdown files (heading slugs and explicit `<a id>`), and `verify-doc-refs` catches `docs/*.md` citations in TypeScript comments; anchors cited from TypeScript strings still need a manual grep when their output never reaches gate-scanned Markdown (today's three — `scripts/gen-doc-graphs.ts`, `scripts/gen-persistence-catalog.ts`, `packages/typert/generator/src/cordis-catalog.ts` — all render into scanned pages, so the gate catches them via the committed output).
- A move is atomic: remove from the old home, add to the new home, and fix every inbound link in the same change.
## Audit the corpus

View File

@@ -73,5 +73,3 @@ Hunt these in any doc; the [dsh-doc-standards](../.agents/skills/dsh-doc-standar
## Cross-reference with machine-checkable links, never free prose
Link repository references with relative Markdown paths, never bare filenames or Agent Note numbers. `verify-md-links` rejects missing targets and dead `#fragment` anchors ([rationale](../.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.md)).
The gate checks file existence, not `#anchor` validity — verify anchors yourself when linking to one.

View File

@@ -5,11 +5,11 @@
* external targets stay out of scope.
*/
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { documentAnchors, findViolations, githubSlug } from './verify-md-links.ts'
import { anchorCache, documentAnchors, findViolations, githubSlug } from './verify-md-links.ts'
const roots: string[] = []
afterEach(() => {
@@ -27,19 +27,11 @@ function layout(files: Record<string, string>): string {
}
function violationsIn(root: string, rel: string): { url: string; reason: string }[] {
const cache = new Map<string, Set<string>>()
const anchorsOf = (abs: string): Set<string> => {
const hit = cache.get(abs)
if (hit) return hit
const anchors = documentAnchors(readFileSync(abs, 'utf8'))
cache.set(abs, anchors)
return anchors
}
return findViolations(join(root, rel), anchorsOf, root).map(({ url, reason }) => ({ url, reason }))
return findViolations(join(root, rel), anchorCache(), root).map(({ url, reason }) => ({ url, reason }))
}
describe('documentAnchors', () => {
it('slugs headings, suffixes repeats, and reads explicit <a id> anchors', () => {
it('slugs rendered heading text, suffixes repeats, and reads explicit <a id> anchors', () => {
const anchors = documentAnchors([
'# My Doc',
'## Live `events` — mode!',
@@ -51,6 +43,34 @@ describe('documentAnchors', () => {
expect(anchors).toEqual(new Set(['my-doc', 'live-events--mode', 'repeat', 'repeat-1', 'hand-anchor']))
expect(githubSlug('Security and authority are non-goals')).toBe('security-and-authority-are-non-goals')
})
it('keeps underscores the way GitHub does', () => {
expect(githubSlug('Showcase: web_fetch')).toBe('showcase-web_fetch')
expect(documentAnchors('## Showcase: web_fetch\n')).toEqual(new Set(['showcase-web_fetch']))
})
it('slugs a heading containing a link from its rendered text', () => {
expect(documentAnchors('## [Install](setup.md)\n')).toEqual(new Set(['install']))
})
it('bumps repeat suffixes past occupied slugs, matching GitHub', () => {
const anchors = documentAnchors(['## Repeat', '## Repeat-1', '## Repeat', ''].join('\n'))
expect(anchors).toEqual(new Set(['repeat', 'repeat-1', 'repeat-2']))
})
it('ignores <a id> inside code fences, inline code, and HTML comments', () => {
const anchors = documentAnchors([
'# Doc',
'```md',
'<a id="fenced"></a>',
'```',
'Inline `<a id="inline"></a>` sample.',
'<!-- <a id="commented"></a> -->',
'<a id="real"></a>',
'',
].join('\n'))
expect(anchors).toEqual(new Set(['doc', 'real']))
})
})
describe('findViolations fragments', () => {
@@ -68,6 +88,11 @@ describe('findViolations fragments', () => {
expect(violationsIn(root, 'a.md')).toEqual([{ url: '#deferred-work', reason: 'anchor' }])
})
it('rejects a case-variant fragment: element ids are case-sensitive', () => {
const root = layout({ 'a.md': '# A\n\n## Default Loop\n\n[case](#Default-Loop)\n' })
expect(violationsIn(root, 'a.md')).toEqual([{ url: '#Default-Loop', reason: 'anchor' }])
})
it('rejects a cross-file fragment missing from the target document', () => {
const root = layout({
'a.md': '# A\n\n[stale](b.md#old-heading)\n',

View File

@@ -10,7 +10,7 @@
import { existsSync, readFileSync } from 'node:fs'
import { dirname, relative, resolve } from 'node:path'
import type { Nodes } from 'mdast'
import { parseMarkdown, visitMarkdown } from './markdown.ts'
import { markdownHeadingLines, parseMarkdown, visitMarkdown } from './markdown.ts'
import { isArchivedAgentNotePath, uniqueRepoFiles } from './repo-files.ts'
const root = resolve(import.meta.dirname, '..')
@@ -88,45 +88,59 @@ function fragmentPart(url: string): string | null {
/**
* GitHub's heading-slug algorithm (lowercase; drop everything but letters,
* numbers, spaces, hyphens; spaces become hyphens) — the same rule
* `gen-cordis-catalog`'s region anchors are built from, kept in sync by the
* corpus passing this gate rather than by a shared import across the
* script/package boundary.
* @param heading - the rendered heading text.
* numbers, underscores, spaces, hyphens; spaces become hyphens). Underscores
* survive (`## Showcase: web_fetch` → `#showcase-web_fetch`), unlike
* `gen-cordis-catalog`'s region-anchor slugs — the generator's headings are
* always reachable through its explicit `<a id>` anchors, so the two need not
* share one rule.
* @param heading - the RENDERED heading text (Markdown syntax already gone).
* @returns the anchor GitHub assigns the first occurrence of the heading.
*/
export function githubSlug(heading: string): string {
return heading.toLowerCase().replace(/[^\p{L}\p{N} -]/gu, '').replaceAll(' ', '-')
return heading.toLowerCase().replace(/[^\p{L}\p{N}_ -]/gu, '').replaceAll(' ', '-')
}
/**
* Every anchor one Markdown document exposes: each heading's GitHub slug
* (repeated headings get the renderer's `-1`, `-2`, … suffixes) plus every
* explicit `<a id="…">`. Lowercased for case-insensitive fragment matching.
* Every anchor one Markdown document exposes: each heading's GitHub slug
* computed from the RENDERED heading text, so links, images, inline code, and
* emphasis inside a heading slug the way GitHub renders them — plus every
* explicit `<a id="…">` that appears in real HTML flow (a fenced or inline
* code sample and a commented-out anchor register nothing). Repeated slugs
* get GitHub's occupied-set `-1`, `-2`, … suffixes: each collision bumps the
* ORIGINAL slug's counter until a free name is found, so `Repeat`, `Repeat-1`,
* `Repeat` yields `repeat`, `repeat-1`, `repeat-2`. Matching is exact —
* element ids are case-sensitive.
* @param source - the document's full Markdown text.
* @returns the set of valid fragments for links into this document.
*/
export function documentAnchors(source: string): Set<string> {
const anchors = new Set<string>()
const seen = new Map<string, number>()
const tree = parseMarkdown(source)
visitMarkdown(tree, (node: Nodes): void => {
if (node.type === 'heading') {
const text = source.slice(node.position?.start.offset ?? 0, node.position?.end.offset ?? 0)
.replace(/^#{1,6}\s+/, '')
.replace(/[`*_]/g, '')
const base = githubSlug(text)
const bump = seen.get(base) ?? 0
seen.set(base, bump + 1)
anchors.add(bump === 0 ? base : `${base}-${bump}`)
const occurrences = new Map<string, number>()
for (const heading of markdownHeadingLines(source)) {
const base = githubSlug(heading.text)
let result = base
let bump = occurrences.get(base) ?? 0
while (anchors.has(result)) {
bump += 1
result = `${base}-${bump}`
}
occurrences.set(base, bump)
anchors.add(result)
}
visitMarkdown(parseMarkdown(source), (node: Nodes): void => {
if (node.type !== 'html') return
const html = node.value.replace(/<!--[\s\S]*?-->/g, '')
for (const match of html.matchAll(/<a id="([^"]+)"/g)) anchors.add(match[1] ?? '')
})
for (const match of source.matchAll(/<a id="([^"]+)"/g)) anchors.add((match[1] ?? '').toLowerCase())
return anchors
}
/** Lazily collect and cache the anchor set of any existing Markdown file. */
function anchorCache(): (absPath: string) => Set<string> {
/**
* Lazily collect and cache the anchor set of any existing Markdown file —
* shared across all scanned sources so a target parses once.
* @returns the memoized absolute-path → anchor-set lookup.
*/
export function anchorCache(): (absPath: string) => Set<string> {
const cache = new Map<string, Set<string>>()
return (absPath) => {
const hit = cache.get(absPath)
@@ -169,7 +183,7 @@ export function findViolations(
}
const fragment = fragmentPart(url)
if (fragment === null || !resolved.endsWith('.md')) return
if (!anchorsOf(resolved).has(fragment.toLowerCase())) {
if (!anchorsOf(resolved).has(fragment)) {
out.push({ file, line: node.position?.start.line ?? 0, url, reason: 'anchor' })
}
}