@umami/shiso 1.3.0 → 1.5.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/CHANGELOG.md +17 -0
- package/bin/shiso.mjs +10 -1
- package/config.js +8 -0
- package/dist/chunks/App.js +270 -66
- package/dist/chunks/local.js +12 -3
- package/dist/chunks/pagefind.js +100 -0
- package/dist/entry-client.js +1 -1
- package/dist/entry-server.js +16 -5
- package/dist/search.js +10 -1
- package/docs.schema.json +30 -27
- package/package.json +10 -1
- package/scripts/build-runtime.mjs +1 -0
- package/scripts/check-package.mjs +1 -0
- package/scripts/generate-icon-registry.mjs +12 -2
- package/scripts/generate-search-index.mjs +4 -3
- package/scripts/load-shiso-config.mjs +167 -0
- package/scripts/pagefind-index.mjs +127 -0
- package/scripts/prerender.mjs +4 -2
- package/scripts/validate-config.mjs +23 -10
- package/scripts/vite-docs-config.mjs +52 -13
- package/src/App.tsx +12 -3
- package/src/components/DocContent.tsx +18 -4
- package/src/components/Header.tsx +61 -24
- package/src/components/Search.tsx +29 -2
- package/src/components/TopNav.tsx +9 -7
- package/src/components/docs/Button.tsx +67 -0
- package/src/components/docs/index.ts +1 -0
- package/src/components/ui/command.tsx +2 -2
- package/src/declarations.d.ts +7 -0
- package/src/entry-server.tsx +20 -3
- package/src/lib/content.ts +10 -2
- package/src/lib/head.ts +15 -7
- package/src/lib/locale.ts +1 -1
- package/src/lib/paths.ts +11 -7
- package/src/lib/search/provider.ts +13 -2
- package/src/lib/search/providers/pagefind.ts +180 -0
- package/src/lib/search.ts +32 -4
- package/src/lib/site-config.ts +21 -3
- package/src/lib/site-model.ts +7 -2
- package/src/lib/standalone-pages.ts +129 -0
- package/src/lib/types.ts +34 -4
- package/src/pages/StandalonePage.tsx +42 -0
- package/types/config.d.ts +19 -0
- package/types/search.d.ts +9 -0
- package/vite.config.ts +58 -18
package/dist/chunks/local.js
CHANGED
|
@@ -8,10 +8,19 @@ function filterRecordsByScope(records, context) {
|
|
|
8
8
|
return records.filter((record) => !record.scopeId || record.scopeId === context.scopeId);
|
|
9
9
|
}
|
|
10
10
|
const SNIPPET_RADIUS = 60;
|
|
11
|
-
function
|
|
11
|
+
function escapeRegExp(value) {
|
|
12
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
13
|
+
}
|
|
14
|
+
/** Wraps every term occurrence in `<mark>` so the dialog can highlight it. */
|
|
15
|
+
function highlightTerms(snippet, terms) {
|
|
16
|
+
if (!terms.length) return snippet;
|
|
17
|
+
const pattern = new RegExp([...terms].sort((a, b) => b.length - a.length).map(escapeRegExp).join("|"), "gi");
|
|
18
|
+
return snippet.replace(pattern, "<mark>$&</mark>");
|
|
19
|
+
}
|
|
20
|
+
function makeSnippet(text, index, length, terms) {
|
|
12
21
|
const start = Math.max(0, index - SNIPPET_RADIUS);
|
|
13
22
|
const end = Math.min(text.length, index + length + SNIPPET_RADIUS);
|
|
14
|
-
return `${start > 0 ? "…" : ""}${text.slice(start, end).trim()}${end < text.length ? "…" : ""}
|
|
23
|
+
return highlightTerms(`${start > 0 ? "…" : ""}${text.slice(start, end).trim()}${end < text.length ? "…" : ""}`, terms);
|
|
15
24
|
}
|
|
16
25
|
function searchIndex(records, query, limit = 10) {
|
|
17
26
|
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
@@ -44,7 +53,7 @@ function searchIndex(records, query, limit = 10) {
|
|
|
44
53
|
url: record.id ? `${record.url}#${record.id}` : record.url,
|
|
45
54
|
page: record.page,
|
|
46
55
|
heading: record.heading,
|
|
47
|
-
snippet: snippetAt >= 0 ? makeSnippet(record.text, snippetAt, snippetLength) : void 0,
|
|
56
|
+
snippet: snippetAt >= 0 ? makeSnippet(record.text, snippetAt, snippetLength, terms) : void 0,
|
|
48
57
|
score
|
|
49
58
|
});
|
|
50
59
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
//#region src/lib/search/providers/pagefind.ts
|
|
2
|
+
const DEFAULT_LIMIT = 10;
|
|
3
|
+
/**
|
|
4
|
+
* Sanitizes a Pagefind excerpt: keeps the `<mark>` highlight tags (rendered
|
|
5
|
+
* as highlights by the search dialog, never as raw HTML), strips every other
|
|
6
|
+
* tag, and decodes basic HTML entities.
|
|
7
|
+
*/
|
|
8
|
+
function sanitizePagefindExcerpt(excerpt) {
|
|
9
|
+
return excerpt.replace(/<(?!\/?mark>)[^>]*>/g, "").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'").replace(/&/g, "&").trim();
|
|
10
|
+
}
|
|
11
|
+
/** Converts a Pagefind result URL into a router-relative path: strips a
|
|
12
|
+
* trailing `/index.html` and trailing slash while preserving `#anchor`. */
|
|
13
|
+
function normalizePagefindUrl(url) {
|
|
14
|
+
const hashIndex = url.indexOf("#");
|
|
15
|
+
const hash = hashIndex === -1 ? "" : url.slice(hashIndex);
|
|
16
|
+
let pathname = hashIndex === -1 ? url : url.slice(0, hashIndex);
|
|
17
|
+
pathname = pathname.replace(/\/index\.html$/, "/");
|
|
18
|
+
if (pathname.length > 1) pathname = pathname.replace(/\/+$/, "") || "/";
|
|
19
|
+
return `${pathname}${hash}`;
|
|
20
|
+
}
|
|
21
|
+
/** Maps loaded Pagefind fragments to `SearchResult`s. Each sub-result (a
|
|
22
|
+
* heading-bounded section) becomes its own result; duplicate URLs are
|
|
23
|
+
* dropped because the search dialog keys items by URL. */
|
|
24
|
+
function mapPagefindResults(fragments, limit) {
|
|
25
|
+
const results = [];
|
|
26
|
+
const seen = /* @__PURE__ */ new Set();
|
|
27
|
+
for (const { fragment, score } of fragments) {
|
|
28
|
+
const page = fragment.meta?.title || fragment.url;
|
|
29
|
+
const subResults = fragment.sub_results?.length ? fragment.sub_results : [{
|
|
30
|
+
url: fragment.url,
|
|
31
|
+
excerpt: fragment.excerpt
|
|
32
|
+
}];
|
|
33
|
+
for (const subResult of subResults) {
|
|
34
|
+
const url = normalizePagefindUrl(subResult.url);
|
|
35
|
+
if (seen.has(url)) continue;
|
|
36
|
+
seen.add(url);
|
|
37
|
+
const heading = url.includes("#") && subResult.title && subResult.title !== page ? subResult.title : void 0;
|
|
38
|
+
const excerpt = subResult.excerpt || fragment.excerpt;
|
|
39
|
+
results.push({
|
|
40
|
+
url,
|
|
41
|
+
page,
|
|
42
|
+
heading,
|
|
43
|
+
snippet: excerpt ? sanitizePagefindExcerpt(excerpt) : void 0,
|
|
44
|
+
score: score ?? 0
|
|
45
|
+
});
|
|
46
|
+
if (results.length >= limit) return results;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return results;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Built-in provider backed by a Pagefind index generated during `shiso build`.
|
|
53
|
+
* When the Pagefind bundle is unavailable (for example in dev, where no
|
|
54
|
+
* prerendered HTML exists), it falls back to the local provider.
|
|
55
|
+
*/
|
|
56
|
+
function createPagefindSearchProvider(options = {}) {
|
|
57
|
+
let backend = null;
|
|
58
|
+
async function loadBackend() {
|
|
59
|
+
const base = (import.meta.env?.BASE_URL || "/").trim().replace(/\/+$/, "");
|
|
60
|
+
try {
|
|
61
|
+
const api = await import(
|
|
62
|
+
/* @vite-ignore */
|
|
63
|
+
`${base}/pagefind/pagefind.js`
|
|
64
|
+
);
|
|
65
|
+
const ranking = options.ranking;
|
|
66
|
+
await api.options({
|
|
67
|
+
baseUrl: "/",
|
|
68
|
+
...ranking && typeof ranking === "object" ? { ranking } : {}
|
|
69
|
+
});
|
|
70
|
+
await api.init();
|
|
71
|
+
return {
|
|
72
|
+
kind: "pagefind",
|
|
73
|
+
api
|
|
74
|
+
};
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.warn("[shiso] Pagefind bundle not found — falling back to local search. This is expected in dev; run \"shiso build\" to generate the Pagefind index.", error);
|
|
77
|
+
const { createLocalSearchProvider } = await import("./local.js");
|
|
78
|
+
return {
|
|
79
|
+
kind: "local",
|
|
80
|
+
provider: createLocalSearchProvider({})
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { async search(query, limit = DEFAULT_LIMIT, context) {
|
|
85
|
+
backend ||= loadBackend();
|
|
86
|
+
const resolved = await backend;
|
|
87
|
+
if (resolved.kind === "local") return resolved.provider.search(query, limit, context);
|
|
88
|
+
const filters = context?.scopeId && context.scopeId !== "default" ? { filters: { scope: context.scopeId } } : void 0;
|
|
89
|
+
const response = await resolved.api.search(query, filters);
|
|
90
|
+
const fragments = [];
|
|
91
|
+
for (const result of response.results.slice(0, limit)) fragments.push({
|
|
92
|
+
fragment: await result.data(),
|
|
93
|
+
score: result.score
|
|
94
|
+
});
|
|
95
|
+
return mapPagefindResults(fragments, limit);
|
|
96
|
+
} };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
//#endregion
|
|
100
|
+
export { createPagefindSearchProvider };
|
package/dist/entry-client.js
CHANGED
package/dist/entry-server.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { _ as
|
|
1
|
+
import { _ as Router, a as docsSite, b as ABSOLUTE_URL_REGEX, c as getSeo, d as getDocModule, f as getLastModified, h as toAbsoluteUrl, i as docsHomeUrl, l as siteName, m as BASE_URL, n as buildHead, o as getLocaleByPathname, p as getScopeForPage, r as renderHeadToString, s as getRedirects, t as App, u as standalonePages, v as createPath, y as parsePath } from "./chunks/App.js";
|
|
2
2
|
import * as React$1 from "react";
|
|
3
3
|
import { jsx } from "react/jsx-runtime";
|
|
4
4
|
import { renderToString } from "react-dom/server";
|
|
@@ -88,7 +88,7 @@ function encodeLocation(to) {
|
|
|
88
88
|
//#region src/entry-server.tsx
|
|
89
89
|
/** Base-relative routes for every scope. The prerenderer prepends the deploy base itself. */
|
|
90
90
|
function getRoutes() {
|
|
91
|
-
return docsSite.pages.map((page) => page.url);
|
|
91
|
+
return [...docsSite.pages.map((page) => page.url), ...standalonePages.map((page) => page.path)];
|
|
92
92
|
}
|
|
93
93
|
/**
|
|
94
94
|
* Source file for every routed page, so the prerenderer can publish raw
|
|
@@ -97,14 +97,17 @@ function getRoutes() {
|
|
|
97
97
|
* "/content/docs/index.mdx", resolved against the project root.
|
|
98
98
|
*/
|
|
99
99
|
function getMarkdownPages() {
|
|
100
|
-
return docsSite.pages.map((page) => ({
|
|
100
|
+
return [...docsSite.pages.map((page) => ({
|
|
101
101
|
route: page.url,
|
|
102
102
|
filePath: page.filePath
|
|
103
|
-
}))
|
|
103
|
+
})), ...standalonePages.map((page) => ({
|
|
104
|
+
route: page.path,
|
|
105
|
+
filePath: page.filePath
|
|
106
|
+
}))];
|
|
104
107
|
}
|
|
105
108
|
/**
|
|
106
109
|
* Absolute URLs for the sitemap, honoring `seo.indexing` and per-page
|
|
107
|
-
* noindex. Empty when
|
|
110
|
+
* noindex. Empty when the shiso.config `siteUrl` is not configured, since a sitemap
|
|
108
111
|
* of relative URLs is invalid.
|
|
109
112
|
*/
|
|
110
113
|
function getSitemapEntries() {
|
|
@@ -119,6 +122,14 @@ function getSitemapEntries() {
|
|
|
119
122
|
lastmod: getLastModified(page.filePath)
|
|
120
123
|
});
|
|
121
124
|
}
|
|
125
|
+
for (const page of standalonePages) {
|
|
126
|
+
if (getDocModule(page.filePath)?.frontmatter?.noindex === true) continue;
|
|
127
|
+
const url = toAbsoluteUrl(page.path);
|
|
128
|
+
if (url) entries.push({
|
|
129
|
+
url,
|
|
130
|
+
lastmod: getLastModified(page.filePath)
|
|
131
|
+
});
|
|
132
|
+
}
|
|
122
133
|
return entries;
|
|
123
134
|
}
|
|
124
135
|
function render(url) {
|
package/dist/search.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
//#region src/lib/search/provider.ts
|
|
2
2
|
const factories = /* @__PURE__ */ new Map();
|
|
3
|
+
const BUILTIN_PROVIDERS = /* @__PURE__ */ new Set(["local", "pagefind"]);
|
|
3
4
|
function normalizeProviderId(id) {
|
|
4
5
|
return id.trim().toLowerCase();
|
|
5
6
|
}
|
|
@@ -9,7 +10,7 @@ function normalizeProviderId(id) {
|
|
|
9
10
|
*/
|
|
10
11
|
function registerSearchProvider(id, factory) {
|
|
11
12
|
const providerId = normalizeProviderId(id);
|
|
12
|
-
if (!providerId || providerId
|
|
13
|
+
if (!providerId || BUILTIN_PROVIDERS.has(providerId)) throw new Error("Search provider ids must be non-empty and cannot replace the built-in providers (\"local\", \"pagefind\").");
|
|
13
14
|
factories.set(providerId, factory);
|
|
14
15
|
return () => {
|
|
15
16
|
if (factories.get(providerId) === factory) factories.delete(providerId);
|
|
@@ -27,6 +28,14 @@ async function resolveSearchProvider(id, options = {}) {
|
|
|
27
28
|
providerId: "local",
|
|
28
29
|
fellBack: false
|
|
29
30
|
};
|
|
31
|
+
if (requestedId === "pagefind") {
|
|
32
|
+
const { createPagefindSearchProvider } = await import("./chunks/pagefind.js");
|
|
33
|
+
return {
|
|
34
|
+
provider: createPagefindSearchProvider(options),
|
|
35
|
+
providerId: "pagefind",
|
|
36
|
+
fellBack: false
|
|
37
|
+
};
|
|
38
|
+
}
|
|
30
39
|
const factory = factories.get(requestedId);
|
|
31
40
|
if (factory) return {
|
|
32
41
|
provider: await factory(options),
|
package/docs.schema.json
CHANGED
|
@@ -14,32 +14,6 @@
|
|
|
14
14
|
"allOf": [{ "$ref": "#/definitions/stringValue" }],
|
|
15
15
|
"description": "Path or URL to this schema, for editor autocomplete."
|
|
16
16
|
},
|
|
17
|
-
"$shiso": {
|
|
18
|
-
"type": "object",
|
|
19
|
-
"description": "Shiso-only options. Namespaced so the rest of the file stays portable.",
|
|
20
|
-
"additionalProperties": false,
|
|
21
|
-
"properties": {
|
|
22
|
-
"$ref": { "$ref": "#/definitions/configRef" },
|
|
23
|
-
"docsPrefix": {
|
|
24
|
-
"allOf": [{ "$ref": "#/definitions/stringValue" }],
|
|
25
|
-
"description": "Route prefix for docs pages. Defaults to \"/docs\"; use \"\" to serve docs at the site root.",
|
|
26
|
-
"default": "/docs"
|
|
27
|
-
},
|
|
28
|
-
"contentDir": {
|
|
29
|
-
"allOf": [{ "$ref": "#/definitions/stringValue" }],
|
|
30
|
-
"description": "Content directory relative to the project root.",
|
|
31
|
-
"default": "content/docs"
|
|
32
|
-
},
|
|
33
|
-
"siteUrl": {
|
|
34
|
-
"allOf": [{ "$ref": "#/definitions/stringValue" }],
|
|
35
|
-
"description": "Absolute site origin (e.g. \"https://docs.example.com\"). Required for canonical, og:url, and structured data."
|
|
36
|
-
},
|
|
37
|
-
"locale": {
|
|
38
|
-
"allOf": [{ "$ref": "#/definitions/nonEmptyStringValue" }],
|
|
39
|
-
"description": "Locale used for dates and theme-provided interface copy. Defaults to en-US."
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
},
|
|
43
17
|
"name": {
|
|
44
18
|
"allOf": [{ "$ref": "#/definitions/nonEmptyStringValue" }],
|
|
45
19
|
"description": "Site name, shown in the header and the page title."
|
|
@@ -101,6 +75,35 @@
|
|
|
101
75
|
"allOf": [{ "$ref": "#/definitions/stringValue" }],
|
|
102
76
|
"description": "Path to the favicon, relative to the public directory."
|
|
103
77
|
},
|
|
78
|
+
"pages": {
|
|
79
|
+
"type": ["array", "object"],
|
|
80
|
+
"if": { "type": "object" },
|
|
81
|
+
"then": { "$ref": "#/definitions/configRefValue" },
|
|
82
|
+
"description": "Standalone pages outside the docs navigation, such as a landing page. Each entry maps a route path to a file under content/pages.",
|
|
83
|
+
"items": {
|
|
84
|
+
"type": "object",
|
|
85
|
+
"anyOf": [{ "required": ["$ref"] }, { "required": ["path", "page"] }],
|
|
86
|
+
"additionalProperties": false,
|
|
87
|
+
"properties": {
|
|
88
|
+
"$ref": { "$ref": "#/definitions/configRef" },
|
|
89
|
+
"path": {
|
|
90
|
+
"anyOf": [
|
|
91
|
+
{ "type": "string", "pattern": "^/[^*:]*$" },
|
|
92
|
+
{ "$ref": "#/definitions/configRefValue" }
|
|
93
|
+
],
|
|
94
|
+
"description": "Route path starting with \"/\". Use \"/\" for the site home page."
|
|
95
|
+
},
|
|
96
|
+
"page": {
|
|
97
|
+
"allOf": [{ "$ref": "#/definitions/nonEmptyStringValue" }],
|
|
98
|
+
"description": "File slug under content/pages, e.g. \"home\" for content/pages/home.mdx."
|
|
99
|
+
},
|
|
100
|
+
"title": {
|
|
101
|
+
"allOf": [{ "$ref": "#/definitions/stringValue" }],
|
|
102
|
+
"description": "Page title used in the document head. Frontmatter title wins."
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
},
|
|
104
107
|
"navigation": {
|
|
105
108
|
"type": "object",
|
|
106
109
|
"description": "Site navigation. Define exactly one of: tabs, dropdowns, versions, languages, or top-level groups/pages.",
|
|
@@ -274,7 +277,7 @@
|
|
|
274
277
|
},
|
|
275
278
|
"provider": {
|
|
276
279
|
"allOf": [{ "$ref": "#/definitions/nonEmptyStringValue" }],
|
|
277
|
-
"description": "
|
|
280
|
+
"description": "Search provider id: \"local\" (default), \"pagefind\", or a provider registered at runtime."
|
|
278
281
|
},
|
|
279
282
|
"options": {
|
|
280
283
|
"type": "object",
|
package/package.json
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umami/shiso",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Open-source documentation framework for Markdown and MDX sites.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"shiso": "./bin/shiso.mjs"
|
|
8
8
|
},
|
|
9
9
|
"exports": {
|
|
10
|
+
"./config": {
|
|
11
|
+
"types": "./types/config.d.ts",
|
|
12
|
+
"default": "./config.js"
|
|
13
|
+
},
|
|
10
14
|
"./client": {
|
|
11
15
|
"types": "./types/client.d.ts",
|
|
12
16
|
"default": "./dist/entry-client.js"
|
|
@@ -22,6 +26,7 @@
|
|
|
22
26
|
},
|
|
23
27
|
"files": [
|
|
24
28
|
"bin",
|
|
29
|
+
"config.js",
|
|
25
30
|
"dist",
|
|
26
31
|
"scripts",
|
|
27
32
|
"src",
|
|
@@ -72,6 +77,7 @@
|
|
|
72
77
|
"estree-util-value-to-estree": "^3.5.0",
|
|
73
78
|
"github-slugger": "^2.0.0",
|
|
74
79
|
"highlight.js": "^11.11.1",
|
|
80
|
+
"jiti": "^2.4.0",
|
|
75
81
|
"lucide-react": "^1.28.0",
|
|
76
82
|
"react-router": "^8.3.0",
|
|
77
83
|
"rehype-autolink-headings": "^7.1.0",
|
|
@@ -89,6 +95,9 @@
|
|
|
89
95
|
"unified": "^11.0.5",
|
|
90
96
|
"vite": "^8.2.0"
|
|
91
97
|
},
|
|
98
|
+
"optionalDependencies": {
|
|
99
|
+
"pagefind": "^1.4.0"
|
|
100
|
+
},
|
|
92
101
|
"peerDependencies": {
|
|
93
102
|
"react": "^19.0.0",
|
|
94
103
|
"react-dom": "^19.0.0"
|
|
@@ -18,8 +18,18 @@ const DEFAULT_ROOT = process.cwd();
|
|
|
18
18
|
const SCAN_DIRS = ['content', 'src'];
|
|
19
19
|
const SCAN_EXTENSIONS = new Set(['.md', '.mdx', '.tsx']);
|
|
20
20
|
|
|
21
|
-
// Icons used by the docs component library itself, so pages always have a baseline
|
|
22
|
-
|
|
21
|
+
// Icons used by the docs component library itself, so pages always have a baseline
|
|
22
|
+
// set. `copy` and `external-link` back the built-in contextual menu options, whose
|
|
23
|
+
// names are assigned in site-model.ts rather than as `icon="..."` attributes.
|
|
24
|
+
const ALWAYS_INCLUDE = [
|
|
25
|
+
'rocket',
|
|
26
|
+
'code',
|
|
27
|
+
'book-open',
|
|
28
|
+
'circle-check',
|
|
29
|
+
'circle-alert',
|
|
30
|
+
'copy',
|
|
31
|
+
'external-link',
|
|
32
|
+
];
|
|
23
33
|
|
|
24
34
|
const ICON_ATTRIBUTE = /\bicon\s*=\s*["']([^"'{}\n]+)["']/g;
|
|
25
35
|
|
|
@@ -24,6 +24,7 @@ import { unified } from 'unified';
|
|
|
24
24
|
import { headingText } from './lib/mdast.mjs';
|
|
25
25
|
import { createSlugger, slugifyId } from './lib/slug.mjs';
|
|
26
26
|
import { loadDocsConfig } from './load-docs-config.mjs';
|
|
27
|
+
import { loadShisoConfig } from './load-shiso-config.mjs';
|
|
27
28
|
|
|
28
29
|
const DEFAULT_ROOT = process.cwd();
|
|
29
30
|
|
|
@@ -197,15 +198,15 @@ function collectSections(tree) {
|
|
|
197
198
|
.filter(section => section.heading || section.text);
|
|
198
199
|
}
|
|
199
200
|
|
|
200
|
-
/** @param {{ config?: object, root?: string, output?: string }} [options] */
|
|
201
|
+
/** @param {{ config?: object, shiso?: object, root?: string, output?: string }} [options] */
|
|
201
202
|
export async function generateSearchIndex({
|
|
202
203
|
config,
|
|
204
|
+
shiso,
|
|
203
205
|
root = DEFAULT_ROOT,
|
|
204
206
|
output = path.join(root, '.shiso/search-index.generated.ts'),
|
|
205
207
|
} = {}) {
|
|
206
208
|
const docsJson = config ?? (await loadDocsConfig({ root })).config;
|
|
207
|
-
const docsPrefix =
|
|
208
|
-
const contentDir = (docsJson.$shiso?.contentDir ?? 'content/docs').replace(/^\/+|\/+$/g, '');
|
|
209
|
+
const { docsPrefix, contentDir } = shiso ?? (await loadShisoConfig({ root })).config;
|
|
209
210
|
|
|
210
211
|
const seen = new Set();
|
|
211
212
|
const records = [];
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { createJiti } from 'jiti';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Loads the optional project code config (shiso.config.ts/.mjs/.js).
|
|
8
|
+
*
|
|
9
|
+
* docs.json stays declarative data; this file holds engine settings that may
|
|
10
|
+
* grow programmatic forms later (env logic, plugins). The user's file is
|
|
11
|
+
* evaluated with jiti so TypeScript works without Node type stripping; this
|
|
12
|
+
* loader itself stays plain JS per the scripts constraint.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Candidate filenames in precedence order. Exactly one may exist. */
|
|
16
|
+
export const SHISO_CONFIG_FILES = ['shiso.config.ts', 'shiso.config.mjs', 'shiso.config.js'];
|
|
17
|
+
|
|
18
|
+
const KNOWN_KEYS = ['docsPrefix', 'contentDir', 'siteUrl', 'locale'];
|
|
19
|
+
|
|
20
|
+
let importGeneration = 0;
|
|
21
|
+
|
|
22
|
+
/** Error raised while locating, evaluating, or validating shiso.config. */
|
|
23
|
+
export class ShisoConfigLoadError extends Error {
|
|
24
|
+
constructor(message, { cause, code, sourcePath } = {}) {
|
|
25
|
+
super(message, { cause });
|
|
26
|
+
this.name = 'ShisoConfigLoadError';
|
|
27
|
+
this.code = code;
|
|
28
|
+
this.sourcePath = sourcePath;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isPlainObject(value) {
|
|
33
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Strips trailing slashes; "/" and "" both normalize to "". */
|
|
37
|
+
function normalizePrefix(value) {
|
|
38
|
+
const trimmed = value.trim().replace(/\/+$/, '');
|
|
39
|
+
|
|
40
|
+
if (!trimmed || trimmed === '/') {
|
|
41
|
+
return '';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function assertStringOption(raw, key, sourcePath) {
|
|
48
|
+
if (raw[key] !== undefined && typeof raw[key] !== 'string') {
|
|
49
|
+
throw new ShisoConfigLoadError(
|
|
50
|
+
`Shiso config option "${key}" must be a string, received ${Array.isArray(raw[key]) ? 'array' : typeof raw[key]}.`,
|
|
51
|
+
{ code: 'INVALID_OPTION', sourcePath },
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Applies defaults and normalization. Single source of truth for resolved
|
|
58
|
+
* values, so runtime and build-time consumers never re-implement defaulting.
|
|
59
|
+
*/
|
|
60
|
+
export function resolveShisoConfig(raw = {}, sourcePath = null) {
|
|
61
|
+
for (const key of KNOWN_KEYS) {
|
|
62
|
+
assertStringOption(raw, key, sourcePath);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
docsPrefix: normalizePrefix(raw.docsPrefix ?? '/docs'),
|
|
67
|
+
contentDir: (raw.contentDir ?? 'content/docs').trim().replace(/^\/+|\/+$/g, ''),
|
|
68
|
+
siteUrl: raw.siteUrl?.trim().replace(/\/+$/, '') || undefined,
|
|
69
|
+
locale: raw.locale?.trim() || 'en-US',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function findConfigFiles(projectRoot) {
|
|
74
|
+
const found = [];
|
|
75
|
+
|
|
76
|
+
for (const name of SHISO_CONFIG_FILES) {
|
|
77
|
+
const candidate = path.resolve(projectRoot, name);
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const stats = await fs.stat(candidate);
|
|
81
|
+
|
|
82
|
+
if (stats.isFile()) {
|
|
83
|
+
found.push(candidate);
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// Missing candidate; the config file is optional.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return found;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Loads and resolves the project shiso config.
|
|
95
|
+
*
|
|
96
|
+
* Absent file yields resolved defaults with a null sourcePath, keeping
|
|
97
|
+
* zero-config projects zero-config.
|
|
98
|
+
*/
|
|
99
|
+
export async function loadShisoConfig({ root = process.cwd() } = {}) {
|
|
100
|
+
const projectRoot = path.resolve(root);
|
|
101
|
+
const found = await findConfigFiles(projectRoot);
|
|
102
|
+
|
|
103
|
+
if (found.length > 1) {
|
|
104
|
+
throw new ShisoConfigLoadError(
|
|
105
|
+
`Multiple shiso config files found in "${projectRoot}": ${found.map(item => path.basename(item)).join(', ')}. Keep exactly one.`,
|
|
106
|
+
{ code: 'MULTIPLE_CONFIGS', sourcePath: found[0] },
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (found.length === 0) {
|
|
111
|
+
return { config: resolveShisoConfig(), raw: {}, projectRoot, sourcePath: null, sourcePaths: [] };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const sourcePath = found[0];
|
|
115
|
+
let raw;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
if (!sourcePath.endsWith('.mjs')) {
|
|
119
|
+
// .ts and .js go through jiti, which transpiles TypeScript and handles
|
|
120
|
+
// ESM syntax in .js files regardless of the project's package type.
|
|
121
|
+
// No module or filesystem cache: nothing is written into (possibly
|
|
122
|
+
// missing) node_modules, and dev-server hot updates re-evaluate a fresh
|
|
123
|
+
// copy of the transpiled file.
|
|
124
|
+
const jiti = createJiti(import.meta.url, {
|
|
125
|
+
interopDefault: true,
|
|
126
|
+
moduleCache: false,
|
|
127
|
+
fsCache: false,
|
|
128
|
+
});
|
|
129
|
+
raw = await jiti.import(pathToFileURL(sourcePath).href, { default: true });
|
|
130
|
+
} else {
|
|
131
|
+
// Plain .mjs goes through Node's own loader; the unique cache-busting
|
|
132
|
+
// query defeats the permanent ESM module registry so edits are picked up.
|
|
133
|
+
importGeneration += 1;
|
|
134
|
+
raw = (await import(`${pathToFileURL(sourcePath).href}?v=${importGeneration}.${Date.now()}`))
|
|
135
|
+
.default;
|
|
136
|
+
}
|
|
137
|
+
} catch (error) {
|
|
138
|
+
throw new ShisoConfigLoadError(
|
|
139
|
+
`Shiso config "${sourcePath}" failed to load: ${error.message}`,
|
|
140
|
+
{ cause: error, code: 'LOAD_FAILED', sourcePath },
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!isPlainObject(raw)) {
|
|
145
|
+
throw new ShisoConfigLoadError(
|
|
146
|
+
`Shiso config "${sourcePath}" must default-export a plain object.`,
|
|
147
|
+
{ code: 'INVALID_CONFIG', sourcePath },
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const unknownKeys = Object.keys(raw).filter(key => !KNOWN_KEYS.includes(key));
|
|
152
|
+
|
|
153
|
+
if (unknownKeys.length > 0) {
|
|
154
|
+
throw new ShisoConfigLoadError(
|
|
155
|
+
`Shiso config "${sourcePath}" has unknown ${unknownKeys.length === 1 ? 'key' : 'keys'} ${unknownKeys.map(key => `"${key}"`).join(', ')}. Supported keys: ${KNOWN_KEYS.join(', ')}.`,
|
|
156
|
+
{ code: 'UNKNOWN_OPTION', sourcePath },
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
config: resolveShisoConfig(raw, sourcePath),
|
|
162
|
+
raw,
|
|
163
|
+
projectRoot,
|
|
164
|
+
sourcePath,
|
|
165
|
+
sourcePaths: [sourcePath],
|
|
166
|
+
};
|
|
167
|
+
}
|