@open-agent-toolkit/cli 0.3.33 → 0.3.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/README.md +1 -1
  2. package/assets/bundle-metadata.json +1 -1
  3. package/assets/docs/contributing/documentation.md +1 -0
  4. package/assets/docs/docs-tooling/check.md +7 -7
  5. package/assets/docs/reference/docs-index-contract.md +1 -0
  6. package/assets/public-package-versions.json +4 -4
  7. package/assets/skills/oat-docs-analyze/SKILL.md +1 -1
  8. package/assets/skills/oat-docs-analyze/references/quality-checklist.md +3 -1
  9. package/assets/skills/oat-docs-authoring/SKILL.md +1 -1
  10. package/assets/skills/oat-docs-authoring/references/oat-fumadocs-contract.md +6 -1
  11. package/dist/commands/docs/check/checker.d.ts.map +1 -1
  12. package/dist/commands/docs/check/checker.js +4 -1
  13. package/dist/commands/docs/check/mermaid-runtime.d.ts +1 -1
  14. package/dist/commands/docs/check/mermaid-runtime.d.ts.map +1 -1
  15. package/dist/commands/docs/check/mermaid-runtime.js +24 -49
  16. package/dist/commands/docs/check/navigation.d.ts +6 -2
  17. package/dist/commands/docs/check/navigation.d.ts.map +1 -1
  18. package/dist/commands/docs/check/navigation.js +21 -1
  19. package/dist/commands/docs/check/reader-rules.d.ts.map +1 -1
  20. package/dist/commands/docs/check/reader-rules.js +12 -9
  21. package/dist/commands/docs/nav/contents.d.ts +50 -2
  22. package/dist/commands/docs/nav/contents.d.ts.map +1 -1
  23. package/dist/commands/docs/nav/contents.js +137 -14
  24. package/dist/commands/docs/nav/fumadocs.d.ts.map +1 -1
  25. package/dist/commands/docs/nav/fumadocs.js +8 -36
  26. package/dist/commands/docs/nav/membership.d.ts.map +1 -1
  27. package/dist/commands/docs/nav/membership.js +1 -2
  28. package/package.json +4 -6
package/README.md CHANGED
@@ -49,7 +49,7 @@ For an approved fresh reader model, pass `--ia-preset <preset> --planned-pages <
49
49
 
50
50
  Fumadocs static exports include `llms.txt`, `llms-full.txt` and `llms/<page-slugs>/content.md` (root `llms/content.md`) for loaded `.md` readers. These retain authored Markdown and source-relative-link guidance; configured non-readers and MDX are excluded. Use authored Contents maps and `oat docs nav sync`, followed by `--check`, for navigation changes.
51
51
 
52
- `oat docs check [--target-dir <dir>] [--rule <id>...] [--json]` performs read-only reader, navigation, contributor-command and Mermaid/SVG checks without a site build. Errors exit 1; warnings exit 0. Configure validated severities, numeric thresholds and reason-bearing exceptions under `documentation.check`. Mermaid syntax parsing lazily uses a real DOM with strict sanitization and no script/resource execution; Node 22.17 remains the minimum. See [checker rules and configuration](../../apps/oat-docs/docs/docs-tooling/check.md).
52
+ `oat docs check [--target-dir <dir>] [--rule <id>...] [--json]` performs read-only reader, navigation, contributor-command and Mermaid/SVG checks without a site build. Errors exit 1; warnings exit 0. Configure validated severities, numeric thresholds and reason-bearing exceptions under `documentation.check`. Mermaid syntax parsing lazily loads the pinned Pele parser, which needs no DOM and never renders; Node 22.17 remains the minimum. See [checker rules and configuration](../../apps/oat-docs/docs/docs-tooling/check.md).
53
53
 
54
54
  `oat docs literals [--target-dir <dir>] --page <content-relative-reader.md> --source-root <repo-relative-dir> [--source-exclude <pattern>] --json` supplies read-only inline-code occurrence candidates for a matching triad IA mirror. Repeat each single-value selection flag as needed. Sources stay within narrow explicit implementation roots; searched/skipped evidence and first source physical lines are reported. Once-only strict HTML entity decoding and case-sensitive line-local substring matching do not establish runtime validity; analyze judges canonical relevance and authored IA acceptance. Completed not-found/not-applicable exits 0, input/config errors 1, unexpected failures 2. See [literal helper scope and limits](../../apps/oat-docs/docs/docs-tooling/commands.md#oat-docs-literals).
55
55
 
@@ -1,4 +1,4 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "oatVersion": "0.3.33"
3
+ "oatVersion": "0.3.35"
4
4
  }
@@ -13,6 +13,7 @@ Documentation should ship with the code it explains. This page covers the core d
13
13
  - Each `index.md` must include a `## Contents` section.
14
14
  - The `## Contents` section is the machine-readable local map for sibling pages and child directories.
15
15
  - Use `.md`-suffixed relative links in `## Contents`: `[Page](page.md)` for leaf pages and `[Section](subdir/index.md)` for child directories.
16
+ - A directory whose `index.md` is its only reader page, with no child sections, may instead list same-page heading anchors (`[Setup](#setup)`) as an in-page table of contents; anywhere else, Contents links must target pages or sections.
16
17
  - Fumadocs and MkDocs share this authored contract, but they regenerate different artifacts.
17
18
 
18
19
  Within Contents, use dash-only link bullets with optional descriptions. A `### Label` introduces a group containing at least one authored entry; entries before the first group stay ungrouped. `*`/`+` link bullets and empty groups produce file/line errors. The map ends at the next level-two heading, and fenced examples are ignored. A self-index link retains landing-page behavior. Missing Contents retains deterministic manifest fallback, while strict nav still requires maps and reports unlisted reader pages.
@@ -91,23 +91,23 @@ recursive tabs participate.
91
91
 
92
92
  | Rule ID | Default behavior |
93
93
  | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
94
- | `directory-index` | Error for a reader-bearing directory without `index.md` and a nonempty dash-bullet Contents map, invalid map grammar or unresolved strict Contents target. Ancestors of reader pages participate. |
94
+ | `directory-index` | Error for a reader-bearing directory without `index.md` and a nonempty dash-bullet Contents map, invalid map grammar or unresolved strict Contents target. Ancestors of reader pages participate. A same-page anchor such as `#setup` is not a Contents target, except in an index-only leaf. |
95
95
  | `root-width` | Flat root Contents links warn above `rootWarn: 10`, error above `rootError: 14`. Real groups exempt the width. |
96
96
  | `contents-width` | Ungrouped Contents links warn above `contentsWarn: 10`, error above `contentsError: 15`. `catalog: true` or the recognized OAT generated-index marker exempts only this width rule. |
97
- | `leaf-size` | Direct reader pages include index. A leaf has no reader-bearing child directory: index-only errors; two total pages warn. |
97
+ | `leaf-size` | Direct reader pages include index. A leaf has no reader-bearing child directory. An index-only leaf (its `index.md` is the only reader page) warns: it renders as a single-page section, so flattening it is a suggestion. Two total pages warn. |
98
98
  | `folder-size` | Direct reader pages including index warn above `folderWarn: 12`, error above `folderError: 20`, unless the index has real groups. |
99
99
  | `depth` | Content root is depth 0; warn at `depthWarn: 4`, error at `depthError: 5`. |
100
100
  | `page-length` | Warn above `pageLines: 500` physical source lines. Explicit `type: reference` or `reference`/`references` path segments use `referenceLines: 1000` and warn above `referenceH3: 40` real H3 headings. No default length error limit. `line` is the first physical line past the line limit, or the first H3 past the heading limit. |
101
101
  | `frontmatter` | Unreadable source, invalid YAML or a non-mapping frontmatter document errors. Read failures use relative paths and safe error codes; invalid metadata skips description validation while independent body checks continue at physical positions. |
102
102
  | `description` | Missing/empty frontmatter description errors; character lengths below `descriptionMin: 40` or above `descriptionMax: 160` warn. |
103
103
  | `h1` | Error unless each page has exactly one real H1. |
104
- | `links` | Error for unresolved in-root source links/anchors. No network checking. Ordinary escaping links are provenance warnings and their targets are not read. Contents links retain stricter local-map rules. |
104
+ | `links` | Error for unresolved in-root source links/anchors. No network checking. Ordinary escaping links are provenance warnings and their targets are not read. Contents links retain stricter local-map rules; the same-page anchors an index-only leaf may list in Contents are checked here, against that page's headings. |
105
105
  | `contents-description` | Error for descriptions ending in an ellipsis/truncation. |
106
106
  | `overview` | Warn below `overviewWords: 10` before Contents. |
107
107
  | `contents-separator` | Warn when Contents descriptions mix separators in a tree. |
108
108
  | `navigation` | Error for explicit navigation drift. Nav-sync freshness stays strict; hand-authored/undeclared compare membership and order only (separators, labels, links and borrows are not compared), and absent optional meta/pages/nav skips comparison. Markdown skips it. |
109
109
  | `commands` | Error for missing literal contributor scripts; framework app targets try the app package first, then the repository root. Implicit pnpm shorthand also accepts installed package/workspace `.bin` entries. Explicit pnpm run/npm run stays script-only; unresolved context errors; echo scripts warn. |
110
- | `mermaid` | Parse every real Mermaid fence using the pinned Mermaid parser. Invalid syntax errors. |
110
+ | `mermaid` | Parse every real Mermaid fence with the pinned Pele parser. Invalid syntax errors, with the diagram line and column. |
111
111
  | `diagram-pairs` | Diagram light/dark SVG siblings and both page references are required. Conventional diagram paths and explicit paired references participate; ordinary logos are not diagrams. |
112
112
  | `diagram-accessibility` | Mermaid missing `accTitle`/`accDescr` warns. SVGs require `role="img"`, nonempty title/desc. Rendered diagrams without page-visible explanatory text or an associated long description warn. |
113
113
  | `diagram-freshness` | Warn when changed Mermaid lacks both associated SVG changes; association follows the actual section/tab group. Missing usable Git comparison is explicitly unverified. |
@@ -140,9 +140,9 @@ binary shorthand may be reported missing. Use `pnpm exec` for explicit binary ex
140
140
 
141
141
  ## Diagram comparison and limits
142
142
 
143
- Syntax validation lazily loads the pinned Mermaid parser with a real Node DOM
144
- and strict sanitization; script execution and resource loading stay disabled.
145
- It does not render or prove semantic/visual correctness. Inspect both themes
143
+ Syntax validation lazily loads the pinned Pele parser, which reads Mermaid
144
+ syntax without a DOM and never renders, runs scripts or loads resources.
145
+ It does not prove semantic/visual correctness. Inspect both themes
146
146
  for important diagrams. Put meaningful `accTitle` and `accDescr` in Mermaid,
147
147
  keep SVG title/desc and page explanations, and change both theme SVGs with source.
148
148
 
@@ -39,6 +39,7 @@ Notes:
39
39
  - A group with no authored entries is an error with its file and line; add a dash link or remove the empty heading. A `*` or `+` link bullet is also an error with file and line; replace it with `-`.
40
40
  - Child directories should link to their `index.md`.
41
41
  - Leaf pages should link to their `.md` filename.
42
+ - An index-only leaf, a directory whose `index.md` is its only reader page and that has no child sections, may list same-page heading anchors such as `[Setup](#setup)` as an in-page table of contents. Each must be a valid Markdown link; a destination Markdown renders as plain text, such as `(#set up)`, is an error. Nav sync adds no navigation entry for them, `oat docs check` reports an anchor whose heading does not exist, and the `leaf-size` rule warns that the section could be flattened. In any other directory a same-page anchor in Contents is an error: link a page or section instead.
42
43
  - Prose outside `## Contents` is ignored by nav generation and remains freeform; it can explain scope, reader paths, or migration status.
43
44
 
44
45
  ## Plain Markdown Ownership
@@ -1,6 +1,6 @@
1
1
  {
2
- "cli": "0.3.33",
3
- "docs-config": "0.3.33",
4
- "docs-theme": "0.3.33",
5
- "docs-transforms": "0.3.33"
2
+ "cli": "0.3.35",
3
+ "docs-config": "0.3.35",
4
+ "docs-theme": "0.3.35",
5
+ "docs-transforms": "0.3.35"
6
6
  }
@@ -6,7 +6,7 @@ disable-model-invocation: true
6
6
  user-invocable: true
7
7
  allowed-tools: Read, Write, Bash(git:*), Bash(oat config:*), Bash(oat docs nav sync --check:*), Bash(oat docs check:*), Bash(oat docs literals:*), Bash(pnpm:*), Bash(bash:*), Bash(jq:*), Bash(node:*), Bash(command -v:*), Bash(sed:*), Bash(npm root -g:*), Bash(test:*), Bash(oat --version), Bash(mkdir:*), Bash(date:*), Bash(echo:*), Bash(cd:*), Bash(pwd:*), Glob, Grep, AskUserQuestion, Task
8
8
  metadata:
9
- version: '1.9.0'
9
+ version: '1.9.1'
10
10
  ---
11
11
 
12
12
  # Docs Analysis
@@ -15,7 +15,9 @@ Use this checklist when evaluating a docs surface.
15
15
  - Parent `## Contents` maps include immediate child directories that contain
16
16
  docs.
17
17
  - Single-page directories still expose a useful `index.md` entrypoint or local
18
- section map.
18
+ section map. An index-only leaf may list same-page heading anchors in
19
+ `## Contents`; treat it as a flattening suggestion (`leaf-size` warning), not
20
+ a broken map.
19
21
  - Asset-only directories are exempt when they contain no Markdown content and
20
22
  are not linked as navigable docs sections.
21
23
  - `overview.md` is not used as the directory entrypoint.
@@ -6,7 +6,7 @@ disable-model-invocation: false
6
6
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion
7
7
  user-invocable: true
8
8
  metadata:
9
- version: '1.2.1'
9
+ version: '1.2.2'
10
10
  ---
11
11
 
12
12
  # OAT Docs Authoring
@@ -29,7 +29,12 @@ Use file-path-friendly relative Markdown links:
29
29
  - child directory: `[Section](section/index.md)`;
30
30
  - avoid extensionless local docs links for new authored navigation entries;
31
31
  - preserve anchors only when the target heading exists and the local renderer
32
- supports the anchor.
32
+ supports the anchor;
33
+ - index-only leaf (its `index.md` is the directory's only reader page and it
34
+ has no child sections): may list same-page heading anchors such as `[Setup](#setup)` as an in-page table
35
+ of contents. They add no navigation entry, and `oat docs check` warns
36
+ (`leaf-size`) that the section could be flattened. Anywhere else a same-page
37
+ anchor in Contents is an error.
33
38
 
34
39
  Existing extensionless links are drift, not precedent. Normalize them only when
35
40
  the task scope or an approved recommendation covers that change.
@@ -1 +1 @@
1
- {"version":3,"file":"checker.d.ts","sourceRoot":"","sources":["../../../../src/commands/docs/check/checker.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,eAAe,EAAuB,MAAM,eAAe,CAAC;AAQ1E,gFAAgF;AAChF,wBAAsB,SAAS,CAAC,OAAO,EAAE;IACvC,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,gEAAgE;IAChE,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACtC,GAAG,OAAO,CAAC,eAAe,CAAC,CAyF3B"}
1
+ {"version":3,"file":"checker.d.ts","sourceRoot":"","sources":["../../../../src/commands/docs/check/checker.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,eAAe,EAAuB,MAAM,eAAe,CAAC;AAQ1E,gFAAgF;AAChF,wBAAsB,SAAS,CAAC,OAAO,EAAE;IACvC,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,gEAAgE;IAChE,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACtC,GAAG,OAAO,CAAC,eAAe,CAAC,CAiG3B"}
@@ -55,7 +55,10 @@ export async function checkDocs(options) {
55
55
  await checkReaders(target, pages, report);
56
56
  checkProvenance(target, pages, report);
57
57
  if (!options.rules?.length || options.rules.includes('navigation'))
58
- await checkNavigation(target, report);
58
+ await checkNavigation(target, report, (ruleId, path, line) => findings.some((f) => f.severity === 'error' &&
59
+ f.ruleId === ruleId &&
60
+ f.path === path &&
61
+ f.line === line));
59
62
  if (!options.rules?.length || options.rules.includes('commands'))
60
63
  await checkCommands(target, pages, report);
61
64
  if (!options.rules?.length ||
@@ -1,3 +1,3 @@
1
- /** Lazy, real Node DOM for Mermaid syntax parsing only. Never renders SVG. */
1
+ /** Mermaid syntax check with the pinned, DOM-free Pele parser. Never renders. */
2
2
  export declare function parseMermaid(source: string): Promise<void>;
3
3
  //# sourceMappingURL=mermaid-runtime.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"mermaid-runtime.d.ts","sourceRoot":"","sources":["../../../../src/commands/docs/check/mermaid-runtime.ts"],"names":[],"mappings":"AA6CA,8EAA8E;AAC9E,wBAAsB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAehE"}
1
+ {"version":3,"file":"mermaid-runtime.d.ts","sourceRoot":"","sources":["../../../../src/commands/docs/check/mermaid-runtime.ts"],"names":[],"mappings":"AAyBA,iFAAiF;AACjF,wBAAsB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAUhE"}
@@ -1,57 +1,32 @@
1
1
  let runtime;
2
- let queue = Promise.resolve();
3
- async function withDom(dom, action) {
4
- const keys = ['window', 'document'];
5
- const saved = keys.map((key) => Object.getOwnPropertyDescriptor(globalThis, key));
6
- Object.defineProperty(globalThis, 'window', {
7
- configurable: true,
8
- value: dom.window,
9
- });
10
- Object.defineProperty(globalThis, 'document', {
11
- configurable: true,
12
- value: dom.window.document,
13
- });
14
- try {
15
- return await action();
16
- }
17
- finally {
18
- keys.forEach((key, index) => {
19
- const descriptor = saved[index];
20
- if (descriptor)
21
- Object.defineProperty(globalThis, key, descriptor);
22
- else
23
- Reflect.deleteProperty(globalThis, key);
24
- });
25
- }
2
+ /**
3
+ * Pele reports syntax errors in Mermaid's multi-line form: a "Parse error on
4
+ * line N:" header, the offending source line, a caret line and the expected
5
+ * tokens. Keep only the explanation and append the diagram-relative position.
6
+ */
7
+ function conciseMessage(error) {
8
+ const message = error.snippet
9
+ ? error.message.replace(error.snippet, '')
10
+ : error.message;
11
+ const detail = message
12
+ .split('\n')
13
+ .map((line) => line.trim())
14
+ .filter((line) => line && !/^Parse error on line \d+:?$/.test(line))
15
+ .join(' ');
16
+ return error.line > 0
17
+ ? `${detail} (diagram line ${error.line}, column ${error.column})`
18
+ : detail;
26
19
  }
27
- async function loadRuntime() {
28
- const { JSDOM } = await import('jsdom');
29
- // Defaults intentionally disable script execution and external resources.
30
- const dom = new JSDOM('');
20
+ /** Mermaid syntax check with the pinned, DOM-free Pele parser. Never renders. */
21
+ export async function parseMermaid(source) {
22
+ runtime ??= import('pele');
23
+ const pele = await runtime;
31
24
  try {
32
- const mermaid = await withDom(dom, async () => (await import('mermaid')).default);
33
- process.once('beforeExit', () => dom.window.close());
34
- return { dom, mermaid };
25
+ pele.parse(source);
35
26
  }
36
27
  catch (error) {
37
- dom.window.close();
28
+ if (error instanceof pele.PeleError)
29
+ throw new Error(conciseMessage(error), { cause: error });
38
30
  throw error;
39
31
  }
40
32
  }
41
- /** Lazy, real Node DOM for Mermaid syntax parsing only. Never renders SVG. */
42
- export async function parseMermaid(source) {
43
- const result = queue.then(async () => {
44
- runtime ??= loadRuntime();
45
- const { dom, mermaid } = await runtime;
46
- await withDom(dom, async () => {
47
- mermaid.initialize({
48
- startOnLoad: false,
49
- securityLevel: 'strict',
50
- logLevel: 'fatal',
51
- });
52
- await mermaid.parse(source);
53
- });
54
- });
55
- queue = result.catch(() => undefined);
56
- await result;
57
- }
@@ -1,10 +1,14 @@
1
1
  import type { Report } from './check.types.js';
2
- import type { CheckTarget } from './files.js';
2
+ import { type CheckTarget } from './files.js';
3
3
  /**
4
4
  * `navOwner: nav-sync` keeps strict generated freshness. Hand-authored and
5
5
  * undeclared navigation gets the membership-and-order comparison (issue #367
6
6
  * P21/G5): separators, labels, links, rest entries and borrowed pages are the
7
7
  * author's presentation, never generator output to match.
8
+ *
9
+ * `reported` tells whether an error-severity finding already exists for a
10
+ * rule, path and line. A policy-downgraded finding does not count: the defect
11
+ * still stops navigation, so navigation keeps reporting it as an error.
8
12
  */
9
- export declare function checkNavigation(target: CheckTarget, report: Report): Promise<void>;
13
+ export declare function checkNavigation(target: CheckTarget, report: Report, reported: (ruleId: string, path: string, line?: number) => boolean): Promise<void>;
10
14
  //# sourceMappingURL=navigation.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"navigation.d.ts","sourceRoot":"","sources":["../../../../src/commands/docs/check/navigation.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAC5C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAE3C;;;;;GAKG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,WAAW,EACnB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,IAAI,CAAC,CA0Df"}
1
+ {"version":3,"file":"navigation.d.ts","sourceRoot":"","sources":["../../../../src/commands/docs/check/navigation.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAC5C,OAAO,EAAiB,KAAK,WAAW,EAAE,MAAM,SAAS,CAAC;AAE1D;;;;;;;;;GASG;AACH,wBAAsB,eAAe,CACnC,MAAM,EAAE,WAAW,EACnB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,OAAO,GACjE,OAAO,CAAC,IAAI,CAAC,CAyEf"}
@@ -1,13 +1,20 @@
1
+ import { relative } from 'node:path';
2
+ import { DocsContentsError } from '../../docs/nav/contents.js';
1
3
  import { syncFumadocsNavigation } from '../../docs/nav/fumadocs.js';
2
4
  import { compareFumadocsMembership, compareMkDocsMembership, } from '../../docs/nav/membership.js';
3
5
  import { syncDocsNavigation } from '../../docs/nav/sync.js';
6
+ import { inside, posix } from './files.js';
4
7
  /**
5
8
  * `navOwner: nav-sync` keeps strict generated freshness. Hand-authored and
6
9
  * undeclared navigation gets the membership-and-order comparison (issue #367
7
10
  * P21/G5): separators, labels, links, rest entries and borrowed pages are the
8
11
  * author's presentation, never generator output to match.
12
+ *
13
+ * `reported` tells whether an error-severity finding already exists for a
14
+ * rule, path and line. A policy-downgraded finding does not count: the defect
15
+ * still stops navigation, so navigation keeps reporting it as an error.
9
16
  */
10
- export async function checkNavigation(target, report) {
17
+ export async function checkNavigation(target, report, reported) {
11
18
  if (target.framework === 'markdown')
12
19
  return;
13
20
  try {
@@ -50,6 +57,19 @@ export async function checkNavigation(target, report) {
50
57
  }
51
58
  }
52
59
  catch (error) {
60
+ if (error instanceof DocsContentsError &&
61
+ inside(target.contentRoot, error.indexPath)) {
62
+ // Navigation stops at the first invalid Contents map. directory-index
63
+ // resolves every map the same way and reports each defect at its own
64
+ // index.md, so repeat it here only when that rule did not report it as
65
+ // an error: it was not selected, policy downgraded it, or the defect is
66
+ // framework-only (MkDocs nav accepts only .md targets, while the reader
67
+ // check also accepts .mdx).
68
+ const path = posix(relative(target.contentRoot, error.indexPath));
69
+ if (!reported('directory-index', path, error.line))
70
+ report('navigation', 'error', path, error.message, error.line);
71
+ return;
72
+ }
53
73
  report('navigation', 'error', 'index.md', error.message);
54
74
  }
55
75
  }
@@ -1 +1 @@
1
- {"version":3,"file":"reader-rules.d.ts","sourceRoot":"","sources":["../../../../src/commands/docs/check/reader-rules.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAC5C,OAAO,EAML,KAAK,WAAW,EACjB,MAAM,SAAS,CAAC;AACjB,OAAO,EAAY,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AAEvD,wBAAsB,YAAY,CAChC,MAAM,EAAE,WAAW,EACnB,KAAK,EAAE,UAAU,EAAE,EACnB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,IAAI,CAAC,CA4Vf;AAED,wBAAgB,eAAe,CAC7B,MAAM,EAAE,WAAW,EACnB,KAAK,EAAE,UAAU,EAAE,EACnB,MAAM,EAAE,MAAM,GACb,IAAI,CA+CN"}
1
+ {"version":3,"file":"reader-rules.d.ts","sourceRoot":"","sources":["../../../../src/commands/docs/check/reader-rules.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAC5C,OAAO,EAML,KAAK,WAAW,EACjB,MAAM,SAAS,CAAC;AACjB,OAAO,EAAY,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AAEvD,wBAAsB,YAAY,CAChC,MAAM,EAAE,WAAW,EACnB,KAAK,EAAE,UAAU,EAAE,EACnB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,IAAI,CAAC,CAgWf;AAED,wBAAgB,eAAe,CAC7B,MAAM,EAAE,WAAW,EACnB,KAAK,EAAE,UAAU,EAAE,EACnB,MAAM,EAAE,MAAM,GACb,IAAI,CA+CN"}
@@ -1,5 +1,5 @@
1
1
  import { dirname, extname, join, resolve } from 'node:path';
2
- import { parseIndexContents, resolveEntryTarget, } from '../../docs/nav/contents.js';
2
+ import { isIndexOnlyLeaf, parseIndexContents, resolveEntryTarget, } from '../../docs/nav/contents.js';
3
3
  import { isExcludedPath } from '../../docs/shared/exclusions.js';
4
4
  import { DOCS_CHECK_THRESHOLDS } from '../../../config/documentation-check.js';
5
5
  import { containedFile, inside, posix, present, readerDirectories, } from './files.js';
@@ -10,11 +10,13 @@ export async function checkReaders(target, pages, report) {
10
10
  ...target.documentation.check?.thresholds,
11
11
  };
12
12
  const byPath = new Map(pages.map((p) => [p.path, p]));
13
- const dirs = readerDirectories(pages.map((p) => p.path));
13
+ const pagePaths = pages.map((p) => p.path);
14
+ const dirs = readerDirectories(pagePaths);
14
15
  const separators = new Map();
15
16
  for (const dir of dirs) {
16
17
  const path = posix(join(dir, 'index.md'));
17
18
  const page = byPath.get(path);
19
+ const indexOnlyLeaf = isIndexOnlyLeaf(dir, pagePaths);
18
20
  let grouped = false;
19
21
  if (!page)
20
22
  report('directory-index', 'error', path, 'Reader-bearing directory needs index.md with a nonempty ## Contents map.');
@@ -36,7 +38,9 @@ export async function checkReaders(target, pages, report) {
36
38
  separators.set(entry.separator, { path, line: entry.line });
37
39
  try {
38
40
  // Existing strict Contents resolution owns local map semantics.
39
- await resolveEntryTarget(target.contentRoot, join(target.contentRoot, dir), dir, entry, ['.md', '.mdx'], target.matcher);
41
+ // Leaf same-page anchors resolve without error; the links rule
42
+ // below reports any that name a missing heading.
43
+ await resolveEntryTarget(target.contentRoot, join(target.contentRoot, dir), dir, entry, ['.md', '.mdx'], target.matcher, { indexOnlyLeaf });
40
44
  }
41
45
  catch (error) {
42
46
  report('directory-index', 'error', path, String(error.message), entry.line);
@@ -78,12 +82,11 @@ export async function checkReaders(target, pages, report) {
78
82
  }
79
83
  const count = pages.filter((p) => dirname(p.path) === dir).length;
80
84
  const child = dirs.some((d) => d !== dir && (dir === '.' || d.startsWith(`${dir}/`)));
81
- if (!child) {
82
- if (count === 1 && page)
83
- report('leaf-size', 'error', path, 'Index-only leaf has no reader destination; flatten it or add its reader pages.');
84
- else if (count === 2)
85
- report('leaf-size', 'warn', path, 'Leaf has only two direct reader pages including index.md; consider flattening.');
86
- }
85
+ // A single-page section renders fine, so its shape is a style suggestion.
86
+ if (indexOnlyLeaf)
87
+ report('leaf-size', 'warn', path, 'Index-only leaf; consider flattening it to a single page or adding reader pages.');
88
+ else if (!child && count === 2)
89
+ report('leaf-size', 'warn', path, 'Leaf has only two direct reader pages including index.md; consider flattening.');
87
90
  if (!grouped) {
88
91
  if (count > t.folderError)
89
92
  report('folder-size', 'error', path, `Folder has ${count} direct reader pages including index; maximum ${t.folderError} without groups.`);
@@ -5,11 +5,45 @@ export interface DocsContentsEntry {
5
5
  line?: number;
6
6
  description?: string;
7
7
  separator?: string;
8
+ /**
9
+ * The authored line from the link's opening `[` onward. `href` is captured
10
+ * only up to the first `)`, so Markdown-level validation reads this instead.
11
+ */
12
+ source?: string;
8
13
  group?: {
9
14
  title: string;
10
15
  line: number;
11
16
  };
12
17
  }
18
+ /**
19
+ * A defect in one index.md Contents map. `indexPath` is the index file that
20
+ * contains it, as the caller named it, and `line` the entry line when the
21
+ * entries were parsed with locations, so a consumer that only sees the thrown
22
+ * error can still report it against the file that contains it.
23
+ */
24
+ export declare class DocsContentsError extends Error {
25
+ readonly indexPath: string;
26
+ readonly line?: number | undefined;
27
+ constructor(message: string, indexPath: string, line?: number | undefined);
28
+ }
29
+ export interface DocsInventory {
30
+ /** Docs-relative POSIX directory paths, `.` for the docs root. */
31
+ directories: string[];
32
+ /** Docs-relative POSIX page paths. */
33
+ pages: string[];
34
+ }
35
+ /** Reader page extensions: Fumadocs renders `.md` and `.mdx` pages. */
36
+ export declare const PAGE_EXTENSIONS: readonly string[];
37
+ /** Every non-excluded directory and reader page under the docs root. */
38
+ export declare function inventoryDocs(docsRoot: string, matcher: ExclusionMatcher): Promise<DocsInventory>;
39
+ /**
40
+ * Whether a directory is an index-only leaf: its own `index.md` is the only
41
+ * reader page in it or below it, so it renders as a single-page section.
42
+ * `pagePaths` are docs-relative POSIX reader page paths.
43
+ */
44
+ export declare function isIndexOnlyLeaf(dirRelativePath: string, pagePaths: readonly string[]): boolean;
45
+ /** A Contents href naming a heading on the same page, such as `#setup`. */
46
+ export declare function isSamePageAnchor(href: string): boolean;
13
47
  /** Shared discovery lets inventory generation retain its no-map fallback. */
14
48
  export declare function hasIndexContents(markdown: string): boolean;
15
49
  export declare function parseIndexContents(markdown: string, sourcePath?: string, options?: {
@@ -22,14 +56,28 @@ interface BuildDocsNavTreeOptions {
22
56
  dirRelativePath?: string;
23
57
  visitedDirectories?: Set<string>;
24
58
  nonReaderPaths?: readonly string[];
59
+ /** Reader page inventory; the root call computes it once. */
60
+ pagePaths?: readonly string[];
25
61
  }
62
+ /**
63
+ * Resolve one Contents entry to the page or section it places in navigation.
64
+ *
65
+ * Contents is machine-readable navigation, so every entry must target a page
66
+ * or a section index. The one exception is an index-only leaf: a single-page
67
+ * section may list same-page heading anchors as an in-page table of contents.
68
+ * Those resolve to `anchor` and place nothing in navigation; the `links` check
69
+ * rule validates that each heading exists.
70
+ */
26
71
  export declare function resolveEntryTarget(docsRoot: string, directoryPath: string, dirRelativePath: string, entry: DocsContentsEntry,
27
72
  /**
28
73
  * Page extensions a Contents link may target. MkDocs renders only `.md`;
29
74
  * Fumadocs also renders `.mdx` pages.
30
75
  */
31
- pageExtensions?: readonly string[], matcher?: ExclusionMatcher): Promise<{
32
- kind: 'page' | 'section' | 'excluded';
76
+ pageExtensions?: readonly string[], matcher?: ExclusionMatcher, options?: {
77
+ /** The directory is an index-only leaf (see {@link isIndexOnlyLeaf}). */
78
+ indexOnlyLeaf?: boolean;
79
+ }): Promise<{
80
+ kind: 'page' | 'section' | 'excluded' | 'anchor';
33
81
  path: string;
34
82
  }>;
35
83
  export declare function buildDocsNavTree(options: BuildDocsNavTreeOptions): Promise<DocsNavTree>;
@@ -1 +1 @@
1
- {"version":3,"file":"contents.d.ts","sourceRoot":"","sources":["../../../../src/commands/docs/nav/contents.ts"],"names":[],"mappings":"AAGA,OAAO,EAGL,KAAK,gBAAgB,EACtB,MAAM,kCAAkC,CAAC;AAG1C,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;CACzC;AA8BD,6EAA6E;AAC7E,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAE1D;AAED,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,MAAM,EAChB,UAAU,SAAa,EACvB,OAAO,GAAE;IAAE,UAAU,CAAC,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAA;CAAO,GAC1D,iBAAiB,EAAE,CA8DrB;AAED,MAAM,MAAM,WAAW,GAAG,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,CAAC,GAAG,MAAM,CAAC,CAAC;AAE/E,UAAU,uBAAuB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,kBAAkB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACjC,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACpC;AAMD,wBAAsB,kBAAkB,CACtC,QAAQ,EAAE,MAAM,EAChB,aAAa,EAAE,MAAM,EACrB,eAAe,EAAE,MAAM,EACvB,KAAK,EAAE,iBAAiB;AACxB;;;GAGG;AACH,cAAc,GAAE,SAAS,MAAM,EAAY,EAC3C,OAAO,CAAC,EAAE,gBAAgB,GACzB,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,UAAU,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAuElE;AAED,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,WAAW,CAAC,CA0EtB"}
1
+ {"version":3,"file":"contents.d.ts","sourceRoot":"","sources":["../../../../src/commands/docs/nav/contents.ts"],"names":[],"mappings":"AAWA,OAAO,EAGL,KAAK,gBAAgB,EACtB,MAAM,kCAAkC,CAAC;AAK1C,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;CACzC;AAED;;;;;GAKG;AACH,qBAAa,iBAAkB,SAAQ,KAAK;IAGxC,QAAQ,CAAC,SAAS,EAAE,MAAM;IAC1B,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM;IAHxB,YACE,OAAO,EAAE,MAAM,EACN,SAAS,EAAE,MAAM,EACjB,IAAI,CAAC,EAAE,MAAM,YAAA,EAIvB;CACF;AAED,MAAM,WAAW,aAAa;IAC5B,kEAAkE;IAClE,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,sCAAsC;IACtC,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,uEAAuE;AACvE,eAAO,MAAM,eAAe,EAAE,SAAS,MAAM,EAAoB,CAAC;AAElE,wEAAwE;AACxE,wBAAsB,aAAa,CACjC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,aAAa,CAAC,CA8BxB;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,eAAe,EAAE,MAAM,EACvB,SAAS,EAAE,SAAS,MAAM,EAAE,GAC3B,OAAO,CAUT;AAED,2EAA2E;AAC3E,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEtD;AA+DD,6EAA6E;AAC7E,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAE1D;AAED,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,MAAM,EAChB,UAAU,SAAa,EACvB,OAAO,GAAE;IAAE,UAAU,CAAC,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAA;CAAO,GAC1D,iBAAiB,EAAE,CAqErB;AAED,MAAM,MAAM,WAAW,GAAG,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,CAAC,GAAG,MAAM,CAAC,CAAC;AAE/E,UAAU,uBAAuB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,kBAAkB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACjC,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,6DAA6D;IAC7D,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC/B;AAMD;;;;;;;;GAQG;AACH,wBAAsB,kBAAkB,CACtC,QAAQ,EAAE,MAAM,EAChB,aAAa,EAAE,MAAM,EACrB,eAAe,EAAE,MAAM,EACvB,KAAK,EAAE,iBAAiB;AACxB;;;GAGG;AACH,cAAc,GAAE,SAAS,MAAM,EAAY,EAC3C,OAAO,CAAC,EAAE,gBAAgB,EAC1B,OAAO,GAAE;IACP,yEAAyE;IACzE,aAAa,CAAC,EAAE,OAAO,CAAC;CACpB,GACL,OAAO,CAAC;IACT,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,UAAU,GAAG,QAAQ,CAAC;IACjD,IAAI,EAAE,MAAM,CAAC;CACd,CAAC,CAyED;AAED,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,uBAAuB,GAC/B,OAAO,CAAC,WAAW,CAAC,CAkFtB"}
@@ -1,7 +1,99 @@
1
- import { readFile } from 'node:fs/promises';
2
- import { basename, dirname, extname, join, relative, resolve } from 'node:path';
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { basename, dirname, extname, join, posix, relative, resolve, } from 'node:path';
3
3
  import { createExclusionMatcher, isExcludedPath, } from '../../docs/shared/exclusions.js';
4
4
  import { dirExists, fileExists } from '../../../fs/io.js';
5
+ import remarkParse from 'remark-parse';
6
+ import { unified } from 'unified';
7
+ /**
8
+ * A defect in one index.md Contents map. `indexPath` is the index file that
9
+ * contains it, as the caller named it, and `line` the entry line when the
10
+ * entries were parsed with locations, so a consumer that only sees the thrown
11
+ * error can still report it against the file that contains it.
12
+ */
13
+ export class DocsContentsError extends Error {
14
+ indexPath;
15
+ line;
16
+ constructor(message, indexPath, line) {
17
+ super(message);
18
+ this.indexPath = indexPath;
19
+ this.line = line;
20
+ this.name = 'DocsContentsError';
21
+ }
22
+ }
23
+ /** Reader page extensions: Fumadocs renders `.md` and `.mdx` pages. */
24
+ export const PAGE_EXTENSIONS = ['.md', '.mdx'];
25
+ /** Every non-excluded directory and reader page under the docs root. */
26
+ export async function inventoryDocs(docsRoot, matcher) {
27
+ const directories = [];
28
+ const pages = [];
29
+ async function walk(relativeDir) {
30
+ directories.push(relativeDir);
31
+ const entries = await readdir(join(docsRoot, relativeDir), {
32
+ withFileTypes: true,
33
+ });
34
+ for (const entry of entries) {
35
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') {
36
+ continue;
37
+ }
38
+ const entryPath = posix.join(relativeDir, entry.name);
39
+ if (entry.isDirectory()) {
40
+ if (matcher.excludesDirectory(entryPath))
41
+ continue;
42
+ await walk(entryPath);
43
+ }
44
+ else if (entry.isFile() &&
45
+ PAGE_EXTENSIONS.includes(extname(entry.name))) {
46
+ if (!matcher.excludesFile(entryPath))
47
+ pages.push(entryPath);
48
+ }
49
+ }
50
+ }
51
+ await walk('.');
52
+ directories.sort();
53
+ pages.sort();
54
+ return { directories, pages };
55
+ }
56
+ /**
57
+ * Whether a directory is an index-only leaf: its own `index.md` is the only
58
+ * reader page in it or below it, so it renders as a single-page section.
59
+ * `pagePaths` are docs-relative POSIX reader page paths.
60
+ */
61
+ export function isIndexOnlyLeaf(dirRelativePath, pagePaths) {
62
+ const prefix = dirRelativePath === '.' ? '' : `${dirRelativePath}/`;
63
+ const ownIndex = `${prefix}index.md`;
64
+ let hasIndex = false;
65
+ for (const path of pagePaths) {
66
+ if (!path.startsWith(prefix))
67
+ continue;
68
+ if (path !== ownIndex)
69
+ return false;
70
+ hasIndex = true;
71
+ }
72
+ return hasIndex;
73
+ }
74
+ /** A Contents href naming a heading on the same page, such as `#setup`. */
75
+ export function isSamePageAnchor(href) {
76
+ return href.trim().startsWith('#');
77
+ }
78
+ const inlineMarkdown = unified().use(remarkParse);
79
+ /**
80
+ * Whether a Contents entry is a real Markdown link to a same-page fragment.
81
+ * The Contents grammar is line-based, so it captures destinations such as
82
+ * `#a b`, `#a(b` or `#a\` that Markdown does not parse as links; those render
83
+ * as plain text and no link rule would ever validate them. The CommonMark
84
+ * parser the reader check uses decides, over the authored link text rather
85
+ * than the `href` truncated at the first `)`, so balanced `#a(b)` and escaped
86
+ * `#a\(b\)` fragments stay links. The links rule then checks the URL remark
87
+ * parsed against the page's headings.
88
+ */
89
+ function isMarkdownSamePageLink(entry) {
90
+ const tree = inlineMarkdown.parse(entry.source ?? `[${entry.title}](${entry.href})`);
91
+ const paragraph = tree.children?.length === 1 ? tree.children[0] : undefined;
92
+ const link = paragraph?.type === 'paragraph' ? paragraph.children?.[0] : undefined;
93
+ return (link?.type === 'link' &&
94
+ link.position?.start.offset === 0 &&
95
+ (link.url ?? '').startsWith('#'));
96
+ }
5
97
  /** Mask fenced examples without changing authored source line numbers. */
6
98
  function unfencedLines(markdown) {
7
99
  let fence;
@@ -34,14 +126,14 @@ export function parseIndexContents(markdown, sourcePath = 'index.md', options =
34
126
  const lines = unfencedLines(markdown);
35
127
  const headingIndex = contentsHeadingIndex(lines);
36
128
  if (headingIndex < 0) {
37
- throw new Error(`Missing required ## Contents section in ${sourcePath}`);
129
+ throw new DocsContentsError(`Missing required ## Contents section in ${sourcePath}`, sourcePath);
38
130
  }
39
131
  const entries = [];
40
132
  let group;
41
133
  let groupEntries = 0;
42
134
  const checkGroup = () => {
43
135
  if (group && groupEntries === 0) {
44
- throw new Error(`${sourcePath}:${group.line}: Contents group "${group.title}" has no entries`);
136
+ throw new DocsContentsError(`${sourcePath}:${group.line}: Contents group "${group.title}" has no entries`, sourcePath);
45
137
  }
46
138
  };
47
139
  for (let i = headingIndex + 1; i < lines.length; i += 1) {
@@ -56,7 +148,7 @@ export function parseIndexContents(markdown, sourcePath = 'index.md', options =
56
148
  continue;
57
149
  }
58
150
  if (/^\s*[*+]\s+\[[^\]]+\]\([^)]+\)/.test(line)) {
59
- throw new Error(`${sourcePath}:${i + 1}: Contents links must use a dash (-) bullet`);
151
+ throw new DocsContentsError(`${sourcePath}:${i + 1}: Contents links must use a dash (-) bullet`, sourcePath);
60
152
  }
61
153
  const match = line.match(/^\s*-\s+\[([^\]]+)\]\(([^)]+)\)/);
62
154
  if (match) {
@@ -72,6 +164,7 @@ export function parseIndexContents(markdown, sourcePath = 'index.md', options =
72
164
  .replace(/^\s*[-–—:]\s*/, '')
73
165
  .trim(),
74
166
  separator: /^\s*([-–—:])/.exec(line.slice(match[0].length))?.[1],
167
+ source: line.slice(match[0].indexOf('[')),
75
168
  }
76
169
  : {}),
77
170
  });
@@ -80,22 +173,47 @@ export function parseIndexContents(markdown, sourcePath = 'index.md', options =
80
173
  }
81
174
  checkGroup();
82
175
  if (entries.length === 0 && !options.allowEmpty) {
83
- throw new Error(`No machine-readable links found under ## Contents in ${sourcePath}`);
176
+ throw new DocsContentsError(`No machine-readable links found under ## Contents in ${sourcePath}`, sourcePath);
84
177
  }
85
178
  return entries;
86
179
  }
87
180
  function toPosixPath(path) {
88
181
  return path.replaceAll('\\', '/');
89
182
  }
183
+ /**
184
+ * Resolve one Contents entry to the page or section it places in navigation.
185
+ *
186
+ * Contents is machine-readable navigation, so every entry must target a page
187
+ * or a section index. The one exception is an index-only leaf: a single-page
188
+ * section may list same-page heading anchors as an in-page table of contents.
189
+ * Those resolve to `anchor` and place nothing in navigation; the `links` check
190
+ * rule validates that each heading exists.
191
+ */
90
192
  export async function resolveEntryTarget(docsRoot, directoryPath, dirRelativePath, entry,
91
193
  /**
92
194
  * Page extensions a Contents link may target. MkDocs renders only `.md`;
93
195
  * Fumadocs also renders `.mdx` pages.
94
196
  */
95
- pageExtensions = ['.md'], matcher) {
197
+ pageExtensions = ['.md'], matcher, options = {}) {
198
+ const indexPath = join(docsRoot, dirRelativePath, 'index.md');
199
+ const fail = (message) => {
200
+ throw new DocsContentsError(message, indexPath, entry.line);
201
+ };
202
+ if (isSamePageAnchor(entry.href)) {
203
+ if (!options.indexOnlyLeaf) {
204
+ fail(`Contents link "${entry.href}" in ${indexPath} is a same-page anchor; Contents must link pages or sections unless index.md is the only reader page in its directory and it has no child sections.`);
205
+ }
206
+ if (!isMarkdownSamePageLink(entry)) {
207
+ fail(`Contents link "${entry.href}" in ${indexPath} is not a valid Markdown link; fix its destination (escape or remove spaces and unbalanced parentheses).`);
208
+ }
209
+ return {
210
+ kind: 'anchor',
211
+ path: toPosixPath(posix.join(dirRelativePath, 'index.md')),
212
+ };
213
+ }
96
214
  const href = entry.href.split('#', 1)[0]?.trim() ?? '';
97
215
  if (!href) {
98
- throw new Error(`Invalid contents link "${entry.href}" in ${join(docsRoot, dirRelativePath, 'index.md')}`);
216
+ fail(`Invalid contents link "${entry.href}" in ${indexPath}`);
99
217
  }
100
218
  let targetPath = resolve(directoryPath, href);
101
219
  if (!href.endsWith('.md')) {
@@ -108,16 +226,16 @@ pageExtensions = ['.md'], matcher) {
108
226
  if (relativeTargetPath.startsWith('../') ||
109
227
  relativeTargetPath === '..' ||
110
228
  relativeTargetPath.length === 0) {
111
- throw new Error(`Contents link "${entry.href}" escapes the docs directory from ${join(docsRoot, dirRelativePath, 'index.md')}`);
229
+ fail(`Contents link "${entry.href}" escapes the docs directory from ${indexPath}`);
112
230
  }
113
231
  if (matcher && isExcludedPath(matcher, relativeTargetPath)) {
114
232
  return { kind: 'excluded', path: relativeTargetPath };
115
233
  }
116
234
  if (!(await fileExists(targetPath))) {
117
- throw new Error(`Contents link "${entry.href}" in ${join(docsRoot, dirRelativePath, 'index.md')} does not resolve to a Markdown file`);
235
+ fail(`Contents link "${entry.href}" in ${indexPath} does not resolve to a Markdown file`);
118
236
  }
119
237
  if (!pageExtensions.includes(extname(targetPath))) {
120
- throw new Error(`Contents link "${entry.href}" in ${join(docsRoot, dirRelativePath, 'index.md')} must point to a Markdown file or directory index`);
238
+ fail(`Contents link "${entry.href}" in ${indexPath} must point to a Markdown file or directory index`);
121
239
  }
122
240
  if (basename(targetPath) === 'index.md') {
123
241
  return {
@@ -139,16 +257,20 @@ export async function buildDocsNavTree(options) {
139
257
  throw new Error(`Missing required index.md at ${indexPath}`);
140
258
  }
141
259
  const indexMarkdown = await readFile(indexPath, 'utf8');
142
- const contentsEntries = parseIndexContents(indexMarkdown, indexPath);
260
+ const contentsEntries = parseIndexContents(indexMarkdown, indexPath, {
261
+ locations: true,
262
+ });
143
263
  const matcher = createExclusionMatcher(options.nonReaderPaths ?? []);
264
+ const pagePaths = options.pagePaths ?? (await inventoryDocs(options.docsRoot, matcher)).pages;
265
+ const indexOnlyLeaf = isIndexOnlyLeaf(dirRelativePath, pagePaths);
144
266
  const nav = dirRelativePath === '.'
145
267
  ? [{ Home: 'index.md' }]
146
268
  : [toPosixPath(join(dirRelativePath, 'index.md'))];
147
269
  let currentGroup;
148
270
  let groupNav = nav;
149
271
  for (const entry of contentsEntries) {
150
- const resolvedTarget = await resolveEntryTarget(options.docsRoot, directoryPath, dirRelativePath, entry, ['.md'], matcher);
151
- if (resolvedTarget.kind === 'excluded')
272
+ const resolvedTarget = await resolveEntryTarget(options.docsRoot, directoryPath, dirRelativePath, entry, ['.md'], matcher, { indexOnlyLeaf });
273
+ if (resolvedTarget.kind === 'excluded' || resolvedTarget.kind === 'anchor')
152
274
  continue;
153
275
  if (resolvedTarget.kind === 'section' &&
154
276
  resolvedTarget.path === toPosixPath(join(dirRelativePath, 'index.md'))) {
@@ -175,6 +297,7 @@ export async function buildDocsNavTree(options) {
175
297
  dirRelativePath: childDirRelativePath,
176
298
  visitedDirectories: childVisitedDirectories,
177
299
  nonReaderPaths: options.nonReaderPaths,
300
+ pagePaths,
178
301
  }),
179
302
  });
180
303
  }
@@ -1 +1 @@
1
- {"version":3,"file":"fumadocs.d.ts","sourceRoot":"","sources":["../../../../src/commands/docs/nav/fumadocs.ts"],"names":[],"mappings":"AAoBA;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,gBAAgB;IAC/B,wDAAwD;IACxD,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,YAAY,CAAC;IACnB,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,4BAA4B;IAC3C,SAAS,EAAE,gBAAgB,EAAE,CAAC;IAC9B,mEAAmE;IACnE,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB;;;OAGG;IACH,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB;;;OAGG;IACH,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAOD,eAAO,MAAM,mBAAmB,EAAE,WAAW,CAAC,MAAM,CAGlD,CAAC;AACH,eAAO,MAAM,kBAAkB,QACoB,CAAC;AACpD,eAAO,MAAM,aAAa,QAC6D,CAAC;AAmDxF;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQxD;AAED,gFAAgF;AAChF,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAoCvE;AAED;;;;GAIG;AACH,wBAAgB,OAAO,CACrB,QAAQ,EAAE,MAAM,EAChB,YAAY,GAAE,WAAW,CAAC,MAAM,CAAa,GAC5C,MAAM,CAgBR;AAYD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEvD;AAOD;;;;;;;;;GASG;AACH;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAC9B,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,GACvC,OAAO,CAUT;AA8KD;;;;;;;GAOG;AACH,wBAAsB,sBAAsB,CAAC,OAAO,EAAE;IACpD,QAAQ,EAAE,MAAM,CAAC;IACjB,6CAA6C;IAC7C,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACpC,GAAG,OAAO,CAAC,4BAA4B,CAAC,CA8ExC"}
1
+ {"version":3,"file":"fumadocs.d.ts","sourceRoot":"","sources":["../../../../src/commands/docs/nav/fumadocs.ts"],"names":[],"mappings":"AAwBA;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC3B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,gBAAgB;IAC/B,wDAAwD;IACxD,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,YAAY,CAAC;IACnB,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,4BAA4B;IAC3C,SAAS,EAAE,gBAAgB,EAAE,CAAC;IAC9B,mEAAmE;IACnE,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB;;;OAGG;IACH,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB;;;OAGG;IACH,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAMD,eAAO,MAAM,mBAAmB,EAAE,WAAW,CAAC,MAAM,CAGlD,CAAC;AACH,eAAO,MAAM,kBAAkB,QACoB,CAAC;AACpD,eAAO,MAAM,aAAa,QAC6D,CAAC;AASxF;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQxD;AAED,gFAAgF;AAChF,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAoCvE;AAED;;;;GAIG;AACH,wBAAgB,OAAO,CACrB,QAAQ,EAAE,MAAM,EAChB,YAAY,GAAE,WAAW,CAAC,MAAM,CAAa,GAC5C,MAAM,CAgBR;AAYD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEvD;AAOD;;;;;;;;;GASG;AACH;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAC9B,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,GACvC,OAAO,CAUT;AAkLD;;;;;;;GAOG;AACH,wBAAsB,sBAAsB,CAAC,OAAO,EAAE;IACpD,QAAQ,EAAE,MAAM,CAAC;IACjB,6CAA6C;IAC7C,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACpC,GAAG,OAAO,CAAC,4BAA4B,CAAC,CA8ExC"}
@@ -1,11 +1,10 @@
1
- import { readdir, readFile, writeFile } from 'node:fs/promises';
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
2
  import { basename, extname, join, posix } from 'node:path';
3
3
  import { isDeepStrictEqual } from 'node:util';
4
4
  import { createExclusionMatcher, } from '../../docs/shared/exclusions.js';
5
5
  import { getFrontmatterBlock, parseFrontmatterScalarFields, } from '../../shared/frontmatter.js';
6
6
  import { fileExists } from '../../../fs/io.js';
7
- import { parseIndexContents, resolveEntryTarget, } from './contents.js';
8
- const PAGE_EXTENSIONS = ['.md', '.mdx'];
7
+ import { inventoryDocs, isIndexOnlyLeaf, PAGE_EXTENSIONS, parseIndexContents, resolveEntryTarget, } from './contents.js';
9
8
  const GROUP_FOLDER = /^\(.+\)$/;
10
9
  // The `pages` item grammar of the fumadocs-core 16.10.2 page-tree builder
11
10
  // (`resolveFolderItem`): exact rest entries, separators, links, and the
@@ -22,36 +21,6 @@ const FENCE = /^\s*(```|~~~)/;
22
21
  function parentDir(path) {
23
22
  return posix.dirname(path);
24
23
  }
25
- async function inventoryDocs(docsRoot, matcher) {
26
- const directories = [];
27
- const pages = [];
28
- async function walk(relativeDir) {
29
- directories.push(relativeDir);
30
- const entries = await readdir(join(docsRoot, relativeDir), {
31
- withFileTypes: true,
32
- });
33
- for (const entry of entries) {
34
- if (entry.name.startsWith('.') || entry.name === 'node_modules') {
35
- continue;
36
- }
37
- const entryPath = posix.join(relativeDir, entry.name);
38
- if (entry.isDirectory()) {
39
- if (matcher.excludesDirectory(entryPath))
40
- continue;
41
- await walk(entryPath);
42
- }
43
- else if (entry.isFile() &&
44
- PAGE_EXTENSIONS.includes(extname(entry.name))) {
45
- if (!matcher.excludesFile(entryPath))
46
- pages.push(entryPath);
47
- }
48
- }
49
- }
50
- await walk('.');
51
- directories.sort();
52
- pages.sort();
53
- return { directories, pages };
54
- }
55
24
  /**
56
25
  * Reduce a Markdown heading to the plain text a sidebar shows: images and
57
26
  * links keep their text, code spans keep their content, and emphasis and
@@ -174,7 +143,8 @@ export function hasImplicitIndex(relativeDir, existing) {
174
143
  }
175
144
  async function buildFolderPages(docsRoot, relativeDir, markdown, implicitIndex, matcher, inventory, nonIndexUrls) {
176
145
  const indexPath = join(docsRoot, relativeDir, 'index.md');
177
- const entries = parseIndexContents(markdown, indexPath);
146
+ const entries = parseIndexContents(markdown, indexPath, { locations: true });
147
+ const indexOnlyLeaf = isIndexOnlyLeaf(relativeDir, inventory.pages);
178
148
  // A folder the loader gives no implicit index (the docs root, a `root: true`
179
149
  // folder) must list `index` to show its landing page. Elsewhere, listing
180
150
  // `index` would demote the folder index to an ordinary child, so it is left
@@ -182,8 +152,10 @@ async function buildFolderPages(docsRoot, relativeDir, markdown, implicitIndex,
182
152
  const pages = implicitIndex ? [] : ['index'];
183
153
  let currentGroup;
184
154
  for (const entry of entries) {
185
- const target = await resolveEntryTarget(docsRoot, join(docsRoot, relativeDir), relativeDir, entry, PAGE_EXTENSIONS, matcher);
186
- if (target.kind === 'excluded')
155
+ const target = await resolveEntryTarget(docsRoot, join(docsRoot, relativeDir), relativeDir, entry, PAGE_EXTENSIONS, matcher, { indexOnlyLeaf });
156
+ // Same-page anchors in an index-only leaf are an in-page table of
157
+ // contents, not navigation.
158
+ if (target.kind === 'excluded' || target.kind === 'anchor')
187
159
  continue;
188
160
  const targetDir = parentDir(target.path);
189
161
  let item;
@@ -1 +1 @@
1
- {"version":3,"file":"membership.d.ts","sourceRoot":"","sources":["../../../../src/commands/docs/nav/membership.ts"],"names":[],"mappings":"AAMA,OAAO,EAML,KAAK,gBAAgB,EACtB,MAAM,YAAY,CAAC;AAEpB;;;;;;GAMG;AACH,MAAM,WAAW,kBAAkB;IACjC,+DAA+D;IAC/D,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,SAAS,GAAG,UAAU,GAAG,OAAO,GAAG,WAAW,GAAG,YAAY,CAAC;IACpE,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAqHD;;;;;;;;GAQG;AACH,wBAAsB,yBAAyB,CAC7C,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,SAAS,gBAAgB,EAAE,GACrC,OAAO,CAAC,kBAAkB,EAAE,CAAC,CAoS/B;AAeD;;;;;GAKG;AACH,wBAAsB,uBAAuB,CAAC,OAAO,EAAE;IACrD,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACpC,GAAG,OAAO,CAAC,kBAAkB,EAAE,CAAC,CAwEhC"}
1
+ {"version":3,"file":"membership.d.ts","sourceRoot":"","sources":["../../../../src/commands/docs/nav/membership.ts"],"names":[],"mappings":"AAMA,OAAO,EAML,KAAK,gBAAgB,EACtB,MAAM,YAAY,CAAC;AAEpB;;;;;;GAMG;AACH,MAAM,WAAW,kBAAkB;IACjC,+DAA+D;IAC/D,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,SAAS,GAAG,UAAU,GAAG,OAAO,GAAG,WAAW,GAAG,YAAY,CAAC;IACpE,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAoHD;;;;;;;;GAQG;AACH,wBAAsB,yBAAyB,CAC7C,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,SAAS,gBAAgB,EAAE,GACrC,OAAO,CAAC,kBAAkB,EAAE,CAAC,CAoS/B;AAeD;;;;;GAKG;AACH,wBAAsB,uBAAuB,CAAC,OAAO,EAAE;IACrD,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACpC,GAAG,OAAO,CAAC,kBAAkB,EAAE,CAAC,CAwEhC"}
@@ -1,9 +1,8 @@
1
1
  import { readdir, readFile, stat } from 'node:fs/promises';
2
2
  import { extname, join, posix } from 'node:path';
3
3
  import YAML from 'yaml';
4
- import { buildDocsNavTree } from './contents.js';
4
+ import { buildDocsNavTree, PAGE_EXTENSIONS } from './contents.js';
5
5
  import { FUMADOCS_LINK, FUMADOCS_REST_ITEMS, FUMADOCS_SEPARATOR, hasImplicitIndex, pageUrl, } from './fumadocs.js';
6
- const PAGE_EXTENSIONS = ['.md', '.mdx'];
7
6
  const URL_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
8
7
  function lineOf(source, needle) {
9
8
  const index = source.indexOf(needle);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-agent-toolkit/cli",
3
- "version": "0.3.33",
3
+ "version": "0.3.35",
4
4
  "private": false,
5
5
  "description": "Open Agent Toolkit CLI",
6
6
  "homepage": "https://github.com/voxmedia/open-agent-toolkit/tree/main/packages/cli",
@@ -32,21 +32,19 @@
32
32
  "commander": "^12.1.0",
33
33
  "entities": "6.0.1",
34
34
  "github-slugger": "2.0.0",
35
- "jsdom": "29.1.1",
36
35
  "jsonc-parser": "3.2.1",
37
- "mermaid": "11.12.3",
38
36
  "ora": "^9.0.0",
39
37
  "parse5": "7.3.0",
38
+ "pele": "1.10.0",
40
39
  "remark-gfm": "4.0.1",
41
40
  "remark-parse": "11.0.0",
42
41
  "unified": "11.0.5",
43
42
  "yaml": "2.8.2",
44
43
  "zod": "^3.25.76",
45
- "@open-agent-toolkit/control-plane": "0.3.33",
46
- "@open-agent-toolkit/docs-transforms": "0.3.33"
44
+ "@open-agent-toolkit/control-plane": "0.3.35",
45
+ "@open-agent-toolkit/docs-transforms": "0.3.35"
47
46
  },
48
47
  "devDependencies": {
49
- "@types/jsdom": "28.0.3",
50
48
  "@types/node": "^22.10.0",
51
49
  "tsc-alias": "^1.8.10",
52
50
  "tsx": "^4.21.0",