@umami/shiso 0.55.0 → 1.0.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 (104) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +9 -49
  3. package/bin/shiso.mjs +132 -0
  4. package/docs.schema.json +896 -0
  5. package/mdx.config.ts +143 -0
  6. package/package.json +74 -83
  7. package/scripts/check-package.mjs +54 -0
  8. package/scripts/generate-icon-registry.mjs +196 -0
  9. package/scripts/generate-last-modified.mjs +128 -0
  10. package/scripts/generate-search-index.mjs +298 -0
  11. package/scripts/lib/mdast.mjs +23 -0
  12. package/scripts/lib/slug.mjs +21 -0
  13. package/scripts/load-docs-config.mjs +244 -0
  14. package/scripts/prerender.mjs +194 -0
  15. package/scripts/validate-config.mjs +104 -0
  16. package/scripts/vite-docs-config.mjs +60 -0
  17. package/src/App.tsx +38 -0
  18. package/src/components/Banner.tsx +69 -0
  19. package/src/components/CodeBlock.tsx +46 -0
  20. package/src/components/ConfiguredIcon.tsx +15 -0
  21. package/src/components/ContextualMenu.tsx +93 -0
  22. package/src/components/DocContent.tsx +105 -0
  23. package/src/components/Docs.tsx +134 -0
  24. package/src/components/Footer.tsx +81 -0
  25. package/src/components/Header.tsx +82 -0
  26. package/src/components/LanguageSwitcher.tsx +59 -0
  27. package/src/components/Layout.tsx +24 -0
  28. package/src/components/PageLinks.tsx +71 -0
  29. package/src/components/Search.tsx +217 -0
  30. package/src/components/SideNav.tsx +347 -0
  31. package/src/components/SocialIcon.tsx +88 -0
  32. package/src/components/ThemeToggle.tsx +34 -0
  33. package/src/components/TopNav.tsx +127 -0
  34. package/src/components/VersionSwitcher.tsx +60 -0
  35. package/src/components/docs/Accordion.tsx +68 -0
  36. package/src/components/docs/Badge.tsx +171 -0
  37. package/src/components/docs/Callout.tsx +73 -0
  38. package/src/components/docs/Card.tsx +158 -0
  39. package/src/components/docs/CodeGroup.tsx +73 -0
  40. package/src/components/docs/Columns.tsx +20 -0
  41. package/src/components/docs/Expandable.tsx +28 -0
  42. package/src/components/docs/Frame.tsx +56 -0
  43. package/src/components/docs/Icon.tsx +30 -0
  44. package/src/components/docs/ParamField.tsx +45 -0
  45. package/src/components/docs/PropertiesTable.tsx +84 -0
  46. package/src/components/docs/ResponseField.tsx +36 -0
  47. package/src/components/docs/Steps.tsx +47 -0
  48. package/src/components/docs/Tabs.tsx +116 -0
  49. package/src/components/docs/Tooltip.tsx +21 -0
  50. package/src/components/docs/index.ts +15 -0
  51. package/src/components/docs/styles.ts +82 -0
  52. package/src/components/docs/utils.ts +118 -0
  53. package/src/components/icons/index.ts +17 -0
  54. package/src/components/ui/accordion.tsx +69 -0
  55. package/src/components/ui/alert.tsx +69 -0
  56. package/src/components/ui/badge.tsx +49 -0
  57. package/src/components/ui/button.tsx +58 -0
  58. package/src/components/ui/card.tsx +88 -0
  59. package/src/components/ui/collapsible.tsx +15 -0
  60. package/src/components/ui/command.tsx +173 -0
  61. package/src/components/ui/dialog.tsx +137 -0
  62. package/src/components/ui/dropdown-menu.tsx +257 -0
  63. package/src/components/ui/scroll-area.tsx +71 -0
  64. package/src/components/ui/sheet.tsx +124 -0
  65. package/src/components/ui/tabs.tsx +73 -0
  66. package/src/components/ui/tooltip.tsx +52 -0
  67. package/src/declarations.d.ts +9 -0
  68. package/src/entry-client.tsx +17 -0
  69. package/src/entry-server.tsx +89 -0
  70. package/src/generated/last-modified.ts +2 -0
  71. package/src/lib/content.ts +44 -0
  72. package/src/lib/docs-config.ts +986 -0
  73. package/src/lib/head.ts +231 -0
  74. package/src/lib/icon-registry.generated.ts +4 -0
  75. package/src/lib/icons.ts +29 -0
  76. package/src/lib/inline-markdown.tsx +86 -0
  77. package/src/lib/locale.ts +39 -0
  78. package/src/lib/mdast.ts +56 -0
  79. package/src/lib/paths.ts +86 -0
  80. package/src/lib/remark-toc.ts +71 -0
  81. package/src/lib/search/config.ts +43 -0
  82. package/src/lib/search/provider.ts +85 -0
  83. package/src/lib/search/providers/local.ts +15 -0
  84. package/src/lib/search-index.generated.ts +4 -0
  85. package/src/lib/search.ts +128 -0
  86. package/src/lib/site-config.ts +117 -0
  87. package/src/lib/site-model.ts +221 -0
  88. package/src/lib/slug.ts +38 -0
  89. package/src/lib/types.ts +515 -0
  90. package/src/lib/utils.ts +6 -0
  91. package/src/pages/DocPage.tsx +33 -0
  92. package/src/styles/global.css +268 -0
  93. package/src/styles/tokens.css +114 -0
  94. package/types/client.d.ts +3 -0
  95. package/types/search.d.ts +27 -0
  96. package/vite.config.ts +342 -0
  97. package/LICENSE +0 -21
  98. package/dist/index.css +0 -189
  99. package/dist/index.d.ts +0 -57
  100. package/dist/index.js +0 -464
  101. package/dist/index.mjs +0 -437
  102. package/server/index.d.ts +0 -30
  103. package/server/index.js +0 -189
  104. package/styles.css +0 -4766
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Generates the project-local .shiso/search-index.generated.ts cache.
3
+ *
4
+ * Client-side search needs the plain text of every page, which only exists in
5
+ * the MDX sources at build time. Each navigable page is parsed to mdast and
6
+ * split into heading-bounded sections; headings share the slug algorithm with
7
+ * rehype-slug (src/lib/slug.ts), so result anchors always match rendered ids.
8
+ *
9
+ * Pages marked `hidden` in navigation are excluded, matching their exclusion
10
+ * from the sidebar and sitemap.
11
+ *
12
+ * Run via `pnpm search:index`, or automatically by the Vite plugin in
13
+ * vite.config.ts. The generated module is dynamically imported by the search
14
+ * dialog, so Vite splits it out of the initial bundle.
15
+ */
16
+ import fs from 'node:fs/promises';
17
+ import path from 'node:path';
18
+ import { pathToFileURL } from 'node:url';
19
+ import remarkFrontmatter from 'remark-frontmatter';
20
+ import remarkGfm from 'remark-gfm';
21
+ import remarkMdx from 'remark-mdx';
22
+ import remarkParse from 'remark-parse';
23
+ import { unified } from 'unified';
24
+ import { headingText } from './lib/mdast.mjs';
25
+ import { createSlugger, slugifyId } from './lib/slug.mjs';
26
+ import { loadDocsConfig } from './load-docs-config.mjs';
27
+
28
+ const DEFAULT_ROOT = process.cwd();
29
+
30
+ const parser = unified().use(remarkParse).use(remarkMdx).use(remarkFrontmatter).use(remarkGfm);
31
+
32
+ /** Mirrors normalizePageReference in src/lib/docs-config.ts. */
33
+ function normalizePageReference(pageRef) {
34
+ const value = pageRef
35
+ .trim()
36
+ .replace(/\\/g, '/')
37
+ .replace(/^\/+/, '')
38
+ .replace(/^docs\//, '')
39
+ .replace(/\.mdx?$/, '')
40
+ .replace(/\/+$/, '');
41
+
42
+ if (!value) {
43
+ return { fileSlug: 'index', slug: 'index' };
44
+ }
45
+
46
+ return {
47
+ fileSlug: value,
48
+ slug: value === 'index' ? 'index' : value.replace(/\/index$/, '') || 'index',
49
+ };
50
+ }
51
+
52
+ /**
53
+ * One entry per navigation scope, mirroring collectScopeSources in
54
+ * src/lib/docs-config.ts: ordinary navigation, versions, languages, and
55
+ * versions nested inside languages. Hidden scopes are excluded from search,
56
+ * matching how hidden pages are excluded.
57
+ */
58
+ function collectScopes(navigation) {
59
+ if (Array.isArray(navigation.versions)) {
60
+ return navigation.versions
61
+ .filter(version => !version.hidden)
62
+ .map(version => ({
63
+ id: slugifyId(version.version?.trim() || '', 'scope'),
64
+ version: version.version?.trim(),
65
+ container: version,
66
+ }));
67
+ }
68
+
69
+ if (Array.isArray(navigation.languages)) {
70
+ return navigation.languages
71
+ .filter(language => !language.hidden)
72
+ .flatMap(language => {
73
+ const languageLabel = language.language?.trim();
74
+
75
+ if (Array.isArray(language.versions)) {
76
+ return language.versions
77
+ .filter(version => !version.hidden)
78
+ .map(version => ({
79
+ id: slugifyId(`${languageLabel}-${version.version?.trim()}`, 'scope'),
80
+ language: languageLabel,
81
+ version: version.version?.trim(),
82
+ container: version,
83
+ }));
84
+ }
85
+
86
+ return [
87
+ {
88
+ id: slugifyId(languageLabel || '', 'scope'),
89
+ language: languageLabel,
90
+ container: language,
91
+ },
92
+ ];
93
+ });
94
+ }
95
+
96
+ return [{ id: 'default', container: navigation }];
97
+ }
98
+
99
+ /**
100
+ * Collects `{ fileSlug, slug }` for every non-hidden page in the navigation
101
+ * tree. A simplified mirror of the config walker: search only needs page refs
102
+ * and hidden inheritance, not labels or ordering.
103
+ */
104
+ function collectVisiblePages(container, pages = [], hidden = false) {
105
+ const items = [
106
+ ...(container.pages || []),
107
+ ...(container.groups || []),
108
+ ...(container.tabs || []),
109
+ ...(container.dropdowns || []),
110
+ ];
111
+
112
+ for (const item of items) {
113
+ if (typeof item === 'string') {
114
+ if (!hidden) {
115
+ pages.push(normalizePageReference(item));
116
+ }
117
+ continue;
118
+ }
119
+
120
+ if (!item || typeof item !== 'object') {
121
+ continue;
122
+ }
123
+
124
+ const itemHidden = hidden || item.hidden === true;
125
+
126
+ if (typeof item.page === 'string') {
127
+ if (!itemHidden) {
128
+ pages.push(normalizePageReference(item.page));
129
+ }
130
+ continue;
131
+ }
132
+
133
+ if (typeof item.root === 'string' && !itemHidden) {
134
+ pages.push(normalizePageReference(item.root));
135
+ }
136
+
137
+ collectVisiblePages(item, pages, itemHidden);
138
+ }
139
+
140
+ return pages;
141
+ }
142
+
143
+ /** Frontmatter is YAML, but search only needs the title line. */
144
+ function frontmatterTitle(tree) {
145
+ const yaml = tree.children?.find(node => node.type === 'yaml');
146
+ const match = yaml?.value?.match(/^title:\s*(.+)$/m);
147
+ return match ? match[1].trim().replace(/^["']|["']$/g, '') : undefined;
148
+ }
149
+
150
+ /** Node types whose children are separate blocks, so their text needs a space between. */
151
+ const BLOCK_TYPES = new Set([
152
+ 'root',
153
+ 'list',
154
+ 'listItem',
155
+ 'table',
156
+ 'tableRow',
157
+ 'tableCell',
158
+ 'blockquote',
159
+ 'mdxJsxFlowElement',
160
+ ]);
161
+
162
+ /** Like toText in src/lib/mdast.ts, but block children are space-separated. */
163
+ function blockText(node) {
164
+ if (typeof node.value === 'string') {
165
+ return node.value;
166
+ }
167
+
168
+ const separator = BLOCK_TYPES.has(node.type) ? ' ' : '';
169
+ return (node.children || []).map(blockText).join(separator);
170
+ }
171
+
172
+ /** Splits a document into heading-bounded sections of plain text. */
173
+ function collectSections(tree) {
174
+ const slugger = createSlugger();
175
+ const sections = [{ heading: undefined, id: undefined, parts: [] }];
176
+
177
+ for (const node of tree.children || []) {
178
+ if (node.type === 'yaml' || node.type === 'mdxjsEsm') {
179
+ continue;
180
+ }
181
+
182
+ if (node.type === 'heading') {
183
+ const heading = headingText(node);
184
+ sections.push({ heading, id: slugger.slug(heading), parts: [] });
185
+ continue;
186
+ }
187
+
188
+ const text = blockText(node).replace(/\s+/g, ' ').trim();
189
+
190
+ if (text) {
191
+ sections.at(-1).parts.push(text);
192
+ }
193
+ }
194
+
195
+ return sections
196
+ .map(({ heading, id, parts }) => ({ heading, id, text: parts.join(' ') }))
197
+ .filter(section => section.heading || section.text);
198
+ }
199
+
200
+ /** @param {{ config?: object, root?: string, output?: string }} [options] */
201
+ export async function generateSearchIndex({
202
+ config,
203
+ root = DEFAULT_ROOT,
204
+ output = path.join(root, '.shiso/search-index.generated.ts'),
205
+ } = {}) {
206
+ 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
+
210
+ const seen = new Set();
211
+ const records = [];
212
+
213
+ for (const scope of collectScopes(docsJson.navigation || {})) {
214
+ // Single-scope sites omit scope fields so their index stays unchanged.
215
+ const scopeFields =
216
+ scope.id === 'default'
217
+ ? {}
218
+ : { scopeId: scope.id, language: scope.language, version: scope.version };
219
+
220
+ for (const { fileSlug, slug } of collectVisiblePages(scope.container)) {
221
+ if (seen.has(fileSlug)) {
222
+ continue;
223
+ }
224
+
225
+ seen.add(fileSlug);
226
+
227
+ let source;
228
+ let filePath;
229
+
230
+ for (const extension of ['mdx', 'md']) {
231
+ filePath = path.join(root, contentDir, `${fileSlug}.${extension}`);
232
+ source = await fs.readFile(filePath, 'utf8').catch(() => undefined);
233
+
234
+ if (source !== undefined) {
235
+ break;
236
+ }
237
+ }
238
+
239
+ if (source === undefined) {
240
+ // Missing files fail config normalization; search just skips them.
241
+ continue;
242
+ }
243
+
244
+ const tree = parser.parse(source);
245
+ const url = slug === 'index' ? docsPrefix || '/' : `${docsPrefix}/${slug}`;
246
+ const page = frontmatterTitle(tree) || fileSlug;
247
+
248
+ for (const { heading, id, text } of collectSections(tree)) {
249
+ records.push({ url, page, heading, id, text, ...scopeFields });
250
+ }
251
+ }
252
+ }
253
+
254
+ // Emitted in the repo's formatter style (single quotes, bare keys, trailing
255
+ // commas) so the generated file passes `biome check` unchanged.
256
+ const quote = value => {
257
+ const escaped = value.replace(/\\/g, '\\\\');
258
+ const singles = (value.match(/'/g) || []).length;
259
+ const doubles = (value.match(/"/g) || []).length;
260
+
261
+ // Like the formatter: whichever quote needs fewer escapes, single on ties.
262
+ return singles > doubles
263
+ ? `"${escaped.replace(/"/g, '\\"')}"`
264
+ : `'${escaped.replace(/'/g, "\\'")}'`;
265
+ };
266
+ const body = records
267
+ .map(record => {
268
+ const fields = Object.entries(record)
269
+ .filter(([, value]) => value !== undefined)
270
+ .map(([key, value]) => ` ${key}: ${quote(value)},`)
271
+ .join('\n');
272
+
273
+ return ` {\n${fields}\n },`;
274
+ })
275
+ .join('\n');
276
+
277
+ const contents = `// Generated by scripts/generate-search-index.mjs. Do not edit by hand.
278
+ import type { SearchRecord } from '@/lib/search';
279
+
280
+ export const SEARCH_INDEX: SearchRecord[] = [
281
+ ${body}
282
+ ];
283
+ `;
284
+
285
+ const previous = await fs.readFile(output, 'utf8').catch(() => '');
286
+
287
+ if (previous !== contents) {
288
+ await fs.mkdir(path.dirname(output), { recursive: true });
289
+ await fs.writeFile(output, contents);
290
+ }
291
+
292
+ return { pages: seen.size, records: records.length, changed: previous !== contents };
293
+ }
294
+
295
+ if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
296
+ const { pages, records } = await generateSearchIndex();
297
+ console.log(`Search index: ${records} sections across ${pages} pages`);
298
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Mirrors the mdast helpers in src/lib/mdast.ts for Node scripts, which must
3
+ * not import raw TypeScript (that would depend on Node's experimental type
4
+ * stripping). tests/script-mirrors.test.ts asserts the two stay in sync.
5
+ */
6
+
7
+ /** Concatenates the text content of a node, including JSX text children. */
8
+ export function toText(node) {
9
+ if (!node) {
10
+ return '';
11
+ }
12
+
13
+ if (typeof node.value === 'string') {
14
+ return node.value;
15
+ }
16
+
17
+ return (node.children || []).map(toText).join('');
18
+ }
19
+
20
+ /** Text of a heading node, trimmed. */
21
+ export function headingText(node) {
22
+ return toText(node).trim();
23
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Mirrors src/lib/slug.ts for Node scripts, which must not import raw
3
+ * TypeScript (that would depend on Node's experimental type stripping).
4
+ * tests/script-mirrors.test.ts asserts the two stay in sync.
5
+ */
6
+ import GithubSlugger, { slug as slugifyOnce } from 'github-slugger';
7
+
8
+ /** Stateful slugger matching rehype-slug: repeated headings get -1, -2, ... */
9
+ export function createSlugger() {
10
+ return new GithubSlugger();
11
+ }
12
+
13
+ /** Stateless slugify for one-off ids that do not need de-duplication. */
14
+ export function slugify(value) {
15
+ return slugifyOnce(value.trim());
16
+ }
17
+
18
+ /** Slugifies a label into a config-level identifier, with a fallback. */
19
+ export function slugifyId(value, fallback) {
20
+ return slugify(value) || fallback;
21
+ }
@@ -0,0 +1,244 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ /** Error raised while locating, reading, or parsing a Shiso configuration file. */
5
+ export class DocsConfigLoadError extends Error {
6
+ constructor(message, { cause, code, sourcePath }) {
7
+ super(message, { cause });
8
+ this.name = 'DocsConfigLoadError';
9
+ this.code = code;
10
+ this.sourcePath = sourcePath;
11
+ }
12
+ }
13
+
14
+ function isObject(value) {
15
+ return !!value && typeof value === 'object' && !Array.isArray(value);
16
+ }
17
+
18
+ function isInsideRoot(projectRoot, candidate) {
19
+ const relative = path.relative(projectRoot, candidate);
20
+ return (
21
+ relative === '' ||
22
+ (!path.isAbsolute(relative) && !relative.startsWith(`..${path.sep}`) && relative !== '..')
23
+ );
24
+ }
25
+
26
+ function parseLocation(source, error) {
27
+ const reported = error.message.match(/line\s+(\d+)(?:\s+column\s+(\d+))?/i);
28
+
29
+ if (reported) {
30
+ return ` at line ${reported[1]}${reported[2] ? `, column ${reported[2]}` : ''}`;
31
+ }
32
+
33
+ const position = error.message.match(/position\s+(\d+)/i)?.[1];
34
+ const unexpectedToken = error.message.match(/Unexpected token '([^']+)'/i)?.[1];
35
+ const parsedOffset = position === undefined ? -1 : Number(position);
36
+ const offset = parsedOffset >= 0 ? parsedOffset : source.indexOf(unexpectedToken || '');
37
+
38
+ if (offset < 0 || (!position && !unexpectedToken)) {
39
+ return '';
40
+ }
41
+
42
+ const before = source.slice(0, offset);
43
+ const line = before.split('\n').length;
44
+ const lastNewline = before.lastIndexOf('\n');
45
+ const column = offset - lastNewline;
46
+
47
+ return ` at line ${line}, column ${column}`;
48
+ }
49
+
50
+ /** Reads a JSON document while preserving its source path in every error. */
51
+ export async function loadJsonDocument(sourcePath, label = 'JSON document') {
52
+ const resolvedPath = path.resolve(sourcePath);
53
+ let source;
54
+
55
+ try {
56
+ source = await fs.readFile(resolvedPath, 'utf8');
57
+ } catch (error) {
58
+ const reason =
59
+ error.code === 'ENOENT' ? 'does not exist' : `could not be read: ${error.message}`;
60
+
61
+ throw new DocsConfigLoadError(`${label} "${resolvedPath}" ${reason}.`, {
62
+ cause: error,
63
+ code: error.code === 'ENOENT' ? 'NOT_FOUND' : 'READ_FAILED',
64
+ sourcePath: resolvedPath,
65
+ });
66
+ }
67
+
68
+ try {
69
+ return JSON.parse(source);
70
+ } catch (error) {
71
+ throw new DocsConfigLoadError(
72
+ `${label} "${resolvedPath}" contains invalid JSON${parseLocation(source, error)}: ${error.message}`,
73
+ {
74
+ cause: error,
75
+ code: 'INVALID_JSON',
76
+ sourcePath: resolvedPath,
77
+ },
78
+ );
79
+ }
80
+ }
81
+
82
+ function referenceError(message, { code, sourcePath }) {
83
+ return new DocsConfigLoadError(message, { code, sourcePath });
84
+ }
85
+
86
+ /** Resolves every `$ref` in one project configuration tree. */
87
+ async function resolveConfigReferences(entryPath, projectRoot) {
88
+ const lexicalRoot = path.resolve(projectRoot);
89
+ const canonicalRoot = await fs.realpath(projectRoot);
90
+ const sourcePaths = new Set();
91
+ const cache = new Map();
92
+
93
+ async function canonicalDocumentPath(sourcePath, referringPath) {
94
+ const resolvedPath = path.resolve(sourcePath);
95
+
96
+ if (!isInsideRoot(lexicalRoot, resolvedPath) && !isInsideRoot(canonicalRoot, resolvedPath)) {
97
+ throw referenceError(
98
+ `Config reference "${resolvedPath}" from "${referringPath}" leaves the project root "${canonicalRoot}".`,
99
+ { code: 'REF_OUTSIDE_ROOT', sourcePath: resolvedPath },
100
+ );
101
+ }
102
+
103
+ let canonicalPath;
104
+
105
+ try {
106
+ canonicalPath = await fs.realpath(resolvedPath);
107
+ } catch (error) {
108
+ const reason =
109
+ error.code === 'ENOENT' ? 'does not exist' : `could not be resolved: ${error.message}`;
110
+
111
+ throw new DocsConfigLoadError(`Referenced config "${resolvedPath}" ${reason}.`, {
112
+ cause: error,
113
+ code: error.code === 'ENOENT' ? 'NOT_FOUND' : 'READ_FAILED',
114
+ sourcePath: resolvedPath,
115
+ });
116
+ }
117
+
118
+ if (!isInsideRoot(canonicalRoot, canonicalPath)) {
119
+ throw referenceError(
120
+ `Config reference "${resolvedPath}" from "${referringPath}" resolves outside the project root.`,
121
+ { code: 'REF_OUTSIDE_ROOT', sourcePath: resolvedPath },
122
+ );
123
+ }
124
+
125
+ return canonicalPath;
126
+ }
127
+
128
+ async function resolveDocument(sourcePath, stack = []) {
129
+ const referringPath = stack.at(-1) || entryPath;
130
+ const canonicalPath = await canonicalDocumentPath(sourcePath, referringPath);
131
+
132
+ if (stack.includes(canonicalPath)) {
133
+ const cycle = [...stack.slice(stack.indexOf(canonicalPath)), canonicalPath]
134
+ .map(item => path.relative(projectRoot, item) || path.basename(item))
135
+ .join(' -> ');
136
+
137
+ throw referenceError(`Circular config reference detected: ${cycle}.`, {
138
+ code: 'CIRCULAR_REF',
139
+ sourcePath: canonicalPath,
140
+ });
141
+ }
142
+
143
+ if (cache.has(canonicalPath)) {
144
+ return cache.get(canonicalPath);
145
+ }
146
+
147
+ sourcePaths.add(canonicalPath);
148
+ const document = await loadJsonDocument(canonicalPath, 'Referenced config');
149
+ const resolved = await resolveValue(document, canonicalPath, [...stack, canonicalPath]);
150
+ cache.set(canonicalPath, resolved);
151
+ return resolved;
152
+ }
153
+
154
+ async function resolveReference(reference, sourcePath, stack) {
155
+ if (typeof reference !== 'string' || !reference.trim()) {
156
+ throw referenceError(`Config $ref in "${sourcePath}" must be a non-empty string.`, {
157
+ code: 'INVALID_REF',
158
+ sourcePath,
159
+ });
160
+ }
161
+
162
+ if (path.isAbsolute(reference) || /^[a-z][a-z0-9+.-]*:/i.test(reference)) {
163
+ throw referenceError(
164
+ `Config $ref "${reference}" in "${sourcePath}" must be a relative JSON file path.`,
165
+ { code: 'INVALID_REF', sourcePath },
166
+ );
167
+ }
168
+
169
+ if (path.extname(reference).toLowerCase() !== '.json') {
170
+ throw referenceError(`Config $ref "${reference}" in "${sourcePath}" must end in .json.`, {
171
+ code: 'INVALID_REF',
172
+ sourcePath,
173
+ });
174
+ }
175
+
176
+ return resolveDocument(path.resolve(path.dirname(sourcePath), reference), stack);
177
+ }
178
+
179
+ async function resolveValue(value, sourcePath, stack) {
180
+ if (Array.isArray(value)) {
181
+ return Promise.all(value.map(item => resolveValue(item, sourcePath, stack)));
182
+ }
183
+
184
+ if (!isObject(value)) {
185
+ return value;
186
+ }
187
+
188
+ if ('$ref' in value) {
189
+ const referenced = await resolveReference(value.$ref, sourcePath, stack);
190
+
191
+ // Object targets receive shallow sibling overrides. For arrays and
192
+ // primitives, the reference replaces the whole object and siblings are
193
+ // intentionally ignored.
194
+ if (!isObject(referenced)) {
195
+ return referenced;
196
+ }
197
+
198
+ const siblings = Object.fromEntries(Object.entries(value).filter(([key]) => key !== '$ref'));
199
+ const resolvedSiblings = await resolveValue(siblings, sourcePath, stack);
200
+ return { ...referenced, ...resolvedSiblings };
201
+ }
202
+
203
+ const entries = await Promise.all(
204
+ Object.entries(value).map(async ([key, item]) => [
205
+ key,
206
+ await resolveValue(item, sourcePath, stack),
207
+ ]),
208
+ );
209
+ return Object.fromEntries(entries);
210
+ }
211
+
212
+ const config = await resolveDocument(entryPath);
213
+ return { config, projectRoot: canonicalRoot, sourcePaths: [...sourcePaths] };
214
+ }
215
+
216
+ /**
217
+ * Loads the project docs configuration.
218
+ *
219
+ * Returning its resolved source path now gives the later `$ref` resolver a
220
+ * stable place from which to resolve relative references without changing
221
+ * every build-time consumer again.
222
+ */
223
+ export async function loadDocsConfig({ root = process.cwd(), configFile = 'docs.json' } = {}) {
224
+ const requestedRoot = path.resolve(root);
225
+ const requestedSourcePath = path.resolve(requestedRoot, configFile);
226
+ const { config, projectRoot, sourcePaths } = await resolveConfigReferences(
227
+ requestedSourcePath,
228
+ requestedRoot,
229
+ );
230
+ const sourcePath = sourcePaths[0] || requestedSourcePath;
231
+
232
+ return { config, projectRoot, sourcePath, sourcePaths };
233
+ }
234
+
235
+ export async function loadDocsSchema({
236
+ root = process.cwd(),
237
+ schemaFile = 'docs.schema.json',
238
+ } = {}) {
239
+ const projectRoot = path.resolve(root);
240
+ const sourcePath = path.resolve(projectRoot, schemaFile);
241
+ const schema = await loadJsonDocument(sourcePath, 'Docs schema');
242
+
243
+ return { projectRoot, schema, sourcePath };
244
+ }