blume 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/cli/index.js +2260 -1100
  3. package/dist/cli/index.js.map +123 -117
  4. package/dist/types/ai/component-markdown.d.ts +14 -4
  5. package/dist/types/core/base-path.d.ts +8 -0
  6. package/dist/types/core/config-input.d.ts +87 -27
  7. package/dist/types/core/config.d.ts +2 -1
  8. package/dist/types/core/data.d.ts +16 -1
  9. package/dist/types/core/diagnostics.d.ts +5 -1
  10. package/dist/types/core/i18n-ui.d.ts +12 -0
  11. package/dist/types/core/schema.d.ts +116 -15
  12. package/dist/types/core/sources/types.d.ts +11 -1
  13. package/dist/types/core/standard-schema.d.ts +7 -3
  14. package/dist/types/core/types.d.ts +43 -2
  15. package/dist/types/core/ui-packs/index.d.ts +9 -1
  16. package/dist/types/openapi/references.d.ts +14 -7
  17. package/dist/types/seo/x-handle.d.ts +3 -2
  18. package/docs/advanced/api-reference.mdx +8 -6
  19. package/docs/configuration/ai.mdx +26 -8
  20. package/docs/configuration/search.mdx +2 -0
  21. package/docs/configuration/seo.mdx +1 -1
  22. package/docs/content/i18n.mdx +1 -1
  23. package/docs/content/meta.mdx +2 -1
  24. package/docs/content/meta.ts +1 -0
  25. package/docs/content/navigation.mdx +35 -1
  26. package/docs/content/sources.mdx +1 -1
  27. package/docs/content/versioning.mdx +106 -0
  28. package/docs/reference/cli.mdx +1 -0
  29. package/docs/reference/frontmatter.mdx +3 -0
  30. package/package.json +13 -1
  31. package/skills/blume-migrate/SKILL.md +2 -2
  32. package/skills/blume-migrate/references/docusaurus.md +1 -1
  33. package/skills/blume-migrate/references/fumadocs.md +1 -1
  34. package/skills/blume-migrate/references/mintlify.md +1 -1
  35. package/src/ai/agent-readability.ts +40 -12
  36. package/src/ai/api-catalog.ts +2 -2
  37. package/src/ai/ask-context.ts +49 -12
  38. package/src/ai/ask.ts +10 -1
  39. package/src/ai/component-markdown.ts +80 -43
  40. package/src/ai/llms.ts +42 -17
  41. package/src/ai/mcp/data.ts +48 -12
  42. package/src/ai/mcp/discovery.ts +52 -16
  43. package/src/ai/mcp/server.ts +280 -125
  44. package/src/ai/mcp/tools.ts +3 -3
  45. package/src/ai/skills.ts +32 -9
  46. package/src/ai/tar.ts +29 -70
  47. package/src/ai/visibility.ts +2 -2
  48. package/src/astro/component-slots.ts +2 -0
  49. package/src/astro/examples.ts +13 -5
  50. package/src/astro/generate.ts +113 -63
  51. package/src/astro/integration.ts +13 -2
  52. package/src/astro/islands.ts +23 -12
  53. package/src/astro/templates.ts +185 -41
  54. package/src/audit/agent.ts +16 -31
  55. package/src/audit/checks/content.ts +26 -11
  56. package/src/audit/checks/dns-aid.ts +3 -0
  57. package/src/audit/checks/indexability.ts +24 -6
  58. package/src/audit/checks/llms.ts +9 -4
  59. package/src/audit/checks/network.ts +2 -0
  60. package/src/audit/checks/social.ts +18 -10
  61. package/src/audit/crawl.ts +78 -25
  62. package/src/audit/report.ts +20 -19
  63. package/src/audit/run.ts +15 -5
  64. package/src/audit/snapshot.ts +29 -6
  65. package/src/audit/types.ts +25 -3
  66. package/src/blume-modules.d.ts +5 -1
  67. package/src/cli/commands/audit.ts +21 -21
  68. package/src/cli/commands/build.ts +30 -16
  69. package/src/cli/commands/dev.ts +15 -15
  70. package/src/cli/commands/doctor.ts +2 -0
  71. package/src/cli/commands/eject.ts +4 -4
  72. package/src/cli/commands/eval.ts +24 -30
  73. package/src/cli/commands/init.ts +9 -9
  74. package/src/cli/commands/mcp-stdio.ts +3 -0
  75. package/src/cli/commands/translate.ts +14 -3
  76. package/src/cli/commands/version.ts +85 -0
  77. package/src/cli/dev-lock.ts +31 -10
  78. package/src/cli/eject-scripts.ts +17 -2
  79. package/src/cli/env.ts +13 -30
  80. package/src/cli/index.ts +2 -0
  81. package/src/cli/init/questions.ts +1 -1
  82. package/src/cli/init/scaffold.ts +41 -13
  83. package/src/cli/internal-error.ts +1 -0
  84. package/src/cli/report-format.ts +22 -0
  85. package/src/components/content/AccordionItem.astro +2 -9
  86. package/src/components/content/ColorItem.astro +5 -13
  87. package/src/components/content/Component.astro +12 -8
  88. package/src/components/content/Frame.astro +2 -12
  89. package/src/components/content/Prompt.astro +12 -31
  90. package/src/components/content/Tab.astro +2 -9
  91. package/src/components/content/Tooltip.astro +1 -9
  92. package/src/components/content/Update.astro +2 -9
  93. package/src/components/content/auto-type-table.ts +3 -0
  94. package/src/components/content/diff.ts +9 -5
  95. package/src/components/content/github-info.ts +2 -0
  96. package/src/components/content/inline-markdown.ts +28 -0
  97. package/src/components/copy-feedback.ts +96 -0
  98. package/src/components/islands/ask-ai.tsx +111 -34
  99. package/src/components/islands/hooks.ts +5 -1
  100. package/src/components/islands/webmcp.ts +49 -12
  101. package/src/components/layout/Header.astro +25 -1
  102. package/src/components/layout/NavSelector.astro +11 -2
  103. package/src/components/layout/NavTree.astro +4 -2
  104. package/src/components/layout/PageActions.astro +20 -32
  105. package/src/components/layout/PageLayout.astro +8 -28
  106. package/src/components/layout/RootLayout.astro +24 -48
  107. package/src/components/layout/Search.astro +133 -22
  108. package/src/components/layout/VersionBanner.astro +39 -0
  109. package/src/components/layout/analytics-client.ts +8 -5
  110. package/src/components/layout/drawer-inert.ts +31 -0
  111. package/src/components/layout/hydration-hint.ts +1 -1
  112. package/src/components/layout/nav-utils.ts +1 -4
  113. package/src/components/layout/overrides.ts +25 -12
  114. package/src/components/layout/search/algolia.ts +18 -5
  115. package/src/components/layout/search/endpoint.ts +3 -0
  116. package/src/components/layout/search/flexsearch.ts +23 -7
  117. package/src/components/layout/search/orama-cloud.ts +1 -1
  118. package/src/components/layout/search/orama.ts +4 -1
  119. package/src/components/layout/search/pagefind.ts +8 -5
  120. package/src/components/layout/search/types.ts +45 -1
  121. package/src/components/layout/search/typesense.ts +19 -3
  122. package/src/components/openapi/ApiOverview.astro +32 -6
  123. package/src/components/openapi/AsyncApiOperation.astro +237 -0
  124. package/src/components/openapi/Bindings.astro +89 -0
  125. package/src/components/openapi/MethodBadge.astro +3 -0
  126. package/src/components/openapi/Operation.astro +7 -2
  127. package/src/components/openapi/PanelTabs.astro +131 -0
  128. package/src/components/openapi/ParametersTable.astro +2 -0
  129. package/src/components/openapi/RequestPanel.astro +12 -119
  130. package/src/components/openapi/async-snippets.ts +174 -0
  131. package/src/components/openapi/async.ts +348 -0
  132. package/src/components/openapi/helpers.ts +52 -20
  133. package/src/components/openapi/panel.ts +11 -8
  134. package/src/components/openapi/security.ts +102 -29
  135. package/src/components/openapi/snippets.ts +11 -11
  136. package/src/components/raf-throttle.ts +21 -0
  137. package/src/components/slug.ts +14 -0
  138. package/src/core/base-path.ts +18 -1
  139. package/src/core/component-overrides.ts +28 -23
  140. package/src/core/config-input.ts +96 -27
  141. package/src/core/config.ts +20 -7
  142. package/src/core/content.ts +3 -1
  143. package/src/core/data.ts +16 -1
  144. package/src/core/define-components.ts +5 -0
  145. package/src/core/diagnostics.ts +46 -38
  146. package/src/core/frontmatter.ts +74 -4
  147. package/src/core/graph.ts +137 -53
  148. package/src/core/i18n-ui.ts +15 -0
  149. package/src/core/i18n.ts +16 -8
  150. package/src/core/load-module.ts +1 -0
  151. package/src/core/manifest.ts +92 -3
  152. package/src/core/meta.ts +44 -14
  153. package/src/core/nav-diagnostics.ts +3 -3
  154. package/src/core/navigation.ts +247 -67
  155. package/src/core/probe.ts +7 -19
  156. package/src/core/project-graph.ts +27 -4
  157. package/src/core/schema.ts +219 -67
  158. package/src/core/site-url.ts +27 -0
  159. package/src/core/sources/assets.ts +2 -0
  160. package/src/core/sources/cache.ts +16 -8
  161. package/src/core/sources/github-releases.ts +39 -11
  162. package/src/core/sources/mdx-remote.ts +4 -0
  163. package/src/core/sources/normalize.ts +93 -22
  164. package/src/core/sources/notion.ts +76 -22
  165. package/src/core/sources/portable-text.ts +48 -12
  166. package/src/core/sources/resolve.ts +1 -0
  167. package/src/core/sources/sanity.ts +68 -14
  168. package/src/core/sources/types.ts +17 -1
  169. package/src/core/sources/watch.ts +1 -1
  170. package/src/core/standard-schema.ts +9 -3
  171. package/src/core/text-width.ts +26 -0
  172. package/src/core/tsconfig-aliases.ts +9 -5
  173. package/src/core/types.ts +45 -2
  174. package/src/core/ui-packs/index.ts +9 -1
  175. package/src/core/version-cut.ts +301 -0
  176. package/src/core/version.ts +2 -0
  177. package/src/core/versions.ts +170 -0
  178. package/src/deploy/adapter-output.ts +5 -2
  179. package/src/deploy/cloudflare-negotiation.ts +40 -11
  180. package/src/deploy/robots.ts +2 -1
  181. package/src/deploy/rss.ts +2 -1
  182. package/src/deploy/sitemap.ts +89 -8
  183. package/src/deploy/vercel-negotiation.ts +11 -4
  184. package/src/eval/agents.ts +13 -10
  185. package/src/eval/report.ts +5 -18
  186. package/src/eval/run.ts +2 -2
  187. package/src/eval/schema.ts +1 -1
  188. package/src/markdown/base-links.ts +6 -6
  189. package/src/markdown/directives.ts +7 -1
  190. package/src/markdown/heading-anchors.ts +17 -6
  191. package/src/markdown/index.ts +73 -24
  192. package/src/markdown/inline-code.ts +14 -2
  193. package/src/markdown/language-icon.ts +6 -2
  194. package/src/markdown/mdast.ts +18 -4
  195. package/src/markdown/package-commands.ts +63 -58
  196. package/src/markdown/table-wrap.ts +4 -1
  197. package/src/markdown/twoslash.ts +2 -0
  198. package/src/og/card.ts +50 -33
  199. package/src/og/derive.ts +43 -27
  200. package/src/openapi/asyncapi.ts +366 -0
  201. package/src/openapi/model.ts +135 -66
  202. package/src/openapi/parse.ts +166 -33
  203. package/src/openapi/references.ts +47 -22
  204. package/src/openapi/render-mdx.ts +137 -59
  205. package/src/openapi/scalar.ts +8 -10
  206. package/src/openapi/source.ts +126 -29
  207. package/src/registry/eject.ts +7 -2
  208. package/src/search/documents.ts +103 -39
  209. package/src/search/facets.ts +7 -5
  210. package/src/search/orama-index.ts +117 -32
  211. package/src/search/popular.ts +10 -5
  212. package/src/search/providers.ts +2 -2
  213. package/src/search/sync/index.ts +2 -0
  214. package/src/search/sync/typesense.ts +4 -2
  215. package/src/seo/jsonld.ts +24 -6
  216. package/src/seo/x-handle.ts +8 -3
  217. package/src/theme/chrome-icons.ts +7 -2
  218. package/src/theme/fonts.ts +8 -4
  219. package/src/theme/icons.ts +4 -2
  220. package/src/theme/palette.ts +27 -15
  221. package/src/translate/ledger.ts +4 -2
  222. package/src/translate/meta.ts +15 -6
  223. package/src/translate/report.ts +10 -19
  224. package/src/translate/run.ts +29 -38
  225. package/src/translate/validate.ts +52 -17
  226. package/src/translate/work-list.ts +0 -0
  227. package/src/cli/coalesce.ts +0 -43
@@ -23,6 +23,14 @@ import type { StandardSchema } from "./standard-schema.ts";
23
23
  // Shared primitives
24
24
  // ---------------------------------------------------------------------------
25
25
 
26
+ // `typeof` checks live in named predicates (the form the oxlint anti-slop
27
+ // config sanctions); generic so each site keeps its own union narrowing.
28
+ const isString = <Value>(value: Value): value is Value & string =>
29
+ typeof value === "string";
30
+
31
+ const isBoolean = <Value>(value: Value): value is Value & boolean =>
32
+ typeof value === "boolean";
33
+
26
34
  /** Icon inputs in serializable contexts (frontmatter, meta files). */
27
35
  const iconName = z.string().min(1);
28
36
 
@@ -40,12 +48,28 @@ const dateSchema = z
40
48
  .union([z.string(), z.date()])
41
49
  .transform((value) => (value instanceof Date ? value.toISOString() : value));
42
50
 
51
+ /**
52
+ * How a sidebar group renders:
53
+ * - `flat`: a non-collapsible header with its items listed beneath (default).
54
+ * - `group`: a collapsible `<details>` disclosure.
55
+ * - `page`: a single row that drills into a sub-panel showing only this group's
56
+ * items, with a back arrow at the top.
57
+ */
58
+ const sidebarDisplaySchema = z.enum(["flat", "group", "page"]);
59
+ export type SidebarDisplay = z.infer<typeof sidebarDisplaySchema>;
60
+
43
61
  // ---------------------------------------------------------------------------
44
62
  // Page frontmatter
45
63
  // ---------------------------------------------------------------------------
46
64
 
47
65
  const sidebarMetaSchema = z.strictObject({
48
66
  badge: z.string().optional(),
67
+ /**
68
+ * Render mode for this page's folder group. Only meaningful on a folder's
69
+ * `index` page — it configures the group, not the page. Overrides the
70
+ * folder's `meta.ts` `display` and the global `navigation.sidebar.display`.
71
+ */
72
+ display: sidebarDisplaySchema.optional(),
49
73
  hidden: z.boolean().default(false),
50
74
  icon: iconName.optional(),
51
75
  label: z.string().optional(),
@@ -141,6 +165,11 @@ export const pageMetaSchema = pageMetaBaseSchema;
141
165
  export type PageMeta = z.infer<typeof pageMetaBaseSchema>;
142
166
  export type PageMetaInput = z.input<typeof pageMetaBaseSchema>;
143
167
 
168
+ /** Built-in page frontmatter keys; custom keys must never redeclare one. */
169
+ const BUILT_IN_PAGE_META_KEYS = new Set<string>(
170
+ pageMetaBaseSchema.keyof().options
171
+ );
172
+
144
173
  /**
145
174
  * A map of custom frontmatter keys to user-supplied validation schemas,
146
175
  * consumed through the Standard Schema `~standard` contract — never Zod's own
@@ -162,7 +191,7 @@ const customKeySchemaRecord = (where: string) =>
162
191
  .default({})
163
192
  .superRefine((value, ctx) => {
164
193
  for (const key of Object.keys(value)) {
165
- if (Object.hasOwn(pageMetaBaseSchema.shape, key)) {
194
+ if (BUILT_IN_PAGE_META_KEYS.has(key)) {
166
195
  ctx.addIssue({
167
196
  code: z.ZodIssueCode.custom,
168
197
  message: `"${key}" is a built-in frontmatter field and cannot be redeclared via ${where}.`,
@@ -176,18 +205,10 @@ const customKeySchemaRecord = (where: string) =>
176
205
  // Folder meta (meta.ts)
177
206
  // ---------------------------------------------------------------------------
178
207
 
179
- /**
180
- * How a sidebar group renders:
181
- * - `flat`: a non-collapsible header with its items listed beneath (default).
182
- * - `group`: a collapsible `<details>` disclosure.
183
- * - `page`: a single row that drills into a sub-panel showing only this group's
184
- * items, with a back arrow at the top.
185
- */
186
- const sidebarDisplaySchema = z.enum(["flat", "group", "page"]);
187
- export type SidebarDisplay = z.infer<typeof sidebarDisplaySchema>;
188
-
189
208
  export const folderMetaSchema = z.strictObject({
190
209
  collapsed: z.boolean().optional(),
210
+ /** Render mode for this group; overrides `navigation.sidebar.display`. */
211
+ display: sidebarDisplaySchema.optional(),
191
212
  icon: iconName.optional(),
192
213
  order: z.number().optional(),
193
214
  /** Explicit child ordering by slug segment (without numeric prefix). */
@@ -304,6 +325,8 @@ const sanitySourceSchema = z.object({
304
325
 
305
326
  /** A Notion database; pages become entries, blocks become MDX. */
306
327
  const notionSourceSchema = z.object({
328
+ /** Max concurrent Notion API requests; default 3 (Notion's per-integration pace). */
329
+ concurrency: z.number().positive().optional(),
307
330
  database: z.string(),
308
331
  /** Opt-in dev polling interval (seconds); omit to freeze for the session. */
309
332
  pollInterval: z.number().positive().optional(),
@@ -353,11 +376,13 @@ const githubReleasesSourceSchema = z.strictObject({
353
376
  */
354
377
  const customSourceSchema = z.object({
355
378
  source: z.custom<ContentSource>(
356
- (val) =>
379
+ (val): val is ContentSource =>
357
380
  typeof val === "object" &&
358
381
  val !== null &&
359
- typeof (val as { load?: unknown }).load === "function" &&
360
- typeof (val as { name?: unknown }).name === "string",
382
+ "load" in val &&
383
+ typeof val.load === "function" &&
384
+ "name" in val &&
385
+ typeof val.name === "string",
361
386
  { message: "custom source must be a ContentSource (with name + load)" }
362
387
  ),
363
388
  type: z.literal("custom"),
@@ -556,7 +581,7 @@ const localFontSchema = z.strictObject({
556
581
  const fontValueSchema = z
557
582
  .union([z.string(), remoteFontSchema, localFontSchema])
558
583
  .superRefine((value, ctx) => {
559
- if (typeof value === "string" && !isFontSlug(value)) {
584
+ if (isString(value) && !isFontSlug(value)) {
560
585
  ctx.addIssue({
561
586
  code: z.ZodIssueCode.custom,
562
587
  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).`,
@@ -579,7 +604,7 @@ const perModeValueSchema = z
579
604
  ])
580
605
  .optional()
581
606
  .transform((value) =>
582
- typeof value === "string" ? { dark: value, light: value } : value
607
+ isString(value) ? { dark: value, light: value } : value
583
608
  );
584
609
 
585
610
  const themeConfigSchema = z.strictObject({
@@ -590,7 +615,7 @@ const themeConfigSchema = z.strictObject({
590
615
  ])
591
616
  .default("blue")
592
617
  .transform((value) =>
593
- typeof value === "string" ? { dark: value, light: value } : value
618
+ isString(value) ? { dark: value, light: value } : value
594
619
  ),
595
620
  action: z.string().optional(),
596
621
  background: perModeValueSchema,
@@ -680,6 +705,8 @@ const searchConfigSchema = z
680
705
  .superRefine((value, ctx) => {
681
706
  // Hosted providers can't work without their credentials; flag a missing
682
707
  // block with a path so the diagnostic points at `search.<provider>`.
708
+ // SAFETY: providers without a config block (orama, pagefind, …) miss the
709
+ // map and read undefined, which the `field &&` guard below absorbs.
683
710
  const field =
684
711
  PROVIDER_CONFIG_KEY[value.provider as keyof typeof PROVIDER_CONFIG_KEY];
685
712
  if (field && !value[field]) {
@@ -716,7 +743,7 @@ const PRIVATE_JWK_PARAMS = ["d", "p", "q", "dp", "dq", "qi", "oth", "k"];
716
743
  const publicJwkSchema = z
717
744
  .record(z.string(), z.unknown())
718
745
  .superRefine((jwk, ctx) => {
719
- if (typeof jwk.kty !== "string" || jwk.kty.length === 0) {
746
+ if (!isString(jwk.kty) || jwk.kty.length === 0) {
720
747
  ctx.addIssue({
721
748
  code: z.ZodIssueCode.custom,
722
749
  message: 'A JWK must declare its key type ("kty").',
@@ -780,6 +807,10 @@ const aiConfigSchema = z.strictObject({
780
807
  // and host Ask AI in an existing backend. Absolute URLs and root-relative
781
808
  // paths are both valid; the built-in request/stream contract is unchanged.
782
809
  endpoint: askEndpointSchema.optional(),
810
+ // Extra system-prompt text (identity, language, tone) appended to the
811
+ // built-in instructions, so the grounding contract — answer from the
812
+ // retrieved excerpts, cite pages as Markdown links — stays intact.
813
+ instructions: z.string().trim().min(1).optional(),
783
814
  model: z.string().default("openai/gpt-5.5"),
784
815
  provider: z.enum(askAiProviders).default("gateway"),
785
816
  // Empty-state prompts shown before the first question. Each renders as a
@@ -824,7 +855,7 @@ const aiConfigSchema = z.strictObject({
824
855
  ])
825
856
  .default(true)
826
857
  .transform((value) =>
827
- typeof value === "boolean" ? { enabled: value, openapi: true } : value
858
+ isBoolean(value) ? { enabled: value, openapi: true } : value
828
859
  ),
829
860
  // Serializers for the agent-facing Markdown downlevel (the `.md` mirror,
830
861
  // llms-full.txt, MCP get_page), keyed by JSX name. Functions live here —
@@ -836,9 +867,12 @@ const aiConfigSchema = z.strictObject({
836
867
  markdownComponents: z
837
868
  .record(
838
869
  z.string(),
839
- z.custom<ComponentMarkdown>((value) => typeof value === "function", {
840
- message: "Expected a serializer function.",
841
- })
870
+ z.custom<ComponentMarkdown>(
871
+ (value): value is ComponentMarkdown => typeof value === "function",
872
+ {
873
+ message: "Expected a serializer function.",
874
+ }
875
+ )
842
876
  )
843
877
  .default({}),
844
878
  /** Expose the docs as an MCP server for connecting agents. */
@@ -927,7 +961,7 @@ const exportConfigSchema = z
927
961
  }),
928
962
  ])
929
963
  .transform((value) =>
930
- typeof value === "boolean" ? { epub: value, pdf: value } : value
964
+ isBoolean(value) ? { epub: value, pdf: value } : value
931
965
  );
932
966
 
933
967
  /** A configured locale: ISO-ish code plus display metadata for the switcher. */
@@ -984,6 +1018,82 @@ const i18nConfigSchema = z
984
1018
  }
985
1019
  });
986
1020
 
1021
+ /**
1022
+ * Version ids must start with a letter (`v1.0`, not `1.0`): the id doubles as
1023
+ * the snapshot directory name, and a leading digit would collide with the
1024
+ * numeric-prefix ordering convention (`01-intro.mdx`), which strips `1.0/` to
1025
+ * `0/`. The rest allows word characters, dots, and hyphens — URL-safe as-is.
1026
+ */
1027
+ export const VERSION_ID = /^[A-Za-z][\w.-]*$/u;
1028
+
1029
+ /** A frozen documentation snapshot: a directory under the content root. */
1030
+ const archivedVersionSchema = z.strictObject({
1031
+ /**
1032
+ * The "you're viewing an old version" notice: `true` for the built-in
1033
+ * message, a string for custom copy, `false` to hide it.
1034
+ */
1035
+ banner: z.union([z.boolean(), z.string()]).default(true),
1036
+ /**
1037
+ * Where this version's pages point their canonical URL: `latest` targets the
1038
+ * same page in the current docs when it still exists (self otherwise), so
1039
+ * search engines treat the live page as authoritative without deindexing
1040
+ * version-only content. `self` keeps every page authoritative.
1041
+ */
1042
+ canonical: z.enum(["latest", "self"]).default("latest"),
1043
+ /** Directory name under the content root, and the URL segment. */
1044
+ id: z
1045
+ .string()
1046
+ .regex(
1047
+ VERSION_ID,
1048
+ 'Version ids must start with a letter (e.g. "v1.0") and contain only letters, digits, dots, hyphens, and underscores.'
1049
+ ),
1050
+ /** Switcher label; defaults to the id. */
1051
+ label: z.string().optional(),
1052
+ /** Emit `noindex` on every page of this version. */
1053
+ noindex: z.boolean().default(false),
1054
+ });
1055
+
1056
+ /**
1057
+ * Docs versioning. Opt-in: the latest docs live at the content root with
1058
+ * unprefixed URLs, and each archived version is a frozen snapshot directory
1059
+ * (`content/docs/<id>/`) cut with `blume version <id>`. Archived means frozen:
1060
+ * snapshots carry their own translations and are never retranslated.
1061
+ */
1062
+ const versionsConfigSchema = z
1063
+ .strictObject({
1064
+ /** Frozen snapshots, newest first — this order is the switcher order. */
1065
+ archived: z.array(archivedVersionSchema).default([]),
1066
+ /** Labels the unprefixed tree (the latest docs) in the switcher. */
1067
+ current: z.strictObject({
1068
+ /** Small tag rendered next to the label (e.g. `Latest`). */
1069
+ badge: z.string().optional(),
1070
+ label: z.string(),
1071
+ }),
1072
+ switcher: z
1073
+ .strictObject({
1074
+ /**
1075
+ * Where switching lands when the page has no equivalent in the target
1076
+ * version: `same-page` goes to the equivalent when it exists (version
1077
+ * root otherwise); `root` always goes to the version root.
1078
+ */
1079
+ redirect: z.enum(["same-page", "root"]).default("same-page"),
1080
+ })
1081
+ .prefault({}),
1082
+ })
1083
+ .superRefine((value, ctx) => {
1084
+ const seen = new Set<string>();
1085
+ for (const [position, version] of value.archived.entries()) {
1086
+ if (seen.has(version.id)) {
1087
+ ctx.addIssue({
1088
+ code: z.ZodIssueCode.custom,
1089
+ message: `versions.archived declares "${version.id}" more than once.`,
1090
+ path: ["archived", position, "id"],
1091
+ });
1092
+ }
1093
+ seen.add(version.id);
1094
+ }
1095
+ });
1096
+
987
1097
  const analyticsScriptSchema = z
988
1098
  .strictObject({
989
1099
  // Extra attributes (e.g. `data-domain`, `id`) spread onto the <script>.
@@ -1228,11 +1338,22 @@ const githubConfigSchema = z.strictObject({
1228
1338
  repo: z.string(),
1229
1339
  });
1230
1340
 
1341
+ /** The theme fields the structural code-theme check inspects. */
1342
+ interface CodeThemeFields {
1343
+ colors?: unknown;
1344
+ settings?: unknown;
1345
+ tokenColors?: unknown;
1346
+ }
1347
+
1348
+ /** A non-null, non-array object — the floor for a theme and its `colors` map. */
1349
+ const isThemeObject = <Value>(value: Value): value is Value & CodeThemeFields =>
1350
+ typeof value === "object" && value !== null && !Array.isArray(value);
1351
+
1231
1352
  const codeThemeSchema = z.custom<CodeTheme>((value) => {
1232
- if (typeof value === "string") {
1353
+ if (isString(value)) {
1233
1354
  return true;
1234
1355
  }
1235
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
1356
+ if (!isThemeObject(value)) {
1236
1357
  return false;
1237
1358
  }
1238
1359
  // Token rules live in `settings` (Shiki's canonical field, also the TextMate
@@ -1240,20 +1361,15 @@ const codeThemeSchema = z.custom<CodeTheme>((value) => {
1240
1361
  // VS Code spelling Shiki falls back to). A colors-only theme (editor fg/bg,
1241
1362
  // no token rules) is also valid — Shiki renders it from `colors` alone. Each
1242
1363
  // field present must have the right shape, and at least one must be present.
1243
- const theme = value as Record<string, unknown>;
1244
1364
  const settingsValid =
1245
- theme.settings === undefined || Array.isArray(theme.settings);
1365
+ value.settings === undefined || Array.isArray(value.settings);
1246
1366
  const tokenColorsValid =
1247
- theme.tokenColors === undefined || Array.isArray(theme.tokenColors);
1248
- const colorsValid =
1249
- theme.colors === undefined ||
1250
- (typeof theme.colors === "object" &&
1251
- theme.colors !== null &&
1252
- !Array.isArray(theme.colors));
1367
+ value.tokenColors === undefined || Array.isArray(value.tokenColors);
1368
+ const colorsValid = value.colors === undefined || isThemeObject(value.colors);
1253
1369
  const hasContent =
1254
- theme.settings !== undefined ||
1255
- theme.tokenColors !== undefined ||
1256
- theme.colors !== undefined;
1370
+ value.settings !== undefined ||
1371
+ value.tokenColors !== undefined ||
1372
+ value.colors !== undefined;
1257
1373
  return settingsValid && tokenColorsValid && colorsValid && hasContent;
1258
1374
  }, "Expected a Shiki theme name or custom theme object");
1259
1375
 
@@ -1292,7 +1408,7 @@ const examplesConfigSchema = z
1292
1408
  }),
1293
1409
  ])
1294
1410
  .transform((value): { css?: string; source: string } =>
1295
- typeof value === "string" ? { source: value } : value
1411
+ isString(value) ? { source: value } : value
1296
1412
  );
1297
1413
 
1298
1414
  /**
@@ -1392,7 +1508,8 @@ const reactConfigSchema = z.strictObject({
1392
1508
 
1393
1509
  /**
1394
1510
  * A single spec rendered by the API reference. `spec` is a local path or an
1395
- * `http(s)` URL (OpenAPI for the Blume renderer; OpenAPI or AsyncAPI for Scalar).
1511
+ * `http(s)` URL (an OpenAPI document under `openapi`, an AsyncAPI document
1512
+ * under `asyncapi`).
1396
1513
  */
1397
1514
  const openapiSourceSchema = z.strictObject({
1398
1515
  /** Include generated pages from this spec in llms.txt/llms-full.txt. */
@@ -1421,6 +1538,35 @@ export type OpenApiSource = z.input<typeof openapiSourceSchema>;
1421
1538
  */
1422
1539
  const scalarConfigSchema = z.record(z.string(), z.unknown()).optional();
1423
1540
 
1541
+ /**
1542
+ * The shared shape of both API-reference blocks — only the mount route and
1543
+ * code-sample defaults differ per spec kind, so each block declares just
1544
+ * those.
1545
+ */
1546
+ const referenceConfigSchema = (defaults: {
1547
+ codeSamples: string[];
1548
+ route: string;
1549
+ }) =>
1550
+ z.strictObject({
1551
+ /** Code-sample languages/tools shown per operation (Blume renderer). */
1552
+ codeSamples: z.array(z.string()).default(defaults.codeSamples),
1553
+ enabled: z.boolean().default(false),
1554
+ /** Start nested schema rows expanded rather than collapsed (Blume renderer). */
1555
+ expandSchemas: z.boolean().default(false),
1556
+ /** Who renders the reference: Blume's own UI, or the embedded Scalar SPA. */
1557
+ renderer: z.enum(["blume", "scalar"]).default("blume"),
1558
+ /** Where the reference mounts. */
1559
+ route: z.string().default(defaults.route),
1560
+ /** Extra Scalar config forwarded to `<ScalarComponent>` (Scalar renderer only). */
1561
+ scalar: scalarConfigSchema,
1562
+ /** One or more specs; each renders on its own route by default. */
1563
+ sources: z.array(openapiSourceSchema).default([]),
1564
+ /** Shorthand for a single source: `sources: [{ spec }]`. */
1565
+ spec: z.string().optional(),
1566
+ /** Scalar theme name (Scalar renderer only). */
1567
+ theme: z.string().optional(),
1568
+ });
1569
+
1424
1570
  /**
1425
1571
  * OpenAPI reference. By default (`renderer: "blume"`) Blume parses the spec with
1426
1572
  * Scalar's parser and renders its own UI: one real page per operation, grouped
@@ -1428,38 +1574,22 @@ const scalarConfigSchema = z.record(z.string(), z.unknown()).optional();
1428
1574
  * `renderer: "scalar"` to fall back to the embedded Scalar SPA (a single
1429
1575
  * self-contained route that doesn't weave into the sidebar or search).
1430
1576
  */
1431
- const openapiConfigSchema = z.strictObject({
1432
- /** Code-sample languages shown per operation (Blume renderer). */
1433
- codeSamples: z.array(z.string()).default(["curl", "js", "python"]),
1434
- enabled: z.boolean().default(false),
1435
- /** Start nested schema rows expanded rather than collapsed (Blume renderer). */
1436
- expandSchemas: z.boolean().default(false),
1437
- /** Who renders the reference: Blume's own UI, or the embedded Scalar SPA. */
1438
- renderer: z.enum(["blume", "scalar"]).default("blume"),
1439
- /** Where the reference mounts. */
1440
- route: z.string().default("/reference"),
1441
- /** Extra Scalar config forwarded to `<ScalarComponent>` (Scalar renderer only). */
1442
- scalar: scalarConfigSchema,
1443
- /** One or more specs; each renders on its own route by default. */
1444
- sources: z.array(openapiSourceSchema).default([]),
1445
- /** Shorthand for a single source: `sources: [{ spec }]`. */
1446
- spec: z.string().optional(),
1447
- /** Scalar theme name (Scalar renderer only). */
1448
- theme: z.string().optional(),
1577
+ const openapiConfigSchema = referenceConfigSchema({
1578
+ codeSamples: ["curl", "js", "python"],
1579
+ route: "/reference",
1449
1580
  });
1450
1581
 
1451
1582
  /**
1452
- * AsyncAPI reference. Same shape and Scalar pipeline as {@link openapiConfigSchema}
1453
- * (Scalar auto-detects the document type); only the default `route` differs.
1583
+ * AsyncAPI reference. Same shape as {@link openapiConfigSchema}: by default
1584
+ * (`renderer: "blume"`) Blume normalizes the spec to AsyncAPI 3.x and renders
1585
+ * its own UI — one real page per operation — with `renderer: "scalar"` as the
1586
+ * embedded-SPA opt-out. Only the defaults differ: the reference mounts at
1587
+ * `/events`, and empty `codeSamples` means every tool the operation's protocol
1588
+ * binding suggests.
1454
1589
  */
1455
- const asyncapiConfigSchema = z.strictObject({
1456
- enabled: z.boolean().default(false),
1457
- route: z.string().default("/events"),
1458
- /** Extra Scalar config forwarded to `<ScalarComponent>`. */
1459
- scalar: scalarConfigSchema,
1460
- sources: z.array(openapiSourceSchema).default([]),
1461
- spec: z.string().optional(),
1462
- theme: z.string().optional(),
1590
+ const asyncapiConfigSchema = referenceConfigSchema({
1591
+ codeSamples: [],
1592
+ route: "/events",
1463
1593
  });
1464
1594
 
1465
1595
  /**
@@ -1494,7 +1624,7 @@ const tocConfigSchema = z
1494
1624
  ])
1495
1625
  .default(true)
1496
1626
  .transform((value) => {
1497
- if (typeof value === "boolean") {
1627
+ if (isBoolean(value)) {
1498
1628
  return { enabled: value, maxLevel: 3, minLevel: 2 };
1499
1629
  }
1500
1630
  return {
@@ -1562,8 +1692,26 @@ export const blumeConfigSchema = z
1562
1692
  theme: themeConfigSchema.prefault({}),
1563
1693
  title: z.string().default("Documentation"),
1564
1694
  toc: tocConfigSchema,
1695
+ versions: versionsConfigSchema.optional(),
1565
1696
  })
1566
1697
  .superRefine((config, ctx) => {
1698
+ // A version id that is also a configured locale code would make a leading
1699
+ // `<id>/` directory ambiguous between the two axes — refuse it outright so
1700
+ // detection order (version first, then locale) never has to guess.
1701
+ if (config.versions && config.i18n) {
1702
+ const localeCodes = new Set(
1703
+ config.i18n.locales.map((locale) => locale.code.toLowerCase())
1704
+ );
1705
+ for (const [position, version] of config.versions.archived.entries()) {
1706
+ if (localeCodes.has(version.id.toLowerCase())) {
1707
+ ctx.addIssue({
1708
+ code: z.ZodIssueCode.custom,
1709
+ message: `Version id "${version.id}" is also a configured locale code — rename the version (e.g. "v${version.id}").`,
1710
+ path: ["versions", "archived", position, "id"],
1711
+ });
1712
+ }
1713
+ }
1714
+ }
1567
1715
  // A custom key is declared site-wide (`frontmatter.extend`) or per-type
1568
1716
  // (`content.types`), never both — two schemas for one key would make
1569
1717
  // precedence on pages of that type ambiguous.
@@ -1607,6 +1755,10 @@ export type FrontmatterExtend = Record<string, StandardSchema>;
1607
1755
  export type ResolvedI18nConfig = z.infer<typeof i18nConfigSchema>;
1608
1756
  /** A configured locale with display metadata. */
1609
1757
  export type LocaleConfig = z.infer<typeof localeSchema>;
1758
+ /** Resolved versions block (present only when the project opts into versioning). */
1759
+ export type ResolvedVersionsConfig = z.infer<typeof versionsConfigSchema>;
1760
+ /** A configured archived (frozen) version. */
1761
+ export type ArchivedVersionConfig = z.infer<typeof archivedVersionSchema>;
1610
1762
  /**
1611
1763
  * User-authored config, straight off the schema. The public, hand-documented
1612
1764
  * authoring type is `BlumeConfig` in `./config-input.ts`, which a compile-time
@@ -0,0 +1,27 @@
1
+ import { joinURL } from "ufo";
2
+
3
+ import { trimEnd } from "./trim.ts";
4
+
5
+ /**
6
+ * Absolute-URL building for the configured `deployment.site`, shared by every
7
+ * emitter that prints site URLs (sitemap, RSS, robots, llms.txt, the MCP and
8
+ * agent-discovery documents). One implementation replaces eight per-file
9
+ * copies that had drifted across three different trailing-slash treatments.
10
+ *
11
+ * Deliberately not `new URL(path, site)`: a root-absolute path would drop the
12
+ * base path of a subpath deployment (`acme.com/docs`). ufo's `joinURL` joins
13
+ * without that footgun; the site is first trimmed with the ReDoS-safe
14
+ * `trimEnd` loop so even a malformed `site` with piled-up trailing slashes
15
+ * joins cleanly.
16
+ */
17
+
18
+ /** The configured site with any trailing slashes dropped. */
19
+ export const siteRoot = (site: string): string => trimEnd(site, "/");
20
+
21
+ /** `site` + root-absolute `path` (already carrying any deployment base). */
22
+ export const absoluteUrl = (site: string, path: string): string => {
23
+ const root = siteRoot(site);
24
+ // joinURL folds a lone "/" away entirely; the homepage keeps its slash
25
+ // (`https://example.com/`), matching what every emitter always printed.
26
+ return path === "/" ? `${root}/` : joinURL(root, path);
27
+ };
@@ -78,6 +78,8 @@ export const materializeAssets = async (
78
78
  await writeFile(join(ctx.assetsDir, file), bytes);
79
79
  rewrites.set(url, `${ctx.assetsBaseUrl}/${file}`);
80
80
  } catch (error) {
81
+ // SAFETY: everything thrown in this block is an Error — the manual
82
+ // `!res.ok` throw above, and fetch/fs failures.
81
83
  diagnostics.push({
82
84
  code: "BLUME_ASSET_FETCH_FAILED",
83
85
  message: `Failed to download asset ${url}: ${(error as Error).message}`,
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
3
 
3
4
  import { join } from "pathe";
@@ -6,14 +7,15 @@ import { BlumeError } from "../diagnostics.ts";
6
7
  import type { Diagnostic } from "../types.ts";
7
8
  import type { SourceEntry, SourceLoadResult } from "./types.ts";
8
9
 
9
- /** Small, stable content hash for cache/HMR bookkeeping (non-bitwise). */
10
- export const hashText = (text: string): string => {
11
- let hash = 5381;
12
- for (let i = 0; i < text.length; i += 1) {
13
- hash = (hash * 33 + (text.codePointAt(i) ?? 0)) % 2_147_483_647;
14
- }
15
- return hash.toString(36);
16
- };
10
+ /**
11
+ * Small, stable content hash for cache/HMR bookkeeping — and for staged asset
12
+ * *filenames* (see sources/assets.ts and content-assets.ts), where a collision
13
+ * silently serves the wrong file. 64 bits of SHA-256 keeps those names
14
+ * collision-safe at any realistic asset count; the old 31-bit DJB2 hash had a
15
+ * ~46k-item birthday bound.
16
+ */
17
+ export const hashText = (text: string): string =>
18
+ createHash("sha256").update(text).digest("hex").slice(0, 16);
17
19
 
18
20
  /** A stable digest of a source's entries, for change detection while polling. */
19
21
  export const entriesDigest = (entries: SourceEntry[]): string =>
@@ -83,6 +85,9 @@ export const snapshotCache = (cacheDir: string): SnapshotCache => {
83
85
  return {
84
86
  read: async () => {
85
87
  try {
88
+ // SAFETY: the snapshot file is only ever written by `write` below, from
89
+ // a `SourceEntry[]` via JSON.stringify; a corrupt file lands in the
90
+ // catch and reads as empty.
86
91
  return JSON.parse(await readFile(file, "utf-8")) as SourceEntry[];
87
92
  } catch {
88
93
  return [];
@@ -124,6 +129,8 @@ export const loadWithCache = async (
124
129
  } catch (error) {
125
130
  const fallback = await cache.read();
126
131
  if (fallback.length > 0) {
132
+ // SAFETY: everything thrown on this path is an Error — fetch rejects
133
+ // with a TypeError and the source adapters throw Error instances.
127
134
  const diagnostic: Diagnostic = {
128
135
  code: "BLUME_SOURCE_OFFLINE",
129
136
  message: `Source "${name}" could not be fetched (${(error as Error).message}); served ${fallback.length} cached entries.`,
@@ -131,6 +138,7 @@ export const loadWithCache = async (
131
138
  };
132
139
  return { diagnostics: [diagnostic], entries: fallback };
133
140
  }
141
+ // SAFETY: same invariant as above — `fetchEntries` failures are Errors.
134
142
  throw new BlumeError({
135
143
  code: "BLUME_SOURCE_FETCH_FAILED",
136
144
  message: `Source "${name}" failed to load and no cache is available: ${(error as Error).message}`,