@open-agent-toolkit/cli 0.3.26 → 0.3.28

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 (104) hide show
  1. package/README.md +12 -0
  2. package/assets/bundle-metadata.json +1 -1
  3. package/assets/docs/contributing/documentation.md +48 -6
  4. package/assets/docs/contributing/markdown-features.md +14 -2
  5. package/assets/docs/docs-tooling/add-docs-to-a-repo.md +22 -9
  6. package/assets/docs/docs-tooling/check.md +154 -0
  7. package/assets/docs/docs-tooling/commands.md +15 -10
  8. package/assets/docs/docs-tooling/index.md +1 -0
  9. package/assets/docs/docs-tooling/meta.json +7 -1
  10. package/assets/docs/docs-tooling/workflows.md +18 -10
  11. package/assets/docs/getting-started/tool-packs.md +5 -3
  12. package/assets/docs/reference/cli-reference.md +2 -0
  13. package/assets/docs/reference/configuration.md +1 -0
  14. package/assets/docs/skills/index.md +8 -8
  15. package/assets/public-package-versions.json +4 -4
  16. package/assets/skills/oat-docs-analyze/SKILL.md +14 -3
  17. package/assets/skills/oat-docs-analyze/references/legacy-scaffold-checks.md +4 -0
  18. package/assets/skills/oat-docs-authoring/SKILL.md +1 -1
  19. package/assets/skills/oat-docs-authoring/references/validation.md +4 -0
  20. package/assets/skills/oat-docs-bootstrap/SKILL.md +92 -61
  21. package/assets/skills/oat-docs-bootstrap/references/pr-ci.md +89 -0
  22. package/assets/templates/docs-app-fuma/AGENTS.md +57 -0
  23. package/assets/templates/docs-app-fuma/app/[[...slug]]/page.tsx +7 -3
  24. package/assets/templates/docs-app-fuma/app/llms/[...slug]/route.ts +15 -0
  25. package/assets/templates/docs-app-fuma/app/llms-full.txt/route.ts +7 -0
  26. package/assets/templates/docs-app-fuma/app/llms.txt/route.ts +7 -0
  27. package/assets/templates/docs-app-fuma/docs/contributing/docs-toolchain.md +85 -0
  28. package/assets/templates/docs-app-fuma/docs/contributing/index.md +13 -0
  29. package/assets/templates/docs-app-fuma/docs/contributing.md +83 -41
  30. package/assets/templates/docs-app-fuma/docs/getting-started.md +3 -33
  31. package/assets/templates/docs-app-fuma/docs/index.md +3 -3
  32. package/assets/templates/docs-app-fuma/gitignore.template +12 -0
  33. package/assets/templates/docs-app-fuma/lib/source.ts +5 -0
  34. package/assets/templates/docs-app-fuma/package.json.template +2 -1
  35. package/assets/templates/docs-app-fuma/pnpm-workspace.yaml +5 -0
  36. package/assets/templates/docs-app-mkdocs/AGENTS.md +48 -0
  37. package/assets/templates/docs-app-mkdocs/docs/contributing/docs-toolchain.md +46 -0
  38. package/assets/templates/docs-app-mkdocs/docs/contributing/index.md +13 -0
  39. package/assets/templates/docs-app-mkdocs/docs/contributing.md +111 -21
  40. package/assets/templates/docs-app-mkdocs/docs/getting-started.md +7 -25
  41. package/assets/templates/docs-app-mkdocs/docs/index.md +7 -2
  42. package/assets/templates/docs-app-mkdocs/gitignore.template +8 -0
  43. package/assets/templates/docs-app-mkdocs/mkdocs.yml +1 -0
  44. package/assets/templates/docs-app-mkdocs/package.json.template +3 -2
  45. package/dist/commands/config/index.d.ts.map +1 -1
  46. package/dist/commands/config/index.js +35 -3
  47. package/dist/commands/docs/check/check.d.ts +3 -0
  48. package/dist/commands/docs/check/check.d.ts.map +1 -0
  49. package/dist/commands/docs/check/check.js +48 -0
  50. package/dist/commands/docs/check/check.types.d.ts +17 -0
  51. package/dist/commands/docs/check/check.types.d.ts.map +1 -0
  52. package/dist/commands/docs/check/check.types.js +1 -0
  53. package/dist/commands/docs/check/checker.d.ts +8 -0
  54. package/dist/commands/docs/check/checker.d.ts.map +1 -0
  55. package/dist/commands/docs/check/checker.js +82 -0
  56. package/dist/commands/docs/check/commands.d.ts +5 -0
  57. package/dist/commands/docs/check/commands.d.ts.map +1 -0
  58. package/dist/commands/docs/check/commands.js +232 -0
  59. package/dist/commands/docs/check/diagrams.d.ts +5 -0
  60. package/dist/commands/docs/check/diagrams.d.ts.map +1 -0
  61. package/dist/commands/docs/check/diagrams.js +197 -0
  62. package/dist/commands/docs/check/files.d.ts +21 -0
  63. package/dist/commands/docs/check/files.d.ts.map +1 -0
  64. package/dist/commands/docs/check/files.js +121 -0
  65. package/dist/commands/docs/check/markdown.d.ts +60 -0
  66. package/dist/commands/docs/check/markdown.d.ts.map +1 -0
  67. package/dist/commands/docs/check/markdown.js +125 -0
  68. package/dist/commands/docs/check/mermaid-runtime.d.ts +3 -0
  69. package/dist/commands/docs/check/mermaid-runtime.d.ts.map +1 -0
  70. package/dist/commands/docs/check/mermaid-runtime.js +57 -0
  71. package/dist/commands/docs/check/navigation.d.ts +4 -0
  72. package/dist/commands/docs/check/navigation.d.ts.map +1 -0
  73. package/dist/commands/docs/check/navigation.js +104 -0
  74. package/dist/commands/docs/check/reader-rules.d.ts +6 -0
  75. package/dist/commands/docs/check/reader-rules.d.ts.map +1 -0
  76. package/dist/commands/docs/check/reader-rules.js +214 -0
  77. package/dist/commands/docs/index.d.ts.map +1 -1
  78. package/dist/commands/docs/index.js +2 -0
  79. package/dist/commands/docs/init/docs-commands.d.ts +1 -0
  80. package/dist/commands/docs/init/docs-commands.d.ts.map +1 -1
  81. package/dist/commands/docs/init/docs-commands.js +8 -3
  82. package/dist/commands/docs/init/index.d.ts +1 -0
  83. package/dist/commands/docs/init/index.d.ts.map +1 -1
  84. package/dist/commands/docs/init/index.js +8 -0
  85. package/dist/commands/docs/init/scaffold.d.ts +1 -0
  86. package/dist/commands/docs/init/scaffold.d.ts.map +1 -1
  87. package/dist/commands/docs/init/scaffold.js +61 -13
  88. package/dist/commands/docs/nav/contents.d.ts +4 -0
  89. package/dist/commands/docs/nav/contents.d.ts.map +1 -1
  90. package/dist/commands/docs/nav/contents.js +10 -0
  91. package/dist/commands/docs/nav/fumadocs.d.ts.map +1 -1
  92. package/dist/commands/docs/nav/fumadocs.js +32 -10
  93. package/dist/config/documentation-check.d.ts +38 -0
  94. package/dist/config/documentation-check.d.ts.map +1 -0
  95. package/dist/config/documentation-check.js +132 -0
  96. package/dist/config/oat-config.d.ts +2 -0
  97. package/dist/config/oat-config.d.ts.map +1 -1
  98. package/dist/config/oat-config.js +4 -0
  99. package/dist/config/resolve.d.ts.map +1 -1
  100. package/dist/config/resolve.js +1 -0
  101. package/dist/release/public-package-contract.d.ts.map +1 -1
  102. package/dist/release/public-package-contract.js +3 -0
  103. package/package.json +11 -2
  104. package/assets/skills/oat-docs-bootstrap/assets/AGENTS.md.template +0 -64
@@ -0,0 +1,232 @@
1
+ import { readFile, readdir } from 'node:fs/promises';
2
+ import { dirname, join, relative, resolve } from 'node:path';
3
+ import { createExclusionMatcher } from '../../docs/shared/exclusions.js';
4
+ import YAML from 'yaml';
5
+ import { inside, posix, present } from './files.js';
6
+ const BUILTINS = new Set([
7
+ 'add',
8
+ 'install',
9
+ 'i',
10
+ 'exec',
11
+ 'dlx',
12
+ 'remove',
13
+ 'rm',
14
+ 'uninstall',
15
+ 'update',
16
+ 'up',
17
+ 'outdated',
18
+ 'list',
19
+ 'ls',
20
+ 'why',
21
+ 'link',
22
+ 'unlink',
23
+ 'import',
24
+ 'rebuild',
25
+ 'prune',
26
+ 'fetch',
27
+ 'store',
28
+ 'config',
29
+ 'setup',
30
+ 'env',
31
+ 'help',
32
+ 'init',
33
+ 'create',
34
+ 'publish',
35
+ 'pack',
36
+ 'audit',
37
+ 'licenses',
38
+ 'patch',
39
+ 'patch-commit',
40
+ 'patch-remove',
41
+ 'deploy',
42
+ 'dedupe',
43
+ 'approve-builds',
44
+ 'ignored-builds',
45
+ 'server',
46
+ 'self-update',
47
+ 'root',
48
+ 'bin',
49
+ 'version',
50
+ ]);
51
+ const illustrative = (value) => /[<$`{}]|\.\.\./.test(value);
52
+ function tokens(source) {
53
+ return (source.match(/"(?:\\.|[^"\\])*"|'[^']*'|[^\s]+/g) ?? []).map((t) => /^['"]/.test(t) ? t.slice(1, -1) : t);
54
+ }
55
+ async function packages(target) {
56
+ let patterns = [];
57
+ if (await present(join(target.repoRoot, 'pnpm-workspace.yaml'))) {
58
+ const config = YAML.parse(await readFile(join(target.repoRoot, 'pnpm-workspace.yaml'), 'utf8'));
59
+ patterns = config.packages?.filter((p) => !p.startsWith('!')) ?? [];
60
+ }
61
+ const match = createExclusionMatcher(patterns);
62
+ // A proper pattern prefix can still contain a member below it. A globstar
63
+ // prefix remains open at every depth. Reuse the membership matcher.
64
+ const descend = createExclusionMatcher(patterns.flatMap((pattern) => {
65
+ const parts = pattern.trim().replace(/\/+$/, '').split('/');
66
+ return parts.flatMap((part, index) => index < parts.length - 1 || part === '**'
67
+ ? [parts.slice(0, index + 1).join('/')]
68
+ : []);
69
+ }));
70
+ const excluded = createExclusionMatcher((await present(join(target.repoRoot, 'pnpm-workspace.yaml')))
71
+ ? (YAML.parse(await readFile(join(target.repoRoot, 'pnpm-workspace.yaml'), 'utf8')).packages ?? [])
72
+ .filter((p) => p.startsWith('!'))
73
+ .map((p) => p.slice(1))
74
+ : []);
75
+ const result = [];
76
+ const add = async (root) => {
77
+ if (!(await present(join(root, 'package.json'))))
78
+ return;
79
+ const p = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
80
+ result.push({ root, name: p.name, scripts: p.scripts });
81
+ };
82
+ await add(target.repoRoot);
83
+ if (target.appRoot !== target.repoRoot)
84
+ await add(target.appRoot);
85
+ if (patterns.length) {
86
+ const walk = async (root) => {
87
+ for (const e of await readdir(root, { withFileTypes: true })) {
88
+ if (!e.isDirectory() ||
89
+ e.name.startsWith('.') ||
90
+ e.name === 'node_modules')
91
+ continue;
92
+ const dir = join(root, e.name);
93
+ const rel = posix(relative(target.repoRoot, dir));
94
+ if (excluded.excludesDirectory(rel))
95
+ continue;
96
+ if (match.excludesDirectory(rel)) {
97
+ if (!result.some((p) => p.root === dir))
98
+ await add(dir);
99
+ }
100
+ // Package patterns, rather than arbitrary repo fixtures, define scope.
101
+ if (descend.excludesDirectory(rel))
102
+ await walk(dir);
103
+ }
104
+ };
105
+ await walk(target.repoRoot);
106
+ }
107
+ return result;
108
+ }
109
+ export async function checkCommands(target, pages, report) {
110
+ const docs = pages.filter((p) => /(?:^|\/)contributing(?:\.mdx?$|\/)/.test(p.path));
111
+ if (!docs.length)
112
+ return;
113
+ const manifests = await packages(target);
114
+ for (const p of docs)
115
+ for (const node of p.nodes) {
116
+ if (node.type !== 'code' && node.type !== 'inlineCode')
117
+ continue;
118
+ if (node.type === 'code' &&
119
+ node.lang &&
120
+ !['sh', 'shell', 'bash', 'zsh', 'console', 'powershell'].includes(node.lang))
121
+ continue;
122
+ let cwd = target.repoRoot;
123
+ const start = node.position?.start;
124
+ const openingLine = p.source.split(/\r?\n/).at((start?.line ?? 1) - 1) ?? '';
125
+ const fenced = node.type === 'code' &&
126
+ /^(?:`{3,}|~{3,})/.test(openingLine.slice((start?.column ?? 1) - 1));
127
+ for (const [lineIndex, original] of (node.value ?? '')
128
+ .split('\n')
129
+ .entries()) {
130
+ const line = (node.position?.start.line ?? 1) + lineIndex + (fenced ? 1 : 0);
131
+ // Split shell command boundaries while retaining quoted arguments. No
132
+ // subprocess is executed, and placeholders are never guessed.
133
+ const segments = original.match(/(?:"(?:\\.|[^"\\])*"|'[^']*'|[^;&\n])+/g) ?? [];
134
+ for (let segment of segments) {
135
+ segment = segment.replace(/^\s*\$\s+/, '').trim();
136
+ if (!segment || segment.startsWith('#'))
137
+ continue;
138
+ const words = tokens(segment);
139
+ if (words[0] === 'cd') {
140
+ cwd =
141
+ words[1] && !illustrative(words[1]) && cwd
142
+ ? resolve(cwd, words[1])
143
+ : undefined;
144
+ continue;
145
+ }
146
+ const manager = words.findIndex((w) => w === 'pnpm' || w === 'npm');
147
+ if (manager < 0)
148
+ continue;
149
+ const kind = words[manager];
150
+ let root = cwd;
151
+ let filter;
152
+ let index = manager + 1;
153
+ while (words[index]?.startsWith('-')) {
154
+ const option = words[index++];
155
+ if (['-w', '--workspace-root'].includes(option))
156
+ root = target.repoRoot;
157
+ else if (option === '--filter' || option === '-F')
158
+ filter = words[index++];
159
+ else if (option.startsWith('--filter='))
160
+ filter = option.slice(9);
161
+ else if (['--dir', '-C', '--prefix'].includes(option)) {
162
+ const dir = words[index++];
163
+ root =
164
+ root && dir && !illustrative(dir)
165
+ ? resolve(root, dir)
166
+ : undefined;
167
+ }
168
+ else if (['-r', '--recursive', '--workspace'].includes(option))
169
+ root = undefined;
170
+ }
171
+ let script = words[index];
172
+ if (!script)
173
+ continue;
174
+ if (kind === 'npm' &&
175
+ script !== 'run' &&
176
+ script !== 'run-script' &&
177
+ script !== 'test' &&
178
+ script !== 'start')
179
+ continue;
180
+ const explicitRun = script === 'run' || script === 'run-script';
181
+ if (explicitRun) {
182
+ index++;
183
+ while (words[index]?.startsWith('-'))
184
+ index++;
185
+ script = words[index];
186
+ }
187
+ else if (BUILTINS.has(script))
188
+ continue;
189
+ if (!script ||
190
+ illustrative(script) ||
191
+ (filter && illustrative(filter)))
192
+ continue;
193
+ let pkg;
194
+ if (filter) {
195
+ const selected = manifests.filter((item) => item.name === filter ||
196
+ (filter.startsWith('./') &&
197
+ posix(relative(target.repoRoot, item.root)) ===
198
+ filter.slice(2)));
199
+ if (selected.length === 1)
200
+ pkg = selected[0];
201
+ }
202
+ else if (root &&
203
+ inside(target.repoRoot, root) &&
204
+ (await present(root))) {
205
+ let current = root;
206
+ while (inside(target.repoRoot, current)) {
207
+ pkg = manifests.find((item) => item.root === current);
208
+ if (pkg || current === target.repoRoot)
209
+ break;
210
+ current = dirname(current);
211
+ }
212
+ }
213
+ if (!pkg)
214
+ report('commands', 'error', p.path, `Cannot resolve package context for ${kind} ${script}${filter ? ` (--filter ${filter})` : ''}; use literal cd, -w or --filter.`, line);
215
+ else if (!Object.hasOwn(pkg.scripts ?? {}, script)) {
216
+ // pnpm shorthand can invoke an installed local binary. Explicit run
217
+ // remains script-only; never execute a binary or search system PATH.
218
+ const installedBinary = kind === 'pnpm' &&
219
+ !explicitRun &&
220
+ !/[\\/]/.test(script) &&
221
+ script !== '.' &&
222
+ script !== '..' &&
223
+ (await Promise.all([pkg.root, target.repoRoot].map((binRoot) => present(join(binRoot, 'node_modules/.bin', script))))).some(Boolean);
224
+ if (!installedBinary)
225
+ report('commands', 'error', p.path, `Script "${script}" does not exist in ${posix(relative(target.repoRoot, join(pkg.root, 'package.json'))) || 'package.json'}.`, line);
226
+ }
227
+ else if (/^\s*echo(?:\s|$)/.test(pkg.scripts[script]))
228
+ report('commands', 'warn', p.path, `Script "${script}" is an echo stub in ${posix(relative(target.repoRoot, join(pkg.root, 'package.json')))}.`, line);
229
+ }
230
+ }
231
+ }
232
+ }
@@ -0,0 +1,5 @@
1
+ import type { Report } from './check.types.js';
2
+ import { type CheckTarget } from './files.js';
3
+ import { type ParsedPage } from './markdown.js';
4
+ export declare function checkDiagrams(target: CheckTarget, pages: ParsedPage[], report: Report): Promise<void>;
5
+ //# sourceMappingURL=diagrams.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"diagrams.d.ts","sourceRoot":"","sources":["../../../../src/commands/docs/check/diagrams.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAC5C,OAAO,EAKL,KAAK,WAAW,EACjB,MAAM,SAAS,CAAC;AACjB,OAAO,EAKL,KAAK,UAAU,EAChB,MAAM,YAAY,CAAC;AAsEpB,wBAAsB,aAAa,CACjC,MAAM,EAAE,WAAW,EACnB,KAAK,EAAE,UAAU,EAAE,EACnB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,IAAI,CAAC,CA2Of"}
@@ -0,0 +1,197 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { readFile, readdir } from 'node:fs/promises';
3
+ import { basename, dirname, join, relative, resolve } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { DOCS_CHECK_THRESHOLDS } from '../../../config/documentation-check.js';
6
+ import { containedFile, inside, posix, present, } from './files.js';
7
+ import { attributes, htmlNodes, parsePage, } from './markdown.js';
8
+ import { parseMermaid } from './mermaid-runtime.js';
9
+ const exec = promisify(execFile);
10
+ async function git(target, args) {
11
+ return (await exec('git', ['-C', target.repoRoot, ...args], {
12
+ maxBuffer: 16 * 1024 * 1024,
13
+ })).stdout;
14
+ }
15
+ function sourceDiagrams(page) {
16
+ return page.nodes.filter((n) => n.type === 'code' && n.lang?.toLowerCase() === 'mermaid');
17
+ }
18
+ function imageFile(target, page, href) {
19
+ if (/^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(href))
20
+ return undefined;
21
+ let decoded;
22
+ try {
23
+ decoded = decodeURIComponent(href.split(/[?#]/)[0]);
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ const root = decoded.startsWith('/')
29
+ ? join(target.appRoot, 'public')
30
+ : target.contentRoot;
31
+ const path = decoded.startsWith('/')
32
+ ? resolve(root, `.${decoded}`)
33
+ : resolve(target.contentRoot, dirname(page.path), decoded);
34
+ return inside(root, path) ? { root, path } : undefined;
35
+ }
36
+ function diagramSection(page, node, tabsOnly = true) {
37
+ const line = node.position?.start.line ?? 1;
38
+ const tabs = page.nodes
39
+ .filter((n) => n.name === 'Tabs' &&
40
+ (n.position?.start.line ?? Infinity) <= line &&
41
+ (n.position?.end.line ?? 0) >= line)
42
+ .at(-1);
43
+ if (tabsOnly && tabs?.position)
44
+ return { start: tabs.position.start.line, end: tabs.position.end.line + 1 };
45
+ // The containing authored section (and nested tab group) owns association;
46
+ // diagrams in another heading never satisfy this diagram's pair contract.
47
+ const headings = page.nodes.filter((n) => n.type === 'heading' && (n.depth ?? 7) <= 3);
48
+ const before = headings
49
+ .filter((n) => (n.position?.start.line ?? 1) < line)
50
+ .at(-1);
51
+ const after = headings.find((n) => (n.position?.start.line ?? 1) > line &&
52
+ (n.depth ?? 7) <= (before?.depth ?? 3));
53
+ return {
54
+ start: before?.position?.start.line ?? 1,
55
+ end: after?.position?.start.line ?? Infinity,
56
+ };
57
+ }
58
+ export async function checkDiagrams(target, pages, report) {
59
+ const altMax = target.documentation.check?.thresholds?.altMax ??
60
+ DOCS_CHECK_THRESHOLDS.altMax;
61
+ const assets = new Map();
62
+ const associations = [];
63
+ for (const page of pages) {
64
+ for (const image of page.links.filter((l) => l.image)) {
65
+ const alt = image.alt?.trim() ?? '';
66
+ const slug = basename(image.href)
67
+ .replace(/\.[^.]+$/, '')
68
+ .replace(/[-_]/g, ' ')
69
+ .toLowerCase();
70
+ if (!alt ||
71
+ alt.length > altMax ||
72
+ !/\s/.test(alt) ||
73
+ alt.toLowerCase().replace(/[-_]/g, ' ') === slug)
74
+ report('image-alt', 'warn', page.path, `Image alt text is empty, filename/slug-like or longer than ${altMax} characters.`, image.line);
75
+ const asset = imageFile(target, page, image.href);
76
+ if (!asset || !/\.svg$/i.test(asset.path))
77
+ continue;
78
+ if (/(?:^|[\\/])diagrams[\\/]/.test(asset.path) ||
79
+ /-(light|dark)\.svg$/.test(asset.path))
80
+ assets.set(asset.path, asset);
81
+ if (/-(light|dark)\.svg$/.test(asset.path)) {
82
+ const other = asset.path.replace(/-(light|dark)\.svg$/, (_, theme) => `-${theme === 'light' ? 'dark' : 'light'}.svg`);
83
+ const referenced = page.links.some((l) => l.image && imageFile(target, page, l.href)?.path === other);
84
+ if (!referenced)
85
+ report('diagram-pairs', 'error', page.path, `Diagram must reference both light and dark SVGs: ${basename(asset.path)}.`, image.line);
86
+ }
87
+ const section = diagramSection(page, {
88
+ type: 'image',
89
+ position: {
90
+ start: { line: image.line, column: 1 },
91
+ end: { line: image.line, column: 1 },
92
+ },
93
+ }, false);
94
+ const description = page.nodes.some((n) => n.type === 'paragraph' &&
95
+ (n.position?.start.line ?? 0) >= section.start &&
96
+ (n.position?.end.line ?? Infinity) < section.end &&
97
+ n.children?.some((c) => c.type === 'text' && c.value?.trim()));
98
+ const associated = image.describedBy
99
+ ?.split(/\s+/)
100
+ .some((id) => page.anchors.has(id));
101
+ if (assets.has(asset.path) && !description && !associated)
102
+ report('diagram-accessibility', 'warn', page.path, 'Rendered diagram needs page-visible explanatory text or an associated HTML long description; SVG desc alone is insufficient.', image.line);
103
+ }
104
+ for (const node of sourceDiagrams(page)) {
105
+ const line = node.position?.start.line ?? 1;
106
+ try {
107
+ await parseMermaid(node.value ?? '');
108
+ }
109
+ catch (error) {
110
+ report('mermaid', 'error', page.path, `Invalid Mermaid: ${error.message}`, line);
111
+ }
112
+ if (!/^\s*accTitle\s*:/m.test(node.value ?? '') ||
113
+ !/^\s*accDescr\s*(?::|\{)/m.test(node.value ?? ''))
114
+ report('diagram-accessibility', 'warn', page.path, 'Mermaid needs accTitle and accDescr for accessibility.', line);
115
+ const section = diagramSection(page, node);
116
+ const pairs = page.links
117
+ .filter((l) => l.image && l.line >= section.start && l.line < section.end)
118
+ .map((l) => imageFile(target, page, l.href)?.path)
119
+ .filter((p) => !!p && /-(light|dark)\.svg$/.test(p));
120
+ if (pairs.length)
121
+ associations.push({ page, node, pairs });
122
+ }
123
+ }
124
+ // Inspect only conventional diagram directories or explicit paired refs;
125
+ // ordinary SVG logos remain ordinary assets.
126
+ for (const root of [join(target.appRoot, 'public'), target.contentRoot]) {
127
+ const diagrams = join(root, 'diagrams');
128
+ if (!(await present(diagrams)))
129
+ continue;
130
+ const walk = async (dir) => {
131
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
132
+ const path = join(dir, entry.name);
133
+ if (entry.isDirectory())
134
+ await walk(path);
135
+ else if (entry.isFile() && path.endsWith('.svg'))
136
+ assets.set(path, { root, path });
137
+ }
138
+ };
139
+ await walk(diagrams);
140
+ }
141
+ for (const { root, path } of assets.values()) {
142
+ const label = posix(relative(target.contentRoot, path));
143
+ if (!(await containedFile(root, path))) {
144
+ report('diagram-pairs', 'error', label, 'Referenced diagram SVG does not exist inside its asset root.');
145
+ continue;
146
+ }
147
+ if (/-(light|dark)\.svg$/.test(path)) {
148
+ const sibling = path.replace(/-(light|dark)\.svg$/, (_, theme) => `-${theme === 'light' ? 'dark' : 'light'}.svg`);
149
+ if (!(await containedFile(root, sibling)))
150
+ report('diagram-pairs', 'error', label, 'Diagram SVG is missing its light/dark sibling.');
151
+ }
152
+ const nodes = htmlNodes(await readFile(path, 'utf8'));
153
+ const svg = nodes.find((n) => n.nodeName === 'svg');
154
+ const title = nodes.find((n) => n.nodeName === 'title' && n.childNodes?.some((c) => c.value?.trim()));
155
+ const desc = nodes.find((n) => n.nodeName === 'desc' && n.childNodes?.some((c) => c.value?.trim()));
156
+ if (!svg || attributes(svg).role !== 'img' || !title || !desc)
157
+ report('diagram-accessibility', 'error', label, 'Diagram SVG requires role="img", a nonempty title and desc.');
158
+ }
159
+ if (!associations.length)
160
+ return;
161
+ const base = target.documentation.check?.diagramBaseRef ?? 'origin/main';
162
+ try {
163
+ const mergeBase = (await git(target, ['merge-base', 'HEAD', base])).trim();
164
+ const committed = (await git(target, ['diff', '--name-only', '-z', mergeBase, 'HEAD', '--'])).split('\0');
165
+ const working = (await git(target, ['diff', '--name-only', '-z', 'HEAD', '--'])).split('\0');
166
+ const untracked = (await git(target, [
167
+ 'ls-files',
168
+ '--others',
169
+ '--exclude-standard',
170
+ '-z',
171
+ '--',
172
+ ])).split('\0');
173
+ const changed = new Set([...committed, ...working, ...untracked]);
174
+ for (const { page, node, pairs } of associations) {
175
+ const sourcePath = posix(relative(target.repoRoot, join(target.contentRoot, page.path)));
176
+ if (!changed.has(sourcePath))
177
+ continue;
178
+ let old;
179
+ try {
180
+ old = parsePage(page.path, await git(target, ['show', `${mergeBase}:${sourcePath}`]));
181
+ }
182
+ catch {
183
+ /* new page */
184
+ }
185
+ const current = sourceDiagrams(page).map((n) => n.value);
186
+ const previous = old ? sourceDiagrams(old).map((n) => n.value) : [];
187
+ const index = sourceDiagrams(page).indexOf(node);
188
+ if (current[index] === previous[index] || previous.includes(node.value))
189
+ continue;
190
+ if (pairs.some((path) => !changed.has(posix(relative(target.repoRoot, path)))))
191
+ report('diagram-freshness', 'warn', page.path, 'Mermaid source changed without both associated SVGs in the same committed/working-tree change.', node.position?.start.line);
192
+ }
193
+ }
194
+ catch {
195
+ report('diagram-freshness', 'warn', 'index.md', `Diagram diff check unverified: no usable Git merge base for "${base}"; static checks still ran.`);
196
+ }
197
+ }
@@ -0,0 +1,21 @@
1
+ import { type ExclusionMatcher } from '../../docs/shared/exclusions.js';
2
+ import { type OatDocumentationConfig } from '../../../config/oat-config.js';
3
+ import { type ParsedPage } from './markdown.js';
4
+ export declare function posix(path: string): string;
5
+ export declare function inside(root: string, path: string): boolean;
6
+ export declare function present(path: string): Promise<boolean>;
7
+ export interface CheckTarget {
8
+ repoRoot: string;
9
+ appRoot: string;
10
+ contentRoot: string;
11
+ framework: 'markdown' | 'fumadocs' | 'mkdocs';
12
+ documentation: OatDocumentationConfig;
13
+ matcher: ExclusionMatcher;
14
+ }
15
+ export declare function resolveCheckTarget(cwd: string, targetDir?: string): Promise<CheckTarget>;
16
+ export declare function readerFiles(target: CheckTarget): Promise<string[]>;
17
+ export declare function readPage(target: CheckTarget, path: string): Promise<ParsedPage>;
18
+ /** Never follow a link outside its allowed root, including symlink escapes. */
19
+ export declare function containedFile(root: string, path: string): Promise<boolean>;
20
+ export declare function readerDirectories(paths: string[]): string[];
21
+ //# sourceMappingURL=files.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"files.d.ts","sourceRoot":"","sources":["../../../../src/commands/docs/check/files.ts"],"names":[],"mappings":"AAGA,OAAO,EAGL,KAAK,gBAAgB,EACtB,MAAM,kCAAkC,CAAC;AAC1C,OAAO,EAGL,KAAK,sBAAsB,EAC5B,MAAM,oBAAoB,CAAC;AAG5B,OAAO,EAAa,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AAExD,wBAAgB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE1C;AACD,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAQ1D;AACD,wBAAsB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAa5D;AAKD,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,UAAU,GAAG,UAAU,GAAG,QAAQ,CAAC;IAC9C,aAAa,EAAE,sBAAsB,CAAC;IACtC,OAAO,EAAE,gBAAgB,CAAC;CAC3B;AACD,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,MAAM,EACX,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC,WAAW,CAAC,CAiEtB;AACD,wBAAsB,WAAW,CAAC,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAsBxE;AACD,wBAAsB,QAAQ,CAC5B,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,UAAU,CAAC,CAKrB;AACD,+EAA+E;AAC/E,wBAAsB,aAAa,CACjC,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,OAAO,CAAC,CAMlB;AACD,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAU3D"}
@@ -0,0 +1,121 @@
1
+ import { readFile, readdir, realpath, stat } from 'node:fs/promises';
2
+ import { dirname, join, relative, resolve, isAbsolute } from 'node:path';
3
+ import { createExclusionMatcher, isExcludedPath, } from '../../docs/shared/exclusions.js';
4
+ import { readOatConfig, resolveDocumentationContentRoot, } from '../../../config/oat-config.js';
5
+ import { resolveProjectRoot } from '../../../fs/paths.js';
6
+ import { parsePage } from './markdown.js';
7
+ export function posix(path) {
8
+ return path.replaceAll('\\', '/');
9
+ }
10
+ export function inside(root, path) {
11
+ const rel = relative(root, path);
12
+ return (rel !== '..' &&
13
+ !rel.startsWith('../') &&
14
+ !rel.startsWith('..\\') &&
15
+ !isAbsolute(rel));
16
+ }
17
+ export async function present(path) {
18
+ try {
19
+ await stat(path);
20
+ return true;
21
+ }
22
+ catch (error) {
23
+ if (['ENOENT', 'ENOTDIR'].includes(error.code ?? ''))
24
+ return false;
25
+ throw error;
26
+ }
27
+ }
28
+ // Preserve the lexical missing-path diagnostic, but compare existing aliases by identity.
29
+ async function canonicalPath(path) {
30
+ return (await present(path)) ? realpath(path) : path;
31
+ }
32
+ export async function resolveCheckTarget(cwd, targetDir) {
33
+ let repoRoot;
34
+ try {
35
+ repoRoot = await resolveProjectRoot(cwd);
36
+ }
37
+ catch {
38
+ repoRoot = cwd;
39
+ }
40
+ repoRoot = await realpath(repoRoot);
41
+ const config = await readOatConfig(repoRoot);
42
+ const doc = config.documentation ?? {};
43
+ const configured = doc.root
44
+ ? await canonicalPath(resolve(repoRoot, doc.root))
45
+ : undefined;
46
+ const requested = resolve(cwd, targetDir ?? configured ?? join(repoRoot, 'docs'));
47
+ const appRoot = await canonicalPath(requested);
48
+ const matching = configured === appRoot;
49
+ const framework = matching && doc.tooling === 'markdown'
50
+ ? 'markdown'
51
+ : (await present(join(appRoot, 'mkdocs.yml')))
52
+ ? 'mkdocs'
53
+ : (await Promise.all(['ts', 'mts', 'js', 'mjs'].map((ext) => present(join(appRoot, `source.config.${ext}`))))).some(Boolean)
54
+ ? 'fumadocs'
55
+ : matching && (doc.tooling === 'mkdocs' || doc.tooling === 'fumadocs')
56
+ ? doc.tooling
57
+ : 'markdown';
58
+ // The established resolver owns configured nested docs semantics. An
59
+ // unrelated explicit Markdown root remains literal, even with a docs child.
60
+ const configuredContent = await resolveDocumentationContentRoot(repoRoot, config);
61
+ const configuredContentPath = configuredContent
62
+ ? await canonicalPath(resolve(repoRoot, configuredContent))
63
+ : undefined;
64
+ const contentRoot = matching && configuredContentPath
65
+ ? configuredContentPath
66
+ : framework === 'markdown'
67
+ ? appRoot
68
+ : join(appRoot, 'docs');
69
+ if (!(await present(contentRoot)))
70
+ throw new Error(`Docs content directory does not exist: ${contentRoot}. Set documentation.root or pass --target-dir.`);
71
+ const matchesContent = configuredContentPath !== undefined && configuredContentPath === appRoot;
72
+ const documentation = matching || matchesContent ? doc : {};
73
+ return {
74
+ repoRoot,
75
+ appRoot,
76
+ contentRoot: await realpath(contentRoot),
77
+ framework,
78
+ documentation,
79
+ matcher: createExclusionMatcher(documentation.nonReaderPaths ?? []),
80
+ };
81
+ }
82
+ export async function readerFiles(target) {
83
+ const files = [];
84
+ const walk = async (dir) => {
85
+ const entries = await readdir(join(target.contentRoot, dir), {
86
+ withFileTypes: true,
87
+ });
88
+ for (const e of entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) {
89
+ const path = posix(join(dir, e.name));
90
+ if (e.isDirectory() && !isExcludedPath(target.matcher, path, true))
91
+ await walk(path);
92
+ else if (e.isFile() &&
93
+ /\.mdx?$/.test(e.name) &&
94
+ !isExcludedPath(target.matcher, path))
95
+ files.push(path);
96
+ }
97
+ };
98
+ await walk('');
99
+ return files;
100
+ }
101
+ export async function readPage(target, path) {
102
+ return parsePage(path, await readFile(join(target.contentRoot, path), 'utf8'));
103
+ }
104
+ /** Never follow a link outside its allowed root, including symlink escapes. */
105
+ export async function containedFile(root, path) {
106
+ if (!inside(root, path) || !(await present(path)))
107
+ return false;
108
+ return (inside(await realpath(root), await realpath(path)) &&
109
+ (await stat(path)).isFile());
110
+ }
111
+ export function readerDirectories(paths) {
112
+ const dirs = new Set(['.']);
113
+ for (const path of paths) {
114
+ let dir = dirname(path);
115
+ while (dir !== '.') {
116
+ dirs.add(posix(dir));
117
+ dir = dirname(dir);
118
+ }
119
+ }
120
+ return [...dirs].sort();
121
+ }
@@ -0,0 +1,60 @@
1
+ export interface MarkdownNode {
2
+ type: string;
3
+ name?: string;
4
+ value?: string;
5
+ depth?: number;
6
+ url?: string;
7
+ alt?: string;
8
+ identifier?: string;
9
+ lang?: string | null;
10
+ children?: MarkdownNode[];
11
+ position?: {
12
+ start: {
13
+ line: number;
14
+ column: number;
15
+ offset?: number;
16
+ };
17
+ end: {
18
+ line: number;
19
+ column: number;
20
+ offset?: number;
21
+ };
22
+ };
23
+ }
24
+ export interface PageLink {
25
+ href: string;
26
+ line: number;
27
+ image: boolean;
28
+ alt?: string;
29
+ html?: boolean;
30
+ describedBy?: string;
31
+ }
32
+ export interface ParsedPage {
33
+ path: string;
34
+ source: string;
35
+ body: string;
36
+ frontmatter: Record<string, unknown>;
37
+ frontmatterError?: string;
38
+ nodes: MarkdownNode[];
39
+ anchors: Set<string>;
40
+ links: PageLink[];
41
+ text: string;
42
+ }
43
+ export declare function nodeText(node: MarkdownNode): string;
44
+ interface HtmlNode {
45
+ nodeName: string;
46
+ value?: string;
47
+ attrs?: Array<{
48
+ name: string;
49
+ value: string;
50
+ }>;
51
+ childNodes?: HtmlNode[];
52
+ sourceCodeLocation?: {
53
+ startLine: number;
54
+ };
55
+ }
56
+ export declare function htmlNodes(html: string): HtmlNode[];
57
+ export declare function attributes(node: HtmlNode): Record<string, string>;
58
+ export declare function parsePage(path: string, source: string): ParsedPage;
59
+ export {};
60
+ //# sourceMappingURL=markdown.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"markdown.d.ts","sourceRoot":"","sources":["../../../../src/commands/docs/check/markdown.ts"],"names":[],"mappings":"AAQA,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,QAAQ,CAAC,EAAE,YAAY,EAAE,CAAC;IAC1B,QAAQ,CAAC,EAAE;QACT,KAAK,EAAE;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,MAAM,EAAE,MAAM,CAAC;YAAC,MAAM,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;QACzD,GAAG,EAAE;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,MAAM,EAAE,MAAM,CAAC;YAAC,MAAM,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;KACxD,CAAC;CACH;AACD,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,OAAO,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AACD,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,KAAK,EAAE,YAAY,EAAE,CAAC;IACtB,OAAO,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACrB,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;CACd;AACD,wBAAgB,QAAQ,CAAC,IAAI,EAAE,YAAY,GAAG,MAAM,CAInD;AACD,UAAU,QAAQ;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC/C,UAAU,CAAC,EAAE,QAAQ,EAAE,CAAC;IACxB,kBAAkB,CAAC,EAAE;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;CAC5C;AACD,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,EAAE,CAQlD;AACD,wBAAgB,UAAU,CAAC,IAAI,EAAE,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAEjE;AACD,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,UAAU,CAoGlE"}