blume 1.2.0 → 1.3.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 (77) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/cli/index.js +1715 -539
  3. package/dist/cli/index.js.map +40 -31
  4. package/dist/types/core/config-input.d.ts +131 -11
  5. package/dist/types/core/config.d.ts +9 -1
  6. package/dist/types/core/data.d.ts +24 -5
  7. package/dist/types/core/i18n-ui.d.ts +58 -799
  8. package/dist/types/core/schema.d.ts +534 -3305
  9. package/dist/types/theme/fonts.d.ts +55 -11
  10. package/docs/02-deployment.mdx +2 -0
  11. package/docs/07-faq.mdx +14 -14
  12. package/docs/advanced/skills.mdx +2 -2
  13. package/docs/configuration/ai.mdx +126 -2
  14. package/docs/configuration/index.mdx +19 -1
  15. package/docs/configuration/search.mdx +17 -0
  16. package/docs/configuration/seo.mdx +26 -3
  17. package/docs/configuration/theming.mdx +44 -2
  18. package/docs/content/syntax.mdx +18 -2
  19. package/docs/reference/cli.mdx +3 -3
  20. package/package.json +9 -8
  21. package/skills/blume/SKILL.md +6 -4
  22. package/skills/blume-migrate/SKILL.md +5 -3
  23. package/skills/blume-migrate/references/mintlify.md +5 -5
  24. package/skills/blume-migrate/references/monorepo.md +2 -1
  25. package/src/ai/agent-readability.ts +31 -1
  26. package/src/ai/api-catalog.ts +81 -0
  27. package/src/ai/ask-context.ts +7 -1
  28. package/src/ai/ask-data.ts +1 -0
  29. package/src/ai/link-headers.ts +52 -0
  30. package/src/ai/llms.ts +12 -1
  31. package/src/ai/markdown.ts +15 -2
  32. package/src/ai/mcp/data.ts +7 -0
  33. package/src/ai/mcp/discovery.ts +70 -15
  34. package/src/ai/mcp/server.ts +14 -8
  35. package/src/ai/mcp/stdio.ts +4 -1
  36. package/src/ai/skills.ts +193 -0
  37. package/src/ai/tar.ts +104 -0
  38. package/src/ai/web-bot-auth.ts +30 -0
  39. package/src/astro/generate.ts +116 -6
  40. package/src/astro/integration.ts +52 -14
  41. package/src/astro/templates.ts +191 -37
  42. package/src/audit/catalog.ts +20 -0
  43. package/src/audit/checks/dns-aid.ts +190 -0
  44. package/src/audit/report.ts +5 -0
  45. package/src/audit/run.ts +2 -0
  46. package/src/cli/commands/build.ts +178 -9
  47. package/src/cli/init/scaffold.ts +1 -1
  48. package/src/components/islands/ask-ai.tsx +4 -1
  49. package/src/components/islands/webmcp.ts +203 -0
  50. package/src/components/layout/NavTree.astro +4 -4
  51. package/src/components/layout/PageLayout.astro +2 -0
  52. package/src/components/layout/ReferenceLayout.astro +2 -0
  53. package/src/components/layout/RootLayout.astro +63 -11
  54. package/src/components/layout/Search.astro +2 -2
  55. package/src/components/layout/WebMcp.astro +49 -0
  56. package/src/components/layout/search/orama.ts +5 -2
  57. package/src/core/config-input.ts +143 -11
  58. package/src/core/config.ts +17 -1
  59. package/src/core/content-assets.ts +199 -0
  60. package/src/core/data.ts +21 -5
  61. package/src/core/diagnostics.ts +6 -5
  62. package/src/core/i18n-ui.ts +19 -28
  63. package/src/core/project-graph.ts +6 -0
  64. package/src/core/schema.ts +224 -71
  65. package/src/core/sources/normalize.ts +5 -5
  66. package/src/deploy/headers.ts +45 -3
  67. package/src/deploy/vercel-negotiation.ts +233 -0
  68. package/src/markdown/mermaid.ts +7 -1
  69. package/src/markdown/table-wrap.ts +33 -1
  70. package/src/og/card.ts +91 -22
  71. package/src/og/derive.ts +200 -0
  72. package/src/og/index.ts +6 -1
  73. package/src/search/orama-index.ts +151 -7
  74. package/src/theme/entry.ts +34 -13
  75. package/src/theme/fonts.ts +183 -30
  76. package/dist/types/og/card.d.ts +0 -63
  77. package/dist/types/og/dimensions.d.ts +0 -12
@@ -31,7 +31,7 @@ const uiStringsObject = z.object({
31
31
  openInChat: z.string().default("Open in chat"),
32
32
  scrollToTop: z.string().default("Scroll to top"),
33
33
  })
34
- .default({}),
34
+ .prefault({}),
35
35
  ask: z
36
36
  .object({
37
37
  ai: z.string().default("AI"),
@@ -47,12 +47,12 @@ const uiStringsObject = z.object({
47
47
  title: z.string().default("Ask AI"),
48
48
  you: z.string().default("You"),
49
49
  })
50
- .default({}),
50
+ .prefault({}),
51
51
  banner: z
52
52
  .object({
53
53
  dismiss: z.string().default("Dismiss announcement"),
54
54
  })
55
- .default({}),
55
+ .prefault({}),
56
56
  changelog: z
57
57
  .object({
58
58
  description: z
@@ -64,12 +64,12 @@ const uiStringsObject = z.object({
64
64
  showReleases: z.string().default("Show {version} releases"),
65
65
  title: z.string().default("Changelog"),
66
66
  })
67
- .default({}),
67
+ .prefault({}),
68
68
  content: z
69
69
  .object({
70
70
  diagramError: z.string().default("Could not render this diagram."),
71
71
  })
72
- .default({}),
72
+ .prefault({}),
73
73
  feedback: z
74
74
  .object({
75
75
  no: z.string().default("No"),
@@ -77,13 +77,13 @@ const uiStringsObject = z.object({
77
77
  thanks: z.string().default("Thanks for your feedback!"),
78
78
  yes: z.string().default("Yes"),
79
79
  })
80
- .default({}),
80
+ .prefault({}),
81
81
  languageSwitcher: z
82
82
  .object({
83
83
  label: z.string().default("Language"),
84
84
  untranslated: z.string().default("Not translated"),
85
85
  })
86
- .default({}),
86
+ .prefault({}),
87
87
  nav: z
88
88
  .object({
89
89
  back: z.string().default("Back"),
@@ -98,7 +98,7 @@ const uiStringsObject = z.object({
98
98
  toggleNavigation: z.string().default("Toggle navigation"),
99
99
  toggleTheme: z.string().default("Toggle color theme"),
100
100
  })
101
- .default({}),
101
+ .prefault({}),
102
102
  notFound: z
103
103
  .object({
104
104
  description: z
@@ -107,7 +107,7 @@ const uiStringsObject = z.object({
107
107
  home: z.string().default("Back to home"),
108
108
  title: z.string().default("Page not found"),
109
109
  })
110
- .default({}),
110
+ .prefault({}),
111
111
  page: z
112
112
  .object({
113
113
  lastUpdated: z.string().default("Last updated on"),
@@ -116,7 +116,7 @@ const uiStringsObject = z.object({
116
116
  previous: z.string().default("Previous"),
117
117
  skipToContent: z.string().default("Skip to content"),
118
118
  })
119
- .default({}),
119
+ .prefault({}),
120
120
  search: z
121
121
  .object({
122
122
  all: z.string().default("All"),
@@ -137,36 +137,27 @@ const uiStringsObject = z.object({
137
137
  preview: z.string().default("preview"),
138
138
  results: z.string().default("Results"),
139
139
  })
140
- .default({}),
140
+ .prefault({}),
141
141
  toc: z
142
142
  .object({
143
143
  title: z.string().default("On this page"),
144
144
  })
145
- .default({}),
145
+ .prefault({}),
146
146
  });
147
147
 
148
- export const uiStringsSchema = uiStringsObject.default({});
148
+ export const uiStringsSchema = uiStringsObject.prefault({});
149
149
 
150
150
  /** A fully-resolved dictionary; every key present. */
151
151
  export type UIStrings = z.infer<typeof uiStringsObject>;
152
152
 
153
153
  /**
154
- * The English baseline, derived from the schema defaults.
155
- *
156
- * Each group is passed explicitly as `{}` rather than parsing a bare `{}`: a
157
- * group's `.default({})` only re-applies its inner field defaults when the group
158
- * is *present but empty*. Zod 4 changed `.default()` to return the literal
159
- * default value without re-parsing it through the inner type, so a bare
160
- * `parse({})` leaves every group undefined and collapses each to `{}` there.
161
- * Because these components resolve Zod from the consuming project — which may be
162
- * on v4 while the CLI bundles v3 — the runtime baseline would silently render
163
- * blank (empty search labels, aria-labels, skip link). Naming every group keeps
164
- * the baseline fully populated on both Zod 3 and 4.
154
+ * The English baseline, derived from the schema defaults. The groups use
155
+ * `.prefault({})` (not `.default({})`) so an absent group is still parsed
156
+ * through its inner type and every field default applies under Zod 4's
157
+ * `.default()` semantics a bare `parse({})` would collapse each group to a
158
+ * literal `{}` and the runtime would silently render blank chrome.
165
159
  */
166
- const EN_UI_INPUT: Record<string, unknown> = Object.fromEntries(
167
- Object.keys(uiStringsObject.shape).map((group) => [group, {}])
168
- );
169
- export const EN_UI: UIStrings = uiStringsObject.parse(EN_UI_INPUT);
160
+ export const EN_UI: UIStrings = uiStringsObject.parse({});
170
161
 
171
162
  /**
172
163
  * A partial override: `{ group: { key: "translation" } }`. Validated loosely
@@ -75,6 +75,11 @@ export interface BlumeProject {
75
75
  droppedPages: number;
76
76
  /** The instantiated content sources, for lazy entry reads (search/AI/raw). */
77
77
  sources: ContentSource[];
78
+ /**
79
+ * Whether the config file set `theme.fonts` itself (see
80
+ * {@link ConfigLoadResult.themeFontsConfigured}); gates OG font derivation.
81
+ */
82
+ themeFontsConfigured: boolean;
78
83
  /**
79
84
  * Discovered `examples/` sources keyed by `<Component path>`, attached by the
80
85
  * runtime/eject layer after {@link scanProject} (example discovery is an Astro
@@ -306,5 +311,6 @@ export const scanProject = async (
306
311
  manifest,
307
312
  mode,
308
313
  sources,
314
+ themeFontsConfigured: configResult.themeFontsConfigured,
309
315
  };
310
316
  };
@@ -61,10 +61,7 @@ const sidebarMetaSchema = z.strictObject({
61
61
  const xHandleSchema = z.string().transform(normalizeXHandle).optional();
62
62
 
63
63
  const seoMetaSchema = z.strictObject({
64
- // blume bundles Zod 3; top-level `z.url()` is undefined at runtime and
65
- // schemas must stay dual-compatible with consumer projects on Zod 4.
66
- // oxlint-disable-next-line react-doctor/zod-v4-prefer-top-level-string-formats
67
- canonical: z.string().url().optional(),
64
+ canonical: z.url().optional(),
68
65
  description: z.string().optional(),
69
66
  image: z.string().optional(),
70
67
  noindex: z.boolean().default(false),
@@ -109,9 +106,14 @@ const authorSchema = z.union([
109
106
  .catchall(z.unknown()),
110
107
  ]);
111
108
 
109
+ // Shorthand defaults use `.prefault()`, not `.default()`, wherever the value
110
+ // must still be parsed — Zod 4's `.default()` returns the value as-is, so a
111
+ // `.default({})` on an object with inner defaults (or a transform) would
112
+ // resolve to a bare `{}` instead of the fully-defaulted shape.
113
+
112
114
  /** Frontmatter accepted on any content page. */
113
115
  const pageMetaBaseSchema = z.strictObject({
114
- ai: aiMetaSchema.default({}),
116
+ ai: aiMetaSchema.prefault({}),
115
117
  /** Post author(s) for blog/changelog content; preserved, not yet rendered. */
116
118
  authors: z.union([authorSchema, z.array(authorSchema)]).optional(),
117
119
  changelog: changelogMetaSchema.optional(),
@@ -125,9 +127,9 @@ const pageMetaBaseSchema = z.strictObject({
125
127
  /** Overrides the git-derived last-modified date when `lastModified` is on. */
126
128
  lastModified: dateSchema.optional(),
127
129
  noindex: z.boolean().default(false),
128
- search: searchMetaSchema.default({}),
129
- seo: seoMetaSchema.default({}),
130
- sidebar: sidebarMetaSchema.default({}),
130
+ search: searchMetaSchema.prefault({}),
131
+ seo: seoMetaSchema.prefault({}),
132
+ sidebar: sidebarMetaSchema.prefault({}),
131
133
  slug: z.string().optional(),
132
134
  title: z.string().optional(),
133
135
  // No default: an absent `type` must fall through to `content.defaultType`.
@@ -410,33 +412,79 @@ export type SidebarItemConfig =
410
412
  root?: string;
411
413
  };
412
414
 
413
- const sidebarItemSchema: z.ZodType<SidebarItemConfig> = z.lazy(() =>
414
- z.union([
415
- z.string(),
416
- z.strictObject({
417
- badge: z.string().optional(),
418
- collapsed: z.boolean().optional(),
419
- directory: directoryModeSchema.optional(),
420
- display: sidebarDisplaySchema.optional(),
421
- href: z.string().optional(),
422
- icon: iconName.optional(),
423
- items: z.array(sidebarItemSchema).optional(),
424
- label: z.string(),
425
- root: z.string().optional(),
426
- }),
427
- ])
428
- );
429
-
430
- /** A curated Google Font slug (see `theme/fonts.ts`). */
431
- const fontSlug = z.string().superRefine((value, ctx) => {
432
- if (!isFontSlug(value)) {
433
- ctx.addIssue({
434
- code: z.ZodIssueCode.custom,
435
- message: `Unknown font "${value}". Supported fonts: ${FONT_SLUGS.join(", ")}.`,
436
- });
437
- }
415
+ // Zod 4's `ZodType` defaults its Input parameter to `unknown` (it no longer
416
+ // mirrors Output), so the recursive annotation names both — otherwise
417
+ // `z.input` of anything containing this schema degrades to `unknown`.
418
+ const sidebarItemSchema: z.ZodType<SidebarItemConfig, SidebarItemConfig> =
419
+ z.lazy(() =>
420
+ z.union([
421
+ z.string(),
422
+ z.strictObject({
423
+ badge: z.string().optional(),
424
+ collapsed: z.boolean().optional(),
425
+ directory: directoryModeSchema.optional(),
426
+ display: sidebarDisplaySchema.optional(),
427
+ href: z.string().optional(),
428
+ icon: iconName.optional(),
429
+ items: z.array(sidebarItemSchema).optional(),
430
+ label: z.string(),
431
+ root: z.string().optional(),
432
+ }),
433
+ ])
434
+ );
435
+
436
+ const fontFallbackSchema = z.enum(["sans", "serif", "mono"]);
437
+
438
+ /** Any family from a zero-config Astro provider, by name. */
439
+ const remoteFontSchema = z.strictObject({
440
+ fallback: fontFallbackSchema.optional(),
441
+ name: z.string().min(1),
442
+ provider: z
443
+ .enum(["google", "fontsource", "bunny", "fontshare"])
444
+ .default("google"),
445
+ weights: z
446
+ .array(
447
+ z.union([z.number().int().positive(), z.string().regex(/^\d+\.\.\d+$/u)])
448
+ )
449
+ .nonempty()
450
+ .optional(),
451
+ });
452
+
453
+ /** One local `@font-face`: a file plus optional weight/style (else inferred). */
454
+ const localFontVariantSchema = z.strictObject({
455
+ src: z.string().min(1),
456
+ style: z.enum(["normal", "italic", "oblique"]).optional(),
457
+ weight: z
458
+ .union([
459
+ z.number().int().positive(),
460
+ z.string().regex(/^\d+(?:\.\.\d+)?$/u),
461
+ ])
462
+ .optional(),
463
+ });
464
+
465
+ /** A self-hosted family loaded from font files in the project. */
466
+ const localFontSchema = z.strictObject({
467
+ fallback: fontFallbackSchema.optional(),
468
+ name: z.string().min(1),
469
+ variants: z.array(localFontVariantSchema).nonempty(),
438
470
  });
439
471
 
472
+ /**
473
+ * A role's font: a curated Google Font slug (see `theme/fonts.ts`), a
474
+ * remote-provider family, or local font files. Bare strings must be curated
475
+ * slugs so a typo fails with the supported list instead of a provider error.
476
+ */
477
+ const fontValueSchema = z
478
+ .union([z.string(), remoteFontSchema, localFontSchema])
479
+ .superRefine((value, ctx) => {
480
+ if (typeof value === "string" && !isFontSlug(value)) {
481
+ ctx.addIssue({
482
+ code: z.ZodIssueCode.custom,
483
+ message: `Unknown font "${value}". Supported fonts: ${FONT_SLUGS.join(", ")}. For any other family, use the object form: { name: "..." } (remote provider) or { name: "...", variants: [...] } (local files).`,
484
+ });
485
+ }
486
+ });
487
+
440
488
  /**
441
489
  * An optional per-mode theme value: a string applies to both color modes; a
442
490
  * `{ light, dark }` object sets each mode individually (either may be
@@ -470,11 +518,11 @@ const themeConfigSchema = z.strictObject({
470
518
  backgroundImage: perModeValueSchema,
471
519
  fonts: z
472
520
  .strictObject({
473
- body: fontSlug.default("inter"),
474
- display: fontSlug.default("inter-tight"),
475
- mono: fontSlug.default("ibm-plex-mono"),
521
+ body: fontValueSchema.default("inter"),
522
+ display: fontValueSchema.default("inter-tight"),
523
+ mono: fontValueSchema.default("ibm-plex-mono"),
476
524
  })
477
- .default({}),
525
+ .prefault({}),
478
526
  layout: z.enum(["sidebar"]).default("sidebar"),
479
527
  mode: z.enum(["system", "light", "dark"]).default("system"),
480
528
  radius: z.enum(["none", "sm", "md", "lg"]).default("md"),
@@ -542,7 +590,7 @@ const searchConfigSchema = z
542
590
  .strictObject({
543
591
  includeHiddenPages: z.boolean().default(false),
544
592
  })
545
- .default({}),
593
+ .prefault({}),
546
594
  mixedbread: mixedbreadSearchSchema.optional(),
547
595
  oramaCloud: oramaCloudSearchSchema.optional(),
548
596
  /** Curated links for the Cmd+K empty state; defaults to the first sidebar pages. */
@@ -573,6 +621,37 @@ export const askAiProviders = [
573
621
  "openai-compatible",
574
622
  ] as const;
575
623
 
624
+ /**
625
+ * JWK parameters that carry private or secret key material (RFC 7518): the
626
+ * private exponent/scalar, the RSA CRT parameters, and the symmetric key.
627
+ * A directory is public by definition, so any of these in a configured key is
628
+ * a leaked credential, not a config style choice — reject loudly.
629
+ */
630
+ const PRIVATE_JWK_PARAMS = ["d", "p", "q", "dp", "dq", "qi", "oth", "k"];
631
+
632
+ /**
633
+ * A public JWK for the Web Bot Auth signature directory. Shape is left to the
634
+ * signing setup (Ed25519 `OKP` keys in current deployments) — validation only
635
+ * requires the mandatory `kty` and refuses private key material.
636
+ */
637
+ const publicJwkSchema = z
638
+ .record(z.string(), z.unknown())
639
+ .superRefine((jwk, ctx) => {
640
+ if (typeof jwk.kty !== "string" || jwk.kty.length === 0) {
641
+ ctx.addIssue({
642
+ code: z.ZodIssueCode.custom,
643
+ message: 'A JWK must declare its key type ("kty").',
644
+ });
645
+ }
646
+ const leaked = PRIVATE_JWK_PARAMS.filter((param) => param in jwk);
647
+ if (leaked.length > 0) {
648
+ ctx.addIssue({
649
+ code: z.ZodIssueCode.custom,
650
+ message: `The JWK contains private key material ("${leaked.join('", "')}") — the signatures directory is public, so list only public keys and keep the private key where the signing agent runs.`,
651
+ });
652
+ }
653
+ });
654
+
576
655
  const mcpConfigSchema = z.strictObject({
577
656
  enabled: z.boolean().default(false),
578
657
  /** Optional system hint passed to connecting agents. */
@@ -616,9 +695,7 @@ const aiConfigSchema = z.strictObject({
616
695
  apiKeyEnv: z.string().optional(),
617
696
  // Base URL of the backend. Required for `openai-compatible` only when no
618
697
  // external endpoint is supplied; for named providers it overrides the preset.
619
- // blume bundles Zod 3; top-level `z.url()` is undefined at runtime.
620
- // oxlint-disable-next-line react-doctor/zod-v4-prefer-top-level-string-formats
621
- baseUrl: z.string().url().optional(),
698
+ baseUrl: z.url().optional(),
622
699
  enabled: z.boolean().default(false),
623
700
  // Optional external endpoint for projects that keep their docs static
624
701
  // and host Ask AI in an existing backend. Absolute URLs and root-relative
@@ -686,7 +763,35 @@ const aiConfigSchema = z.strictObject({
686
763
  )
687
764
  .default({}),
688
765
  /** Expose the docs as an MCP server for connecting agents. */
689
- mcp: mcpConfigSchema.default({}),
766
+ mcp: mcpConfigSchema.prefault({}),
767
+ /**
768
+ * Publish Agent Skills for discovery: a directory (resolved against the
769
+ * project root) whose subdirectories each hold a `SKILL.md`. The build
770
+ * copies each skill under `/.well-known/agent-skills/` — a lone `SKILL.md`
771
+ * verbatim, a skill with supporting files as a `.tar.gz` — and emits the
772
+ * discovery index (`index.json`) with SHA-256 digests per the Agent Skills
773
+ * Discovery RFC.
774
+ */
775
+ skills: z.string().min(1).optional(),
776
+ /**
777
+ * Web Bot Auth (IETF `webbotauth`): publish the org's HTTP Message
778
+ * Signature public keys at `/.well-known/http-message-signatures-directory`
779
+ * so sites receiving requests from the org's agents can verify them.
780
+ * Opt-in and public-keys-only — the private keys live wherever the signing
781
+ * agents run, never in the site.
782
+ */
783
+ webBotAuth: z
784
+ .strictObject({
785
+ keys: z.array(publicJwkSchema).default([]),
786
+ })
787
+ .prefault({}),
788
+ /**
789
+ * WebMCP: register in-page tools (search, page Markdown, the docs index)
790
+ * on the browser's model context so agentic browsers can drive the docs
791
+ * without a separate MCP connection. A tiny script that no-ops in browsers
792
+ * without the API; on by default.
793
+ */
794
+ webmcp: z.boolean().default(true),
690
795
  });
691
796
 
692
797
  /**
@@ -720,7 +825,7 @@ const navigationConfigSchema = z.strictObject({
720
825
  items: z.array(sidebarItemSchema).optional(),
721
826
  }),
722
827
  ])
723
- .default({})
828
+ .prefault({})
724
829
  .transform((value) =>
725
830
  Array.isArray(value) ? { display: "flat" as const, items: value } : value
726
831
  ),
@@ -828,9 +933,7 @@ const deploymentConfigSchema = z.strictObject({
828
933
  .default(null),
829
934
  base: z.string().optional(),
830
935
  output: z.enum(["static", "server"]).default("static"),
831
- // blume bundles Zod 3; top-level `z.url()` is undefined at runtime.
832
- // oxlint-disable-next-line react-doctor/zod-v4-prefer-top-level-string-formats
833
- site: z.string().url().optional(),
936
+ site: z.url().optional(),
834
937
  });
835
938
 
836
939
  const redirectSchema = z.strictObject({
@@ -841,6 +944,33 @@ const redirectSchema = z.strictObject({
841
944
  to: z.string(),
842
945
  });
843
946
 
947
+ /**
948
+ * One authorized remote image source, passed through to Astro's
949
+ * `image.remotePatterns`. Wildcards follow Astro's rules: `hostname` accepts
950
+ * `**.example.com` (any depth) or `*.example.com` (one level), `pathname`
951
+ * accepts `/dir/**` or `/dir/*` the same way.
952
+ */
953
+ const imageRemotePatternSchema = z.strictObject({
954
+ hostname: z.string().optional(),
955
+ pathname: z.string().optional(),
956
+ port: z.string().optional(),
957
+ protocol: z.string().optional(),
958
+ });
959
+
960
+ /**
961
+ * Image optimization (`image`). Local images referenced by relative path
962
+ * (`![alt](./diagram.png)`) are optimized at build time automatically —
963
+ * compressed, converted to WebP, and stamped with intrinsic dimensions.
964
+ * Remote images are only optimized when their host is authorized here;
965
+ * both fields map directly onto Astro's `image` config.
966
+ */
967
+ const imageConfigSchema = z.strictObject({
968
+ /** Hosts whose remote images may be optimized, e.g. `["cdn.example.com"]`. */
969
+ domains: z.array(z.string()).default([]),
970
+ /** Pattern-based host authorization, for wildcards `domains` can't express. */
971
+ remotePatterns: z.array(imageRemotePatternSchema).default([]),
972
+ });
973
+
844
974
  /**
845
975
  * X (Twitter) attribution. The account fields feed `twitter:site` (the site's
846
976
  * account) and `twitter:creator` (the author's), which is the one piece of X
@@ -872,10 +1002,11 @@ const ogPaletteSchema = z.strictObject({
872
1002
  });
873
1003
 
874
1004
  /**
875
- * A Google Font family to load into the OG card renderer. A bare string is the
876
- * family name; the object form pins the weight (a number, a list, or a variable
877
- * range like `"100..900"`) and style. Fetched from Google Fonts at build and
878
- * handed to Takumi, which does per-glyph fallback so a family covering a script
1005
+ * A font to load into the OG card renderer. A bare string is a Google Fonts
1006
+ * family name; the name-only object form pins the weight (a number, a list, or
1007
+ * a variable range like `"100..900"`) and style, fetched from Google Fonts at
1008
+ * build. The `src` form reads a local font file from the project instead.
1009
+ * Either way Takumi does per-glyph fallback, so a family covering a script
879
1010
  * (e.g. Noto Sans JP for CJK) fixes tofu without touching how Latin renders.
880
1011
  */
881
1012
  const ogFontWeightSchema = z.union([
@@ -891,9 +1022,21 @@ const ogFontSchema = z.union([
891
1022
  style: z.union([ogFontStyleSchema, z.array(ogFontStyleSchema)]).optional(),
892
1023
  weight: ogFontWeightSchema.optional(),
893
1024
  }),
1025
+ /** A local font file, read from the project at build. */
1026
+ z.strictObject({
1027
+ name: z.string(),
1028
+ src: z.string().min(1),
1029
+ style: ogFontStyleSchema.optional(),
1030
+ weight: z.number().int().positive().optional(),
1031
+ }),
894
1032
  ]);
895
1033
 
896
1034
  const ogConfigSchema = z.strictObject({
1035
+ /**
1036
+ * Card subtitle. Defaults to the site description; a string overrides it,
1037
+ * `false` renders the card without one.
1038
+ */
1039
+ description: z.union([z.string(), z.literal(false)]).optional(),
897
1040
  /**
898
1041
  * Generate a per-page Open Graph image. Defaults to on once a deployment
899
1042
  * site URL is known (set or auto-detected) and off otherwise, since
@@ -907,10 +1050,19 @@ const ogConfigSchema = z.strictObject({
907
1050
  * Fetched from Google Fonts at build.
908
1051
  */
909
1052
  fonts: z.array(ogFontSchema).optional(),
910
- /** Local SVG used in the generated card instead of the site logo. */
911
- logo: z.string().optional(),
1053
+ /**
1054
+ * Local SVG used in the generated card instead of the site logo; `false`
1055
+ * renders the card without any brand mark.
1056
+ */
1057
+ logo: z.union([z.string(), z.literal(false)]).optional(),
912
1058
  /** Optional generated-card colors. */
913
1059
  palette: ogPaletteSchema.optional(),
1060
+ /**
1061
+ * Footer site text. Defaults to the deployment site's host plus
1062
+ * `deployment.base` (`docs.acme.com`, `user.github.io/repo`); a string
1063
+ * overrides it, `false` hides it.
1064
+ */
1065
+ site: z.union([z.string(), z.literal(false)]).optional(),
914
1066
  /**
915
1067
  * Card headlines for custom `.astro` pages, keyed by route (`"/"`, `"/cli"`).
916
1068
  * A custom page has no frontmatter to read, so its card is otherwise titled
@@ -970,11 +1122,11 @@ const seoConfigSchema = z.strictObject({
970
1122
  */
971
1123
  agentReadability: z.boolean().default(true),
972
1124
  /** robots.txt `Content-Signal` usage declaration (on by default). */
973
- contentSignals: contentSignalsSchema.default(true),
1125
+ contentSignals: contentSignalsSchema.prefault(true),
974
1126
  og: ogConfigSchema.default({}),
975
1127
  /** Generate robots.txt (with a Sitemap reference when available). */
976
1128
  robots: z.boolean().default(true),
977
- rss: rssConfigSchema.default({}),
1129
+ rss: rssConfigSchema.prefault({}),
978
1130
  /** Generate sitemap.xml (requires deployment.site). */
979
1131
  sitemap: z.boolean().default(true),
980
1132
  /** Emit schema.org JSON-LD in each page's <head>. */
@@ -1026,7 +1178,7 @@ const codeBlockThemeSchema = z.strictObject({
1026
1178
  });
1027
1179
 
1028
1180
  const codeBlocksConfigSchema = z.strictObject({
1029
- theme: codeBlockThemeSchema.default({}),
1181
+ theme: codeBlockThemeSchema.prefault({}),
1030
1182
  });
1031
1183
 
1032
1184
  /**
@@ -1127,8 +1279,8 @@ const codeConfigSchema = z.strictObject({
1127
1279
 
1128
1280
  const markdownConfigSchema = z.strictObject({
1129
1281
  /** Code-block rendering: language icons and line wrapping. */
1130
- code: codeConfigSchema.default({}),
1131
- codeBlocks: codeBlocksConfigSchema.default({}),
1282
+ code: codeConfigSchema.prefault({}),
1283
+ codeBlocks: codeBlocksConfigSchema.prefault({}),
1132
1284
  /**
1133
1285
  * Wrap each `##`–`######` heading in a link to its own anchor so readers can
1134
1286
  * click to copy, bookmark, or share a permalink to that section. On by
@@ -1293,9 +1445,9 @@ const tocConfigSchema = z
1293
1445
  });
1294
1446
 
1295
1447
  export const blumeConfigSchema = z.strictObject({
1296
- ai: aiConfigSchema.default({}),
1448
+ ai: aiConfigSchema.prefault({}),
1297
1449
  analytics: analyticsConfigSchema.optional(),
1298
- asyncapi: asyncapiConfigSchema.default({}),
1450
+ asyncapi: asyncapiConfigSchema.prefault({}),
1299
1451
  banner: bannerConfigSchema.optional(),
1300
1452
  /**
1301
1453
  * Site-wide mount point prepended to every generated route (e.g. `/docs`),
@@ -1307,13 +1459,13 @@ export const blumeConfigSchema = z.strictObject({
1307
1459
  .string()
1308
1460
  .optional()
1309
1461
  .transform((value) => normalizeBasePath(value)),
1310
- content: contentConfigSchema.default({}),
1462
+ content: contentConfigSchema.prefault({}),
1311
1463
  /**
1312
1464
  * Date presentation for the "last updated" stamp and the changelog timeline.
1313
1465
  * Pass-through `Intl.DateTimeFormat` options; defaults to `{ dateStyle: "long" }`.
1314
1466
  */
1315
1467
  dateFormat: dateFormatConfigSchema.default({ dateStyle: "long" }),
1316
- deployment: deploymentConfigSchema.default({}),
1468
+ deployment: deploymentConfigSchema.prefault({}),
1317
1469
  description: z.string().optional(),
1318
1470
  /**
1319
1471
  * Where `<Component path>` resolves live previews and their source from.
@@ -1322,24 +1474,25 @@ export const blumeConfigSchema = z.strictObject({
1322
1474
  * files. The object form adds `css`: a stylesheet injected into every
1323
1475
  * preview frame (design tokens, shadcn variables, `@theme` mappings).
1324
1476
  */
1325
- examples: examplesConfigSchema.default("examples"),
1326
- export: exportConfigSchema.default(false),
1477
+ examples: examplesConfigSchema.prefault("examples"),
1478
+ export: exportConfigSchema.prefault(false),
1327
1479
  feedback: z.boolean().default(true),
1328
1480
  /** Opt-in custom frontmatter keys, validated by user-supplied schemas. */
1329
- frontmatter: frontmatterConfigSchema.default({}),
1481
+ frontmatter: frontmatterConfigSchema.prefault({}),
1330
1482
  github: githubConfigSchema.optional(),
1331
1483
  i18n: i18nConfigSchema.optional(),
1484
+ image: imageConfigSchema.prefault({}),
1332
1485
  integrations: z.array(z.custom<AstroIntegration>()).default([]),
1333
1486
  lastModified: lastModifiedConfigSchema.default(false),
1334
1487
  logo: logoConfigSchema.optional(),
1335
- markdown: markdownConfigSchema.default({}),
1336
- navigation: navigationConfigSchema.default({}),
1337
- openapi: openapiConfigSchema.default({}),
1338
- react: reactConfigSchema.default({}),
1488
+ markdown: markdownConfigSchema.prefault({}),
1489
+ navigation: navigationConfigSchema.prefault({}),
1490
+ openapi: openapiConfigSchema.prefault({}),
1491
+ react: reactConfigSchema.prefault({}),
1339
1492
  redirects: z.array(redirectSchema).default([]),
1340
- search: searchConfigSchema.default({}),
1341
- seo: seoConfigSchema.default({}),
1342
- theme: themeConfigSchema.default({}),
1493
+ search: searchConfigSchema.prefault({}),
1494
+ seo: seoConfigSchema.prefault({}),
1495
+ theme: themeConfigSchema.prefault({}),
1343
1496
  title: z.string().default("Documentation"),
1344
1497
  toc: tocConfigSchema,
1345
1498
  });
@@ -105,14 +105,14 @@ const mapRoute = (
105
105
  const CODE_FENCE = /^(?<delimiter>```|~~~)/u;
106
106
 
107
107
  /** The fence delimiter opening the current code block, or null outside one. */
108
- type FenceState = "```" | "~~~" | null;
108
+ export type FenceState = "```" | "~~~" | null;
109
109
 
110
110
  /**
111
111
  * Advance the fenced-code state for one line: an opening fence records its
112
112
  * delimiter, only the matching delimiter closes it, and any other line leaves
113
113
  * the state untouched.
114
114
  */
115
- const nextFenceState = (line: string, fence: FenceState): FenceState => {
115
+ export const nextFenceState = (line: string, fence: FenceState): FenceState => {
116
116
  const trimmed = line.trimStart();
117
117
  const delimiter = trimmed.match(CODE_FENCE)?.groups?.delimiter as
118
118
  | Exclude<FenceState, null>
@@ -340,12 +340,12 @@ const MD_LINK =
340
340
  /\[(?<label>(?:[^[\]]|\[[^\]]*\])*)\]\((?<target>(?:[^()\s]|\([^()\s]*\))+)(?<title>\s+"[^"]*")?\)/gu;
341
341
  // An image inside a link label; its target was matched (and so validated) as a
342
342
  // link of its own before labels admitted nesting, and still should be.
343
- const MD_IMAGE =
343
+ export const MD_IMAGE =
344
344
  /!\[[^\]]*\]\((?<target>(?:[^()\s]|\([^()\s]*\))+)(?<title>\s+"[^"]*")?\)/gu;
345
- const INLINE_CODE = /`[^`]*`/gu;
345
+ export const INLINE_CODE = /`[^`]*`/gu;
346
346
 
347
347
  /** Column (0-based, within `matched`) where a link/image match's target starts. */
348
- const targetOffsetIn = (
348
+ export const targetOffsetIn = (
349
349
  matched: string,
350
350
  target: string,
351
351
  title: string | undefined