@umami/shiso 1.11.0 → 1.12.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.
- package/bin/shiso.mjs +20 -1
- package/dist/chunks/App.js +360 -26
- package/dist/chunks/docs.js +2 -2
- package/dist/entry-client.js +1 -1
- package/dist/entry-server.js +30 -2
- package/docs.schema.json +90 -0
- package/mdx.config.ts +13 -97
- package/package.json +5 -4
- package/scripts/build-runtime.mjs +1 -0
- package/scripts/check-content.mjs +358 -0
- package/scripts/expand-navigation-globs.mjs +208 -0
- package/scripts/expand-openapi-navigation.mjs +94 -0
- package/scripts/generate-openapi.mjs +117 -0
- package/scripts/generate-search-index.mjs +31 -1
- package/scripts/lib/openapi.mjs +653 -0
- package/scripts/load-docs-config.mjs +52 -6
- package/scripts/load-shiso-config.mjs +45 -3
- package/scripts/prerender.mjs +83 -3
- package/scripts/vite-docs-config.mjs +27 -6
- package/src/App.tsx +0 -1
- package/src/components/CodeBlock.tsx +72 -8
- package/src/components/DocContent.tsx +18 -0
- package/src/components/Docs.tsx +6 -1
- package/src/components/OpenApiOperation.tsx +197 -0
- package/src/components/SideNav.tsx +8 -2
- package/src/components/docs/CodeGroup.tsx +6 -2
- package/src/entry-server.tsx +50 -0
- package/src/lib/code-blocks.ts +18 -0
- package/src/lib/code-meta.ts +87 -0
- package/src/lib/docs-config.ts +4 -1
- package/src/lib/openapi.generated.ts +4 -0
- package/src/lib/openapi.ts +63 -0
- package/src/lib/rehype-shiki.ts +196 -0
- package/src/lib/site-model.ts +2 -0
- package/src/lib/types.ts +116 -2
- package/src/styles/global.css +63 -73
- package/types/config.d.ts +11 -4
- package/vite.config.ts +49 -3
- package/CHANGELOG.md +0 -171
package/docs.schema.json
CHANGED
|
@@ -172,6 +172,37 @@
|
|
|
172
172
|
],
|
|
173
173
|
"description": "Page eyebrow style: the section name (default) or the full navigation path.",
|
|
174
174
|
"default": "section"
|
|
175
|
+
},
|
|
176
|
+
"codeBlocks": {
|
|
177
|
+
"type": "object",
|
|
178
|
+
"description": "Fenced code block defaults: line numbers and the Shiki light/dark themes.",
|
|
179
|
+
"additionalProperties": false,
|
|
180
|
+
"properties": {
|
|
181
|
+
"$ref": { "$ref": "#/definitions/configRef" },
|
|
182
|
+
"lineNumbers": {
|
|
183
|
+
"allOf": [{ "$ref": "#/definitions/booleanValue" }],
|
|
184
|
+
"description": "Show line numbers on every block. Override per block with showLineNumbers or hideLineNumbers in the fence meta.",
|
|
185
|
+
"default": false
|
|
186
|
+
},
|
|
187
|
+
"theme": {
|
|
188
|
+
"type": "object",
|
|
189
|
+
"description": "Bundled Shiki theme names for each color mode (https://shiki.style/themes).",
|
|
190
|
+
"additionalProperties": false,
|
|
191
|
+
"properties": {
|
|
192
|
+
"$ref": { "$ref": "#/definitions/configRef" },
|
|
193
|
+
"light": {
|
|
194
|
+
"allOf": [{ "$ref": "#/definitions/nonEmptyStringValue" }],
|
|
195
|
+
"description": "Theme used in light mode.",
|
|
196
|
+
"default": "github-light"
|
|
197
|
+
},
|
|
198
|
+
"dark": {
|
|
199
|
+
"allOf": [{ "$ref": "#/definitions/nonEmptyStringValue" }],
|
|
200
|
+
"description": "Theme used in dark mode.",
|
|
201
|
+
"default": "github-dark"
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
175
206
|
}
|
|
176
207
|
}
|
|
177
208
|
},
|
|
@@ -567,6 +598,24 @@
|
|
|
567
598
|
}
|
|
568
599
|
}
|
|
569
600
|
},
|
|
601
|
+
"api": {
|
|
602
|
+
"type": "object",
|
|
603
|
+
"description": "API reference settings: the OpenAPI spec powering openapi frontmatter pages.",
|
|
604
|
+
"anyOf": [{ "required": ["$ref"] }, { "required": ["spec"] }],
|
|
605
|
+
"additionalProperties": false,
|
|
606
|
+
"properties": {
|
|
607
|
+
"$ref": { "$ref": "#/definitions/configRef" },
|
|
608
|
+
"spec": {
|
|
609
|
+
"allOf": [{ "$ref": "#/definitions/nonEmptyStringValue" }],
|
|
610
|
+
"description": "Path to a local OpenAPI 3.0 or 3.1 spec (JSON or YAML), relative to the project root."
|
|
611
|
+
},
|
|
612
|
+
"directory": {
|
|
613
|
+
"allOf": [{ "$ref": "#/definitions/nonEmptyStringValue" }],
|
|
614
|
+
"description": "Folder inside the content directory for generated endpoint pages.",
|
|
615
|
+
"default": "api-reference"
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
},
|
|
570
619
|
"errors": {
|
|
571
620
|
"type": "object",
|
|
572
621
|
"description": "Error handling settings.",
|
|
@@ -712,6 +761,8 @@
|
|
|
712
761
|
"description": "Page reference relative to the content directory, without extension (e.g. \"components/tabs\")."
|
|
713
762
|
},
|
|
714
763
|
{ "$ref": "#/definitions/pageObject" },
|
|
764
|
+
{ "$ref": "#/definitions/globItem" },
|
|
765
|
+
{ "$ref": "#/definitions/openapiItem" },
|
|
715
766
|
{ "$ref": "#/definitions/linkItem" },
|
|
716
767
|
{ "$ref": "#/definitions/group" }
|
|
717
768
|
]
|
|
@@ -736,12 +787,51 @@
|
|
|
736
787
|
},
|
|
737
788
|
"icon": { "allOf": [{ "$ref": "#/definitions/stringValue" }], "description": "Icon name shown beside the label." },
|
|
738
789
|
"tag": { "allOf": [{ "$ref": "#/definitions/stringValue" }], "description": "Short label shown after the page name." },
|
|
790
|
+
"method": {
|
|
791
|
+
"anyOf": [
|
|
792
|
+
{ "enum": ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE"] },
|
|
793
|
+
{ "$ref": "#/definitions/configRefValue" }
|
|
794
|
+
],
|
|
795
|
+
"description": "HTTP method badge shown after the page name on API reference pages."
|
|
796
|
+
},
|
|
739
797
|
"hidden": {
|
|
740
798
|
"allOf": [{ "$ref": "#/definitions/booleanValue" }],
|
|
741
799
|
"description": "Hide from the sidebar and search. The page is still built and reachable by URL."
|
|
742
800
|
}
|
|
743
801
|
}
|
|
744
802
|
},
|
|
803
|
+
"globItem": {
|
|
804
|
+
"type": "object",
|
|
805
|
+
"required": ["glob"],
|
|
806
|
+
"additionalProperties": false,
|
|
807
|
+
"properties": {
|
|
808
|
+
"glob": {
|
|
809
|
+
"allOf": [{ "$ref": "#/definitions/nonEmptyStringValue" }],
|
|
810
|
+
"description": "Glob relative to contentDir. Matching Markdown and MDX files are expanded into page entries."
|
|
811
|
+
},
|
|
812
|
+
"exclude": {
|
|
813
|
+
"anyOf": [
|
|
814
|
+
{
|
|
815
|
+
"type": "array",
|
|
816
|
+
"items": { "$ref": "#/definitions/nonEmptyStringValue" }
|
|
817
|
+
},
|
|
818
|
+
{ "$ref": "#/definitions/configRefValue" }
|
|
819
|
+
],
|
|
820
|
+
"description": "Optional glob patterns to omit from the matches."
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
},
|
|
824
|
+
"openapiItem": {
|
|
825
|
+
"type": "object",
|
|
826
|
+
"required": ["openapi"],
|
|
827
|
+
"additionalProperties": false,
|
|
828
|
+
"properties": {
|
|
829
|
+
"openapi": {
|
|
830
|
+
"anyOf": [{ "type": "boolean" }, { "$ref": "#/definitions/nonEmptyStringValue" }],
|
|
831
|
+
"description": "Expand API endpoints into pages: true for every tag (grouped), or a tag name for that tag's operations. Requires the api.spec setting."
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
},
|
|
745
835
|
"linkItem": {
|
|
746
836
|
"type": "object",
|
|
747
837
|
"description": "An external link in the navigation tree.",
|
package/mdx.config.ts
CHANGED
|
@@ -1,104 +1,19 @@
|
|
|
1
1
|
import mdx from '@mdx-js/rollup';
|
|
2
2
|
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
|
|
3
|
-
import rehypeHighlight from 'rehype-highlight';
|
|
4
3
|
import rehypeSlug from 'rehype-slug';
|
|
5
4
|
import remarkFrontmatter from 'remark-frontmatter';
|
|
6
5
|
import remarkGfm from 'remark-gfm';
|
|
7
6
|
import remarkMdxFrontmatter from 'remark-mdx-frontmatter';
|
|
8
7
|
import type { Plugin } from 'vite';
|
|
9
|
-
import {
|
|
8
|
+
import { resolveCodeBlockConfig } from './src/lib/code-blocks.ts';
|
|
9
|
+
import { type MdNode, toText } from './src/lib/mdast.ts';
|
|
10
|
+
import { rehypeShiki } from './src/lib/rehype-shiki.ts';
|
|
10
11
|
import { remarkToc } from './src/lib/remark-toc.ts';
|
|
12
|
+
import type { MdxConfig, ResolvedCodeBlockConfig } from './src/lib/types.ts';
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
return (tree: MdNode) => {
|
|
16
|
-
const visit = (node: MdNode) => {
|
|
17
|
-
if (node.type === 'code' && node.meta?.trim()) {
|
|
18
|
-
const meta = node.meta.trim();
|
|
19
|
-
const titleMatch = meta.match(/(?:^|\s)title=(?:"([^"]+)"|'([^']+)'|([^\s]+))/);
|
|
20
|
-
const title = titleMatch ? titleMatch[1] || titleMatch[2] || titleMatch[3] : meta;
|
|
21
|
-
const hProperties = (node.data?.hProperties || {}) as Record<string, unknown>;
|
|
22
|
-
node.data = {
|
|
23
|
-
...node.data,
|
|
24
|
-
hProperties: { ...hProperties, 'data-title': title },
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
node.children?.forEach(visit);
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
visit(tree);
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function rehypeWrapJsxForHighlighting() {
|
|
36
|
-
return (tree: MdNode) => {
|
|
37
|
-
walkTree(tree, node => {
|
|
38
|
-
if (node.tagName !== 'code') {
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const properties = (node.properties || {}) as Record<string, unknown>;
|
|
43
|
-
const className = properties.className;
|
|
44
|
-
const classes = Array.isArray(className) ? className.map(String) : [String(className || '')];
|
|
45
|
-
const source = toText(node);
|
|
46
|
-
|
|
47
|
-
if (!classes.some(value => /\blanguage-(?:jsx|tsx)\b/.test(value))) {
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
if (!/^\s*<[A-Z][\w.]*(?:\s|>|\/)/.test(source)) {
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
properties[SYNTHETIC_FRAGMENT] = true;
|
|
56
|
-
node.properties = properties;
|
|
57
|
-
node.children = [{ type: 'text', value: `<>\n${source}\n</>` }];
|
|
58
|
-
});
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function boundaryText(node: MdNode, fromEnd = false): MdNode | undefined {
|
|
63
|
-
if (node.type === 'text' && typeof node.value === 'string') {
|
|
64
|
-
return node;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const children = fromEnd ? [...(node.children || [])].reverse() : node.children || [];
|
|
68
|
-
for (const child of children) {
|
|
69
|
-
const match = boundaryText(child, fromEnd);
|
|
70
|
-
if (match) {
|
|
71
|
-
return match;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return undefined;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function rehypeRemoveHighlightingFragments() {
|
|
79
|
-
return (tree: MdNode) => {
|
|
80
|
-
walkTree(tree, node => {
|
|
81
|
-
if (node.tagName !== 'code') {
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const properties = (node.properties || {}) as Record<string, unknown>;
|
|
86
|
-
if (!properties[SYNTHETIC_FRAGMENT]) {
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const first = boundaryText(node);
|
|
91
|
-
const last = boundaryText(node, true);
|
|
92
|
-
if (first?.value) {
|
|
93
|
-
first.value = first.value.replace(/^<>\r?\n/, '');
|
|
94
|
-
}
|
|
95
|
-
if (last?.value) {
|
|
96
|
-
last.value = last.value.replace(/\r?\n<\/>$/, '');
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
delete properties[SYNTHETIC_FRAGMENT];
|
|
100
|
-
});
|
|
101
|
-
};
|
|
14
|
+
export interface ShisoMdxOptions extends MdxConfig {
|
|
15
|
+
/** Resolved docs.json `styling.codeBlocks`; defaults apply when omitted. */
|
|
16
|
+
codeBlocks?: ResolvedCodeBlockConfig;
|
|
102
17
|
}
|
|
103
18
|
|
|
104
19
|
function rehypeZoomableImages() {
|
|
@@ -142,7 +57,9 @@ function rehypeZoomableImages() {
|
|
|
142
57
|
* The MDX compilation pipeline, shared by the app build and the test runner so
|
|
143
58
|
* tests exercise the same transforms the site ships with.
|
|
144
59
|
*/
|
|
145
|
-
export function shisoMdx(): Plugin {
|
|
60
|
+
export function shisoMdx(options: ShisoMdxOptions = {}): Plugin {
|
|
61
|
+
const codeBlocks = options.codeBlocks || resolveCodeBlockConfig();
|
|
62
|
+
|
|
146
63
|
return {
|
|
147
64
|
// Must run before vite:react-babel so MDX is compiled to JSX first.
|
|
148
65
|
enforce: 'pre',
|
|
@@ -152,13 +69,12 @@ export function shisoMdx(): Plugin {
|
|
|
152
69
|
remarkFrontmatter,
|
|
153
70
|
remarkMdxFrontmatter,
|
|
154
71
|
remarkGfm,
|
|
155
|
-
|
|
72
|
+
...(options.remarkPlugins || []),
|
|
156
73
|
remarkToc,
|
|
157
74
|
],
|
|
158
75
|
rehypePlugins: [
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
rehypeRemoveHighlightingFragments,
|
|
76
|
+
...(options.rehypePlugins || []),
|
|
77
|
+
[rehypeShiki, codeBlocks],
|
|
162
78
|
rehypeZoomableImages,
|
|
163
79
|
rehypeSlug,
|
|
164
80
|
[
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umami/shiso",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"description": "Open-source documentation framework for Markdown and MDX sites.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
"@fontsource/jetbrains-mono": "^5.2.5",
|
|
73
73
|
"@mdx-js/react": "^3.1.1",
|
|
74
74
|
"@mdx-js/rollup": "^3.1.1",
|
|
75
|
+
"@shikijs/transformers": "^4.4.3",
|
|
75
76
|
"@tailwindcss/vite": "^4.3.3",
|
|
76
77
|
"@vitejs/plugin-react": "^6.0.5",
|
|
77
78
|
"ajv": "^8.20.0",
|
|
@@ -80,24 +81,24 @@
|
|
|
80
81
|
"cmdk": "^1.1.1",
|
|
81
82
|
"estree-util-value-to-estree": "^3.5.0",
|
|
82
83
|
"github-slugger": "^2.0.0",
|
|
83
|
-
"highlight.js": "^11.11.1",
|
|
84
84
|
"jiti": "^2.4.0",
|
|
85
85
|
"lucide-react": "^1.28.0",
|
|
86
86
|
"react-router": "^8.3.0",
|
|
87
87
|
"rehype-autolink-headings": "^7.1.0",
|
|
88
|
-
"rehype-highlight": "^7.0.1",
|
|
89
88
|
"rehype-slug": "^6.0.0",
|
|
90
89
|
"remark-frontmatter": "^5.0.0",
|
|
91
90
|
"remark-gfm": "^4.0.0",
|
|
92
91
|
"remark-mdx": "^3.1.1",
|
|
93
92
|
"remark-mdx-frontmatter": "^5.2.0",
|
|
94
93
|
"remark-parse": "^11.0.0",
|
|
94
|
+
"shiki": "^4.4.3",
|
|
95
95
|
"simple-icons": "^16.28.0",
|
|
96
96
|
"tailwind-merge": "^3.6.0",
|
|
97
97
|
"tailwindcss": "^4.3.3",
|
|
98
98
|
"tw-animate-css": "^1.4.0",
|
|
99
99
|
"unified": "^11.0.5",
|
|
100
|
-
"vite": "^8.2.0"
|
|
100
|
+
"vite": "^8.2.0",
|
|
101
|
+
"yaml": "^2.9.0"
|
|
101
102
|
},
|
|
102
103
|
"optionalDependencies": {
|
|
103
104
|
"pagefind": "^1.4.0"
|
|
@@ -10,6 +10,7 @@ const outputRoot = path.join(packageRoot, 'dist');
|
|
|
10
10
|
const projectModules = new Set([
|
|
11
11
|
'@/generated/last-modified',
|
|
12
12
|
'@/lib/icon-registry.generated',
|
|
13
|
+
'@/lib/openapi.generated',
|
|
13
14
|
'@/lib/search-index.generated',
|
|
14
15
|
'virtual:shiso-docs-config',
|
|
15
16
|
'virtual:shiso-config',
|
|
@@ -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
|
+
}
|