@open-agent-toolkit/cli 0.3.32 → 0.3.34

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 (47) hide show
  1. package/assets/bundle-metadata.json +1 -1
  2. package/assets/docs/contributing/documentation.md +1 -0
  3. package/assets/docs/docs-tooling/check.md +35 -11
  4. package/assets/docs/docs-tooling/commands.md +20 -2
  5. package/assets/docs/reference/cli-reference.md +2 -2
  6. package/assets/docs/reference/config-and-local-state.md +1 -1
  7. package/assets/docs/reference/configuration.md +1 -1
  8. package/assets/docs/reference/docs-index-contract.md +3 -2
  9. package/assets/docs/reference/oat-directory-structure.md +1 -1
  10. package/assets/public-package-versions.json +4 -4
  11. package/assets/skills/oat-docs-analyze/SKILL.md +35 -11
  12. package/assets/skills/oat-docs-analyze/references/analysis-artifact-template.md +2 -2
  13. package/assets/skills/oat-docs-analyze/references/quality-checklist.md +3 -1
  14. package/assets/skills/oat-docs-analyze/scripts/package-drift.mjs +303 -30
  15. package/assets/skills/oat-docs-apply/SKILL.md +3 -1
  16. package/assets/skills/oat-docs-authoring/SKILL.md +1 -1
  17. package/assets/skills/oat-docs-authoring/references/oat-fumadocs-contract.md +6 -1
  18. package/dist/commands/config/index.d.ts.map +1 -1
  19. package/dist/commands/config/index.js +42 -9
  20. package/dist/commands/docs/check/check.d.ts.map +1 -1
  21. package/dist/commands/docs/check/check.js +1 -0
  22. package/dist/commands/docs/check/checker.d.ts +2 -0
  23. package/dist/commands/docs/check/checker.d.ts.map +1 -1
  24. package/dist/commands/docs/check/checker.js +6 -1
  25. package/dist/commands/docs/check/commands.d.ts.map +1 -1
  26. package/dist/commands/docs/check/commands.js +74 -30
  27. package/dist/commands/docs/check/files.d.ts +2 -0
  28. package/dist/commands/docs/check/files.d.ts.map +1 -1
  29. package/dist/commands/docs/check/files.js +16 -0
  30. package/dist/commands/docs/check/navigation.d.ts +11 -1
  31. package/dist/commands/docs/check/navigation.d.ts.map +1 -1
  32. package/dist/commands/docs/check/navigation.js +40 -69
  33. package/dist/commands/docs/check/reader-rules.d.ts.map +1 -1
  34. package/dist/commands/docs/check/reader-rules.js +12 -9
  35. package/dist/commands/docs/nav/contents.d.ts +50 -2
  36. package/dist/commands/docs/nav/contents.d.ts.map +1 -1
  37. package/dist/commands/docs/nav/contents.js +137 -14
  38. package/dist/commands/docs/nav/fumadocs.d.ts +29 -0
  39. package/dist/commands/docs/nav/fumadocs.d.ts.map +1 -1
  40. package/dist/commands/docs/nav/fumadocs.js +16 -41
  41. package/dist/commands/docs/nav/membership.d.ts +37 -0
  42. package/dist/commands/docs/nav/membership.d.ts.map +1 -0
  43. package/dist/commands/docs/nav/membership.js +474 -0
  44. package/dist/commands/docs/nav/sync.d.ts +26 -2
  45. package/dist/commands/docs/nav/sync.d.ts.map +1 -1
  46. package/dist/commands/docs/nav/sync.js +160 -43
  47. package/package.json +3 -3
@@ -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
  }
@@ -27,6 +27,9 @@ export interface SyncFumadocsNavigationResult {
27
27
  */
28
28
  unlisted: string[];
29
29
  }
30
+ export declare const FUMADOCS_REST_ITEMS: ReadonlySet<string>;
31
+ export declare const FUMADOCS_SEPARATOR: RegExp;
32
+ export declare const FUMADOCS_LINK: RegExp;
30
33
  /**
31
34
  * Reduce a Markdown heading to the plain text a sidebar shows: images and
32
35
  * links keep their text, code spans keep their content, and emphasis and
@@ -35,6 +38,12 @@ export interface SyncFumadocsNavigationResult {
35
38
  export declare function stripInlineMarkdown(text: string): string;
36
39
  /** Folder title: frontmatter `title`, else the first H1 outside code fences. */
37
40
  export declare function resolveFolderTitle(markdown: string): string | undefined;
41
+ /**
42
+ * The URL Fumadocs assigns a page, following `getSlugs` in fumadocs-core's
43
+ * slugs plugin with the scaffolded `baseUrl: '/'`: group folders are dropped,
44
+ * segments are URI-encoded, and an `index` file maps to its folder.
45
+ */
46
+ export declare function pageUrl(pagePath: string, nonIndexUrls?: ReadonlySet<string>): string;
38
47
  /**
39
48
  * The `pages` item for a page or folder in the same directory. A name the
40
49
  * builder would read as a directive (`!hidden`, `z...a`, `---`, ...) is
@@ -42,6 +51,26 @@ export declare function resolveFolderTitle(markdown: string): string | undefined
42
51
  * the same file; every other name is written as is.
43
52
  */
44
53
  export declare function toFumadocsPageItem(name: string): string;
54
+ /**
55
+ * Build one folder's `pages` from its index.md Contents map.
56
+ *
57
+ * A page in this folder or an immediate subfolder is referenced by name.
58
+ * Local same-name page/folder collisions use a precise source-file item, as
59
+ * does `../contributing.md` from its own contributing/ map: pinned core16.10.2
60
+ * selects the folder first for an extensionless reference. Other non-local
61
+ * targets become link entries. There is never a `"..."` rest entry: pages the Contents map does
62
+ * not list stay out of the tree and are reported instead.
63
+ */
64
+ /**
65
+ * Whether the fumadocs-core 16.10.2 page-tree builder supplies this folder's
66
+ * `index` page on its own, given the folder's effective metadata.
67
+ *
68
+ * `buildFolder` attaches `<folder>/index` as the folder index only when the
69
+ * folder is not a root folder (the docs root, or a preserved `root: true`)
70
+ * and no `pagesIndex` names a different page. Otherwise the page is shown
71
+ * only if `pages` lists it.
72
+ */
73
+ export declare function hasImplicitIndex(relativeDir: string, existing: Record<string, unknown> | null): boolean;
45
74
  /**
46
75
  * Write one strict `meta.json` per docs directory that has an index.md,
47
76
  * derived from that index's Contents map, and report every page the
@@ -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;AA6DD;;;;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;AAsCD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEvD;AAqND;;;;;;;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,54 +1,26 @@
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
12
11
  // exclude and extract prefixes are checked before an item is read as a path.
13
- const FUMADOCS_REST_ITEMS = new Set(['...', 'z...a']);
14
- const FUMADOCS_SEPARATOR = /^---(?:\[(?<icon>[^\]]+)])?(?<name>.+)---|^---$/;
15
- const FUMADOCS_LINK = /^(?<external>external:)?(?:\[(?<icon>[^\]]+)])?\[(?<name>[^\]]+)]\((?<url>[^)]+)\)$/;
12
+ export const FUMADOCS_REST_ITEMS = new Set([
13
+ '...',
14
+ 'z...a',
15
+ ]);
16
+ export const FUMADOCS_SEPARATOR = /^---(?:\[(?<icon>[^\]]+)])?(?<name>.+)---|^---$/;
17
+ export const FUMADOCS_LINK = /^(?<external>external:)?(?:\[(?<icon>[^\]]+)])?\[(?<name>[^\]]+)]\((?<url>[^)]+)\)$/;
16
18
  const FUMADOCS_PREFIXES = ['!', '...'];
17
19
  const H1 = /^#\s+(.+?)\s*#*\s*$/;
18
20
  const FENCE = /^\s*(```|~~~)/;
19
21
  function parentDir(path) {
20
22
  return posix.dirname(path);
21
23
  }
22
- async function inventoryDocs(docsRoot, matcher) {
23
- const directories = [];
24
- const pages = [];
25
- async function walk(relativeDir) {
26
- directories.push(relativeDir);
27
- const entries = await readdir(join(docsRoot, relativeDir), {
28
- withFileTypes: true,
29
- });
30
- for (const entry of entries) {
31
- if (entry.name.startsWith('.') || entry.name === 'node_modules') {
32
- continue;
33
- }
34
- const entryPath = posix.join(relativeDir, entry.name);
35
- if (entry.isDirectory()) {
36
- if (matcher.excludesDirectory(entryPath))
37
- continue;
38
- await walk(entryPath);
39
- }
40
- else if (entry.isFile() &&
41
- PAGE_EXTENSIONS.includes(extname(entry.name))) {
42
- if (!matcher.excludesFile(entryPath))
43
- pages.push(entryPath);
44
- }
45
- }
46
- }
47
- await walk('.');
48
- directories.sort();
49
- pages.sort();
50
- return { directories, pages };
51
- }
52
24
  /**
53
25
  * Reduce a Markdown heading to the plain text a sidebar shows: images and
54
26
  * links keep their text, code spans keep their content, and emphasis and
@@ -104,7 +76,7 @@ export function resolveFolderTitle(markdown) {
104
76
  * slugs plugin with the scaffolded `baseUrl: '/'`: group folders are dropped,
105
77
  * segments are URI-encoded, and an `index` file maps to its folder.
106
78
  */
107
- function pageUrl(pagePath, nonIndexUrls = new Set()) {
79
+ export function pageUrl(pagePath, nonIndexUrls = new Set()) {
108
80
  const slugs = parentDir(pagePath)
109
81
  .split('/')
110
82
  .filter((segment) => segment.length > 0 && segment !== '.')
@@ -160,7 +132,7 @@ function hrefFragment(href) {
160
132
  * and no `pagesIndex` names a different page. Otherwise the page is shown
161
133
  * only if `pages` lists it.
162
134
  */
163
- function hasImplicitIndex(relativeDir, existing) {
135
+ export function hasImplicitIndex(relativeDir, existing) {
164
136
  if (relativeDir === '.' ||
165
137
  existing?.root === true ||
166
138
  (typeof existing?.root === 'string' && existing.root.trim().length > 0)) {
@@ -171,7 +143,8 @@ function hasImplicitIndex(relativeDir, existing) {
171
143
  }
172
144
  async function buildFolderPages(docsRoot, relativeDir, markdown, implicitIndex, matcher, inventory, nonIndexUrls) {
173
145
  const indexPath = join(docsRoot, relativeDir, 'index.md');
174
- const entries = parseIndexContents(markdown, indexPath);
146
+ const entries = parseIndexContents(markdown, indexPath, { locations: true });
147
+ const indexOnlyLeaf = isIndexOnlyLeaf(relativeDir, inventory.pages);
175
148
  // A folder the loader gives no implicit index (the docs root, a `root: true`
176
149
  // folder) must list `index` to show its landing page. Elsewhere, listing
177
150
  // `index` would demote the folder index to an ordinary child, so it is left
@@ -179,8 +152,10 @@ async function buildFolderPages(docsRoot, relativeDir, markdown, implicitIndex,
179
152
  const pages = implicitIndex ? [] : ['index'];
180
153
  let currentGroup;
181
154
  for (const entry of entries) {
182
- const target = await resolveEntryTarget(docsRoot, join(docsRoot, relativeDir), relativeDir, entry, PAGE_EXTENSIONS, matcher);
183
- 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')
184
159
  continue;
185
160
  const targetDir = parentDir(target.path);
186
161
  let item;
@@ -0,0 +1,37 @@
1
+ import { type FumadocsMetaFile } from './fumadocs.js';
2
+ /**
3
+ * One way a hand-authored (or undeclared) sidebar disagrees with the authored
4
+ * Contents maps. The comparison checks membership and relative order only:
5
+ * separators, labels, link items, rest entries and pages borrowed from another
6
+ * folder are the author's presentation and are never compared with generator
7
+ * output.
8
+ */
9
+ export interface NavMembershipIssue {
10
+ /** Content-relative path of the meta.json, or `mkdocs.yml`. */
11
+ path: string;
12
+ kind: 'missing' | 'dangling' | 'order' | 'malformed' | 'unreadable';
13
+ message: string;
14
+ line?: number;
15
+ }
16
+ /**
17
+ * Compare present Fumadocs `meta.json` files with the Contents-derived
18
+ * expectation in `metaFiles` (from a `check` run of nav sync).
19
+ *
20
+ * A Contents entry is reachable when its folder's `pages` lists it, a rest
21
+ * entry shows it, any other `pages` entry borrows it, or a root-relative link
22
+ * item points at its URL. An absent meta.json or one without `pages` uses the
23
+ * loader's folder order and is skipped.
24
+ */
25
+ export declare function compareFumadocsMembership(docsRoot: string, metaFiles: readonly FumadocsMetaFile[]): Promise<NavMembershipIssue[]>;
26
+ /**
27
+ * Compare a hand-authored MkDocs `nav:` with the Contents maps: every
28
+ * Contents page appears, every local nav page exists, and the entries of
29
+ * each Contents map (pages and section landing pages) keep their order. Section labels, nesting and external
30
+ * links are the author's. Returns no issues when `nav` is absent.
31
+ */
32
+ export declare function compareMkDocsMembership(options: {
33
+ appRoot: string;
34
+ docsRoot: string;
35
+ nonReaderPaths?: readonly string[];
36
+ }): Promise<NavMembershipIssue[]>;
37
+ //# sourceMappingURL=membership.d.ts.map
@@ -0,0 +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;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"}