blume 1.4.3 → 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 (194) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/cli/index.js +1621 -576
  3. package/dist/cli/index.js.map +109 -104
  4. package/dist/types/ai/component-markdown.d.ts +14 -4
  5. package/dist/types/core/config-input.d.ts +79 -27
  6. package/dist/types/core/config.d.ts +2 -1
  7. package/dist/types/core/data.d.ts +16 -1
  8. package/dist/types/core/diagnostics.d.ts +5 -1
  9. package/dist/types/core/i18n-ui.d.ts +12 -0
  10. package/dist/types/core/schema.d.ts +112 -15
  11. package/dist/types/core/sources/types.d.ts +3 -1
  12. package/dist/types/core/standard-schema.d.ts +7 -3
  13. package/dist/types/core/types.d.ts +43 -2
  14. package/dist/types/core/ui-packs/index.d.ts +9 -1
  15. package/dist/types/openapi/references.d.ts +6 -5
  16. package/dist/types/seo/x-handle.d.ts +3 -2
  17. package/docs/advanced/api-reference.mdx +8 -6
  18. package/docs/configuration/search.mdx +2 -0
  19. package/docs/configuration/seo.mdx +1 -1
  20. package/docs/content/i18n.mdx +1 -1
  21. package/docs/content/meta.mdx +2 -1
  22. package/docs/content/meta.ts +1 -0
  23. package/docs/content/navigation.mdx +35 -1
  24. package/docs/content/versioning.mdx +106 -0
  25. package/docs/reference/cli.mdx +1 -0
  26. package/docs/reference/frontmatter.mdx +3 -0
  27. package/package.json +3 -1
  28. package/skills/blume-migrate/SKILL.md +2 -2
  29. package/skills/blume-migrate/references/docusaurus.md +1 -1
  30. package/skills/blume-migrate/references/fumadocs.md +1 -1
  31. package/skills/blume-migrate/references/mintlify.md +1 -1
  32. package/src/ai/agent-readability.ts +37 -10
  33. package/src/ai/ask-context.ts +5 -1
  34. package/src/ai/ask.ts +10 -1
  35. package/src/ai/component-markdown.ts +80 -43
  36. package/src/ai/llms.ts +40 -16
  37. package/src/ai/mcp/data.ts +48 -12
  38. package/src/ai/mcp/discovery.ts +28 -11
  39. package/src/ai/mcp/server.ts +183 -38
  40. package/src/ai/mcp/tools.ts +3 -3
  41. package/src/ai/skills.ts +32 -9
  42. package/src/ai/visibility.ts +2 -2
  43. package/src/astro/component-slots.ts +2 -0
  44. package/src/astro/examples.ts +6 -2
  45. package/src/astro/generate.ts +54 -29
  46. package/src/astro/integration.ts +13 -2
  47. package/src/astro/islands.ts +16 -9
  48. package/src/astro/templates.ts +152 -33
  49. package/src/audit/agent.ts +2 -2
  50. package/src/audit/checks/content.ts +26 -11
  51. package/src/audit/checks/dns-aid.ts +3 -0
  52. package/src/audit/checks/indexability.ts +24 -6
  53. package/src/audit/checks/llms.ts +9 -4
  54. package/src/audit/checks/network.ts +2 -0
  55. package/src/audit/checks/social.ts +18 -10
  56. package/src/audit/crawl.ts +37 -9
  57. package/src/audit/report.ts +20 -19
  58. package/src/audit/run.ts +5 -2
  59. package/src/audit/snapshot.ts +2 -4
  60. package/src/audit/types.ts +25 -3
  61. package/src/blume-modules.d.ts +5 -1
  62. package/src/cli/commands/audit.ts +9 -4
  63. package/src/cli/commands/build.ts +15 -9
  64. package/src/cli/commands/dev.ts +2 -0
  65. package/src/cli/commands/doctor.ts +2 -0
  66. package/src/cli/commands/eval.ts +7 -3
  67. package/src/cli/commands/init.ts +9 -9
  68. package/src/cli/commands/mcp-stdio.ts +3 -0
  69. package/src/cli/commands/translate.ts +14 -3
  70. package/src/cli/commands/version.ts +85 -0
  71. package/src/cli/dev-lock.ts +31 -10
  72. package/src/cli/eject-scripts.ts +17 -2
  73. package/src/cli/index.ts +2 -0
  74. package/src/cli/init/questions.ts +1 -1
  75. package/src/cli/init/scaffold.ts +22 -15
  76. package/src/cli/internal-error.ts +1 -0
  77. package/src/components/content/auto-type-table.ts +3 -0
  78. package/src/components/content/diff.ts +9 -5
  79. package/src/components/content/github-info.ts +2 -0
  80. package/src/components/islands/ask-ai.tsx +33 -25
  81. package/src/components/islands/hooks.ts +5 -1
  82. package/src/components/islands/webmcp.ts +49 -12
  83. package/src/components/layout/Header.astro +25 -1
  84. package/src/components/layout/NavSelector.astro +11 -2
  85. package/src/components/layout/NavTree.astro +4 -2
  86. package/src/components/layout/RootLayout.astro +18 -0
  87. package/src/components/layout/Search.astro +77 -13
  88. package/src/components/layout/VersionBanner.astro +39 -0
  89. package/src/components/layout/analytics-client.ts +8 -5
  90. package/src/components/layout/hydration-hint.ts +1 -1
  91. package/src/components/layout/nav-utils.ts +1 -4
  92. package/src/components/layout/overrides.ts +25 -12
  93. package/src/components/layout/search/algolia.ts +18 -5
  94. package/src/components/layout/search/endpoint.ts +3 -0
  95. package/src/components/layout/search/flexsearch.ts +23 -7
  96. package/src/components/layout/search/orama-cloud.ts +1 -1
  97. package/src/components/layout/search/orama.ts +4 -1
  98. package/src/components/layout/search/pagefind.ts +2 -0
  99. package/src/components/layout/search/types.ts +13 -1
  100. package/src/components/layout/search/typesense.ts +19 -3
  101. package/src/components/openapi/ApiOverview.astro +32 -6
  102. package/src/components/openapi/AsyncApiOperation.astro +237 -0
  103. package/src/components/openapi/Bindings.astro +89 -0
  104. package/src/components/openapi/MethodBadge.astro +3 -0
  105. package/src/components/openapi/Operation.astro +7 -2
  106. package/src/components/openapi/PanelTabs.astro +131 -0
  107. package/src/components/openapi/ParametersTable.astro +2 -0
  108. package/src/components/openapi/RequestPanel.astro +12 -119
  109. package/src/components/openapi/async-snippets.ts +174 -0
  110. package/src/components/openapi/async.ts +348 -0
  111. package/src/components/openapi/helpers.ts +52 -20
  112. package/src/components/openapi/security.ts +102 -29
  113. package/src/components/openapi/snippets.ts +11 -11
  114. package/src/core/component-overrides.ts +28 -23
  115. package/src/core/config-input.ts +88 -27
  116. package/src/core/config.ts +20 -7
  117. package/src/core/content.ts +3 -1
  118. package/src/core/data.ts +16 -1
  119. package/src/core/define-components.ts +5 -0
  120. package/src/core/diagnostics.ts +46 -38
  121. package/src/core/frontmatter.ts +33 -7
  122. package/src/core/graph.ts +137 -53
  123. package/src/core/i18n-ui.ts +15 -0
  124. package/src/core/i18n.ts +16 -8
  125. package/src/core/load-module.ts +1 -0
  126. package/src/core/manifest.ts +92 -3
  127. package/src/core/meta.ts +44 -14
  128. package/src/core/nav-diagnostics.ts +3 -3
  129. package/src/core/navigation.ts +247 -67
  130. package/src/core/project-graph.ts +15 -3
  131. package/src/core/schema.ts +213 -67
  132. package/src/core/sources/assets.ts +2 -0
  133. package/src/core/sources/cache.ts +6 -0
  134. package/src/core/sources/github-releases.ts +39 -31
  135. package/src/core/sources/mdx-remote.ts +4 -0
  136. package/src/core/sources/normalize.ts +67 -20
  137. package/src/core/sources/notion.ts +49 -17
  138. package/src/core/sources/portable-text.ts +32 -11
  139. package/src/core/sources/sanity.ts +68 -14
  140. package/src/core/sources/types.ts +4 -0
  141. package/src/core/sources/watch.ts +1 -1
  142. package/src/core/standard-schema.ts +9 -3
  143. package/src/core/text-width.ts +26 -0
  144. package/src/core/tsconfig-aliases.ts +9 -5
  145. package/src/core/types.ts +45 -2
  146. package/src/core/ui-packs/index.ts +9 -1
  147. package/src/core/version-cut.ts +301 -0
  148. package/src/core/version.ts +2 -0
  149. package/src/core/versions.ts +170 -0
  150. package/src/deploy/adapter-output.ts +5 -2
  151. package/src/deploy/cloudflare-negotiation.ts +25 -10
  152. package/src/deploy/sitemap.ts +33 -1
  153. package/src/deploy/vercel-negotiation.ts +11 -4
  154. package/src/eval/report.ts +4 -4
  155. package/src/eval/run.ts +2 -2
  156. package/src/eval/schema.ts +1 -1
  157. package/src/markdown/base-links.ts +6 -6
  158. package/src/markdown/directives.ts +7 -1
  159. package/src/markdown/heading-anchors.ts +17 -6
  160. package/src/markdown/index.ts +73 -24
  161. package/src/markdown/inline-code.ts +14 -2
  162. package/src/markdown/language-icon.ts +6 -2
  163. package/src/markdown/mdast.ts +18 -4
  164. package/src/markdown/package-commands.ts +6 -8
  165. package/src/markdown/table-wrap.ts +4 -1
  166. package/src/markdown/twoslash.ts +2 -0
  167. package/src/og/card.ts +30 -11
  168. package/src/og/derive.ts +43 -27
  169. package/src/openapi/asyncapi.ts +366 -0
  170. package/src/openapi/model.ts +126 -57
  171. package/src/openapi/parse.ts +97 -5
  172. package/src/openapi/references.ts +12 -10
  173. package/src/openapi/render-mdx.ts +73 -34
  174. package/src/openapi/scalar.ts +6 -8
  175. package/src/openapi/source.ts +98 -28
  176. package/src/registry/eject.ts +7 -2
  177. package/src/search/documents.ts +25 -5
  178. package/src/search/facets.ts +7 -5
  179. package/src/search/orama-index.ts +66 -20
  180. package/src/search/popular.ts +10 -5
  181. package/src/search/providers.ts +2 -2
  182. package/src/search/sync/index.ts +2 -0
  183. package/src/search/sync/typesense.ts +4 -2
  184. package/src/seo/jsonld.ts +24 -6
  185. package/src/seo/x-handle.ts +8 -3
  186. package/src/theme/chrome-icons.ts +7 -2
  187. package/src/theme/fonts.ts +8 -4
  188. package/src/theme/icons.ts +4 -2
  189. package/src/theme/palette.ts +22 -14
  190. package/src/translate/meta.ts +15 -6
  191. package/src/translate/report.ts +9 -5
  192. package/src/translate/run.ts +10 -4
  193. package/src/translate/validate.ts +52 -17
  194. package/src/translate/work-list.ts +0 -0
@@ -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). */
@@ -355,11 +376,13 @@ const githubReleasesSourceSchema = z.strictObject({
355
376
  */
356
377
  const customSourceSchema = z.object({
357
378
  source: z.custom<ContentSource>(
358
- (val) =>
379
+ (val): val is ContentSource =>
359
380
  typeof val === "object" &&
360
381
  val !== null &&
361
- typeof (val as { load?: unknown }).load === "function" &&
362
- 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",
363
386
  { message: "custom source must be a ContentSource (with name + load)" }
364
387
  ),
365
388
  type: z.literal("custom"),
@@ -558,7 +581,7 @@ const localFontSchema = z.strictObject({
558
581
  const fontValueSchema = z
559
582
  .union([z.string(), remoteFontSchema, localFontSchema])
560
583
  .superRefine((value, ctx) => {
561
- if (typeof value === "string" && !isFontSlug(value)) {
584
+ if (isString(value) && !isFontSlug(value)) {
562
585
  ctx.addIssue({
563
586
  code: z.ZodIssueCode.custom,
564
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).`,
@@ -581,7 +604,7 @@ const perModeValueSchema = z
581
604
  ])
582
605
  .optional()
583
606
  .transform((value) =>
584
- typeof value === "string" ? { dark: value, light: value } : value
607
+ isString(value) ? { dark: value, light: value } : value
585
608
  );
586
609
 
587
610
  const themeConfigSchema = z.strictObject({
@@ -592,7 +615,7 @@ const themeConfigSchema = z.strictObject({
592
615
  ])
593
616
  .default("blue")
594
617
  .transform((value) =>
595
- typeof value === "string" ? { dark: value, light: value } : value
618
+ isString(value) ? { dark: value, light: value } : value
596
619
  ),
597
620
  action: z.string().optional(),
598
621
  background: perModeValueSchema,
@@ -682,6 +705,8 @@ const searchConfigSchema = z
682
705
  .superRefine((value, ctx) => {
683
706
  // Hosted providers can't work without their credentials; flag a missing
684
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.
685
710
  const field =
686
711
  PROVIDER_CONFIG_KEY[value.provider as keyof typeof PROVIDER_CONFIG_KEY];
687
712
  if (field && !value[field]) {
@@ -718,7 +743,7 @@ const PRIVATE_JWK_PARAMS = ["d", "p", "q", "dp", "dq", "qi", "oth", "k"];
718
743
  const publicJwkSchema = z
719
744
  .record(z.string(), z.unknown())
720
745
  .superRefine((jwk, ctx) => {
721
- if (typeof jwk.kty !== "string" || jwk.kty.length === 0) {
746
+ if (!isString(jwk.kty) || jwk.kty.length === 0) {
722
747
  ctx.addIssue({
723
748
  code: z.ZodIssueCode.custom,
724
749
  message: 'A JWK must declare its key type ("kty").',
@@ -830,7 +855,7 @@ const aiConfigSchema = z.strictObject({
830
855
  ])
831
856
  .default(true)
832
857
  .transform((value) =>
833
- typeof value === "boolean" ? { enabled: value, openapi: true } : value
858
+ isBoolean(value) ? { enabled: value, openapi: true } : value
834
859
  ),
835
860
  // Serializers for the agent-facing Markdown downlevel (the `.md` mirror,
836
861
  // llms-full.txt, MCP get_page), keyed by JSX name. Functions live here —
@@ -842,9 +867,12 @@ const aiConfigSchema = z.strictObject({
842
867
  markdownComponents: z
843
868
  .record(
844
869
  z.string(),
845
- z.custom<ComponentMarkdown>((value) => typeof value === "function", {
846
- message: "Expected a serializer function.",
847
- })
870
+ z.custom<ComponentMarkdown>(
871
+ (value): value is ComponentMarkdown => typeof value === "function",
872
+ {
873
+ message: "Expected a serializer function.",
874
+ }
875
+ )
848
876
  )
849
877
  .default({}),
850
878
  /** Expose the docs as an MCP server for connecting agents. */
@@ -933,7 +961,7 @@ const exportConfigSchema = z
933
961
  }),
934
962
  ])
935
963
  .transform((value) =>
936
- typeof value === "boolean" ? { epub: value, pdf: value } : value
964
+ isBoolean(value) ? { epub: value, pdf: value } : value
937
965
  );
938
966
 
939
967
  /** A configured locale: ISO-ish code plus display metadata for the switcher. */
@@ -990,6 +1018,82 @@ const i18nConfigSchema = z
990
1018
  }
991
1019
  });
992
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
+
993
1097
  const analyticsScriptSchema = z
994
1098
  .strictObject({
995
1099
  // Extra attributes (e.g. `data-domain`, `id`) spread onto the <script>.
@@ -1234,11 +1338,22 @@ const githubConfigSchema = z.strictObject({
1234
1338
  repo: z.string(),
1235
1339
  });
1236
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
+
1237
1352
  const codeThemeSchema = z.custom<CodeTheme>((value) => {
1238
- if (typeof value === "string") {
1353
+ if (isString(value)) {
1239
1354
  return true;
1240
1355
  }
1241
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
1356
+ if (!isThemeObject(value)) {
1242
1357
  return false;
1243
1358
  }
1244
1359
  // Token rules live in `settings` (Shiki's canonical field, also the TextMate
@@ -1246,20 +1361,15 @@ const codeThemeSchema = z.custom<CodeTheme>((value) => {
1246
1361
  // VS Code spelling Shiki falls back to). A colors-only theme (editor fg/bg,
1247
1362
  // no token rules) is also valid — Shiki renders it from `colors` alone. Each
1248
1363
  // field present must have the right shape, and at least one must be present.
1249
- const theme = value as Record<string, unknown>;
1250
1364
  const settingsValid =
1251
- theme.settings === undefined || Array.isArray(theme.settings);
1365
+ value.settings === undefined || Array.isArray(value.settings);
1252
1366
  const tokenColorsValid =
1253
- theme.tokenColors === undefined || Array.isArray(theme.tokenColors);
1254
- const colorsValid =
1255
- theme.colors === undefined ||
1256
- (typeof theme.colors === "object" &&
1257
- theme.colors !== null &&
1258
- !Array.isArray(theme.colors));
1367
+ value.tokenColors === undefined || Array.isArray(value.tokenColors);
1368
+ const colorsValid = value.colors === undefined || isThemeObject(value.colors);
1259
1369
  const hasContent =
1260
- theme.settings !== undefined ||
1261
- theme.tokenColors !== undefined ||
1262
- theme.colors !== undefined;
1370
+ value.settings !== undefined ||
1371
+ value.tokenColors !== undefined ||
1372
+ value.colors !== undefined;
1263
1373
  return settingsValid && tokenColorsValid && colorsValid && hasContent;
1264
1374
  }, "Expected a Shiki theme name or custom theme object");
1265
1375
 
@@ -1298,7 +1408,7 @@ const examplesConfigSchema = z
1298
1408
  }),
1299
1409
  ])
1300
1410
  .transform((value): { css?: string; source: string } =>
1301
- typeof value === "string" ? { source: value } : value
1411
+ isString(value) ? { source: value } : value
1302
1412
  );
1303
1413
 
1304
1414
  /**
@@ -1398,7 +1508,8 @@ const reactConfigSchema = z.strictObject({
1398
1508
 
1399
1509
  /**
1400
1510
  * A single spec rendered by the API reference. `spec` is a local path or an
1401
- * `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`).
1402
1513
  */
1403
1514
  const openapiSourceSchema = z.strictObject({
1404
1515
  /** Include generated pages from this spec in llms.txt/llms-full.txt. */
@@ -1427,6 +1538,35 @@ export type OpenApiSource = z.input<typeof openapiSourceSchema>;
1427
1538
  */
1428
1539
  const scalarConfigSchema = z.record(z.string(), z.unknown()).optional();
1429
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
+
1430
1570
  /**
1431
1571
  * OpenAPI reference. By default (`renderer: "blume"`) Blume parses the spec with
1432
1572
  * Scalar's parser and renders its own UI: one real page per operation, grouped
@@ -1434,38 +1574,22 @@ const scalarConfigSchema = z.record(z.string(), z.unknown()).optional();
1434
1574
  * `renderer: "scalar"` to fall back to the embedded Scalar SPA (a single
1435
1575
  * self-contained route that doesn't weave into the sidebar or search).
1436
1576
  */
1437
- const openapiConfigSchema = z.strictObject({
1438
- /** Code-sample languages shown per operation (Blume renderer). */
1439
- codeSamples: z.array(z.string()).default(["curl", "js", "python"]),
1440
- enabled: z.boolean().default(false),
1441
- /** Start nested schema rows expanded rather than collapsed (Blume renderer). */
1442
- expandSchemas: z.boolean().default(false),
1443
- /** Who renders the reference: Blume's own UI, or the embedded Scalar SPA. */
1444
- renderer: z.enum(["blume", "scalar"]).default("blume"),
1445
- /** Where the reference mounts. */
1446
- route: z.string().default("/reference"),
1447
- /** Extra Scalar config forwarded to `<ScalarComponent>` (Scalar renderer only). */
1448
- scalar: scalarConfigSchema,
1449
- /** One or more specs; each renders on its own route by default. */
1450
- sources: z.array(openapiSourceSchema).default([]),
1451
- /** Shorthand for a single source: `sources: [{ spec }]`. */
1452
- spec: z.string().optional(),
1453
- /** Scalar theme name (Scalar renderer only). */
1454
- theme: z.string().optional(),
1577
+ const openapiConfigSchema = referenceConfigSchema({
1578
+ codeSamples: ["curl", "js", "python"],
1579
+ route: "/reference",
1455
1580
  });
1456
1581
 
1457
1582
  /**
1458
- * AsyncAPI reference. Same shape and Scalar pipeline as {@link openapiConfigSchema}
1459
- * (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.
1460
1589
  */
1461
- const asyncapiConfigSchema = z.strictObject({
1462
- enabled: z.boolean().default(false),
1463
- route: z.string().default("/events"),
1464
- /** Extra Scalar config forwarded to `<ScalarComponent>`. */
1465
- scalar: scalarConfigSchema,
1466
- sources: z.array(openapiSourceSchema).default([]),
1467
- spec: z.string().optional(),
1468
- theme: z.string().optional(),
1590
+ const asyncapiConfigSchema = referenceConfigSchema({
1591
+ codeSamples: [],
1592
+ route: "/events",
1469
1593
  });
1470
1594
 
1471
1595
  /**
@@ -1500,7 +1624,7 @@ const tocConfigSchema = z
1500
1624
  ])
1501
1625
  .default(true)
1502
1626
  .transform((value) => {
1503
- if (typeof value === "boolean") {
1627
+ if (isBoolean(value)) {
1504
1628
  return { enabled: value, maxLevel: 3, minLevel: 2 };
1505
1629
  }
1506
1630
  return {
@@ -1568,8 +1692,26 @@ export const blumeConfigSchema = z
1568
1692
  theme: themeConfigSchema.prefault({}),
1569
1693
  title: z.string().default("Documentation"),
1570
1694
  toc: tocConfigSchema,
1695
+ versions: versionsConfigSchema.optional(),
1571
1696
  })
1572
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
+ }
1573
1715
  // A custom key is declared site-wide (`frontmatter.extend`) or per-type
1574
1716
  // (`content.types`), never both — two schemas for one key would make
1575
1717
  // precedence on pages of that type ambiguous.
@@ -1613,6 +1755,10 @@ export type FrontmatterExtend = Record<string, StandardSchema>;
1613
1755
  export type ResolvedI18nConfig = z.infer<typeof i18nConfigSchema>;
1614
1756
  /** A configured locale with display metadata. */
1615
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>;
1616
1762
  /**
1617
1763
  * User-authored config, straight off the schema. The public, hand-documented
1618
1764
  * authoring type is `BlumeConfig` in `./config-input.ts`, which a compile-time
@@ -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}`,
@@ -85,6 +85,9 @@ export const snapshotCache = (cacheDir: string): SnapshotCache => {
85
85
  return {
86
86
  read: async () => {
87
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.
88
91
  return JSON.parse(await readFile(file, "utf-8")) as SourceEntry[];
89
92
  } catch {
90
93
  return [];
@@ -126,6 +129,8 @@ export const loadWithCache = async (
126
129
  } catch (error) {
127
130
  const fallback = await cache.read();
128
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.
129
134
  const diagnostic: Diagnostic = {
130
135
  code: "BLUME_SOURCE_OFFLINE",
131
136
  message: `Source "${name}" could not be fetched (${(error as Error).message}); served ${fallback.length} cached entries.`,
@@ -133,6 +138,7 @@ export const loadWithCache = async (
133
138
  };
134
139
  return { diagnostics: [diagnostic], entries: fallback };
135
140
  }
141
+ // SAFETY: same invariant as above — `fetchEntries` failures are Errors.
136
142
  throw new BlumeError({
137
143
  code: "BLUME_SOURCE_FETCH_FAILED",
138
144
  message: `Source "${name}" failed to load and no cache is available: ${(error as Error).message}`,
@@ -2,8 +2,10 @@ import { fromMarkdown } from "mdast-util-from-markdown";
2
2
  import { gfmFromMarkdown } from "mdast-util-gfm";
3
3
  import { toString as mdastToString } from "mdast-util-to-string";
4
4
  import { gfm } from "micromark-extension-gfm";
5
+ import stringWidth from "string-width";
5
6
 
6
7
  import matter from "../frontmatter.ts";
8
+ import { columnsPrefix } from "../text-width.ts";
7
9
  import {
8
10
  hashText,
9
11
  loadWithCache,
@@ -61,9 +63,10 @@ const LEADING_V = /^v/iu;
61
63
  const NON_SLUG = /[^a-z0-9]+/gu;
62
64
  const EDGE_DASHES = /^-+|-+$/gu;
63
65
 
64
- // `blume audit` grades meta descriptions against the 110–160 character search
65
- // snippet range (audit/types.ts thresholds), so the derived summary aims for
66
- // the longest word-boundary cut under the cap.
66
+ // `blume audit` grades meta descriptions against the 110–160 display-column
67
+ // search snippet range (audit/types.ts thresholds), so the derived summary
68
+ // budgets in the same columns and aims for the longest word-boundary cut
69
+ // under the cap.
67
70
  const DESCRIPTION_MAX = 160;
68
71
  const DESCRIPTION_MIN = 110;
69
72
 
@@ -85,26 +88,6 @@ const NON_PROSE = new Set(["code", "heading", "html", "thematicBreak"]);
85
88
  * content kept — then cut at a word boundary to fit the search snippet cap.
86
89
  * Undefined when the notes have no prose at all.
87
90
  */
88
- /**
89
- * The longest prefix of `text` that fits `max` UTF-16 units without cutting
90
- * inside a grapheme cluster. A bare `String#slice` counts code units, so it
91
- * can split a surrogate pair (emitting a lone surrogate — invalid Unicode —
92
- * into a meta description) or halve an emoji sequence. Grapheme segmentation
93
- * is rule-based (UAX #29), so unlike word segmentation it does not drift
94
- * across ICU builds.
95
- */
96
- const graphemePrefix = (text: string, max: number): string => {
97
- let end = 0;
98
- const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
99
- for (const { index, segment } of graphemes.segment(text)) {
100
- if (index + segment.length > max) {
101
- break;
102
- }
103
- end = index + segment.length;
104
- }
105
- return text.slice(0, end);
106
- };
107
-
108
91
  const releaseDescription = (body: string): string | undefined => {
109
92
  const tree = fromMarkdown(body.replaceAll(CHANGESET_HASH, "$<mark>"), {
110
93
  extensions: [gfm()],
@@ -123,15 +106,17 @@ const releaseDescription = (body: string): string | undefined => {
123
106
  if (!text) {
124
107
  return undefined;
125
108
  }
126
- if (text.length <= DESCRIPTION_MAX) {
109
+ if (stringWidth(text) <= DESCRIPTION_MAX) {
127
110
  return text;
128
111
  }
129
112
  // Cut before the cap at a word boundary (kept only when it doesn't drop the
130
113
  // summary under the minimum), shed any dangling punctuation, and mark the cut.
131
- const slice = graphemePrefix(text, DESCRIPTION_MAX - 1);
114
+ const slice = columnsPrefix(text, DESCRIPTION_MAX - 1);
132
115
  const boundary = slice.lastIndexOf(" ");
133
116
  const head = (
134
- boundary >= DESCRIPTION_MIN ? slice.slice(0, boundary) : slice
117
+ boundary !== -1 && stringWidth(slice.slice(0, boundary)) >= DESCRIPTION_MIN
118
+ ? slice.slice(0, boundary)
119
+ : slice
135
120
  ).replace(TRAILING_FRAGMENT, "");
136
121
  return `${head}…`;
137
122
  };
@@ -150,6 +135,19 @@ const githubHeaders = (): Headers => {
150
135
  return headers;
151
136
  };
152
137
 
138
+ /**
139
+ * The changelog frontmatter one release lowers to. `title`/`type` are always
140
+ * assigned (optional only so assignment order can keep the emitted YAML key
141
+ * order — and so each entry's content hash — stable).
142
+ */
143
+ interface ChangelogFrontmatter {
144
+ changelog: { category: string; version: string };
145
+ date: string;
146
+ seo?: { description: string };
147
+ title?: string;
148
+ type?: string;
149
+ }
150
+
153
151
  /**
154
152
  * Lower one release to a staged Markdown entry: the notes become the body,
155
153
  * `type: changelog` frontmatter (title/date/version/category) drives the
@@ -166,19 +164,25 @@ const releaseToEntry = (release: GithubRelease): SourceEntry => {
166
164
  // description (instead of the site-wide fallback) without also rendering the
167
165
  // visible lede paragraph a top-level `description` would add.
168
166
  const description = releaseDescription(body);
169
- const data = {
167
+ // Assignment order matters: js-yaml serializes keys in insertion order, so
168
+ // `seo` lands between `date` and `title` exactly as it always has.
169
+ const data: ChangelogFrontmatter = {
170
170
  changelog: { category, version },
171
171
  date,
172
- ...(description ? { seo: { description } } : {}),
173
- title,
174
- type: "changelog",
175
172
  };
173
+ if (description) {
174
+ data.seo = { description };
175
+ }
176
+ data.title = title;
177
+ data.type = "changelog";
176
178
  const raw = matter.stringify(`${body}\n`, data);
177
179
  const fallbackRef = `release-${release.id}`;
178
180
  const ref = `${slugifyTag(release.tag_name) || fallbackRef}.md`;
179
181
  return {
180
182
  body: { format: "md", text: body },
181
- data,
183
+ // Spread: `SourceEntry.data` is an open dictionary, which the interface
184
+ // (no index signature) only satisfies as a fresh object literal.
185
+ data: { ...data },
182
186
  editUrl: release.html_url,
183
187
  hash: hashText(raw),
184
188
  lastModified: date,
@@ -214,6 +218,8 @@ export const githubReleasesSource = (
214
218
  if (!res.ok) {
215
219
  throw new Error(`${url} -> ${res.status}`);
216
220
  }
221
+ // SAFETY: GitHub's releases endpoint returns a JSON array of release
222
+ // objects; `GithubRelease` models only the fields the adapter reads.
217
223
  return (await res.json()) as GithubRelease[];
218
224
  };
219
225
 
@@ -253,6 +259,8 @@ export const githubReleasesSource = (
253
259
  // repo), degrade to an empty changelog with a warning rather than failing
254
260
  // the whole build.
255
261
  snapshot = new Map();
262
+ // SAFETY: everything thrown on this path is an Error — fetch rejects
263
+ // with a TypeError, fetchPage and loadWithCache throw Error instances.
256
264
  return {
257
265
  diagnostics: [
258
266
  {
@@ -82,6 +82,8 @@ const enumerateGithub = async (
82
82
  if (!res.ok) {
83
83
  throw new Error(`${treeUrl} -> ${res.status}`);
84
84
  }
85
+ // SAFETY: GitHub's git/trees endpoint returns this envelope; a missing or
86
+ // differently-typed field falls through the `?? []` and blob filters below.
85
87
  const body = (await res.json()) as {
86
88
  tree?: GithubTreeEntry[];
87
89
  truncated?: boolean;
@@ -198,6 +200,8 @@ export const mdxRemoteSource = (
198
200
  } catch (error) {
199
201
  skipped.push({
200
202
  code: "BLUME_SOURCE_FETCH_FAILED",
203
+ // SAFETY: fetch and decode failures throw Error instances;
204
+ // only the message is read for the skip diagnostic.
201
205
  message: `Source "${options.name}" skipped "${ref.ref}" (${(error as Error).message}); the rest were imported.`,
202
206
  severity: "warning",
203
207
  });