blume 0.6.7 → 0.7.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 (108) hide show
  1. package/dist/cli/index.js +1179 -738
  2. package/dist/cli/index.js.map +52 -51
  3. package/dist/types/core/base-path.d.ts +38 -0
  4. package/dist/types/core/config-input.d.ts +74 -10
  5. package/dist/types/core/config.d.ts +3 -2
  6. package/dist/types/core/data.d.ts +2 -0
  7. package/dist/types/core/i18n-ui.d.ts +1 -3
  8. package/dist/types/core/schema.d.ts +95 -52
  9. package/dist/types/core/sources/types.d.ts +2 -0
  10. package/dist/types/core/types.d.ts +6 -1
  11. package/docs/02-deployment.mdx +16 -1
  12. package/docs/03-faq.mdx +8 -8
  13. package/docs/configuration/index.mdx +6 -0
  14. package/docs/content/components.mdx +29 -2
  15. package/docs/content/islands.mdx +8 -0
  16. package/docs/content/syntax.mdx +13 -0
  17. package/package.json +2 -1
  18. package/src/ai/agent-readability.ts +7 -2
  19. package/src/ai/ask.ts +12 -7
  20. package/src/ai/llms.ts +15 -4
  21. package/src/ai/mcp/data.ts +8 -4
  22. package/src/ai/mcp/server.ts +3 -0
  23. package/src/astro/component-slots.ts +5 -3
  24. package/src/astro/examples.ts +12 -7
  25. package/src/astro/generate.ts +317 -144
  26. package/src/astro/index.ts +5 -1
  27. package/src/astro/integration.ts +8 -4
  28. package/src/astro/islands.ts +11 -5
  29. package/src/astro/markdown-negotiation.ts +1 -1
  30. package/src/astro/pages.ts +8 -3
  31. package/src/astro/templates.ts +166 -19
  32. package/src/cli/commands/build.ts +32 -19
  33. package/src/cli/commands/dev.ts +48 -15
  34. package/src/cli/commands/doctor.ts +2 -2
  35. package/src/cli/commands/validate.ts +1 -0
  36. package/src/cli/dev-lock.ts +26 -15
  37. package/src/cli/required-secrets.ts +2 -1
  38. package/src/components/content/CodeBlock.astro +3 -0
  39. package/src/components/content/Component.astro +30 -16
  40. package/src/components/content/Diff.astro +3 -1
  41. package/src/components/content/auto-type-table.ts +18 -8
  42. package/src/components/content/diff.ts +12 -6
  43. package/src/components/content/mermaid-element.ts +3 -0
  44. package/src/components/index.ts +23 -1
  45. package/src/components/islands/ask-ai.tsx +12 -6
  46. package/src/components/islands/base-path.ts +28 -0
  47. package/src/components/islands/hooks.ts +16 -1
  48. package/src/components/layout/Banner.astro +2 -1
  49. package/src/components/layout/Breadcrumbs.astro +2 -1
  50. package/src/components/layout/Favicon.astro +3 -2
  51. package/src/components/layout/Header.astro +2 -1
  52. package/src/components/layout/LanguageSwitcher.astro +2 -1
  53. package/src/components/layout/Logo.astro +2 -1
  54. package/src/components/layout/NavSelector.astro +2 -1
  55. package/src/components/layout/NavTree.astro +5 -4
  56. package/src/components/layout/PageFeedback.astro +4 -1
  57. package/src/components/layout/PageLayout.astro +9 -4
  58. package/src/components/layout/Pagination.astro +3 -2
  59. package/src/components/layout/RootLayout.astro +7 -4
  60. package/src/components/layout/Search.astro +13 -5
  61. package/src/components/layout/nav-utils.ts +18 -10
  62. package/src/components/layout/search/pagefind.ts +3 -0
  63. package/src/components/layout/toc-element.ts +7 -1
  64. package/src/components/openapi/RequestPanel.astro +7 -1
  65. package/src/components/openapi/snippets.ts +25 -11
  66. package/src/core/base-path.ts +70 -0
  67. package/src/core/component-overrides.ts +103 -74
  68. package/src/core/config-input.ts +81 -15
  69. package/src/core/config.ts +5 -3
  70. package/src/core/content.ts +2 -0
  71. package/src/core/data.ts +2 -0
  72. package/src/core/diagnostics.ts +54 -34
  73. package/src/core/gitignore.ts +4 -1
  74. package/src/core/graph.ts +156 -88
  75. package/src/core/i18n-ui.ts +18 -3
  76. package/src/core/last-modified.ts +2 -0
  77. package/src/core/links.ts +38 -18
  78. package/src/core/manifest.ts +62 -45
  79. package/src/core/nav-diagnostics.ts +1 -1
  80. package/src/core/navigation.ts +116 -55
  81. package/src/core/project-graph.ts +10 -9
  82. package/src/core/schema.ts +572 -621
  83. package/src/core/sources/github-releases.ts +2 -1
  84. package/src/core/sources/mdx-remote.ts +58 -54
  85. package/src/core/sources/normalize.ts +116 -73
  86. package/src/core/sources/notion.ts +19 -10
  87. package/src/core/sources/types.ts +2 -0
  88. package/src/core/tsconfig-aliases.ts +59 -30
  89. package/src/core/types.ts +6 -1
  90. package/src/deploy/redirects.ts +18 -0
  91. package/src/deploy/robots.ts +6 -1
  92. package/src/deploy/rss.ts +10 -3
  93. package/src/deploy/sitemap.ts +14 -10
  94. package/src/markdown/base-links.ts +58 -0
  95. package/src/markdown/code-title.ts +11 -14
  96. package/src/markdown/index.ts +34 -9
  97. package/src/markdown/inline-code.ts +7 -2
  98. package/src/markdown/themes.ts +24 -0
  99. package/src/openapi/model.ts +3 -1
  100. package/src/openapi/references.ts +41 -17
  101. package/src/openapi/render-mdx.ts +11 -6
  102. package/src/openapi/scalar.ts +32 -16
  103. package/src/registry/eject.ts +64 -8
  104. package/src/search/build.ts +3 -0
  105. package/src/search/documents.ts +2 -2
  106. package/src/search/sync/typesense.ts +6 -4
  107. package/src/seo/jsonld.ts +16 -6
  108. package/src/theme/entry.ts +85 -20
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
 
3
3
  import { FONT_SLUGS, isFontSlug } from "../theme/fonts.ts";
4
+ import { normalizeBasePath } from "./base-path.ts";
4
5
  import { uiLocaleOverridesSchema } from "./i18n-ui.ts";
5
6
  import type { ContentSource } from "./sources/types.ts";
6
7
 
@@ -18,6 +19,9 @@ import type { ContentSource } from "./sources/types.ts";
18
19
  /** Icon inputs in serializable contexts (frontmatter, meta files). */
19
20
  const iconName = z.string().min(1);
20
21
 
22
+ /** Default include glob for filesystem-backed content sources. */
23
+ const DEFAULT_CONTENT_GLOB = "**/*.{md,mdx}";
24
+
21
25
  const hydrationMode = z.enum(["load", "idle", "visible", "media", "only"]);
22
26
  export type HydrationMode = z.infer<typeof hydrationMode>;
23
27
 
@@ -33,41 +37,36 @@ const dateSchema = z
33
37
  // Page frontmatter
34
38
  // ---------------------------------------------------------------------------
35
39
 
36
- const sidebarMetaSchema = z
37
- .object({
38
- badge: z.string().optional(),
39
- hidden: z.boolean().default(false),
40
- icon: iconName.optional(),
41
- label: z.string().optional(),
42
- order: z.number().optional(),
43
- })
44
- .strict();
45
-
46
- const seoMetaSchema = z
47
- .object({
48
- canonical: z.string().url().optional(),
49
- description: z.string().optional(),
50
- image: z.string().optional(),
51
- noindex: z.boolean().default(false),
52
- title: z.string().optional(),
53
- })
54
- .strict();
40
+ const sidebarMetaSchema = z.strictObject({
41
+ badge: z.string().optional(),
42
+ hidden: z.boolean().default(false),
43
+ icon: iconName.optional(),
44
+ label: z.string().optional(),
45
+ order: z.number().optional(),
46
+ });
55
47
 
56
- const searchMetaSchema = z
57
- .object({
58
- boost: z.number().optional(),
59
- exclude: z.boolean().default(false),
60
- tags: z.array(z.string()).optional(),
61
- })
62
- .strict();
48
+ const seoMetaSchema = z.strictObject({
49
+ // blume bundles Zod 3; top-level `z.url()` is undefined at runtime and
50
+ // schemas must stay dual-compatible with consumer projects on Zod 4.
51
+ // oxlint-disable-next-line react-doctor/zod-v4-prefer-top-level-string-formats
52
+ canonical: z.string().url().optional(),
53
+ description: z.string().optional(),
54
+ image: z.string().optional(),
55
+ noindex: z.boolean().default(false),
56
+ title: z.string().optional(),
57
+ });
63
58
 
64
- const changelogMetaSchema = z
65
- .object({
66
- category: z.string().optional(),
67
- date: dateSchema.optional(),
68
- version: z.string().optional(),
69
- })
70
- .strict();
59
+ const searchMetaSchema = z.strictObject({
60
+ boost: z.number().optional(),
61
+ exclude: z.boolean().default(false),
62
+ tags: z.array(z.string()).optional(),
63
+ });
64
+
65
+ const changelogMetaSchema = z.strictObject({
66
+ category: z.string().optional(),
67
+ date: dateSchema.optional(),
68
+ version: z.string().optional(),
69
+ });
71
70
 
72
71
  /**
73
72
  * A post author: a bare name/handle, or an object with a name plus optional
@@ -85,34 +84,32 @@ const authorSchema = z.union([
85
84
  name: z.string(),
86
85
  url: z.string().optional(),
87
86
  })
88
- .passthrough(),
87
+ .catchall(z.unknown()),
89
88
  ]);
90
89
 
91
90
  /** Frontmatter accepted on any content page. */
92
- const pageMetaBaseSchema = z
93
- .object({
94
- /** Post author(s) for blog/changelog content; preserved, not yet rendered. */
95
- authors: z.union([authorSchema, z.array(authorSchema)]).optional(),
96
- changelog: changelogMetaSchema.optional(),
97
- /** Publish date for feed-backed content like blog/changelog. */
98
- date: dateSchema.optional(),
99
- deprecated: z.boolean().default(false),
100
- description: z.string().optional(),
101
- draft: z.boolean().default(false),
102
- hidden: z.boolean().default(false),
103
- icon: iconName.optional(),
104
- /** Overrides the git-derived last-modified date when `lastModified` is on. */
105
- lastModified: dateSchema.optional(),
106
- noindex: z.boolean().default(false),
107
- search: searchMetaSchema.default({}),
108
- seo: seoMetaSchema.default({}),
109
- sidebar: sidebarMetaSchema.default({}),
110
- slug: z.string().optional(),
111
- title: z.string().optional(),
112
- // No default: an absent `type` must fall through to `content.defaultType`.
113
- type: z.string().optional(),
114
- })
115
- .strict();
91
+ const pageMetaBaseSchema = z.strictObject({
92
+ /** Post author(s) for blog/changelog content; preserved, not yet rendered. */
93
+ authors: z.union([authorSchema, z.array(authorSchema)]).optional(),
94
+ changelog: changelogMetaSchema.optional(),
95
+ /** Publish date for feed-backed content like blog/changelog. */
96
+ date: dateSchema.optional(),
97
+ deprecated: z.boolean().default(false),
98
+ description: z.string().optional(),
99
+ draft: z.boolean().default(false),
100
+ hidden: z.boolean().default(false),
101
+ icon: iconName.optional(),
102
+ /** Overrides the git-derived last-modified date when `lastModified` is on. */
103
+ lastModified: dateSchema.optional(),
104
+ noindex: z.boolean().default(false),
105
+ search: searchMetaSchema.default({}),
106
+ seo: seoMetaSchema.default({}),
107
+ sidebar: sidebarMetaSchema.default({}),
108
+ slug: z.string().optional(),
109
+ title: z.string().optional(),
110
+ // No default: an absent `type` must fall through to `content.defaultType`.
111
+ type: z.string().optional(),
112
+ });
116
113
 
117
114
  export const pageMetaSchema = pageMetaBaseSchema;
118
115
 
@@ -133,16 +130,14 @@ export type PageMetaInput = z.input<typeof pageMetaBaseSchema>;
133
130
  const sidebarDisplaySchema = z.enum(["flat", "group", "page"]);
134
131
  export type SidebarDisplay = z.infer<typeof sidebarDisplaySchema>;
135
132
 
136
- export const folderMetaSchema = z
137
- .object({
138
- collapsed: z.boolean().optional(),
139
- icon: iconName.optional(),
140
- order: z.number().optional(),
141
- /** Explicit child ordering by slug segment (without numeric prefix). */
142
- pages: z.array(z.string()).optional(),
143
- title: z.string().optional(),
144
- })
145
- .strict();
133
+ export const folderMetaSchema = z.strictObject({
134
+ collapsed: z.boolean().optional(),
135
+ icon: iconName.optional(),
136
+ order: z.number().optional(),
137
+ /** Explicit child ordering by slug segment (without numeric prefix). */
138
+ pages: z.array(z.string()).optional(),
139
+ title: z.string().optional(),
140
+ });
146
141
 
147
142
  export type FolderMeta = z.infer<typeof folderMetaSchema>;
148
143
 
@@ -153,13 +148,11 @@ export type FolderMeta = z.infer<typeof folderMetaSchema>;
153
148
  /** The logo mark: a single image path/URL, or light/dark variants with alt text. */
154
149
  const logoImageSchema = z.union([
155
150
  z.string(),
156
- z
157
- .object({
158
- alt: z.string().optional(),
159
- dark: z.string().optional(),
160
- light: z.string().optional(),
161
- })
162
- .strict(),
151
+ z.strictObject({
152
+ alt: z.string().optional(),
153
+ dark: z.string().optional(),
154
+ light: z.string().optional(),
155
+ }),
163
156
  ]);
164
157
 
165
158
  /**
@@ -171,75 +164,63 @@ const logoImageSchema = z.union([
171
164
  */
172
165
  const logoConfigSchema = z.union([
173
166
  z.string(),
174
- z
175
- .object({
176
- href: z.string().optional(),
177
- image: logoImageSchema.optional(),
178
- text: z.string().optional(),
179
- })
180
- .strict(),
167
+ z.strictObject({
168
+ href: z.string().optional(),
169
+ image: logoImageSchema.optional(),
170
+ text: z.string().optional(),
171
+ }),
181
172
  ]);
182
173
 
183
174
  /** Site-wide announcement banner: a string, or text with an optional link. */
184
175
  const bannerConfigSchema = z.union([
185
176
  z.string(),
186
- z
187
- .object({
188
- content: z.string(),
189
- /** Show a dismiss button; the choice is remembered per visitor. */
190
- dismissible: z.boolean().default(false),
191
- /** Stable key for remembering dismissal; defaults to the content. */
192
- id: z.string().optional(),
193
- link: z
194
- .object({ href: z.string(), text: z.string() })
195
- .strict()
196
- .optional(),
197
- })
198
- .strict(),
177
+ z.strictObject({
178
+ content: z.string(),
179
+ /** Show a dismiss button; the choice is remembered per visitor. */
180
+ dismissible: z.boolean().default(false),
181
+ /** Stable key for remembering dismissal; defaults to the content. */
182
+ id: z.string().optional(),
183
+ link: z.strictObject({ href: z.string(), text: z.string() }).optional(),
184
+ }),
199
185
  ]);
200
186
 
201
187
  /** A local filesystem content source. */
202
- const filesystemSourceSchema = z
203
- .object({
204
- exclude: z.array(z.string()).default(["**/_*", "**/.*"]),
205
- include: z.array(z.string()).default(["**/*.{md,mdx}"]),
206
- /** Namespaces the source's routes under `/<prefix>/`. */
207
- prefix: z.string().optional(),
208
- root: z.string().default("docs"),
209
- type: z.literal("filesystem"),
210
- })
211
- .strict();
188
+ const filesystemSourceSchema = z.strictObject({
189
+ exclude: z.array(z.string()).default(["**/_*", "**/.*"]),
190
+ include: z.array(z.string()).default([DEFAULT_CONTENT_GLOB]),
191
+ /** Namespaces the source's routes under `/<prefix>/`. */
192
+ prefix: z.string().optional(),
193
+ root: z.string().default("docs"),
194
+ type: z.literal("filesystem"),
195
+ });
212
196
 
213
197
  /**
214
198
  * Remote Markdown/MDX fetched over HTTP. Enumerate files either explicitly
215
199
  * (`files` against a raw `url` base) or from a GitHub repo subtree (`github`).
216
200
  * The token, when needed, comes from `GITHUB_TOKEN` — never inlined here.
217
201
  */
218
- const mdxRemoteSourceSchema = z
219
- .object({
220
- /** Explicit list of source-relative file paths to fetch from `url`. */
221
- files: z.array(z.string()).optional(),
222
- /** Enumerate a GitHub repo subtree via the git-trees API. */
223
- github: z
224
- .object({
225
- owner: z.string(),
226
- path: z.string().default(""),
227
- ref: z.string().default("main"),
228
- repo: z.string(),
229
- })
230
- .strict()
231
- .optional(),
232
- /** Glob patterns applied to enumerated refs. */
233
- include: z.array(z.string()).default(["**/*.{md,mdx}"]),
234
- /** Opt-in dev polling interval (seconds); omit to freeze for the session. */
235
- pollInterval: z.number().positive().optional(),
236
- /** Namespaces the source's routes under `/<prefix>/`. */
237
- prefix: z.string().optional(),
238
- type: z.literal("mdx-remote"),
239
- /** Raw base URL, e.g. `https://raw.githubusercontent.com/acme/sdk/main/docs`. */
240
- url: z.string().optional(),
241
- })
242
- .strict();
202
+ const mdxRemoteSourceSchema = z.strictObject({
203
+ /** Explicit list of source-relative file paths to fetch from `url`. */
204
+ files: z.array(z.string()).optional(),
205
+ /** Enumerate a GitHub repo subtree via the git-trees API. */
206
+ github: z
207
+ .strictObject({
208
+ owner: z.string(),
209
+ path: z.string().default(""),
210
+ ref: z.string().default("main"),
211
+ repo: z.string(),
212
+ })
213
+ .optional(),
214
+ /** Glob patterns applied to enumerated refs. */
215
+ include: z.array(z.string()).default([DEFAULT_CONTENT_GLOB]),
216
+ /** Opt-in dev polling interval (seconds); omit to freeze for the session. */
217
+ pollInterval: z.number().positive().optional(),
218
+ /** Namespaces the source's routes under `/<prefix>/`. */
219
+ prefix: z.string().optional(),
220
+ type: z.literal("mdx-remote"),
221
+ /** Raw base URL, e.g. `https://raw.githubusercontent.com/acme/sdk/main/docs`. */
222
+ url: z.string().optional(),
223
+ });
243
224
 
244
225
  /** A Sanity dataset queried with GROQ; Portable Text bodies become Markdown. */
245
226
  const sanitySourceSchema = z.object({
@@ -248,14 +229,13 @@ const sanitySourceSchema = z.object({
248
229
  dataset: z.string(),
249
230
  /** Field paths mapping a document onto Blume meta + body. */
250
231
  fields: z
251
- .object({
232
+ .strictObject({
252
233
  body: z.string().optional(),
253
234
  description: z.string().optional(),
254
235
  lastModified: z.string().optional(),
255
236
  slug: z.string().optional(),
256
237
  title: z.string().optional(),
257
238
  })
258
- .strict()
259
239
  .optional(),
260
240
  /** Opt-in dev polling interval (seconds); omit to freeze for the session. */
261
241
  pollInterval: z.number().positive().optional(),
@@ -274,14 +254,13 @@ const notionSourceSchema = z.object({
274
254
  prefix: z.string().optional(),
275
255
  /** Notion property names mapped onto Blume meta. */
276
256
  properties: z
277
- .object({
257
+ .strictObject({
278
258
  description: z.string().optional(),
279
259
  order: z.string().optional(),
280
260
  slug: z.string().optional(),
281
261
  status: z.string().optional(),
282
262
  title: z.string().optional(),
283
263
  })
284
- .strict()
285
264
  .optional(),
286
265
  /** Status value treated as published; others map to `draft`. Default `Published`. */
287
266
  publishedValue: z.string().optional(),
@@ -293,25 +272,23 @@ const notionSourceSchema = z.object({
293
272
  * notes become the changelog with no files to maintain. A private repo reads a
294
273
  * token from `GITHUB_TOKEN`; it is never inlined here.
295
274
  */
296
- const githubReleasesSourceSchema = z
297
- .object({
298
- /** Include draft releases (needs a token with repo write access). */
299
- drafts: z.boolean().optional(),
300
- /** Cap the number of releases materialized, newest-first. Default 100. */
301
- limit: z.number().positive().optional(),
302
- /** Repository owner (user or org). */
303
- owner: z.string(),
304
- /** Opt-in dev polling interval (seconds); omit to freeze for the session. */
305
- pollInterval: z.number().positive().optional(),
306
- /** Namespaces the source's routes under `/<prefix>/`; e.g. `changelog`. */
307
- prefix: z.string().optional(),
308
- /** Include prereleases. */
309
- prereleases: z.boolean().optional(),
310
- /** Repository name. */
311
- repo: z.string(),
312
- type: z.literal("github-releases"),
313
- })
314
- .strict();
275
+ const githubReleasesSourceSchema = z.strictObject({
276
+ /** Include draft releases (needs a token with repo write access). */
277
+ drafts: z.boolean().optional(),
278
+ /** Cap the number of releases materialized, newest-first. Default 100. */
279
+ limit: z.number().positive().optional(),
280
+ /** Repository owner (user or org). */
281
+ owner: z.string(),
282
+ /** Opt-in dev polling interval (seconds); omit to freeze for the session. */
283
+ pollInterval: z.number().positive().optional(),
284
+ /** Namespaces the source's routes under `/<prefix>/`; e.g. `changelog`. */
285
+ prefix: z.string().optional(),
286
+ /** Include prereleases. */
287
+ prereleases: z.boolean().optional(),
288
+ /** Repository name. */
289
+ repo: z.string(),
290
+ type: z.literal("github-releases"),
291
+ });
315
292
 
316
293
  /**
317
294
  * A user-provided `ContentSource` instance, passed straight through from
@@ -343,60 +320,50 @@ const contentSourceSchema = z.discriminatedUnion("type", [
343
320
  /** A resolved content-source config entry (post-defaults). */
344
321
  export type ContentSourceConfig = z.infer<typeof contentSourceSchema>;
345
322
 
346
- const contentConfigSchema = z
347
- .object({
348
- defaultType: z.string().default("doc"),
349
- exclude: z.array(z.string()).default(["**/_*", "**/.*"]),
350
- include: z.array(z.string()).default(["**/*.{md,mdx}"]),
351
- pages: z.string().default("pages"),
352
- root: z.string().default("docs"),
353
- /**
354
- * Pluggable content sources. When omitted, the top-level
355
- * `root`/`include`/`exclude` desugar to one implicit filesystem source, so
356
- * existing projects are unchanged.
357
- */
358
- sources: z.array(contentSourceSchema).optional(),
359
- })
360
- .strict();
361
-
362
- const navTabSchema = z
363
- .object({
364
- icon: iconName.optional(),
365
- items: z
366
- .array(
367
- z
368
- .object({
369
- description: z.string().optional(),
370
- icon: iconName.optional(),
371
- label: z.string(),
372
- path: z.string(),
373
- tag: z.string().optional(),
374
- })
375
- .strict()
376
- )
377
- .optional(),
378
- label: z.string(),
379
- path: z.string(),
380
- })
381
- .strict();
382
-
383
- const navSelectorItemSchema = z
384
- .object({
385
- description: z.string().optional(),
386
- icon: iconName.optional(),
387
- label: z.string(),
388
- path: z.string(),
389
- tag: z.string().optional(),
390
- })
391
- .strict();
323
+ const contentConfigSchema = z.strictObject({
324
+ defaultType: z.string().default("doc"),
325
+ exclude: z.array(z.string()).default(["**/_*", "**/.*"]),
326
+ include: z.array(z.string()).default([DEFAULT_CONTENT_GLOB]),
327
+ pages: z.string().default("pages"),
328
+ root: z.string().default("docs"),
329
+ /**
330
+ * Pluggable content sources. When omitted, the top-level
331
+ * `root`/`include`/`exclude` desugar to one implicit filesystem source, so
332
+ * existing projects are unchanged.
333
+ */
334
+ sources: z.array(contentSourceSchema).optional(),
335
+ });
392
336
 
393
- const navSelectorSchema = z
394
- .object({
395
- items: z.array(navSelectorItemSchema).default([]),
396
- kind: z.enum(["dropdown", "language", "product", "version"]),
397
- label: z.string(),
398
- })
399
- .strict();
337
+ const navTabSchema = z.strictObject({
338
+ icon: iconName.optional(),
339
+ items: z
340
+ .array(
341
+ z.strictObject({
342
+ description: z.string().optional(),
343
+ icon: iconName.optional(),
344
+ label: z.string(),
345
+ path: z.string(),
346
+ tag: z.string().optional(),
347
+ })
348
+ )
349
+ .optional(),
350
+ label: z.string(),
351
+ path: z.string(),
352
+ });
353
+
354
+ const navSelectorItemSchema = z.strictObject({
355
+ description: z.string().optional(),
356
+ icon: iconName.optional(),
357
+ label: z.string(),
358
+ path: z.string(),
359
+ tag: z.string().optional(),
360
+ });
361
+
362
+ const navSelectorSchema = z.strictObject({
363
+ items: z.array(navSelectorItemSchema).default([]),
364
+ kind: z.enum(["dropdown", "language", "product", "version"]),
365
+ label: z.string(),
366
+ });
400
367
 
401
368
  const directoryModeSchema = z.enum(["accordion", "card", "none"]);
402
369
  export type DirectoryMode = z.infer<typeof directoryModeSchema>;
@@ -419,26 +386,29 @@ export type SidebarItemConfig =
419
386
  const sidebarItemSchema: z.ZodType<SidebarItemConfig> = z.lazy(() =>
420
387
  z.union([
421
388
  z.string(),
422
- z
423
- .object({
424
- badge: z.string().optional(),
425
- collapsed: z.boolean().optional(),
426
- directory: directoryModeSchema.optional(),
427
- display: sidebarDisplaySchema.optional(),
428
- href: z.string().optional(),
429
- icon: iconName.optional(),
430
- items: z.array(sidebarItemSchema).optional(),
431
- label: z.string(),
432
- root: z.string().optional(),
433
- })
434
- .strict(),
389
+ z.strictObject({
390
+ badge: z.string().optional(),
391
+ collapsed: z.boolean().optional(),
392
+ directory: directoryModeSchema.optional(),
393
+ display: sidebarDisplaySchema.optional(),
394
+ href: z.string().optional(),
395
+ icon: iconName.optional(),
396
+ items: z.array(sidebarItemSchema).optional(),
397
+ label: z.string(),
398
+ root: z.string().optional(),
399
+ }),
435
400
  ])
436
401
  );
437
402
 
438
403
  /** A curated Google Font slug (see `theme/fonts.ts`). */
439
- const fontSlug = z.string().refine(isFontSlug, (value) => ({
440
- message: `Unknown font "${value}". Supported fonts: ${FONT_SLUGS.join(", ")}.`,
441
- }));
404
+ const fontSlug = z.string().superRefine((value, ctx) => {
405
+ if (!isFontSlug(value)) {
406
+ ctx.addIssue({
407
+ code: z.ZodIssueCode.custom,
408
+ message: `Unknown font "${value}". Supported fonts: ${FONT_SLUGS.join(", ")}.`,
409
+ });
410
+ }
411
+ });
442
412
 
443
413
  /**
444
414
  * An optional per-mode theme value: a string applies to both color modes; a
@@ -448,79 +418,69 @@ const fontSlug = z.string().refine(isFontSlug, (value) => ({
448
418
  const perModeValueSchema = z
449
419
  .union([
450
420
  z.string(),
451
- z
452
- .object({ dark: z.string().optional(), light: z.string().optional() })
453
- .strict(),
421
+ z.strictObject({
422
+ dark: z.string().optional(),
423
+ light: z.string().optional(),
424
+ }),
454
425
  ])
455
426
  .optional()
456
427
  .transform((value) =>
457
428
  typeof value === "string" ? { dark: value, light: value } : value
458
429
  );
459
430
 
460
- const themeConfigSchema = z
461
- .object({
462
- accent: z
463
- .union([
464
- z.string(),
465
- z.object({ dark: z.string(), light: z.string() }).strict(),
466
- ])
467
- .default("blue")
468
- .transform((value) =>
469
- typeof value === "string" ? { dark: value, light: value } : value
470
- ),
471
- action: z.string().optional(),
472
- background: perModeValueSchema,
473
- backgroundImage: perModeValueSchema,
474
- fonts: z
475
- .object({
476
- body: fontSlug.default("inter"),
477
- display: fontSlug.default("inter-tight"),
478
- mono: fontSlug.default("ibm-plex-mono"),
479
- })
480
- .strict()
481
- .default({}),
482
- layout: z.enum(["sidebar"]).default("sidebar"),
483
- mode: z.enum(["system", "light", "dark"]).default("system"),
484
- radius: z.enum(["none", "sm", "md", "lg"]).default("md"),
485
- })
486
- .strict();
431
+ const themeConfigSchema = z.strictObject({
432
+ accent: z
433
+ .union([
434
+ z.string(),
435
+ z.strictObject({ dark: z.string(), light: z.string() }),
436
+ ])
437
+ .default("blue")
438
+ .transform((value) =>
439
+ typeof value === "string" ? { dark: value, light: value } : value
440
+ ),
441
+ action: z.string().optional(),
442
+ background: perModeValueSchema,
443
+ backgroundImage: perModeValueSchema,
444
+ fonts: z
445
+ .strictObject({
446
+ body: fontSlug.default("inter"),
447
+ display: fontSlug.default("inter-tight"),
448
+ mono: fontSlug.default("ibm-plex-mono"),
449
+ })
450
+ .default({}),
451
+ layout: z.enum(["sidebar"]).default("sidebar"),
452
+ mode: z.enum(["system", "light", "dark"]).default("system"),
453
+ radius: z.enum(["none", "sm", "md", "lg"]).default("md"),
454
+ });
487
455
 
488
456
  /** Public credentials for the Algolia search backend (sync key is an env var). */
489
- const algoliaSearchSchema = z
490
- .object({
491
- appId: z.string(),
492
- indexName: z.string(),
493
- searchApiKey: z.string(),
494
- })
495
- .strict();
457
+ const algoliaSearchSchema = z.strictObject({
458
+ appId: z.string(),
459
+ indexName: z.string(),
460
+ searchApiKey: z.string(),
461
+ });
496
462
 
497
463
  /** Public credentials for the Orama Cloud search backend. */
498
- const oramaCloudSearchSchema = z
499
- .object({
500
- apiKey: z.string(),
501
- endpoint: z.string(),
502
- /** Index id used by the build-time sync (with `ORAMA_PRIVATE_API_KEY`). */
503
- indexId: z.string().optional(),
504
- })
505
- .strict();
464
+ const oramaCloudSearchSchema = z.strictObject({
465
+ apiKey: z.string(),
466
+ endpoint: z.string(),
467
+ /** Index id used by the build-time sync (with `ORAMA_PRIVATE_API_KEY`). */
468
+ indexId: z.string().optional(),
469
+ });
506
470
 
507
471
  /** Public credentials for a (self-hosted or cloud) Typesense backend. */
508
- const typesenseSearchSchema = z
509
- .object({
510
- collection: z.string(),
511
- host: z.string(),
512
- port: z.number().int().positive().optional(),
513
- protocol: z.enum(["http", "https"]).optional(),
514
- searchApiKey: z.string(),
515
- })
516
- .strict();
472
+ const typesenseSearchSchema = z.strictObject({
473
+ collection: z.string(),
474
+ host: z.string(),
475
+ port: z.number().int().positive().optional(),
476
+ protocol: z.enum(["http", "https"]).optional(),
477
+ searchApiKey: z.string(),
478
+ });
517
479
 
518
480
  /** Mixedbread semantic search: the store the server endpoint queries. */
519
- const mixedbreadSearchSchema = z
520
- .object({
521
- storeId: z.string(),
522
- })
523
- .strict();
481
+ const mixedbreadSearchSchema = z.strictObject({
482
+ storeId: z.string(),
483
+ });
524
484
 
525
485
  export const searchProviders = [
526
486
  "orama",
@@ -542,20 +502,18 @@ const PROVIDER_CONFIG_KEY = {
542
502
  } as const;
543
503
 
544
504
  const searchConfigSchema = z
545
- .object({
505
+ .strictObject({
546
506
  algolia: algoliaSearchSchema.optional(),
547
507
  indexing: z
548
- .object({
508
+ .strictObject({
549
509
  includeHiddenPages: z.boolean().default(false),
550
510
  })
551
- .strict()
552
511
  .default({}),
553
512
  mixedbread: mixedbreadSearchSchema.optional(),
554
513
  oramaCloud: oramaCloudSearchSchema.optional(),
555
514
  provider: z.enum(searchProviders).default("orama"),
556
515
  typesense: typesenseSearchSchema.optional(),
557
516
  })
558
- .strict()
559
517
  .superRefine((value, ctx) => {
560
518
  // Hosted providers can't work without their credentials; flag a missing
561
519
  // block with a path so the diagnostic points at `search.<provider>`.
@@ -579,95 +537,84 @@ export const askAiProviders = [
579
537
  "openai-compatible",
580
538
  ] as const;
581
539
 
582
- const aiConfigSchema = z
583
- .object({
584
- ask: z
585
- .object({
586
- // Name of the env var holding the provider's API key; each provider has
587
- // a sensible default, so this only needs setting to override it.
588
- apiKeyEnv: z.string().optional(),
589
- // Base URL of the backend. Required for `openai-compatible`; for the
590
- // named providers it overrides the built-in preset.
591
- baseUrl: z.string().url().optional(),
592
- enabled: z.boolean().default(false),
593
- model: z.string().default("openai/gpt-5.5"),
594
- provider: z.enum(askAiProviders).default("gateway"),
595
- // Empty-state prompts shown before the first question. Each renders as a
596
- // clickable suggestion; `icon` is an optional Lucide name beside it.
597
- suggestions: z
598
- .array(
599
- z
600
- .object({
601
- icon: iconName.optional(),
602
- label: z.string().min(1),
603
- })
604
- .strict()
605
- )
606
- .default([]),
607
- })
608
- .strict()
609
- .superRefine((value, ctx) => {
610
- // A generic OpenAI-compatible backend has no preset URL, so the user
611
- // must supply one; the named providers fall back to their preset.
612
- if (value.provider === "openai-compatible" && !value.baseUrl) {
613
- ctx.addIssue({
614
- code: z.ZodIssueCode.custom,
615
- message:
616
- 'ai.ask.baseUrl is required when provider is "openai-compatible".',
617
- path: ["baseUrl"],
618
- });
619
- }
620
- })
621
- .optional(),
622
- llmsTxt: z.boolean().default(true),
623
- })
624
- .strict();
540
+ const aiConfigSchema = z.strictObject({
541
+ ask: z
542
+ .strictObject({
543
+ // Name of the env var holding the provider's API key; each provider has
544
+ // a sensible default, so this only needs setting to override it.
545
+ apiKeyEnv: z.string().optional(),
546
+ // Base URL of the backend. Required for `openai-compatible`; for the
547
+ // named providers it overrides the built-in preset.
548
+ // blume bundles Zod 3; top-level `z.url()` is undefined at runtime.
549
+ // oxlint-disable-next-line react-doctor/zod-v4-prefer-top-level-string-formats
550
+ baseUrl: z.string().url().optional(),
551
+ enabled: z.boolean().default(false),
552
+ model: z.string().default("openai/gpt-5.5"),
553
+ provider: z.enum(askAiProviders).default("gateway"),
554
+ // Empty-state prompts shown before the first question. Each renders as a
555
+ // clickable suggestion; `icon` is an optional Lucide name beside it.
556
+ suggestions: z
557
+ .array(
558
+ z.strictObject({
559
+ icon: iconName.optional(),
560
+ label: z.string().min(1),
561
+ })
562
+ )
563
+ .default([]),
564
+ })
565
+ .superRefine((value, ctx) => {
566
+ // A generic OpenAI-compatible backend has no preset URL, so the user
567
+ // must supply one; the named providers fall back to their preset.
568
+ if (value.provider === "openai-compatible" && !value.baseUrl) {
569
+ ctx.addIssue({
570
+ code: z.ZodIssueCode.custom,
571
+ message:
572
+ 'ai.ask.baseUrl is required when provider is "openai-compatible".',
573
+ path: ["baseUrl"],
574
+ });
575
+ }
576
+ })
577
+ .optional(),
578
+ llmsTxt: z.boolean().default(true),
579
+ });
625
580
 
626
581
  /**
627
582
  * A pinned link rendered above the sidebar sections — a blog, changelog, or
628
583
  * contact page that should always be reachable, regardless of the active tab.
629
584
  * `href` may be an external URL or an internal route.
630
585
  */
631
- const featuredLinkSchema = z
632
- .object({
633
- href: z.string(),
634
- icon: iconName.optional(),
635
- label: z.string(),
636
- })
637
- .strict();
638
-
639
- const navigationConfigSchema = z
640
- .object({
641
- /** Pinned links shown above the generated sidebar sections. */
642
- featured: z.array(featuredLinkSchema).default([]),
643
- /** Show a GitHub repo link in the header (requires `github` configured). */
644
- repo: z.boolean().default(true),
645
- selectors: z.array(navSelectorSchema).default([]),
646
- /**
647
- * Sidebar behavior. `display` sets how every group renders (a group in an
648
- * explicit `items` config may still override it); `items` is an explicit
649
- * sidebar — when omitted the sidebar is generated from the content tree.
650
- * A bare array is shorthand for `{ items }`.
651
- */
652
- sidebar: z
653
- .union([
654
- z.array(sidebarItemSchema),
655
- z
656
- .object({
657
- display: sidebarDisplaySchema.default("flat"),
658
- items: z.array(sidebarItemSchema).optional(),
659
- })
660
- .strict(),
661
- ])
662
- .default({})
663
- .transform((value) =>
664
- Array.isArray(value)
665
- ? { display: "flat" as const, items: value }
666
- : value
667
- ),
668
- tabs: z.array(navTabSchema).optional(),
669
- })
670
- .strict();
586
+ const featuredLinkSchema = z.strictObject({
587
+ href: z.string(),
588
+ icon: iconName.optional(),
589
+ label: z.string(),
590
+ });
591
+
592
+ const navigationConfigSchema = z.strictObject({
593
+ /** Pinned links shown above the generated sidebar sections. */
594
+ featured: z.array(featuredLinkSchema).default([]),
595
+ /** Show a GitHub repo link in the header (requires `github` configured). */
596
+ repo: z.boolean().default(true),
597
+ selectors: z.array(navSelectorSchema).default([]),
598
+ /**
599
+ * Sidebar behavior. `display` sets how every group renders (a group in an
600
+ * explicit `items` config may still override it); `items` is an explicit
601
+ * sidebar — when omitted the sidebar is generated from the content tree.
602
+ * A bare array is shorthand for `{ items }`.
603
+ */
604
+ sidebar: z
605
+ .union([
606
+ z.array(sidebarItemSchema),
607
+ z.strictObject({
608
+ display: sidebarDisplaySchema.default("flat"),
609
+ items: z.array(sidebarItemSchema).optional(),
610
+ }),
611
+ ])
612
+ .default({})
613
+ .transform((value) =>
614
+ Array.isArray(value) ? { display: "flat" as const, items: value } : value
615
+ ),
616
+ tabs: z.array(navTabSchema).optional(),
617
+ });
671
618
 
672
619
  export type AskAiProvider = (typeof askAiProviders)[number];
673
620
  export type AskAiConfig = NonNullable<z.infer<typeof aiConfigSchema>["ask"]>;
@@ -679,37 +626,31 @@ export type AskAiConfig = NonNullable<z.infer<typeof aiConfigSchema>["ask"]>;
679
626
  const exportConfigSchema = z
680
627
  .union([
681
628
  z.boolean(),
682
- z
683
- .object({
684
- epub: z.boolean().default(false),
685
- pdf: z.boolean().default(false),
686
- })
687
- .strict(),
629
+ z.strictObject({
630
+ epub: z.boolean().default(false),
631
+ pdf: z.boolean().default(false),
632
+ }),
688
633
  ])
689
634
  .transform((value) =>
690
635
  typeof value === "boolean" ? { epub: value, pdf: value } : value
691
636
  );
692
637
 
693
- const mcpConfigSchema = z
694
- .object({
695
- enabled: z.boolean().default(false),
696
- /** Optional system hint passed to connecting agents. */
697
- instructions: z.string().optional(),
698
- /** Server name shown to clients; defaults to the site title. */
699
- name: z.string().optional(),
700
- route: z.string().default("/mcp"),
701
- })
702
- .strict();
638
+ const mcpConfigSchema = z.strictObject({
639
+ enabled: z.boolean().default(false),
640
+ /** Optional system hint passed to connecting agents. */
641
+ instructions: z.string().optional(),
642
+ /** Server name shown to clients; defaults to the site title. */
643
+ name: z.string().optional(),
644
+ route: z.string().default("/mcp"),
645
+ });
703
646
 
704
647
  /** A configured locale: ISO-ish code plus display metadata for the switcher. */
705
- const localeSchema = z
706
- .object({
707
- code: z.string().min(1),
708
- /** Text direction; drives `<html dir>` and a future RTL pass. */
709
- dir: z.enum(["ltr", "rtl"]).default("ltr"),
710
- label: z.string(),
711
- })
712
- .strict();
648
+ const localeSchema = z.strictObject({
649
+ code: z.string().min(1),
650
+ /** Text direction; drives `<html dir>` and a future RTL pass. */
651
+ dir: z.enum(["ltr", "rtl"]).default("ltr"),
652
+ label: z.string(),
653
+ });
713
654
 
714
655
  /**
715
656
  * Internationalization. Opt-in: when absent, Blume is single-locale and behaves
@@ -717,7 +658,7 @@ const localeSchema = z
717
658
  * are top-level directories named by `code` (the `dir` parser).
718
659
  */
719
660
  const i18nConfigSchema = z
720
- .object({
661
+ .strictObject({
721
662
  defaultLocale: z.string().default("en"),
722
663
  /** Locale rendered for a missing translation; `null` disables fallback. */
723
664
  fallbackLocale: z.string().nullable().optional(),
@@ -729,7 +670,6 @@ const i18nConfigSchema = z
729
670
  /** Per-locale UI string overrides: `{ fr: { search: { button: "…" } } }`. */
730
671
  ui: uiLocaleOverridesSchema.optional(),
731
672
  })
732
- .strict()
733
673
  .superRefine((value, ctx) => {
734
674
  const codes = new Set(value.locales.map((locale) => locale.code));
735
675
  if (!codes.has(value.defaultLocale)) {
@@ -753,7 +693,7 @@ const i18nConfigSchema = z
753
693
  });
754
694
 
755
695
  const analyticsScriptSchema = z
756
- .object({
696
+ .strictObject({
757
697
  // Extra attributes (e.g. `data-domain`, `id`) spread onto the <script>.
758
698
  attributes: z.record(z.string(), z.string()).optional(),
759
699
  // Inline script body, mutually exclusive with `src`.
@@ -763,69 +703,59 @@ const analyticsScriptSchema = z
763
703
  // Load strategy for an external script.
764
704
  strategy: z.enum(["async", "defer"]).optional(),
765
705
  })
766
- .strict()
767
706
  .refine((value) => Boolean(value.src) !== Boolean(value.content), {
768
707
  message: "An analytics script must set exactly one of `src` or `content`.",
769
708
  });
770
709
 
771
- const analyticsConfigSchema = z
772
- .object({
773
- posthog: z
774
- .object({
775
- host: z.string().optional(),
776
- key: z.string(),
777
- })
778
- .strict()
779
- .optional(),
780
- // Escape hatch for any other provider (Plausible, Fathom, GA, Umami, …).
781
- scripts: z.array(analyticsScriptSchema).optional(),
782
- vercel: z.boolean().optional(),
783
- })
784
- .strict();
785
-
786
- const deploymentConfigSchema = z
787
- .object({
788
- adapter: z
789
- .enum(["vercel", "node", "netlify", "cloudflare"])
790
- .nullable()
791
- .default(null),
792
- base: z.string().optional(),
793
- output: z.enum(["static", "server"]).default("static"),
794
- site: z.string().url().optional(),
795
- })
796
- .strict();
797
-
798
- const redirectSchema = z
799
- .object({
800
- from: z.string(),
801
- status: z
802
- .union([z.literal(301), z.literal(302), z.literal(307), z.literal(308)])
803
- .default(301),
804
- to: z.string(),
805
- })
806
- .strict();
807
-
808
- const ogConfigSchema = z
809
- .object({
810
- /**
811
- * Generate a per-page Open Graph image. Defaults to on once a deployment
812
- * site URL is known (set or auto-detected) and off otherwise, since
813
- * `og:image` must be absolute to be useful to crawlers — resolved in
814
- * `loadConfig`. An explicit value here always wins.
815
- */
816
- enabled: z.boolean().optional(),
817
- })
818
- .strict();
819
-
820
- const rssConfigSchema = z
821
- .object({
822
- enabled: z.boolean().default(true),
823
- /** Max items per feed, newest first. */
824
- limit: z.number().int().positive().default(50),
825
- /** Content types that each get a feed at `/<type>/rss.xml`. */
826
- types: z.array(z.string()).default(["blog", "changelog"]),
827
- })
828
- .strict();
710
+ const analyticsConfigSchema = z.strictObject({
711
+ posthog: z
712
+ .strictObject({
713
+ host: z.string().optional(),
714
+ key: z.string(),
715
+ })
716
+ .optional(),
717
+ // Escape hatch for any other provider (Plausible, Fathom, GA, Umami, …).
718
+ scripts: z.array(analyticsScriptSchema).optional(),
719
+ vercel: z.boolean().optional(),
720
+ });
721
+
722
+ const deploymentConfigSchema = z.strictObject({
723
+ adapter: z
724
+ .enum(["vercel", "node", "netlify", "cloudflare"])
725
+ .nullable()
726
+ .default(null),
727
+ base: z.string().optional(),
728
+ output: z.enum(["static", "server"]).default("static"),
729
+ // blume bundles Zod 3; top-level `z.url()` is undefined at runtime.
730
+ // oxlint-disable-next-line react-doctor/zod-v4-prefer-top-level-string-formats
731
+ site: z.string().url().optional(),
732
+ });
733
+
734
+ const redirectSchema = z.strictObject({
735
+ from: z.string(),
736
+ status: z
737
+ .union([z.literal(301), z.literal(302), z.literal(307), z.literal(308)])
738
+ .default(301),
739
+ to: z.string(),
740
+ });
741
+
742
+ const ogConfigSchema = z.strictObject({
743
+ /**
744
+ * Generate a per-page Open Graph image. Defaults to on once a deployment
745
+ * site URL is known (set or auto-detected) and off otherwise, since
746
+ * `og:image` must be absolute to be useful to crawlers — resolved in
747
+ * `loadConfig`. An explicit value here always wins.
748
+ */
749
+ enabled: z.boolean().optional(),
750
+ });
751
+
752
+ const rssConfigSchema = z.strictObject({
753
+ enabled: z.boolean().default(true),
754
+ /** Max items per feed, newest first. */
755
+ limit: z.number().int().positive().default(50),
756
+ /** Content types that each get a feed at `/<type>/rss.xml`. */
757
+ types: z.array(z.string()).default(["blog", "changelog"]),
758
+ });
829
759
 
830
760
  /**
831
761
  * robots.txt `Content-Signal` preferences — the emerging content-usage
@@ -835,13 +765,11 @@ const rssConfigSchema = z
835
765
  * - `aiInput` → `ai-input` (grounding / RAG use at answer time)
836
766
  * - `aiTrain` → `ai-train` (model training)
837
767
  */
838
- const contentSignalsObjectSchema = z
839
- .object({
840
- aiInput: z.boolean().default(true),
841
- aiTrain: z.boolean().default(true),
842
- search: z.boolean().default(true),
843
- })
844
- .strict();
768
+ const contentSignalsObjectSchema = z.strictObject({
769
+ aiInput: z.boolean().default(true),
770
+ aiTrain: z.boolean().default(true),
771
+ search: z.boolean().default(true),
772
+ });
845
773
 
846
774
  /**
847
775
  * Content signals accept a boolean shorthand or a per-signal object, and
@@ -863,49 +791,70 @@ const contentSignalsSchema = z
863
791
  });
864
792
 
865
793
  /** Discoverability features: OG images, feeds, sitemap, structured data. */
866
- const seoConfigSchema = z
867
- .object({
868
- /**
869
- * Emit `agent-readability.json` at the site root: a manifest that indexes
870
- * the agent-facing surface (llms.txt, Markdown mirrors, MCP server, feeds)
871
- * so agents can discover it without scraping HTML.
872
- */
873
- agentReadability: z.boolean().default(true),
874
- /** robots.txt `Content-Signal` usage declaration (on by default). */
875
- contentSignals: contentSignalsSchema.default(true),
876
- og: ogConfigSchema.default({}),
877
- /** Generate robots.txt (with a Sitemap reference when available). */
878
- robots: z.boolean().default(true),
879
- rss: rssConfigSchema.default({}),
880
- /** Generate sitemap.xml (requires deployment.site). */
881
- sitemap: z.boolean().default(true),
882
- /** Emit schema.org JSON-LD in each page's <head>. */
883
- structuredData: z.boolean().default(true),
884
- })
885
- .strict();
886
-
887
- const githubConfigSchema = z
888
- .object({
889
- branch: z.string().default("main"),
890
- /** Path from the repo root to the project root (for monorepos). */
891
- dir: z.string().optional(),
892
- owner: z.string(),
893
- repo: z.string(),
894
- })
895
- .strict();
794
+ const seoConfigSchema = z.strictObject({
795
+ /**
796
+ * Emit `agent-readability.json` at the site root: a manifest that indexes
797
+ * the agent-facing surface (llms.txt, Markdown mirrors, MCP server, feeds)
798
+ * so agents can discover it without scraping HTML.
799
+ */
800
+ agentReadability: z.boolean().default(true),
801
+ /** robots.txt `Content-Signal` usage declaration (on by default). */
802
+ contentSignals: contentSignalsSchema.default(true),
803
+ og: ogConfigSchema.default({}),
804
+ /** Generate robots.txt (with a Sitemap reference when available). */
805
+ robots: z.boolean().default(true),
806
+ rss: rssConfigSchema.default({}),
807
+ /** Generate sitemap.xml (requires deployment.site). */
808
+ sitemap: z.boolean().default(true),
809
+ /** Emit schema.org JSON-LD in each page's <head>. */
810
+ structuredData: z.boolean().default(true),
811
+ });
896
812
 
897
- const codeBlockThemeSchema = z
898
- .object({
899
- dark: z.string().default("github-dark"),
900
- light: z.string().default("github-light"),
901
- })
902
- .strict();
813
+ const githubConfigSchema = z.strictObject({
814
+ branch: z.string().default("main"),
815
+ /** Path from the repo root to the project root (for monorepos). */
816
+ dir: z.string().optional(),
817
+ owner: z.string(),
818
+ repo: z.string(),
819
+ });
903
820
 
904
- const codeBlocksConfigSchema = z
905
- .object({
906
- theme: codeBlockThemeSchema.default({}),
907
- })
908
- .strict();
821
+ const codeBlockThemeSchema = z.strictObject({
822
+ dark: z.string().default("github-dark"),
823
+ light: z.string().default("github-light"),
824
+ });
825
+
826
+ const codeBlocksConfigSchema = z.strictObject({
827
+ theme: codeBlockThemeSchema.default({}),
828
+ });
829
+
830
+ /**
831
+ * `<Component />` example previews. A string is shorthand for `{ source }`:
832
+ * where examples live, relative to the project root (default `examples`).
833
+ * `source` may be a glob (anything with `*`/`?`/`[]`/`{}`/`!`), in which case
834
+ * only matching files are discovered and each `<Component path>` key is
835
+ * relative to the glob's static prefix — use this for a registry layout that
836
+ * colocates component sources with their examples
837
+ * (`registry/<pkg>/**\/examples/*`), leaving the sources (which have no
838
+ * default export to wrap) out.
839
+ *
840
+ * `css` names a stylesheet (relative to the project root) injected into every
841
+ * preview frame after Blume's default tokens. Previews render in an isolated
842
+ * iframe that the site's docs styles never reach, so this is where design
843
+ * tokens for the previewed components live — e.g. shadcn variables and
844
+ * `@theme` mappings. Tailwind itself is already provided; the file should
845
+ * hold tokens and custom styles, not another `@import "tailwindcss"`.
846
+ */
847
+ const examplesConfigSchema = z
848
+ .union([
849
+ z.string(),
850
+ z.strictObject({
851
+ css: z.string().optional(),
852
+ source: z.string().default("examples"),
853
+ }),
854
+ ])
855
+ .transform((value): { css?: string; source: string } =>
856
+ typeof value === "string" ? { source: value } : value
857
+ );
909
858
 
910
859
  /**
911
860
  * "Last updated" timestamps for content pages. `false` (default) disables the
@@ -914,58 +863,63 @@ const codeBlocksConfigSchema = z
914
863
  */
915
864
  const lastModifiedConfigSchema = z.union([
916
865
  z.boolean(),
917
- z.object({ type: z.enum(["git", "frontmatter"]).default("git") }).strict(),
866
+ z.strictObject({ type: z.enum(["git", "frontmatter"]).default("git") }),
918
867
  ]);
919
868
 
920
869
  /** Code-block rendering options (`markdown.code`). */
921
- const codeConfigSchema = z
922
- .object({
923
- /**
924
- * Show a brand language icon in the code-block header (TypeScript, Python,
925
- * …). On by default; recognized languages only.
926
- */
927
- icons: z.boolean().default(true),
928
- /**
929
- * Wrap long lines instead of scrolling horizontally. Off by default, so
930
- * code keeps its original line breaks and overflows into a scroll area.
931
- */
932
- wrap: z.boolean().default(false),
933
- })
934
- .strict();
935
-
936
- const markdownConfigSchema = z
937
- .object({
938
- /** Code-block rendering: language icons and line wrapping. */
939
- code: codeConfigSchema.default({}),
940
- codeBlocks: codeBlocksConfigSchema.default({}),
941
- /**
942
- * Wrap each `##`–`######` heading in a link to its own anchor so readers can
943
- * click to copy, bookmark, or share a permalink to that section. On by
944
- * default; set to `false` to render plain headings.
945
- */
946
- headingAnchors: z.boolean().default(true),
947
- /**
948
- * Make content images click-to-zoom (open in a lightbox). On by default;
949
- * opt a single image out with `data-no-zoom`.
950
- */
951
- imageZoom: z.boolean().default(true),
952
- })
953
- .strict();
870
+ const codeConfigSchema = z.strictObject({
871
+ /**
872
+ * Show a brand language icon in the code-block header (TypeScript, Python,
873
+ * …). On by default; recognized languages only.
874
+ */
875
+ icons: z.boolean().default(true),
876
+ /**
877
+ * Wrap long lines instead of scrolling horizontally. Off by default, so
878
+ * code keeps its original line breaks and overflows into a scroll area.
879
+ */
880
+ wrap: z.boolean().default(false),
881
+ });
882
+
883
+ const markdownConfigSchema = z.strictObject({
884
+ /** Code-block rendering: language icons and line wrapping. */
885
+ code: codeConfigSchema.default({}),
886
+ codeBlocks: codeBlocksConfigSchema.default({}),
887
+ /**
888
+ * Wrap each `##`–`######` heading in a link to its own anchor so readers can
889
+ * click to copy, bookmark, or share a permalink to that section. On by
890
+ * default; set to `false` to render plain headings.
891
+ */
892
+ headingAnchors: z.boolean().default(true),
893
+ /**
894
+ * Make content images click-to-zoom (open in a lightbox). On by default;
895
+ * opt a single image out with `data-no-zoom`.
896
+ */
897
+ imageZoom: z.boolean().default(true),
898
+ });
899
+
900
+ /** React island behavior (`react`). */
901
+ const reactConfigSchema = z.strictObject({
902
+ /**
903
+ * Auto-memoize React components/hooks with the React Compiler
904
+ * (`babel-plugin-react-compiler`). On by default whenever React is enabled
905
+ * (a project `.tsx`/`.jsx`, a React island/example/override, or Ask AI); set
906
+ * to `false` to skip the compiler's babel pass.
907
+ */
908
+ compiler: z.boolean().default(true),
909
+ });
954
910
 
955
911
  /**
956
912
  * A single spec rendered by the API reference. `spec` is a local path or an
957
913
  * `http(s)` URL (OpenAPI for the Blume renderer; OpenAPI or AsyncAPI for Scalar).
958
914
  */
959
- const openapiSourceSchema = z
960
- .object({
961
- /** Nav/section label for this source. */
962
- label: z.string().optional(),
963
- /** Per-source route; defaults to the block's `route` (or a derived path). */
964
- route: z.string().optional(),
965
- /** Local path or `http(s)` URL to the spec. */
966
- spec: z.string(),
967
- })
968
- .strict();
915
+ const openapiSourceSchema = z.strictObject({
916
+ /** Nav/section label for this source. */
917
+ label: z.string().optional(),
918
+ /** Per-source route; defaults to the block's `route` (or a derived path). */
919
+ route: z.string().optional(),
920
+ /** Local path or `http(s)` URL to the spec. */
921
+ spec: z.string(),
922
+ });
969
923
 
970
924
  export type OpenApiSource = z.infer<typeof openapiSourceSchema>;
971
925
 
@@ -976,39 +930,35 @@ export type OpenApiSource = z.infer<typeof openapiSourceSchema>;
976
930
  * `renderer: "scalar"` to fall back to the embedded Scalar SPA (a single
977
931
  * self-contained route that doesn't weave into the sidebar or search).
978
932
  */
979
- const openapiConfigSchema = z
980
- .object({
981
- /** Code-sample languages shown per operation (Blume renderer). */
982
- codeSamples: z.array(z.string()).default(["curl", "js", "python"]),
983
- enabled: z.boolean().default(false),
984
- /** Start nested schema rows expanded rather than collapsed (Blume renderer). */
985
- expandSchemas: z.boolean().default(false),
986
- /** Who renders the reference: Blume's own UI, or the embedded Scalar SPA. */
987
- renderer: z.enum(["blume", "scalar"]).default("blume"),
988
- /** Where the reference mounts. */
989
- route: z.string().default("/reference"),
990
- /** One or more specs; each renders on its own route by default. */
991
- sources: z.array(openapiSourceSchema).default([]),
992
- /** Shorthand for a single source: `sources: [{ spec }]`. */
993
- spec: z.string().optional(),
994
- /** Scalar theme name (Scalar renderer only). */
995
- theme: z.string().optional(),
996
- })
997
- .strict();
933
+ const openapiConfigSchema = z.strictObject({
934
+ /** Code-sample languages shown per operation (Blume renderer). */
935
+ codeSamples: z.array(z.string()).default(["curl", "js", "python"]),
936
+ enabled: z.boolean().default(false),
937
+ /** Start nested schema rows expanded rather than collapsed (Blume renderer). */
938
+ expandSchemas: z.boolean().default(false),
939
+ /** Who renders the reference: Blume's own UI, or the embedded Scalar SPA. */
940
+ renderer: z.enum(["blume", "scalar"]).default("blume"),
941
+ /** Where the reference mounts. */
942
+ route: z.string().default("/reference"),
943
+ /** One or more specs; each renders on its own route by default. */
944
+ sources: z.array(openapiSourceSchema).default([]),
945
+ /** Shorthand for a single source: `sources: [{ spec }]`. */
946
+ spec: z.string().optional(),
947
+ /** Scalar theme name (Scalar renderer only). */
948
+ theme: z.string().optional(),
949
+ });
998
950
 
999
951
  /**
1000
952
  * AsyncAPI reference. Same shape and Scalar pipeline as {@link openapiConfigSchema}
1001
953
  * (Scalar auto-detects the document type); only the default `route` differs.
1002
954
  */
1003
- const asyncapiConfigSchema = z
1004
- .object({
1005
- enabled: z.boolean().default(false),
1006
- route: z.string().default("/events"),
1007
- sources: z.array(openapiSourceSchema).default([]),
1008
- spec: z.string().optional(),
1009
- theme: z.string().optional(),
1010
- })
1011
- .strict();
955
+ const asyncapiConfigSchema = z.strictObject({
956
+ enabled: z.boolean().default(false),
957
+ route: z.string().default("/events"),
958
+ sources: z.array(openapiSourceSchema).default([]),
959
+ spec: z.string().optional(),
960
+ theme: z.string().optional(),
961
+ });
1012
962
 
1013
963
  /** Full user-facing config schema. All fields optional with defaults. */
1014
964
  /**
@@ -1019,12 +969,10 @@ const asyncapiConfigSchema = z
1019
969
  const tocConfigSchema = z
1020
970
  .union([
1021
971
  z.boolean(),
1022
- z
1023
- .object({
1024
- maxHeadingLevel: z.number().int().min(1).max(6).optional(),
1025
- minHeadingLevel: z.number().int().min(1).max(6).optional(),
1026
- })
1027
- .strict(),
972
+ z.strictObject({
973
+ maxHeadingLevel: z.number().int().min(1).max(6).optional(),
974
+ minHeadingLevel: z.number().int().min(1).max(6).optional(),
975
+ }),
1028
976
  ])
1029
977
  .default(true)
1030
978
  .transform((value) => {
@@ -1038,47 +986,50 @@ const tocConfigSchema = z
1038
986
  };
1039
987
  });
1040
988
 
1041
- export const blumeConfigSchema = z
1042
- .object({
1043
- ai: aiConfigSchema.default({}),
1044
- analytics: analyticsConfigSchema.optional(),
1045
- asyncapi: asyncapiConfigSchema.default({}),
1046
- banner: bannerConfigSchema.optional(),
1047
- content: contentConfigSchema.default({}),
1048
- deployment: deploymentConfigSchema.default({}),
1049
- description: z.string().optional(),
1050
- /**
1051
- * Where `<Component path>` resolves live previews and their source from,
1052
- * relative to the project root. Defaults to the `examples` directory; point
1053
- * it elsewhere when examples live outside a top-level `examples/`.
1054
- *
1055
- * May be a glob (anything with `*`/`?`/`[]`/`{}`/`!`), in which case only
1056
- * matching files are discovered and a `<Component path>` key is relative to
1057
- * the glob's static prefix. Use this for a registry layout that colocates
1058
- * component sources with their examples — `registry/<pkg>/**\/examples/*`
1059
- * targets just the examples, leaving the sources (which have no default
1060
- * export to wrap) out, so the registry needn't be forked into its own
1061
- * examples directory.
1062
- */
1063
- examples: z.string().default("examples"),
1064
- export: exportConfigSchema.default(false),
1065
- feedback: z.boolean().default(true),
1066
- github: githubConfigSchema.optional(),
1067
- i18n: i18nConfigSchema.optional(),
1068
- lastModified: lastModifiedConfigSchema.default(false),
1069
- logo: logoConfigSchema.optional(),
1070
- markdown: markdownConfigSchema.default({}),
1071
- mcp: mcpConfigSchema.default({}),
1072
- navigation: navigationConfigSchema.default({}),
1073
- openapi: openapiConfigSchema.default({}),
1074
- redirects: z.array(redirectSchema).default([]),
1075
- search: searchConfigSchema.default({}),
1076
- seo: seoConfigSchema.default({}),
1077
- theme: themeConfigSchema.default({}),
1078
- title: z.string().default("Documentation"),
1079
- toc: tocConfigSchema,
1080
- })
1081
- .strict();
989
+ export const blumeConfigSchema = z.strictObject({
990
+ ai: aiConfigSchema.default({}),
991
+ analytics: analyticsConfigSchema.optional(),
992
+ asyncapi: asyncapiConfigSchema.default({}),
993
+ banner: bannerConfigSchema.optional(),
994
+ /**
995
+ * Site-wide mount point prepended to every generated route (e.g. `/docs`),
996
+ * while staying invisible to the sidebar/nav tree. Distinct from a per-source
997
+ * `prefix` (which creates a group) and from `deployment.base` (Astro's
998
+ * host-subdirectory base); the two compose. Normalized to `""` or `/seg`.
999
+ */
1000
+ basePath: z
1001
+ .string()
1002
+ .optional()
1003
+ .transform((value) => normalizeBasePath(value)),
1004
+ content: contentConfigSchema.default({}),
1005
+ deployment: deploymentConfigSchema.default({}),
1006
+ description: z.string().optional(),
1007
+ /**
1008
+ * Where `<Component path>` resolves live previews and their source from.
1009
+ * A string is shorthand for `{ source }` — the directory (or glob, for
1010
+ * colocated registry layouts) under the project root that holds example
1011
+ * files. The object form adds `css`: a stylesheet injected into every
1012
+ * preview frame (design tokens, shadcn variables, `@theme` mappings).
1013
+ */
1014
+ examples: examplesConfigSchema.default("examples"),
1015
+ export: exportConfigSchema.default(false),
1016
+ feedback: z.boolean().default(true),
1017
+ github: githubConfigSchema.optional(),
1018
+ i18n: i18nConfigSchema.optional(),
1019
+ lastModified: lastModifiedConfigSchema.default(false),
1020
+ logo: logoConfigSchema.optional(),
1021
+ markdown: markdownConfigSchema.default({}),
1022
+ mcp: mcpConfigSchema.default({}),
1023
+ navigation: navigationConfigSchema.default({}),
1024
+ openapi: openapiConfigSchema.default({}),
1025
+ react: reactConfigSchema.default({}),
1026
+ redirects: z.array(redirectSchema).default([]),
1027
+ search: searchConfigSchema.default({}),
1028
+ seo: seoConfigSchema.default({}),
1029
+ theme: themeConfigSchema.default({}),
1030
+ title: z.string().default("Documentation"),
1031
+ toc: tocConfigSchema,
1032
+ });
1082
1033
 
1083
1034
  /** Resolved config: every field present after defaults are applied. */
1084
1035
  export type ResolvedConfig = z.infer<typeof blumeConfigSchema>;