@umami/shiso 1.4.0 → 1.6.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.
@@ -1,66 +1,5 @@
1
- //#region src/lib/search.ts
2
- /**
3
- * Restricts records to the active scope. Records without a scope id (from
4
- * single-scope indexes or older caches) always pass.
5
- */
6
- function filterRecordsByScope(records, context) {
7
- if (!context?.scopeId) return records;
8
- return records.filter((record) => !record.scopeId || record.scopeId === context.scopeId);
9
- }
10
- const SNIPPET_RADIUS = 60;
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) {
21
- const start = Math.max(0, index - SNIPPET_RADIUS);
22
- const end = Math.min(text.length, index + length + SNIPPET_RADIUS);
23
- return highlightTerms(`${start > 0 ? "…" : ""}${text.slice(start, end).trim()}${end < text.length ? "…" : ""}`, terms);
24
- }
25
- function searchIndex(records, query, limit = 10) {
26
- const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
27
- if (!terms.length) return [];
28
- const results = [];
29
- for (const record of records) {
30
- const page = record.page.toLowerCase();
31
- const heading = (record.heading || "").toLowerCase();
32
- const text = record.text.toLowerCase();
33
- let score = 0;
34
- let snippetAt = -1;
35
- let snippetLength = 0;
36
- let matched = true;
37
- for (const term of terms) if (page.includes(term)) score += page === term ? 40 : 20;
38
- else if (heading.includes(term)) score += heading === term ? 30 : 15;
39
- else {
40
- const index = text.indexOf(term);
41
- if (index === -1) {
42
- matched = false;
43
- break;
44
- }
45
- score += 5;
46
- if (snippetAt === -1) {
47
- snippetAt = index;
48
- snippetLength = term.length;
49
- }
50
- }
51
- if (!matched || !score) continue;
52
- results.push({
53
- url: record.id ? `${record.url}#${record.id}` : record.url,
54
- page: record.page,
55
- heading: record.heading,
56
- snippet: snippetAt >= 0 ? makeSnippet(record.text, snippetAt, snippetLength, terms) : void 0,
57
- score
58
- });
59
- }
60
- return results.sort((a, b) => b.score - a.score).slice(0, limit);
61
- }
1
+ import { r as searchIndex, t as filterRecordsByScope } from "./search.js";
62
2
 
63
- //#endregion
64
3
  //#region src/lib/search/providers/local.ts
65
4
  /** Built-in provider backed by the section index generated during the build. */
66
5
  function createLocalSearchProvider(_options = {}) {
@@ -0,0 +1,64 @@
1
+ //#region src/lib/search.ts
2
+ /**
3
+ * Restricts records to the active scope. Records without a scope id (from
4
+ * single-scope indexes or older caches) always pass.
5
+ */
6
+ function filterRecordsByScope(records, context) {
7
+ if (!context?.scopeId) return records;
8
+ return records.filter((record) => !record.scopeId || record.scopeId === context.scopeId);
9
+ }
10
+ const SNIPPET_RADIUS = 60;
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) {
21
+ const start = Math.max(0, index - SNIPPET_RADIUS);
22
+ const end = Math.min(text.length, index + length + SNIPPET_RADIUS);
23
+ return highlightTerms(`${start > 0 ? "…" : ""}${text.slice(start, end).trim()}${end < text.length ? "…" : ""}`, terms);
24
+ }
25
+ function searchIndex(records, query, limit = 10) {
26
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
27
+ if (!terms.length) return [];
28
+ const results = [];
29
+ for (const record of records) {
30
+ const page = record.page.toLowerCase();
31
+ const heading = (record.heading || "").toLowerCase();
32
+ const text = record.text.toLowerCase();
33
+ let score = 0;
34
+ let snippetAt = -1;
35
+ let snippetLength = 0;
36
+ let matched = true;
37
+ for (const term of terms) if (page.includes(term)) score += page === term ? 40 : 20;
38
+ else if (heading.includes(term)) score += heading === term ? 30 : 15;
39
+ else {
40
+ const index = text.indexOf(term);
41
+ if (index === -1) {
42
+ matched = false;
43
+ break;
44
+ }
45
+ score += 5;
46
+ if (snippetAt === -1) {
47
+ snippetAt = index;
48
+ snippetLength = term.length;
49
+ }
50
+ }
51
+ if (!matched || !score) continue;
52
+ results.push({
53
+ url: record.id ? `${record.url}#${record.id}` : record.url,
54
+ page: record.page,
55
+ heading: record.heading,
56
+ snippet: snippetAt >= 0 ? makeSnippet(record.text, snippetAt, snippetLength, terms) : void 0,
57
+ score
58
+ });
59
+ }
60
+ return results.sort((a, b) => b.score - a.score).slice(0, limit);
61
+ }
62
+
63
+ //#endregion
64
+ export { highlightTerms as n, searchIndex as r, filterRecordsByScope as t };
@@ -1,4 +1,4 @@
1
- import { h as BrowserRouter, p as BASE_URL, t as App } from "./chunks/App.js";
1
+ import { g as BrowserRouter, m as BASE_URL, t as App } from "./chunks/App.js";
2
2
  import { jsx } from "react/jsx-runtime";
3
3
 
4
4
  //#region src/entry-client.tsx
@@ -1,4 +1,4 @@
1
- import { _ as createPath, a as docsSite, c as getSeo, d as getLastModified, f as getScopeForPage, g as Router, i as docsHomeUrl, l as siteName, m as toAbsoluteUrl, n as buildHead, o as getLocaleByPathname, p as BASE_URL, r as renderHeadToString, s as getRedirects, t as App, u as getDocModule, v as parsePath, y as ABSOLUTE_URL_REGEX } from "./chunks/App.js";
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 `$shiso.siteUrl` is not configured, since a sitemap
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/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.",
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "@umami/shiso",
3
- "version": "1.4.0",
3
+ "version": "1.6.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",
@@ -59,7 +64,7 @@
59
64
  },
60
65
  "dependencies": {
61
66
  "@base-ui/react": "^1.7.0",
62
- "@fontsource/inter": "^5.2.5",
67
+ "@fontsource-variable/inter": "^5.3.0",
63
68
  "@fontsource/jetbrains-mono": "^5.2.5",
64
69
  "@mdx-js/react": "^3.1.1",
65
70
  "@mdx-js/rollup": "^3.1.1",
@@ -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",
@@ -12,6 +12,7 @@ const projectModules = new Set([
12
12
  '@/lib/icon-registry.generated',
13
13
  '@/lib/search-index.generated',
14
14
  'virtual:shiso-docs-config',
15
+ 'virtual:shiso-config',
15
16
  ]);
16
17
 
17
18
  const peerModules = new Set(['react', 'react-dom']);
@@ -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 = (docsJson.$shiso?.docsPrefix ?? '/docs').replace(/\/+$/, '');
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
+ }
@@ -86,7 +86,9 @@ for (const route of routes) {
86
86
  // Root entry. When the default scope's landing page is not the root itself the
87
87
  // root is a redirect; a meta refresh alone is slow and SEO-hostile, so pair it
88
88
  // with a canonical link and an immediate history-replacing navigation.
89
- if (docsHomeUrl && docsHomeUrl !== '/') {
89
+ // When a standalone page owns "/", the routes loop above already rendered the
90
+ // real home page into dist/client/index.html — do not overwrite it.
91
+ if (docsHomeUrl && docsHomeUrl !== '/' && !routes.includes('/')) {
90
92
  const target = withBase(`${docsHomeUrl}/`);
91
93
 
92
94
  await writePage(
@@ -144,7 +146,7 @@ for (const { source, destination } of redirects) {
144
146
  );
145
147
  }
146
148
 
147
- // Sitemap, only when $shiso.siteUrl provides an absolute origin.
149
+ // Sitemap, only when shiso.config siteUrl provides an absolute origin.
148
150
  const sitemapEntries = getSitemapEntries();
149
151
 
150
152
  if (sitemapEntries.length) {
@@ -52,25 +52,38 @@ export function suggestKey(unknownKey, knownKeys) {
52
52
  return bestDistance <= threshold ? best : null;
53
53
  }
54
54
 
55
+ const SHISO_KEY_MIGRATION =
56
+ '(root) "$shiso" is no longer supported in docs.json — move docsPrefix, contentDir, ' +
57
+ 'siteUrl, and locale to shiso.config.ts. See https://shiso.umami.is/docs/project-settings.';
58
+
55
59
  export function validateConfig(config, schema) {
56
60
  const ajv = new Ajv({ allErrors: true, allowUnionTypes: true });
57
61
 
58
62
  const validate = ajv.compile(schema);
59
63
  const knownKeys = getSchemaKeys(schema);
64
+ // Checked directly, not just via Ajv's additionalProperties error, so the
65
+ // migration message appears even when other errors change Ajv's output.
66
+ const hasLegacyShisoKey = !!config && typeof config === 'object' && '$shiso' in config;
67
+
68
+ if (!validate(config) || hasLegacyShisoKey) {
69
+ const errors = (validate.errors ?? [])
70
+ .filter(error => !(error.params?.additionalProperty === '$shiso' && !error.instancePath))
71
+ .map(error => {
72
+ const location = error.instancePath || '(root)';
73
+ const extra = error.params?.additionalProperty;
60
74
 
61
- if (!validate(config)) {
62
- const errors = (validate.errors ?? []).map(error => {
63
- const location = error.instancePath || '(root)';
64
- const extra = error.params?.additionalProperty;
75
+ if (extra) {
76
+ const suggestion = !error.instancePath && suggestKey(extra, knownKeys);
65
77
 
66
- if (extra) {
67
- const suggestion = !error.instancePath && suggestKey(extra, knownKeys);
78
+ return `${location} has unknown key "${extra}"${suggestion ? ` — did you mean "${suggestion}"?` : ''}`;
79
+ }
68
80
 
69
- return `${location} has unknown key "${extra}"${suggestion ? ` — did you mean "${suggestion}"?` : ''}`;
70
- }
81
+ return `${location} ${error.message}`;
82
+ });
71
83
 
72
- return `${location} ${error.message}`;
73
- });
84
+ if (hasLegacyShisoKey) {
85
+ errors.unshift(SHISO_KEY_MIGRATION);
86
+ }
74
87
 
75
88
  return { valid: false, errors };
76
89
  }