@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.
- package/assets/bundle-metadata.json +1 -1
- package/assets/docs/contributing/documentation.md +1 -0
- package/assets/docs/docs-tooling/check.md +35 -11
- package/assets/docs/docs-tooling/commands.md +20 -2
- package/assets/docs/reference/cli-reference.md +2 -2
- package/assets/docs/reference/config-and-local-state.md +1 -1
- package/assets/docs/reference/configuration.md +1 -1
- package/assets/docs/reference/docs-index-contract.md +3 -2
- package/assets/docs/reference/oat-directory-structure.md +1 -1
- package/assets/public-package-versions.json +4 -4
- package/assets/skills/oat-docs-analyze/SKILL.md +35 -11
- package/assets/skills/oat-docs-analyze/references/analysis-artifact-template.md +2 -2
- package/assets/skills/oat-docs-analyze/references/quality-checklist.md +3 -1
- package/assets/skills/oat-docs-analyze/scripts/package-drift.mjs +303 -30
- package/assets/skills/oat-docs-apply/SKILL.md +3 -1
- package/assets/skills/oat-docs-authoring/SKILL.md +1 -1
- package/assets/skills/oat-docs-authoring/references/oat-fumadocs-contract.md +6 -1
- package/dist/commands/config/index.d.ts.map +1 -1
- package/dist/commands/config/index.js +42 -9
- package/dist/commands/docs/check/check.d.ts.map +1 -1
- package/dist/commands/docs/check/check.js +1 -0
- package/dist/commands/docs/check/checker.d.ts +2 -0
- package/dist/commands/docs/check/checker.d.ts.map +1 -1
- package/dist/commands/docs/check/checker.js +6 -1
- package/dist/commands/docs/check/commands.d.ts.map +1 -1
- package/dist/commands/docs/check/commands.js +74 -30
- package/dist/commands/docs/check/files.d.ts +2 -0
- package/dist/commands/docs/check/files.d.ts.map +1 -1
- package/dist/commands/docs/check/files.js +16 -0
- package/dist/commands/docs/check/navigation.d.ts +11 -1
- package/dist/commands/docs/check/navigation.d.ts.map +1 -1
- package/dist/commands/docs/check/navigation.js +40 -69
- package/dist/commands/docs/check/reader-rules.d.ts.map +1 -1
- package/dist/commands/docs/check/reader-rules.js +12 -9
- package/dist/commands/docs/nav/contents.d.ts +50 -2
- package/dist/commands/docs/nav/contents.d.ts.map +1 -1
- package/dist/commands/docs/nav/contents.js +137 -14
- package/dist/commands/docs/nav/fumadocs.d.ts +29 -0
- package/dist/commands/docs/nav/fumadocs.d.ts.map +1 -1
- package/dist/commands/docs/nav/fumadocs.js +16 -41
- package/dist/commands/docs/nav/membership.d.ts +37 -0
- package/dist/commands/docs/nav/membership.d.ts.map +1 -0
- package/dist/commands/docs/nav/membership.js +474 -0
- package/dist/commands/docs/nav/sync.d.ts +26 -2
- package/dist/commands/docs/nav/sync.d.ts.map +1 -1
- package/dist/commands/docs/nav/sync.js +160 -43
- package/package.json +3 -3
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { extname, join, posix } from 'node:path';
|
|
3
|
+
import YAML from 'yaml';
|
|
4
|
+
import { buildDocsNavTree, PAGE_EXTENSIONS } from './contents.js';
|
|
5
|
+
import { FUMADOCS_LINK, FUMADOCS_REST_ITEMS, FUMADOCS_SEPARATOR, hasImplicitIndex, pageUrl, } from './fumadocs.js';
|
|
6
|
+
const URL_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
7
|
+
function lineOf(source, needle) {
|
|
8
|
+
const index = source.indexOf(needle);
|
|
9
|
+
return index < 0 ? undefined : source.slice(0, index).split('\n').length;
|
|
10
|
+
}
|
|
11
|
+
function isPlainObject(value) {
|
|
12
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
async function entryType(docsRoot, path) {
|
|
15
|
+
if (path === '' || path === '..' || path.startsWith('../'))
|
|
16
|
+
return null;
|
|
17
|
+
try {
|
|
18
|
+
const info = await stat(join(docsRoot, path));
|
|
19
|
+
return info.isDirectory() ? 'dir' : info.isFile() ? 'file' : null;
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
const code = error.code ?? '';
|
|
23
|
+
if (code === 'ENOENT' || code === 'ENOTDIR')
|
|
24
|
+
return null;
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function createTargetResolver(docsRoot) {
|
|
29
|
+
const cache = new Map();
|
|
30
|
+
const resolveTarget = async (key) => {
|
|
31
|
+
const extension = extname(key);
|
|
32
|
+
if (PAGE_EXTENSIONS.includes(extension)) {
|
|
33
|
+
const flat = key.slice(0, -extension.length);
|
|
34
|
+
return {
|
|
35
|
+
id: `page:${flat}`,
|
|
36
|
+
exists: (await entryType(docsRoot, key)) === 'file',
|
|
37
|
+
folder: false,
|
|
38
|
+
url: pageUrl(key),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if ((await entryType(docsRoot, key)) === 'dir')
|
|
42
|
+
return {
|
|
43
|
+
id: `folder:${key}`,
|
|
44
|
+
exists: true,
|
|
45
|
+
folder: true,
|
|
46
|
+
url: pageUrl(posix.join(key, 'index.md')),
|
|
47
|
+
};
|
|
48
|
+
let exists = false;
|
|
49
|
+
for (const candidate of PAGE_EXTENSIONS)
|
|
50
|
+
if ((await entryType(docsRoot, `${key}${candidate}`)) === 'file')
|
|
51
|
+
exists = true;
|
|
52
|
+
return {
|
|
53
|
+
id: `page:${key}`,
|
|
54
|
+
exists,
|
|
55
|
+
folder: false,
|
|
56
|
+
url: pageUrl(`${key}.md`),
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
return (key) => {
|
|
60
|
+
let found = cache.get(key);
|
|
61
|
+
if (!found) {
|
|
62
|
+
found = resolveTarget(key);
|
|
63
|
+
cache.set(key, found);
|
|
64
|
+
}
|
|
65
|
+
return found;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Read one `pages` item with the fumadocs-core page-tree grammar. */
|
|
69
|
+
function classify(dir, item) {
|
|
70
|
+
if (FUMADOCS_REST_ITEMS.has(item))
|
|
71
|
+
return { kind: 'rest' };
|
|
72
|
+
if (FUMADOCS_SEPARATOR.test(item))
|
|
73
|
+
return { kind: 'presentation' };
|
|
74
|
+
const link = FUMADOCS_LINK.exec(item);
|
|
75
|
+
if (link) {
|
|
76
|
+
const url = link.groups?.url ?? '';
|
|
77
|
+
if (link.groups?.external || URL_SCHEME.test(url) || !url.startsWith('/'))
|
|
78
|
+
return { kind: 'link', url: null };
|
|
79
|
+
const path = url.replace(/[?#].*$/, '').replace(/(.)\/+$/, '$1');
|
|
80
|
+
return { kind: 'link', url: path || '/' };
|
|
81
|
+
}
|
|
82
|
+
if (item.startsWith('!'))
|
|
83
|
+
return { kind: 'exclude', key: posix.join(dir, item.slice(1)) };
|
|
84
|
+
// `...folder` inlines a folder's index and children; it never names a page.
|
|
85
|
+
if (item.startsWith('...'))
|
|
86
|
+
return { kind: 'extract', key: posix.join(dir, item.slice(3)) };
|
|
87
|
+
return { kind: 'path', key: posix.join(dir, item) };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Compare present Fumadocs `meta.json` files with the Contents-derived
|
|
91
|
+
* expectation in `metaFiles` (from a `check` run of nav sync).
|
|
92
|
+
*
|
|
93
|
+
* A Contents entry is reachable when its folder's `pages` lists it, a rest
|
|
94
|
+
* entry shows it, any other `pages` entry borrows it, or a root-relative link
|
|
95
|
+
* item points at its URL. An absent meta.json or one without `pages` uses the
|
|
96
|
+
* loader's folder order and is skipped.
|
|
97
|
+
*/
|
|
98
|
+
export async function compareFumadocsMembership(docsRoot, metaFiles) {
|
|
99
|
+
const issues = [];
|
|
100
|
+
const actuals = [];
|
|
101
|
+
for (const file of metaFiles) {
|
|
102
|
+
let source;
|
|
103
|
+
let parsed;
|
|
104
|
+
try {
|
|
105
|
+
source = await readFile(join(docsRoot, file.path), 'utf8');
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (error.code === 'ENOENT')
|
|
109
|
+
continue;
|
|
110
|
+
source = '';
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
parsed = JSON.parse(source);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
issues.push({
|
|
117
|
+
path: file.path,
|
|
118
|
+
kind: 'unreadable',
|
|
119
|
+
message: 'Cannot read or parse meta.json; check file permissions and JSON syntax.',
|
|
120
|
+
});
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (isPlainObject(parsed) && !Object.hasOwn(parsed, 'pages'))
|
|
124
|
+
continue;
|
|
125
|
+
const pages = isPlainObject(parsed) ? parsed.pages : undefined;
|
|
126
|
+
if (!Array.isArray(pages) ||
|
|
127
|
+
pages.some((item) => typeof item !== 'string')) {
|
|
128
|
+
issues.push({
|
|
129
|
+
path: file.path,
|
|
130
|
+
kind: 'malformed',
|
|
131
|
+
message: 'meta.json "pages" must be an array of strings; fix it or remove "pages" to use folder order.',
|
|
132
|
+
line: lineOf(source, '"pages"'),
|
|
133
|
+
});
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const dir = posix.dirname(file.path);
|
|
137
|
+
const pagesIndex = parsed.pagesIndex;
|
|
138
|
+
actuals.push({
|
|
139
|
+
file,
|
|
140
|
+
dir,
|
|
141
|
+
source,
|
|
142
|
+
metadata: parsed,
|
|
143
|
+
...(typeof pagesIndex === 'string' && pagesIndex.trim()
|
|
144
|
+
? { pagesIndex: pagesIndex.trim() }
|
|
145
|
+
: {}),
|
|
146
|
+
items: pages.map((raw) => ({
|
|
147
|
+
raw,
|
|
148
|
+
item: classify(dir, raw),
|
|
149
|
+
})),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
const resolveTarget = createTargetResolver(docsRoot);
|
|
153
|
+
for (const actual of actuals)
|
|
154
|
+
for (const { raw, item } of actual.items) {
|
|
155
|
+
if (item.kind === 'extract') {
|
|
156
|
+
if (!(await resolveTarget(item.key)).folder)
|
|
157
|
+
issues.push({
|
|
158
|
+
path: actual.file.path,
|
|
159
|
+
kind: 'dangling',
|
|
160
|
+
message: `Missing target: pages entry "${raw}" extracts no folder; "..." inlines a folder's pages, so list a page by its name instead.`,
|
|
161
|
+
line: lineOf(actual.source, JSON.stringify(raw)),
|
|
162
|
+
});
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (item.kind !== 'path')
|
|
166
|
+
continue;
|
|
167
|
+
if (!(await resolveTarget(item.key)).exists)
|
|
168
|
+
issues.push({
|
|
169
|
+
path: actual.file.path,
|
|
170
|
+
kind: 'dangling',
|
|
171
|
+
message: `Missing target: pages entry "${raw}" names no page or folder; remove it or restore the page.`,
|
|
172
|
+
line: lineOf(actual.source, JSON.stringify(raw)),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
// Walk the sidebar the way fumadocs-core 16.10.2 `buildFolder` does, from
|
|
176
|
+
// the docs root through attached folders only: a meta.json in a folder the
|
|
177
|
+
// tree never attaches contributes nothing, and a URL link to a folder's
|
|
178
|
+
// landing page reaches that page but does not attach the folder.
|
|
179
|
+
const reachedIds = new Set();
|
|
180
|
+
const reachedUrls = new Set();
|
|
181
|
+
const byDir = new Map(actuals.map((actual) => [actual.dir, actual]));
|
|
182
|
+
const visited = new Set();
|
|
183
|
+
const stripPage = (key) => key.replace(/\.mdx?$/, '');
|
|
184
|
+
const pagesMetaFor = async (dir) => {
|
|
185
|
+
const known = byDir.get(dir);
|
|
186
|
+
if (known)
|
|
187
|
+
return known;
|
|
188
|
+
let parsed;
|
|
189
|
+
try {
|
|
190
|
+
parsed = JSON.parse(await readFile(join(docsRoot, dir, 'meta.json'), 'utf8'));
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
const pages = isPlainObject(parsed) ? parsed.pages : undefined;
|
|
196
|
+
if (!Array.isArray(pages) || pages.some((p) => typeof p !== 'string'))
|
|
197
|
+
return undefined;
|
|
198
|
+
const pagesIndex = parsed.pagesIndex;
|
|
199
|
+
return {
|
|
200
|
+
metadata: parsed,
|
|
201
|
+
items: pages.map((raw) => ({
|
|
202
|
+
raw,
|
|
203
|
+
item: classify(dir, raw),
|
|
204
|
+
})),
|
|
205
|
+
...(typeof pagesIndex === 'string' && pagesIndex.trim()
|
|
206
|
+
? { pagesIndex: pagesIndex.trim() }
|
|
207
|
+
: {}),
|
|
208
|
+
};
|
|
209
|
+
};
|
|
210
|
+
/**
|
|
211
|
+
* The page fumadocs-core 16.10.2 `buildFolder` selects as a folder's index
|
|
212
|
+
* (its `indexPath`): an existing path-form pagesIndex page, else `index`
|
|
213
|
+
* when the generator's hasImplicitIndex rule gives the folder one.
|
|
214
|
+
*/
|
|
215
|
+
const folderIndexSource = async (dir, meta) => {
|
|
216
|
+
// buildFolder resolves an index as a page (resolveFlattenPath(..., 'page')),
|
|
217
|
+
// never as a same-named folder, so keep the page identity here; the
|
|
218
|
+
// flattened key is only for excluding the page from rest expansion.
|
|
219
|
+
const page = async (key) => {
|
|
220
|
+
const extension = extname(key);
|
|
221
|
+
const candidates = PAGE_EXTENSIONS.includes(extension)
|
|
222
|
+
? [key]
|
|
223
|
+
: PAGE_EXTENSIONS.map((candidate) => `${key}${candidate}`);
|
|
224
|
+
for (const candidate of candidates)
|
|
225
|
+
if ((await entryType(docsRoot, candidate)) === 'file')
|
|
226
|
+
return { id: `page:${stripPage(key)}`, flat: stripPage(key) };
|
|
227
|
+
return undefined;
|
|
228
|
+
};
|
|
229
|
+
if (meta?.pagesIndex !== undefined) {
|
|
230
|
+
const index = classify(dir, meta.pagesIndex);
|
|
231
|
+
return index.kind === 'path' ? page(index.key) : undefined;
|
|
232
|
+
}
|
|
233
|
+
if (!hasImplicitIndex(dir, meta?.metadata ?? null))
|
|
234
|
+
return undefined;
|
|
235
|
+
return page(posix.join(dir, 'index'));
|
|
236
|
+
};
|
|
237
|
+
const visit = async (dir) => {
|
|
238
|
+
if (visited.has(dir))
|
|
239
|
+
return;
|
|
240
|
+
visited.add(dir);
|
|
241
|
+
reachedIds.add(`folder:${dir}`);
|
|
242
|
+
const meta = await pagesMetaFor(dir);
|
|
243
|
+
const excludedKeys = new Set((meta?.items ?? []).flatMap(({ item }) => item.kind === 'exclude' ? [stripPage(item.key)] : []));
|
|
244
|
+
const excluded = (key) => excludedKeys.has(stripPage(key));
|
|
245
|
+
// Folder index: a path-form pagesIndex attaches that page unless pages
|
|
246
|
+
// excludes it; a link-form one routes the folder to its URL (compared
|
|
247
|
+
// like any link item). Without pagesIndex, only a folder the generator
|
|
248
|
+
// also treats as non-root (hasImplicitIndex: not the docs root, no `root`)
|
|
249
|
+
// attaches index. The docs root's own index never enters the sidebar
|
|
250
|
+
// tree (the loader drops the root folder's index), so the root reaches
|
|
251
|
+
// its landing page only through pages, a rest entry or a link.
|
|
252
|
+
// The page buildFolder selects as the folder index is reserved: rest
|
|
253
|
+
// entries and folder listings never repeat it, even at the docs root,
|
|
254
|
+
// where the loader then drops the index with the root folder node.
|
|
255
|
+
const indexSource = await folderIndexSource(dir, meta);
|
|
256
|
+
if (meta?.pagesIndex !== undefined) {
|
|
257
|
+
const index = classify(dir, meta.pagesIndex);
|
|
258
|
+
if (dir !== '.' && index.kind === 'link' && index.url)
|
|
259
|
+
reachedUrls.add(index.url);
|
|
260
|
+
}
|
|
261
|
+
if (dir !== '.' && indexSource !== undefined && !excluded(indexSource.flat))
|
|
262
|
+
reachedIds.add(indexSource.id);
|
|
263
|
+
const reachAll = async () => {
|
|
264
|
+
let entries;
|
|
265
|
+
try {
|
|
266
|
+
entries = await readdir(join(docsRoot, dir), { withFileTypes: true });
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
for (const entry of entries) {
|
|
272
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules')
|
|
273
|
+
continue;
|
|
274
|
+
const key = posix.join(dir, entry.name);
|
|
275
|
+
if (entry.isDirectory()) {
|
|
276
|
+
if (!excluded(key))
|
|
277
|
+
await visit(key);
|
|
278
|
+
}
|
|
279
|
+
else if (entry.isFile() &&
|
|
280
|
+
PAGE_EXTENSIONS.includes(extname(entry.name)) &&
|
|
281
|
+
!excluded(key) &&
|
|
282
|
+
stripPage(key) !== indexSource?.flat)
|
|
283
|
+
reachedIds.add(`page:${stripPage(key)}`);
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
if (!meta) {
|
|
287
|
+
await reachAll();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
for (const { item } of meta.items) {
|
|
291
|
+
if (item.kind === 'rest')
|
|
292
|
+
await reachAll();
|
|
293
|
+
else if (item.kind === 'link' && item.url)
|
|
294
|
+
reachedUrls.add(item.url);
|
|
295
|
+
else if (item.kind === 'extract') {
|
|
296
|
+
// Like buildFolder for `...x`: a folder's index and children are
|
|
297
|
+
// inlined; a page target extracts nothing.
|
|
298
|
+
if ((await resolveTarget(item.key)).folder)
|
|
299
|
+
await visit(item.key);
|
|
300
|
+
}
|
|
301
|
+
else if (item.kind === 'path') {
|
|
302
|
+
const target = await resolveTarget(item.key);
|
|
303
|
+
if (!target.exists)
|
|
304
|
+
continue;
|
|
305
|
+
if (target.folder)
|
|
306
|
+
await visit(item.key);
|
|
307
|
+
else
|
|
308
|
+
reachedIds.add(target.id);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
await visit('.');
|
|
313
|
+
for (const actual of actuals) {
|
|
314
|
+
const expected = [];
|
|
315
|
+
for (const raw of actual.file.meta.pages) {
|
|
316
|
+
const item = classify(actual.dir, raw);
|
|
317
|
+
if (item.kind === 'path')
|
|
318
|
+
expected.push({
|
|
319
|
+
name: posix.relative(actual.dir, item.key) || item.key,
|
|
320
|
+
target: await resolveTarget(item.key),
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
const listed = [];
|
|
324
|
+
const excluded = new Set();
|
|
325
|
+
let rest = false;
|
|
326
|
+
for (const { item } of actual.items) {
|
|
327
|
+
if (item.kind === 'rest')
|
|
328
|
+
rest = true;
|
|
329
|
+
else if (item.kind === 'exclude')
|
|
330
|
+
excluded.add((await resolveTarget(item.key)).id);
|
|
331
|
+
else if (item.kind === 'path')
|
|
332
|
+
listed.push((await resolveTarget(item.key)).id);
|
|
333
|
+
else if (item.kind === 'extract') {
|
|
334
|
+
const target = await resolveTarget(item.key);
|
|
335
|
+
if (target.folder)
|
|
336
|
+
listed.push(target.id);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const pagesLine = lineOf(actual.source, '"pages"');
|
|
340
|
+
// A rest entry never shows the folder's reserved index page.
|
|
341
|
+
const indexSource = await folderIndexSource(actual.dir, actual);
|
|
342
|
+
const reservedId = indexSource?.id;
|
|
343
|
+
// A nested folder's landing page is implicit (never in generated pages),
|
|
344
|
+
// so only an explicit exclusion can hide it.
|
|
345
|
+
const landing = await resolveTarget(posix.join(actual.dir, 'index'));
|
|
346
|
+
if (actual.dir !== '.' &&
|
|
347
|
+
excluded.has(landing.id) &&
|
|
348
|
+
!expected.some((entry) => entry.target.id === landing.id))
|
|
349
|
+
expected.unshift({ name: 'index', target: landing });
|
|
350
|
+
for (const { name, target } of expected) {
|
|
351
|
+
if ((listed.includes(target.id) && !excluded.has(target.id)) ||
|
|
352
|
+
(rest && !excluded.has(target.id) && target.id !== reservedId) ||
|
|
353
|
+
reachedIds.has(target.id) ||
|
|
354
|
+
(!target.folder && reachedUrls.has(target.url)))
|
|
355
|
+
continue;
|
|
356
|
+
issues.push({
|
|
357
|
+
path: actual.file.path,
|
|
358
|
+
kind: 'missing',
|
|
359
|
+
message: `Missing reader ${target.folder ? 'folder' : 'page'}: "${name}" is listed in index.md Contents but no meta.json pages entry reaches it; add it to pages or remove it from Contents.`,
|
|
360
|
+
line: pagesLine,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
const positions = [];
|
|
364
|
+
for (const id of listed) {
|
|
365
|
+
const position = expected.findIndex((entry) => entry.target.id === id);
|
|
366
|
+
if (position >= 0 && !positions.includes(position))
|
|
367
|
+
positions.push(position);
|
|
368
|
+
}
|
|
369
|
+
if (positions.some((position, index) => position < positions[index - 1]))
|
|
370
|
+
issues.push({
|
|
371
|
+
path: actual.file.path,
|
|
372
|
+
kind: 'order',
|
|
373
|
+
message: `Order differs from authored Contents: expected ${[
|
|
374
|
+
...positions,
|
|
375
|
+
]
|
|
376
|
+
.sort((a, b) => a - b)
|
|
377
|
+
.map((position) => expected[position].name)
|
|
378
|
+
.join(', ')}; found ${positions
|
|
379
|
+
.map((position) => expected[position].name)
|
|
380
|
+
.join(', ')}. Reorder pages or Contents (separators, labels and links are not compared).`,
|
|
381
|
+
line: pagesLine,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
return issues;
|
|
385
|
+
}
|
|
386
|
+
function flattenNav(tree, out = []) {
|
|
387
|
+
if (!Array.isArray(tree))
|
|
388
|
+
return out;
|
|
389
|
+
for (const item of tree) {
|
|
390
|
+
if (typeof item === 'string')
|
|
391
|
+
out.push(item);
|
|
392
|
+
else if (isPlainObject(item))
|
|
393
|
+
for (const value of Object.values(item)) {
|
|
394
|
+
if (typeof value === 'string')
|
|
395
|
+
out.push(value);
|
|
396
|
+
else
|
|
397
|
+
flattenNav(value, out);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return out;
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Compare a hand-authored MkDocs `nav:` with the Contents maps: every
|
|
404
|
+
* Contents page appears, every local nav page exists, and the entries of
|
|
405
|
+
* each Contents map (pages and section landing pages) keep their order. Section labels, nesting and external
|
|
406
|
+
* links are the author's. Returns no issues when `nav` is absent.
|
|
407
|
+
*/
|
|
408
|
+
export async function compareMkDocsMembership(options) {
|
|
409
|
+
const source = await readFile(join(options.appRoot, 'mkdocs.yml'), 'utf8');
|
|
410
|
+
const config = YAML.parse(source);
|
|
411
|
+
if (!isPlainObject(config) || !Object.hasOwn(config, 'nav'))
|
|
412
|
+
return [];
|
|
413
|
+
const navLine = lineOf(source, 'nav:');
|
|
414
|
+
if (!Array.isArray(config.nav))
|
|
415
|
+
return [
|
|
416
|
+
{
|
|
417
|
+
path: 'mkdocs.yml',
|
|
418
|
+
kind: 'malformed',
|
|
419
|
+
message: 'mkdocs.yml nav must be a list; fix it or remove nav to use the default page order.',
|
|
420
|
+
line: navLine,
|
|
421
|
+
},
|
|
422
|
+
];
|
|
423
|
+
// A page two Contents maps list appears twice in generated nav; compare
|
|
424
|
+
// first occurrences on both sides.
|
|
425
|
+
const expected = flattenNav(await buildDocsNavTree({
|
|
426
|
+
docsRoot: options.docsRoot,
|
|
427
|
+
nonReaderPaths: options.nonReaderPaths,
|
|
428
|
+
})).filter((path, index, all) => all.indexOf(path) === index);
|
|
429
|
+
const actual = [];
|
|
430
|
+
for (const entry of flattenNav(config.nav)) {
|
|
431
|
+
if (URL_SCHEME.test(entry) ||
|
|
432
|
+
entry.startsWith('/') ||
|
|
433
|
+
entry.startsWith('#'))
|
|
434
|
+
continue;
|
|
435
|
+
const path = posix.normalize(entry);
|
|
436
|
+
if (!actual.includes(path))
|
|
437
|
+
actual.push(path);
|
|
438
|
+
}
|
|
439
|
+
const issues = [];
|
|
440
|
+
for (const path of expected)
|
|
441
|
+
if (!actual.includes(path))
|
|
442
|
+
issues.push({
|
|
443
|
+
path: 'mkdocs.yml',
|
|
444
|
+
kind: 'missing',
|
|
445
|
+
message: `Missing reader page: "${path}" is listed in index.md Contents but not in mkdocs.yml nav; add it to nav or remove it from Contents.`,
|
|
446
|
+
line: navLine,
|
|
447
|
+
});
|
|
448
|
+
for (const path of actual)
|
|
449
|
+
if ((await entryType(options.docsRoot, path)) !== 'file')
|
|
450
|
+
issues.push({
|
|
451
|
+
path: 'mkdocs.yml',
|
|
452
|
+
kind: 'dangling',
|
|
453
|
+
message: `Missing target: nav entry "${path}" names no page; remove it or restore the page.`,
|
|
454
|
+
line: lineOf(source, path) ?? navLine,
|
|
455
|
+
});
|
|
456
|
+
// A section landing page belongs to its parent's Contents map, so whole
|
|
457
|
+
// sections keep the parent order; other pages group by their directory.
|
|
458
|
+
const owner = (path) => /^index\.mdx?$/.test(posix.basename(path)) && posix.dirname(path) !== '.'
|
|
459
|
+
? posix.dirname(posix.dirname(path))
|
|
460
|
+
: posix.dirname(path);
|
|
461
|
+
const directories = [...new Set(expected.map(owner))];
|
|
462
|
+
for (const directory of directories) {
|
|
463
|
+
const want = expected.filter((path) => owner(path) === directory && actual.includes(path));
|
|
464
|
+
const found = actual.filter((path) => want.includes(path));
|
|
465
|
+
if (want.join('\n') !== found.join('\n'))
|
|
466
|
+
issues.push({
|
|
467
|
+
path: 'mkdocs.yml',
|
|
468
|
+
kind: 'order',
|
|
469
|
+
message: `Order differs from authored Contents in ${directory === '.' ? 'the docs root' : `${directory}/`}: expected ${want.join(', ')}; found ${found.join(', ')}. Reorder nav or Contents (section labels and links are not compared).`,
|
|
470
|
+
line: navLine,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
return issues;
|
|
474
|
+
}
|
|
@@ -2,6 +2,7 @@ import { type CommandContext, type GlobalOptions } from '../../../app/command-co
|
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import { type DocsNavTree } from './contents.js';
|
|
4
4
|
import { type FumadocsMetaFile } from './fumadocs.js';
|
|
5
|
+
import { type NavMembershipIssue } from './membership.js';
|
|
5
6
|
export type DocsNavFramework = 'fumadocs' | 'mkdocs';
|
|
6
7
|
interface SyncDocsNavigationOptions {
|
|
7
8
|
appRoot: string;
|
|
@@ -33,11 +34,28 @@ interface FumadocsNavigationResult {
|
|
|
33
34
|
unlisted: string[];
|
|
34
35
|
}
|
|
35
36
|
type SyncDocsNavigationResult = MkDocsNavigationResult | FumadocsNavigationResult;
|
|
37
|
+
/** The repository's `documentation` block, when it names a root. */
|
|
38
|
+
interface ConfiguredDocumentation {
|
|
39
|
+
/** Absolute configured app or content root. */
|
|
40
|
+
root: string;
|
|
41
|
+
/** Repository-relative root as configured. */
|
|
42
|
+
configuredRoot: string;
|
|
43
|
+
tooling: string | null;
|
|
44
|
+
navOwner?: 'nav-sync' | 'hand-authored';
|
|
45
|
+
nonReaderPaths: string[];
|
|
46
|
+
}
|
|
47
|
+
interface HandAuthoredNavigationResult {
|
|
48
|
+
framework: DocsNavFramework;
|
|
49
|
+
appRoot: string;
|
|
50
|
+
docsRoot: string;
|
|
51
|
+
issues: NavMembershipIssue[];
|
|
52
|
+
unlisted: string[];
|
|
53
|
+
}
|
|
36
54
|
interface DocsNavSyncDependencies {
|
|
37
55
|
buildCommandContext: (options: GlobalOptions) => CommandContext;
|
|
38
|
-
|
|
39
|
-
resolveNonReaderPaths?: (cwd: string, appRoot: string) => Promise<string[]>;
|
|
56
|
+
resolveConfiguredDocumentation: (cwd: string) => Promise<ConfiguredDocumentation | null>;
|
|
40
57
|
syncDocsNavigation: (options: SyncDocsNavigationOptions) => Promise<SyncDocsNavigationResult>;
|
|
58
|
+
checkHandAuthoredNavigation: (options: SyncDocsNavigationOptions) => Promise<HandAuthoredNavigationResult>;
|
|
41
59
|
}
|
|
42
60
|
/**
|
|
43
61
|
* Detect the docs framework from the app's own files: `mkdocs.yml` keeps the
|
|
@@ -46,6 +64,12 @@ interface DocsNavSyncDependencies {
|
|
|
46
64
|
* marker, a configured `documentation.tooling` decides.
|
|
47
65
|
*/
|
|
48
66
|
export declare function detectDocsNavFramework(appRoot: string, configuredTooling?: string | null): Promise<DocsNavFramework>;
|
|
67
|
+
/**
|
|
68
|
+
* Read-only comparison for `navOwner: hand-authored`: the same
|
|
69
|
+
* membership-and-order semantics as the `oat docs check` navigation rule,
|
|
70
|
+
* never strict generator output.
|
|
71
|
+
*/
|
|
72
|
+
export declare function checkHandAuthoredNavigation(options: SyncDocsNavigationOptions): Promise<HandAuthoredNavigationResult>;
|
|
49
73
|
export declare function syncDocsNavigation(options: SyncDocsNavigationOptions): Promise<SyncDocsNavigationResult>;
|
|
50
74
|
export declare function createDocsNavSyncCommand(overrides?: Partial<DocsNavSyncDependencies>): Command;
|
|
51
75
|
export {};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../../../../src/commands/docs/nav/sync.ts"],"names":[],"mappings":"AAGA,OAAO,EAEL,KAAK,cAAc,EACnB,KAAK,aAAa,EACnB,MAAM,sBAAsB,CAAC;AAK9B,OAAO,EAAE,OAAO,EAAU,MAAM,WAAW,CAAC;AAG5C,OAAO,EAAoB,KAAK,WAAW,EAAE,MAAM,YAAY,CAAC;AAChE,OAAO,EAA0B,KAAK,gBAAgB,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../../../../src/commands/docs/nav/sync.ts"],"names":[],"mappings":"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"}
|