@umami/shiso 1.11.0 → 1.13.0

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 (148) hide show
  1. package/bin/shiso.mjs +20 -1
  2. package/dist/chunks/App.js +412 -51
  3. package/dist/chunks/abnfDiagram-VCTEODGH.js +109 -0
  4. package/dist/chunks/arc.js +130 -0
  5. package/dist/chunks/architecture-7GRP2DOG.js +4 -0
  6. package/dist/chunks/architectureDiagram-5GKGNRK7.js +7466 -0
  7. package/dist/chunks/array.js +8 -0
  8. package/dist/chunks/blockDiagram-I7D4REHJ.js +2546 -0
  9. package/dist/chunks/c4Diagram-7LVT6UL2.js +3654 -0
  10. package/dist/chunks/channel.js +9 -0
  11. package/dist/chunks/chunk-2Q5K7J3B.js +21 -0
  12. package/dist/chunks/chunk-4HAMMTFA.js +6106 -0
  13. package/dist/chunks/chunk-5VM5RSS4.js +21 -0
  14. package/dist/chunks/chunk-75Z2AOVW.js +2185 -0
  15. package/dist/chunks/chunk-DU6HZSFF.js +8729 -0
  16. package/dist/chunks/chunk-F27PBJKO.js +95 -0
  17. package/dist/chunks/chunk-FOHPRMQF.js +27162 -0
  18. package/dist/chunks/chunk-GMAD6QVW.js +2369 -0
  19. package/dist/chunks/chunk-GVQU2GXP.js +58 -0
  20. package/dist/chunks/chunk-IMKFNOWR.js +2593 -0
  21. package/dist/chunks/chunk-JWPE2WC7.js +12 -0
  22. package/dist/chunks/chunk-L3NEJ4N5.js +420 -0
  23. package/dist/chunks/chunk-OSK3NFVY.js +1029 -0
  24. package/dist/chunks/chunk-P2QGCYS3.js +95 -0
  25. package/dist/chunks/chunk-POPQ4Y6H.js +33 -0
  26. package/dist/chunks/chunk-PWAF6VOD.js +467 -0
  27. package/dist/chunks/chunk-SHT3W25Y.js +4813 -0
  28. package/dist/chunks/chunk-SVP7TREG.js +686 -0
  29. package/dist/chunks/chunk-TICWLB2K.js +3800 -0
  30. package/dist/chunks/chunk-XXDRQBXY.js +12 -0
  31. package/dist/chunks/chunk-Y2CYZVJY.js +15 -0
  32. package/dist/chunks/classDiagram-ZZMXUADV.js +33 -0
  33. package/dist/chunks/classDiagram-v2-VYDZK3BY.js +33 -0
  34. package/dist/chunks/cose-bilkent-JH36ORCC.js +4268 -0
  35. package/dist/chunks/cynefin-OW5HDTMX.js +4 -0
  36. package/dist/chunks/cynefinDiagram-5FMLGOSQ.js +482 -0
  37. package/dist/chunks/cytoscape.esm.js +28282 -0
  38. package/dist/chunks/dagre-GXQ25YYZ.js +583 -0
  39. package/dist/chunks/dagre.js +4235 -0
  40. package/dist/chunks/defaultLocale.js +246 -0
  41. package/dist/chunks/diagram-S7CK7UJ4.js +402 -0
  42. package/dist/chunks/diagram-UQ7AKVKN.js +291 -0
  43. package/dist/chunks/diagram-VSXAHHWV.js +553 -0
  44. package/dist/chunks/diagram-VX7I27RA.js +798 -0
  45. package/dist/chunks/diagram-Z3DM3KII.js +186 -0
  46. package/dist/chunks/dist.js +92 -0
  47. package/dist/chunks/docs.js +989 -93
  48. package/dist/chunks/ebnfDiagram-PWID7BFC.js +123 -0
  49. package/dist/chunks/erDiagram-RLTQ6QDP.js +2295 -0
  50. package/dist/chunks/eventmodeling-NTZA5JFV.js +4 -0
  51. package/dist/chunks/flowDiagram-HODETNUW.js +17 -0
  52. package/dist/chunks/ganttDiagram-EL5Y4UJY.js +3928 -0
  53. package/dist/chunks/gitGraph-4MIJSDKK.js +4 -0
  54. package/dist/chunks/gitGraphDiagram-WWUBYQGX.js +1153 -0
  55. package/dist/chunks/graphlib.js +4194 -0
  56. package/dist/chunks/info-A6RAGUB7.js +4 -0
  57. package/dist/chunks/infoDiagram-27XIBGKW.js +25 -0
  58. package/dist/chunks/init.js +14 -0
  59. package/dist/chunks/ishikawaDiagram-5VMMS53U.js +971 -0
  60. package/dist/chunks/journeyDiagram-3NMN7TZE.js +1226 -0
  61. package/dist/chunks/kanban-definition-UXKFOSKX.js +1241 -0
  62. package/dist/chunks/katex.js +26463 -0
  63. package/dist/chunks/line.js +48 -0
  64. package/dist/chunks/linear.js +391 -0
  65. package/dist/chunks/mermaid-parser.core.js +754 -0
  66. package/dist/chunks/mermaid.core.js +4898 -0
  67. package/dist/chunks/mindmap-definition-YA3MSWOX.js +1309 -0
  68. package/dist/chunks/ordinal.js +84 -0
  69. package/dist/chunks/packet-AYTQ26CC.js +4 -0
  70. package/dist/chunks/path.js +106 -0
  71. package/dist/chunks/pegDiagram-XKGWAZYB.js +115 -0
  72. package/dist/chunks/pie-WAS4IAKB.js +4 -0
  73. package/dist/chunks/pieDiagram-E7YTZNPT.js +297 -0
  74. package/dist/chunks/quadrantDiagram-AXDQQJYC.js +2234 -0
  75. package/dist/chunks/radar-RG4KPBEZ.js +4 -0
  76. package/dist/chunks/railroad-74A4TZTK.js +4 -0
  77. package/dist/chunks/railroad-abnf-HS5TGJTU.js +4 -0
  78. package/dist/chunks/railroad-ebnf-LZEXJU2U.js +4 -0
  79. package/dist/chunks/railroad-peg-WCYAUIDC.js +4 -0
  80. package/dist/chunks/railroadDiagram-O6MQD6OU.js +89 -0
  81. package/dist/chunks/requirementDiagram-BXWQKSXE.js +2464 -0
  82. package/dist/chunks/rough.esm.js +1396 -0
  83. package/dist/chunks/sankeyDiagram-P5KCCOFB.js +1308 -0
  84. package/dist/chunks/sequenceDiagram-WJ2MYXX4.js +5445 -0
  85. package/dist/chunks/sizeCapture-INFHLROL.js +56 -0
  86. package/dist/chunks/src.js +2653 -0
  87. package/dist/chunks/stateDiagram-D77RDMKH.js +368 -0
  88. package/dist/chunks/stateDiagram-v2-MP3YSRHH.js +33 -0
  89. package/dist/chunks/swimlanes-42K2YHIH.js +6925 -0
  90. package/dist/chunks/swimlanesDiagram-VR7AAH4N.js +32 -0
  91. package/dist/chunks/timeline-definition-24CTP7MA.js +1507 -0
  92. package/dist/chunks/treeView-Q6P3EWNA.js +4 -0
  93. package/dist/chunks/treemap-WGGIJYW6.js +4 -0
  94. package/dist/chunks/vennDiagram-4TSXK5OY.js +2803 -0
  95. package/dist/chunks/wardley-WFR3VGLG.js +4 -0
  96. package/dist/chunks/wardleyDiagram-VM6X3IG4.js +877 -0
  97. package/dist/chunks/xychartDiagram-S5SC5T6Z.js +2698 -0
  98. package/dist/components.js +2 -2
  99. package/dist/entry-client.js +2 -2
  100. package/dist/entry-server.js +31 -3
  101. package/docs.schema.json +90 -0
  102. package/mdx.config.ts +13 -97
  103. package/package.json +6 -4
  104. package/scripts/build-runtime.mjs +1 -0
  105. package/scripts/check-content.mjs +358 -0
  106. package/scripts/expand-navigation-globs.mjs +277 -0
  107. package/scripts/expand-openapi-navigation.mjs +94 -0
  108. package/scripts/generate-openapi.mjs +117 -0
  109. package/scripts/generate-search-index.mjs +31 -1
  110. package/scripts/lib/openapi.mjs +653 -0
  111. package/scripts/load-docs-config.mjs +52 -6
  112. package/scripts/load-shiso-config.mjs +45 -3
  113. package/scripts/prerender.mjs +83 -3
  114. package/scripts/vite-docs-config.mjs +27 -6
  115. package/src/App.tsx +0 -1
  116. package/src/components/CodeBlock.tsx +108 -8
  117. package/src/components/DocContent.tsx +18 -0
  118. package/src/components/Docs.tsx +45 -17
  119. package/src/components/OpenApiOperation.tsx +205 -0
  120. package/src/components/SideNav.tsx +13 -2
  121. package/src/components/docs/Changelog.tsx +163 -0
  122. package/src/components/docs/CodeGroup.tsx +6 -2
  123. package/src/components/docs/Mermaid.tsx +164 -0
  124. package/src/components/docs/Panel.tsx +23 -0
  125. package/src/components/docs/ParamField.tsx +17 -5
  126. package/src/components/docs/PropertiesTable.tsx +2 -2
  127. package/src/components/docs/ResponseField.tsx +17 -5
  128. package/src/components/docs/Tiles.tsx +59 -0
  129. package/src/components/docs/Tree.tsx +359 -0
  130. package/src/components/docs/Update.tsx +72 -0
  131. package/src/components/docs/index.ts +7 -0
  132. package/src/components/docs/panel-context.tsx +25 -0
  133. package/src/components/docs/styles.ts +49 -0
  134. package/src/components/icons/index.ts +4 -0
  135. package/src/entry-server.tsx +50 -0
  136. package/src/lib/code-blocks.ts +18 -0
  137. package/src/lib/code-meta.ts +87 -0
  138. package/src/lib/docs-config.ts +4 -1
  139. package/src/lib/openapi.generated.ts +4 -0
  140. package/src/lib/openapi.ts +63 -0
  141. package/src/lib/rehype-shiki.ts +213 -0
  142. package/src/lib/site-model.ts +2 -0
  143. package/src/lib/types.ts +116 -2
  144. package/src/styles/global.css +63 -73
  145. package/types/components.d.ts +79 -0
  146. package/types/config.d.ts +11 -4
  147. package/vite.config.ts +49 -3
  148. package/CHANGELOG.md +0 -171
@@ -0,0 +1,358 @@
1
+ /**
2
+ * Validates the content graph behind docs.json.
3
+ *
4
+ * Schema validation proves the configuration has the right shape; this pass
5
+ * proves that its page references, routes, links, anchors, and local assets
6
+ * describe a site that can actually be navigated.
7
+ */
8
+ import fs from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import remarkFrontmatter from 'remark-frontmatter';
11
+ import remarkGfm from 'remark-gfm';
12
+ import remarkMdx from 'remark-mdx';
13
+ import remarkParse from 'remark-parse';
14
+ import { unified } from 'unified';
15
+ import { headingText } from './lib/mdast.mjs';
16
+ import {
17
+ loadOpenApiSpec,
18
+ normalizeOperationKey,
19
+ normalizeOperations,
20
+ operationAnchors,
21
+ } from './lib/openapi.mjs';
22
+ import { createSlugger } from './lib/slug.mjs';
23
+ import { loadDocsConfig } from './load-docs-config.mjs';
24
+ import { loadShisoConfig } from './load-shiso-config.mjs';
25
+
26
+ const MARKDOWN_EXTENSIONS = new Set(['.md', '.mdx']);
27
+ const PAGE_EXTENSIONS = ['.mdx', '.md'];
28
+ const parser = unified().use(remarkParse).use(remarkMdx).use(remarkFrontmatter).use(remarkGfm);
29
+
30
+ function normalizePageReference(value) {
31
+ const fileSlug =
32
+ String(value || '')
33
+ .trim()
34
+ .replace(/\\/g, '/')
35
+ .replace(/^\/+/, '')
36
+ .replace(/^docs\//, '')
37
+ .replace(/\.mdx?$/i, '')
38
+ .replace(/\/+$/, '') || 'index';
39
+ const routeSlug = fileSlug === 'index' ? 'index' : fileSlug.replace(/\/index$/, '') || 'index';
40
+ return { fileSlug, routeSlug };
41
+ }
42
+
43
+ function normalizeRoute(value) {
44
+ const route = String(value || '/')
45
+ .replace(/\\/g, '/')
46
+ .replace(/\/{2,}/g, '/');
47
+ const withoutIndex = route === '/index' ? '/' : route.replace(/\/index$/, '') || '/';
48
+ return withoutIndex.length > 1 ? withoutIndex.replace(/\/+$/, '') : withoutIndex;
49
+ }
50
+
51
+ function pageRoute(routeSlug, docsPrefix) {
52
+ return normalizeRoute(routeSlug === 'index' ? docsPrefix || '/' : `${docsPrefix}/${routeSlug}`);
53
+ }
54
+
55
+ async function exists(filePath) {
56
+ try {
57
+ return (await fs.stat(filePath)).isFile();
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ async function listFiles(directory) {
64
+ const files = [];
65
+ let entries;
66
+
67
+ try {
68
+ entries = await fs.readdir(directory, { withFileTypes: true });
69
+ } catch {
70
+ return files;
71
+ }
72
+
73
+ for (const entry of entries) {
74
+ const item = path.join(directory, entry.name);
75
+ if (entry.isDirectory()) {
76
+ files.push(...(await listFiles(item)));
77
+ } else if (entry.isFile()) {
78
+ files.push(item);
79
+ }
80
+ }
81
+
82
+ return files;
83
+ }
84
+
85
+ function collectPageReferences(navigation) {
86
+ const references = [];
87
+
88
+ function visitContainer(container) {
89
+ if (!container || typeof container !== 'object') return;
90
+ if (Array.isArray(container.pages)) visitItems(container.pages);
91
+ for (const key of ['tabs', 'dropdowns', 'groups', 'versions', 'languages']) {
92
+ if (Array.isArray(container[key])) container[key].forEach(visitContainer);
93
+ }
94
+ }
95
+
96
+ function visitItems(items) {
97
+ for (const item of items) {
98
+ if (typeof item === 'string') {
99
+ references.push(item);
100
+ } else if (item && typeof item === 'object') {
101
+ if (typeof item.page === 'string') references.push(item.page);
102
+ if (typeof item.root === 'string') references.push(item.root);
103
+ if (Array.isArray(item.pages)) visitItems(item.pages);
104
+ }
105
+ }
106
+ }
107
+
108
+ visitContainer(navigation);
109
+ return references;
110
+ }
111
+
112
+ async function resolveFile(root, directory, slug, extensions) {
113
+ for (const extension of extensions) {
114
+ const candidate = path.resolve(root, directory, `${slug}${extension}`);
115
+ if (await exists(candidate)) return candidate;
116
+ }
117
+ return null;
118
+ }
119
+
120
+ function walk(node, visitor) {
121
+ visitor(node);
122
+ for (const child of node.children || []) walk(child, visitor);
123
+ }
124
+
125
+ function literalAttribute(node, name) {
126
+ const attribute = (node.attributes || []).find(item => item?.name === name);
127
+ return typeof attribute?.value === 'string' ? attribute.value : null;
128
+ }
129
+
130
+ function inspectMarkdown(source, filePath) {
131
+ const tree = parser.parse(source);
132
+ const slugger = createSlugger();
133
+ const anchors = new Set();
134
+ const targets = [];
135
+
136
+ walk(tree, node => {
137
+ if (node.type === 'heading') anchors.add(slugger.slug(headingText(node)));
138
+
139
+ if (node.type === 'link' && typeof node.url === 'string') {
140
+ targets.push({ kind: 'link', value: node.url, line: node.position?.start.line || 1 });
141
+ } else if (node.type === 'image' && typeof node.url === 'string') {
142
+ targets.push({ kind: 'asset', value: node.url, line: node.position?.start.line || 1 });
143
+ } else if (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') {
144
+ const href = literalAttribute(node, 'href');
145
+ const src = literalAttribute(node, 'src');
146
+ if (href) targets.push({ kind: 'link', value: href, line: node.position?.start.line || 1 });
147
+ if (src) targets.push({ kind: 'asset', value: src, line: node.position?.start.line || 1 });
148
+ }
149
+ });
150
+
151
+ const frontmatter = source.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1] || '';
152
+ return {
153
+ filePath,
154
+ anchors,
155
+ targets,
156
+ title: /^title\s*:/m.test(frontmatter),
157
+ description: /^description\s*:/m.test(frontmatter),
158
+ openapi: frontmatter.match(/^openapi:\s*(.+)$/m)?.[1]?.trim(),
159
+ };
160
+ }
161
+
162
+ function diagnostic(relativePath, line, message) {
163
+ return `${relativePath.replace(/\\/g, '/')}:${line} ${message}`;
164
+ }
165
+
166
+ function splitTarget(raw) {
167
+ const value = raw.trim();
168
+ const hashIndex = value.indexOf('#');
169
+ const beforeHash = hashIndex >= 0 ? value.slice(0, hashIndex) : value;
170
+ return {
171
+ pathname: beforeHash.split('?')[0],
172
+ fragment: hashIndex >= 0 ? value.slice(hashIndex + 1) : '',
173
+ };
174
+ }
175
+
176
+ function isExternal(value) {
177
+ return /^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(value);
178
+ }
179
+
180
+ function resolveLinkRoute(rawPath, currentRoute) {
181
+ if (!rawPath) return currentRoute;
182
+ const value = rawPath.replace(/\.mdx?$/i, '');
183
+ if (value.startsWith('/')) return normalizeRoute(value);
184
+ return normalizeRoute(new URL(value, `https://shiso.invalid${currentRoute}`).pathname);
185
+ }
186
+
187
+ /** Returns content errors and non-fatal authoring warnings for one project. */
188
+ export async function checkContent({ root = process.cwd(), config, shiso } = {}) {
189
+ const projectRoot = path.resolve(root);
190
+ const docsConfig = config ?? (await loadDocsConfig({ root: projectRoot })).config;
191
+ const engine = shiso ?? (await loadShisoConfig({ root: projectRoot })).config;
192
+ const errors = [];
193
+ const warnings = [];
194
+ const routes = new Map();
195
+ const pages = [];
196
+ const referencedFiles = new Set();
197
+
198
+ for (const reference of collectPageReferences(docsConfig.navigation)) {
199
+ const { fileSlug, routeSlug } = normalizePageReference(reference);
200
+ const filePath = await resolveFile(projectRoot, engine.contentDir, fileSlug, PAGE_EXTENSIONS);
201
+ const route = pageRoute(routeSlug, engine.docsPrefix);
202
+
203
+ if (!filePath) {
204
+ errors.push(
205
+ `docs.json references missing page "${fileSlug}" (expected ${engine.contentDir}/${fileSlug}.mdx or .md).`,
206
+ );
207
+ continue;
208
+ }
209
+
210
+ const previous = routes.get(route);
211
+ if (previous && previous !== filePath) {
212
+ errors.push(
213
+ `Route "${route}" is produced by both "${path.relative(projectRoot, previous)}" and "${path.relative(projectRoot, filePath)}".`,
214
+ );
215
+ continue;
216
+ }
217
+ if (referencedFiles.has(filePath)) {
218
+ errors.push(`Page "${fileSlug}" is referenced more than once in navigation.`);
219
+ continue;
220
+ }
221
+
222
+ routes.set(route, filePath);
223
+ referencedFiles.add(filePath);
224
+ pages.push({ route, filePath });
225
+ }
226
+
227
+ for (const item of docsConfig.pages || []) {
228
+ const route = normalizeRoute(item?.path);
229
+ const slug =
230
+ String(item?.page || 'index')
231
+ .trim()
232
+ .replace(/\\/g, '/')
233
+ .replace(/^\/+|\/+$/g, '')
234
+ .replace(/^pages\//, '')
235
+ .replace(/\.(?:mdx?|tsx)$/i, '') || 'index';
236
+ const filePath = await resolveFile(projectRoot, 'content/pages', slug, ['.tsx', '.mdx', '.md']);
237
+
238
+ if (!filePath) {
239
+ errors.push(
240
+ `docs.json references missing standalone page "${slug}" (expected content/pages/${slug}.tsx, .mdx, or .md).`,
241
+ );
242
+ continue;
243
+ }
244
+ if (routes.has(route)) {
245
+ errors.push(
246
+ `Standalone route "${route}" collides with "${path.relative(projectRoot, routes.get(route))}".`,
247
+ );
248
+ continue;
249
+ }
250
+ routes.set(route, filePath);
251
+ referencedFiles.add(filePath);
252
+ if (MARKDOWN_EXTENSIONS.has(path.extname(filePath))) pages.push({ route, filePath });
253
+ }
254
+
255
+ // Generated OpenAPI sections render at runtime, so their anchors come from
256
+ // the spec rather than from markdown headings.
257
+ let openApiByKey;
258
+ if (docsConfig.api?.spec) {
259
+ const { spec } = await loadOpenApiSpec({ root: projectRoot, specPath: docsConfig.api.spec });
260
+ openApiByKey = new Map(normalizeOperations(spec).map(operation => [operation.key, operation]));
261
+ }
262
+
263
+ const documents = new Map();
264
+ for (const page of pages) {
265
+ const source = await fs.readFile(page.filePath, 'utf8');
266
+ const document = inspectMarkdown(source, page.filePath);
267
+ const operationKey = normalizeOperationKey(document.openapi);
268
+ const operation = operationKey ? openApiByKey?.get(operationKey) : undefined;
269
+ if (operation) {
270
+ for (const anchor of operationAnchors(operation)) {
271
+ document.anchors.add(anchor);
272
+ }
273
+ }
274
+ documents.set(page.filePath, document);
275
+ const relative = path.relative(projectRoot, page.filePath).replace(/\\/g, '/');
276
+ if (!document.title) warnings.push(`${relative} has no frontmatter title.`);
277
+ if (!document.description) warnings.push(`${relative} has no frontmatter description.`);
278
+ }
279
+
280
+ const publicRoot = path.resolve(projectRoot, 'public');
281
+ for (const page of pages) {
282
+ const document = documents.get(page.filePath);
283
+ const relative = path.relative(projectRoot, page.filePath);
284
+
285
+ for (const target of document.targets) {
286
+ if (!target.value || isExternal(target.value)) continue;
287
+ const { pathname, fragment } = splitTarget(target.value);
288
+
289
+ if (target.kind === 'asset') {
290
+ const assetPath = pathname.startsWith('/')
291
+ ? path.resolve(publicRoot, pathname.replace(/^\/+/, ''))
292
+ : path.resolve(path.dirname(page.filePath), pathname);
293
+ if (!(await exists(assetPath))) {
294
+ errors.push(
295
+ diagnostic(relative, target.line, `references missing asset "${target.value}".`),
296
+ );
297
+ }
298
+ continue;
299
+ }
300
+
301
+ const targetRoute = resolveLinkRoute(pathname, page.route);
302
+ const targetFile = routes.get(targetRoute);
303
+
304
+ if (!targetFile && pathname && /\.[a-z\d]+$/i.test(pathname) && !/\.mdx?$/i.test(pathname)) {
305
+ const publicFile = pathname.startsWith('/')
306
+ ? path.resolve(publicRoot, pathname.replace(/^\/+/, ''))
307
+ : path.resolve(path.dirname(page.filePath), pathname);
308
+ if (await exists(publicFile)) continue;
309
+ }
310
+
311
+ if (!targetFile) {
312
+ errors.push(diagnostic(relative, target.line, `links to unknown route "${target.value}".`));
313
+ continue;
314
+ }
315
+
316
+ if (fragment && MARKDOWN_EXTENSIONS.has(path.extname(targetFile))) {
317
+ const targetDocument = documents.get(targetFile);
318
+ let decoded = fragment;
319
+ try {
320
+ decoded = decodeURIComponent(fragment);
321
+ } catch {
322
+ // Report the original fragment below.
323
+ }
324
+ if (targetDocument && !targetDocument.anchors.has(decoded)) {
325
+ errors.push(
326
+ diagnostic(
327
+ relative,
328
+ target.line,
329
+ `links to missing anchor "#${fragment}" on "${targetRoute}".`,
330
+ ),
331
+ );
332
+ }
333
+ }
334
+ }
335
+ }
336
+
337
+ for (const redirect of docsConfig.redirects || []) {
338
+ if (
339
+ !isExternal(redirect.destination) &&
340
+ !routes.has(normalizeRoute(splitTarget(redirect.destination).pathname))
341
+ ) {
342
+ errors.push(
343
+ `Redirect "${redirect.source}" points to unknown route "${redirect.destination}".`,
344
+ );
345
+ }
346
+ }
347
+
348
+ const docsRoot = path.resolve(projectRoot, engine.contentDir);
349
+ for (const filePath of await listFiles(docsRoot)) {
350
+ if (MARKDOWN_EXTENSIONS.has(path.extname(filePath)) && !referencedFiles.has(filePath)) {
351
+ warnings.push(
352
+ `${path.relative(projectRoot, filePath).replace(/\\/g, '/')} is not referenced by navigation.`,
353
+ );
354
+ }
355
+ }
356
+
357
+ return { valid: errors.length === 0, errors, warnings };
358
+ }
@@ -0,0 +1,277 @@
1
+ /** Expands { glob } navigation entries into ordinary { page } entries. */
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { parse as parseYaml } from 'yaml';
5
+
6
+ const CONTENT_EXTENSIONS = new Set(['.md', '.mdx']);
7
+ const GLOB_KEYS = new Set(['glob', 'exclude']);
8
+
9
+ async function listContentFiles(directory) {
10
+ const files = [];
11
+ let entries;
12
+
13
+ try {
14
+ entries = await fs.readdir(directory, { withFileTypes: true });
15
+ } catch (error) {
16
+ if (error.code === 'ENOENT') return files;
17
+ throw error;
18
+ }
19
+
20
+ for (const entry of entries) {
21
+ const item = path.join(directory, entry.name);
22
+ if (entry.isDirectory()) {
23
+ files.push(...(await listContentFiles(item)));
24
+ } else if (entry.isFile() && CONTENT_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
25
+ files.push(item);
26
+ }
27
+ }
28
+
29
+ return files;
30
+ }
31
+
32
+ function globPattern(value) {
33
+ const pattern = String(value || '')
34
+ .trim()
35
+ .replace(/\\/g, '/')
36
+ .replace(/^\.\//, '')
37
+ .replace(/\/+$/, '');
38
+
39
+ if (!pattern || pattern.startsWith('/') || pattern.split('/').includes('..')) {
40
+ throw new Error(`Invalid navigation glob "${value}": use a relative path inside contentDir.`);
41
+ }
42
+
43
+ return pattern;
44
+ }
45
+
46
+ function globRegex(pattern) {
47
+ let source = '^';
48
+
49
+ for (let index = 0; index < pattern.length; index += 1) {
50
+ const character = pattern[index];
51
+
52
+ if (character === '*' && pattern[index + 1] === '*') {
53
+ if (pattern[index + 2] === '/') {
54
+ source += '(?:.*/)?';
55
+ index += 2;
56
+ } else {
57
+ source += '.*';
58
+ index += 1;
59
+ }
60
+ } else if (character === '*') {
61
+ source += '[^/]*';
62
+ } else if (character === '?') {
63
+ source += '[^/]';
64
+ } else {
65
+ source += character.replace(/[|\\{}()[\]^$+?.]/g, '\\$&');
66
+ }
67
+ }
68
+
69
+ return new RegExp(`${source}$`);
70
+ }
71
+
72
+ function navigationFrontmatter(source, sourcePath) {
73
+ const block = source.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1] || '';
74
+ if (!block) return {};
75
+
76
+ try {
77
+ const values = parseYaml(block);
78
+ return values && typeof values === 'object' && !Array.isArray(values) ? values : {};
79
+ } catch (error) {
80
+ throw new Error(`Could not parse frontmatter in "${sourcePath}": ${error.message}`);
81
+ }
82
+ }
83
+
84
+ function isGlobItem(value) {
85
+ return !!value && typeof value === 'object' && !Array.isArray(value) && 'glob' in value;
86
+ }
87
+
88
+ function assertGlobItem(item) {
89
+ if (typeof item.glob !== 'string' || !item.glob.trim()) {
90
+ throw new Error('Invalid navigation glob: "glob" must be a non-empty string.');
91
+ }
92
+ const unknown = Object.keys(item).filter(key => !GLOB_KEYS.has(key));
93
+ if (unknown.length) {
94
+ throw new Error(
95
+ `Invalid navigation glob "${item.glob}": unknown ${unknown.length === 1 ? 'key' : 'keys'} ${unknown.map(key => `"${key}"`).join(', ')}.`,
96
+ );
97
+ }
98
+ if (item.exclude !== undefined && !Array.isArray(item.exclude)) {
99
+ throw new Error(`Invalid navigation glob "${item.glob}": "exclude" must be an array.`);
100
+ }
101
+ if (item.exclude?.some(pattern => typeof pattern !== 'string' || !pattern.trim())) {
102
+ throw new Error(
103
+ `Invalid navigation glob "${item.glob}": every "exclude" entry must be a non-empty string.`,
104
+ );
105
+ }
106
+ }
107
+
108
+ function pageCandidate(contentRoot, filePath) {
109
+ const relativeFile = path.relative(contentRoot, filePath).replace(/\\/g, '/');
110
+ const fileSlug = relativeFile.replace(/\.mdx?$/i, '');
111
+ return { filePath, relativeFile, fileSlug };
112
+ }
113
+
114
+ function normalizePageReference(value) {
115
+ return String(value || '')
116
+ .trim()
117
+ .replace(/\\/g, '/')
118
+ .replace(/^\/+/, '')
119
+ .replace(/^docs\//, '')
120
+ .replace(/\.mdx?$/i, '')
121
+ .replace(/\/+$/, '');
122
+ }
123
+
124
+ /** Collects manual page references without crossing a version/language scope boundary. */
125
+ function collectExplicitPages(value, pages = new Set()) {
126
+ if (Array.isArray(value)) {
127
+ for (const item of value) {
128
+ if (typeof item === 'string') {
129
+ pages.add(normalizePageReference(item));
130
+ } else {
131
+ collectExplicitPages(item, pages);
132
+ }
133
+ }
134
+ return pages;
135
+ }
136
+
137
+ if (!value || typeof value !== 'object' || isGlobItem(value)) return pages;
138
+
139
+ if (typeof value.page === 'string') pages.add(normalizePageReference(value.page));
140
+ if (typeof value.root === 'string') pages.add(normalizePageReference(value.root));
141
+
142
+ for (const key of ['pages', 'groups', 'tabs', 'dropdowns']) {
143
+ if (Array.isArray(value[key])) collectExplicitPages(value[key], pages);
144
+ }
145
+
146
+ return pages;
147
+ }
148
+
149
+ function matches(candidate, pattern) {
150
+ const target = /\.mdx?$/i.test(pattern) ? candidate.relativeFile : candidate.fileSlug;
151
+ return globRegex(pattern).test(target);
152
+ }
153
+
154
+ async function expandGlob(item, candidates, projectRoot, explicitPages) {
155
+ assertGlobItem(item);
156
+ const pattern = globPattern(item.glob);
157
+ const exclusions = (item.exclude || []).map(globPattern);
158
+ const matched = candidates.filter(
159
+ candidate =>
160
+ matches(candidate, pattern) && !exclusions.some(exclusion => matches(candidate, exclusion)),
161
+ );
162
+
163
+ if (!matched.length) {
164
+ throw new Error(`Navigation glob "${pattern}" matched no Markdown or MDX files.`);
165
+ }
166
+
167
+ // Manual entries own their page's placement and presentation, regardless of
168
+ // whether they appear before or after the glob that would otherwise include it.
169
+ const discovered = matched.filter(candidate => !explicitPages.has(candidate.fileSlug));
170
+
171
+ const entries = await Promise.all(
172
+ discovered.map(async candidate => {
173
+ const source = await fs.readFile(candidate.filePath, 'utf8');
174
+ const sourcePath = path.relative(projectRoot, candidate.filePath).replace(/\\/g, '/');
175
+ const frontmatter = navigationFrontmatter(source, sourcePath);
176
+ const title =
177
+ typeof frontmatter.sidebarTitle === 'string' && frontmatter.sidebarTitle
178
+ ? frontmatter.sidebarTitle
179
+ : typeof frontmatter.title === 'string' && frontmatter.title
180
+ ? frontmatter.title
181
+ : undefined;
182
+
183
+ return {
184
+ page: candidate.fileSlug,
185
+ ...(title ? { title } : {}),
186
+ ...(frontmatter.hidden === true ? { hidden: true } : {}),
187
+ order: typeof frontmatter.order === 'number' ? frontmatter.order : Number.POSITIVE_INFINITY,
188
+ };
189
+ }),
190
+ );
191
+
192
+ entries.sort((left, right) => left.order - right.order || left.page.localeCompare(right.page));
193
+ return entries.map(({ order: _order, ...entry }) => entry);
194
+ }
195
+
196
+ /** True when a navigation tree contains at least one { glob } page entry. */
197
+ export function hasNavigationGlobs(navigation) {
198
+ if (Array.isArray(navigation)) return navigation.some(hasNavigationGlobs);
199
+ if (!navigation || typeof navigation !== 'object') return false;
200
+ if (isGlobItem(navigation)) return true;
201
+ return Object.values(navigation).some(hasNavigationGlobs);
202
+ }
203
+
204
+ /** Returns a config copy whose navigation globs are ordinary page objects. */
205
+ export async function expandNavigationGlobs(config, { root, contentDir }) {
206
+ if (!hasNavigationGlobs(config.navigation)) return config;
207
+
208
+ const projectRoot = path.resolve(root);
209
+ const contentRoot = path.resolve(projectRoot, contentDir);
210
+ const files = await listContentFiles(contentRoot);
211
+ const candidates = files.map(filePath => pageCandidate(contentRoot, filePath));
212
+ const duplicateSlugs = candidates.filter(
213
+ (candidate, index) =>
214
+ candidates.findIndex(item => item.fileSlug === candidate.fileSlug) !== index,
215
+ );
216
+
217
+ if (duplicateSlugs.length) {
218
+ throw new Error(
219
+ `Navigation globs found both .md and .mdx for "${duplicateSlugs[0].fileSlug}" in ${contentDir}.`,
220
+ );
221
+ }
222
+
223
+ async function expandObject(value, explicitPages) {
224
+ if (Array.isArray(value)) {
225
+ return Promise.all(value.map(item => expandObject(item, explicitPages)));
226
+ }
227
+ if (!value || typeof value !== 'object') return value;
228
+
229
+ const entries = await Promise.all(
230
+ Object.entries(value).map(async ([key, child]) => {
231
+ if (key === 'pages' && Array.isArray(child)) {
232
+ const expanded = [];
233
+ for (const item of child) {
234
+ if (isGlobItem(item)) {
235
+ expanded.push(...(await expandGlob(item, candidates, projectRoot, explicitPages)));
236
+ } else {
237
+ expanded.push(await expandObject(item, explicitPages));
238
+ }
239
+ }
240
+ return [key, expanded];
241
+ }
242
+ return [key, await expandObject(child, explicitPages)];
243
+ }),
244
+ );
245
+ return Object.fromEntries(entries);
246
+ }
247
+
248
+ async function expandScope(scope) {
249
+ return expandObject(scope, collectExplicitPages(scope));
250
+ }
251
+
252
+ async function expandNavigation(navigation) {
253
+ if (Array.isArray(navigation.languages)) {
254
+ const languages = await Promise.all(
255
+ navigation.languages.map(async language => {
256
+ if (!Array.isArray(language.versions)) return expandScope(language);
257
+ return {
258
+ ...language,
259
+ versions: await Promise.all(language.versions.map(expandScope)),
260
+ };
261
+ }),
262
+ );
263
+ return { ...navigation, languages };
264
+ }
265
+
266
+ if (Array.isArray(navigation.versions)) {
267
+ return {
268
+ ...navigation,
269
+ versions: await Promise.all(navigation.versions.map(expandScope)),
270
+ };
271
+ }
272
+
273
+ return expandScope(navigation);
274
+ }
275
+
276
+ return { ...config, navigation: await expandNavigation(config.navigation) };
277
+ }