blume 1.4.3 → 1.5.1

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 (204) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +16 -12
  3. package/dist/cli/index.js +1784 -633
  4. package/dist/cli/index.js.map +111 -106
  5. package/dist/types/ai/component-markdown.d.ts +14 -4
  6. package/dist/types/core/config-input.d.ts +80 -28
  7. package/dist/types/core/config.d.ts +2 -1
  8. package/dist/types/core/data.d.ts +19 -3
  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 +112 -15
  12. package/dist/types/core/sources/types.d.ts +3 -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 +6 -5
  17. package/dist/types/seo/x-handle.d.ts +3 -2
  18. package/dist/types/theme/fonts.d.ts +11 -2
  19. package/docs/advanced/api-reference.mdx +8 -6
  20. package/docs/advanced/custom-pages.mdx +5 -1
  21. package/docs/configuration/index.mdx +1 -1
  22. package/docs/configuration/search.mdx +2 -0
  23. package/docs/configuration/seo.mdx +1 -1
  24. package/docs/configuration/theming.mdx +4 -2
  25. package/docs/content/i18n.mdx +1 -1
  26. package/docs/content/meta.mdx +2 -1
  27. package/docs/content/meta.ts +1 -0
  28. package/docs/content/navigation.mdx +35 -1
  29. package/docs/content/versioning.mdx +106 -0
  30. package/docs/reference/cli.mdx +2 -1
  31. package/docs/reference/frontmatter.mdx +3 -0
  32. package/package.json +3 -1
  33. package/skills/blume-migrate/SKILL.md +2 -2
  34. package/skills/blume-migrate/references/docusaurus.md +1 -1
  35. package/skills/blume-migrate/references/fumadocs.md +1 -1
  36. package/skills/blume-migrate/references/mintlify.md +1 -1
  37. package/src/ai/agent-readability.ts +37 -10
  38. package/src/ai/ask-context.ts +5 -1
  39. package/src/ai/ask.ts +10 -1
  40. package/src/ai/component-markdown.ts +80 -43
  41. package/src/ai/llms.ts +40 -16
  42. package/src/ai/mcp/data.ts +48 -12
  43. package/src/ai/mcp/discovery.ts +28 -11
  44. package/src/ai/mcp/server.ts +183 -38
  45. package/src/ai/mcp/tools.ts +3 -3
  46. package/src/ai/skills.ts +32 -9
  47. package/src/ai/visibility.ts +2 -2
  48. package/src/astro/component-slots.ts +2 -0
  49. package/src/astro/examples.ts +6 -2
  50. package/src/astro/generate.ts +64 -34
  51. package/src/astro/integration.ts +13 -2
  52. package/src/astro/islands.ts +16 -9
  53. package/src/astro/templates.ts +181 -40
  54. package/src/audit/agent.ts +2 -2
  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 +37 -9
  62. package/src/audit/report.ts +20 -19
  63. package/src/audit/run.ts +5 -2
  64. package/src/audit/snapshot.ts +2 -4
  65. package/src/audit/types.ts +25 -3
  66. package/src/blume-modules.d.ts +5 -1
  67. package/src/cli/commands/audit.ts +9 -4
  68. package/src/cli/commands/build.ts +15 -9
  69. package/src/cli/commands/dev.ts +2 -0
  70. package/src/cli/commands/doctor.ts +2 -0
  71. package/src/cli/commands/eval.ts +7 -3
  72. package/src/cli/commands/init.ts +9 -9
  73. package/src/cli/commands/mcp-stdio.ts +3 -0
  74. package/src/cli/commands/translate.ts +14 -3
  75. package/src/cli/commands/version.ts +85 -0
  76. package/src/cli/dev-lock.ts +31 -10
  77. package/src/cli/eject-scripts.ts +17 -2
  78. package/src/cli/index.ts +2 -0
  79. package/src/cli/init/questions.ts +1 -1
  80. package/src/cli/init/scaffold.ts +22 -15
  81. package/src/cli/internal-error.ts +1 -0
  82. package/src/components/content/auto-type-table.ts +3 -0
  83. package/src/components/content/diff.ts +9 -5
  84. package/src/components/content/github-info.ts +2 -0
  85. package/src/components/islands/ask-ai.tsx +33 -25
  86. package/src/components/islands/hooks.ts +5 -1
  87. package/src/components/islands/webmcp.ts +49 -12
  88. package/src/components/layout/Fonts.astro +23 -3
  89. package/src/components/layout/Header.astro +25 -1
  90. package/src/components/layout/NavSelector.astro +11 -2
  91. package/src/components/layout/NavTree.astro +4 -2
  92. package/src/components/layout/PageLayout.astro +72 -3
  93. package/src/components/layout/ReferenceLayout.astro +2 -1
  94. package/src/components/layout/RootLayout.astro +20 -1
  95. package/src/components/layout/Search.astro +77 -13
  96. package/src/components/layout/VersionBanner.astro +39 -0
  97. package/src/components/layout/analytics-client.ts +8 -5
  98. package/src/components/layout/hydration-hint.ts +1 -1
  99. package/src/components/layout/nav-utils.ts +1 -4
  100. package/src/components/layout/overrides.ts +25 -12
  101. package/src/components/layout/search/algolia.ts +18 -5
  102. package/src/components/layout/search/endpoint.ts +3 -0
  103. package/src/components/layout/search/flexsearch.ts +23 -7
  104. package/src/components/layout/search/orama-cloud.ts +1 -1
  105. package/src/components/layout/search/orama.ts +4 -1
  106. package/src/components/layout/search/pagefind.ts +2 -0
  107. package/src/components/layout/search/types.ts +13 -1
  108. package/src/components/layout/search/typesense.ts +19 -3
  109. package/src/components/openapi/ApiOverview.astro +32 -6
  110. package/src/components/openapi/AsyncApiOperation.astro +237 -0
  111. package/src/components/openapi/Bindings.astro +89 -0
  112. package/src/components/openapi/MethodBadge.astro +3 -0
  113. package/src/components/openapi/Operation.astro +7 -2
  114. package/src/components/openapi/PanelTabs.astro +131 -0
  115. package/src/components/openapi/ParametersTable.astro +2 -0
  116. package/src/components/openapi/RequestPanel.astro +12 -119
  117. package/src/components/openapi/async-snippets.ts +174 -0
  118. package/src/components/openapi/async.ts +348 -0
  119. package/src/components/openapi/helpers.ts +52 -20
  120. package/src/components/openapi/security.ts +102 -29
  121. package/src/components/openapi/snippets.ts +11 -11
  122. package/src/core/component-overrides.ts +28 -23
  123. package/src/core/config-input.ts +89 -28
  124. package/src/core/config.ts +20 -7
  125. package/src/core/content.ts +3 -1
  126. package/src/core/data.ts +19 -3
  127. package/src/core/define-components.ts +5 -0
  128. package/src/core/diagnostics.ts +46 -38
  129. package/src/core/frontmatter.ts +33 -7
  130. package/src/core/graph.ts +137 -53
  131. package/src/core/i18n-ui.ts +15 -0
  132. package/src/core/i18n.ts +16 -8
  133. package/src/core/last-modified.ts +49 -0
  134. package/src/core/load-module.ts +1 -0
  135. package/src/core/manifest.ts +92 -3
  136. package/src/core/meta.ts +44 -14
  137. package/src/core/nav-diagnostics.ts +3 -3
  138. package/src/core/navigation.ts +247 -67
  139. package/src/core/project-graph.ts +26 -3
  140. package/src/core/schema.ts +214 -68
  141. package/src/core/sources/assets.ts +2 -0
  142. package/src/core/sources/cache.ts +6 -0
  143. package/src/core/sources/github-releases.ts +39 -31
  144. package/src/core/sources/mdx-remote.ts +4 -0
  145. package/src/core/sources/normalize.ts +67 -20
  146. package/src/core/sources/notion.ts +49 -17
  147. package/src/core/sources/portable-text.ts +32 -11
  148. package/src/core/sources/sanity.ts +68 -14
  149. package/src/core/sources/types.ts +4 -0
  150. package/src/core/sources/watch.ts +1 -1
  151. package/src/core/standard-schema.ts +9 -3
  152. package/src/core/text-width.ts +26 -0
  153. package/src/core/tsconfig-aliases.ts +9 -5
  154. package/src/core/types.ts +45 -2
  155. package/src/core/ui-packs/index.ts +9 -1
  156. package/src/core/version-cut.ts +301 -0
  157. package/src/core/version.ts +2 -0
  158. package/src/core/versions.ts +170 -0
  159. package/src/deploy/adapter-output.ts +5 -2
  160. package/src/deploy/cloudflare-negotiation.ts +25 -10
  161. package/src/deploy/sitemap.ts +33 -1
  162. package/src/deploy/vercel-negotiation.ts +45 -18
  163. package/src/eval/report.ts +4 -4
  164. package/src/eval/run.ts +2 -2
  165. package/src/eval/schema.ts +1 -1
  166. package/src/markdown/base-links.ts +6 -6
  167. package/src/markdown/directives.ts +7 -1
  168. package/src/markdown/heading-anchors.ts +17 -6
  169. package/src/markdown/index.ts +73 -24
  170. package/src/markdown/inline-code.ts +14 -2
  171. package/src/markdown/language-icon.ts +6 -2
  172. package/src/markdown/mdast.ts +18 -4
  173. package/src/markdown/package-commands.ts +6 -8
  174. package/src/markdown/table-wrap.ts +4 -1
  175. package/src/markdown/twoslash.ts +2 -0
  176. package/src/og/card.ts +33 -12
  177. package/src/og/derive.ts +43 -27
  178. package/src/openapi/asyncapi.ts +366 -0
  179. package/src/openapi/model.ts +126 -57
  180. package/src/openapi/parse.ts +97 -5
  181. package/src/openapi/references.ts +12 -10
  182. package/src/openapi/render-mdx.ts +73 -34
  183. package/src/openapi/scalar.ts +6 -8
  184. package/src/openapi/source.ts +98 -28
  185. package/src/registry/eject.ts +7 -2
  186. package/src/search/documents.ts +25 -5
  187. package/src/search/facets.ts +7 -5
  188. package/src/search/orama-index.ts +66 -20
  189. package/src/search/popular.ts +10 -5
  190. package/src/search/providers.ts +2 -2
  191. package/src/search/sync/index.ts +2 -0
  192. package/src/search/sync/typesense.ts +4 -2
  193. package/src/seo/jsonld.ts +24 -6
  194. package/src/seo/x-handle.ts +8 -3
  195. package/src/theme/chrome-icons.ts +7 -2
  196. package/src/theme/entry.ts +24 -2
  197. package/src/theme/fonts.ts +83 -7
  198. package/src/theme/icons.ts +4 -2
  199. package/src/theme/palette.ts +22 -14
  200. package/src/translate/meta.ts +15 -6
  201. package/src/translate/report.ts +9 -5
  202. package/src/translate/run.ts +10 -4
  203. package/src/translate/validate.ts +52 -17
  204. package/src/translate/work-list.ts +0 -0
@@ -12,11 +12,16 @@
12
12
  * Values are Lucide inner-SVG markup; the client `svg()` helpers wrap them in an
13
13
  * `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" …>`.
14
14
  */
15
- export const chromeIcons: Record<string, string> = {
15
+ /** Inner-SVG markup keyed by the icon name a client script requests. */
16
+ interface ChromeIconSet {
17
+ [name: string]: string;
18
+ }
19
+
20
+ export const chromeIcons: ChromeIconSet = {
16
21
  check: '<path d="M20 6 9 17l-5-5"/>',
17
22
  copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
18
23
  file: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
19
24
  search: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
20
25
  sparkles:
21
- '<path d="m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1 1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3Z"/><path d="M5 3v4"/><path d="M3 5h4"/><path d="M19 17v4"/><path d="M17 19h4"/>',
26
+ '<path d="m12 3-1.9 5.8a2 2 0 0 1-1.3 1.3L3 12l5.8 1.9a2 2 0 0 1 1.3 1.3L12 21l1.9-5.8a2 2 0 0 1-1.3-1.3L21 12l-5.8-1.9a2 2 0 0 1-1.3-1.3Z"/><path d="M5 3v4"/><path d="M3 5h4"/><path d="M19 17v4"/><path d="M17 19h4"/>',
22
27
  };
@@ -179,7 +179,11 @@ ${THEME_MAPPING}
179
179
  scroll-padding-top: 4.5rem;
180
180
  text-rendering: optimizeLegibility;
181
181
  }
182
- /* Headings use the display font (defaults to the body font when unset). */
182
+ /* Headings use the display font (defaults to the body font when unset).
183
+ The tightened tracking is part of the theme, not the font: display-tuned
184
+ families bake it into their metrics, but a text family promoted to
185
+ headings (including the Inter default) reads loose without it. -0.05em
186
+ was matched visually against Inter Tight, the previous display default. */
183
187
  h1,
184
188
  h2,
185
189
  h3,
@@ -187,6 +191,7 @@ ${THEME_MAPPING}
187
191
  h5,
188
192
  h6 {
189
193
  font-family: var(--font-display);
194
+ letter-spacing: -0.05em;
190
195
  }
191
196
  :focus-visible {
192
197
  outline: 2px solid var(--blume-accent);
@@ -207,6 +212,22 @@ ${THEME_MAPPING}
207
212
  }
208
213
  }
209
214
 
215
+ /* Same-origin navigations are full document loads (no client router); opting
216
+ into cross-document view transitions has the browser crossfade between the
217
+ old and new page instead of hard-swapping, in browsers that support it.
218
+ Pairs with the prefetch option in the generated Astro config. */
219
+ @view-transition {
220
+ navigation: auto;
221
+ }
222
+
223
+ @media (prefers-reduced-motion: reduce) {
224
+ ::view-transition-group(*),
225
+ ::view-transition-old(*),
226
+ ::view-transition-new(*) {
227
+ animation: none !important;
228
+ }
229
+ }
230
+
210
231
  /* Code reads left-to-right regardless of page direction; only the surrounding
211
232
  chrome mirrors for RTL. Inline code is isolated so LTR identifiers don't
212
233
  disturb the bidi flow of right-to-left prose. */
@@ -242,9 +263,10 @@ ${THEME_MAPPING}
242
263
  line-height: 1.7;
243
264
  }
244
265
 
266
+ /* No letter-spacing here: prose headings inherit the base h1-h6 rule's
267
+ display tracking, same as headings outside the prose column. */
245
268
  .prose :where(h1, h2, h3, h4) {
246
269
  font-weight: 500;
247
- letter-spacing: 0;
248
270
  }
249
271
 
250
272
  /* A heading can carry one long unbreakable token — an OpenAPI operation's title
@@ -82,11 +82,11 @@ export type FontEntry =
82
82
  variants: LocalFontVariant[];
83
83
  };
84
84
 
85
- const FALLBACKS: Record<FontCategory, string[]> = {
85
+ const FALLBACKS = {
86
86
  mono: ["ui-monospace", "SF Mono", "Menlo", "monospace"],
87
87
  sans: ["ui-sans-serif", "system-ui", "sans-serif"],
88
88
  serif: ["ui-serif", "Georgia", "serif"],
89
- };
89
+ } satisfies Record<FontCategory, string[]>;
90
90
 
91
91
  /** Slug -> Google family + weights + fallback category. Keep keys alphabetical. */
92
92
  export const GOOGLE_FONTS = {
@@ -223,12 +223,16 @@ const SLOTS: FontSlot[] = ["display", "body", "mono"];
223
223
  const slotCategory = (slot: FontSlot): FontCategory =>
224
224
  slot === "mono" ? "mono" : "sans";
225
225
 
226
+ /** Whether a slot value is the slug-string form (vs a custom font object). */
227
+ const isSlugValue = (value: FontValue): value is string =>
228
+ typeof value === "string";
229
+
226
230
  /** A slot value normalized into an entry, or null for an unknown slug string. */
227
231
  const resolveFontValue = (
228
232
  slot: FontSlot,
229
233
  value: FontValue
230
234
  ): FontEntry | null => {
231
- if (typeof value === "string") {
235
+ if (isSlugValue(value)) {
232
236
  if (!isFontSlug(value)) {
233
237
  return null;
234
238
  }
@@ -320,7 +324,7 @@ export const buildFontEntries = (fonts: FontsConfig): FontEntry[] => {
320
324
 
321
325
  /** The slug backing a slot's CSS variable, or null for an unknown slug string. */
322
326
  const slotSlug = (value: FontValue): string | null => {
323
- if (typeof value === "string") {
327
+ if (isSlugValue(value)) {
324
328
  return isFontSlug(value) ? value : null;
325
329
  }
326
330
  return slugifyFontName(value.name);
@@ -348,6 +352,78 @@ export const buildFontsCss = (fonts: FontsConfig): string => {
348
352
  : "";
349
353
  };
350
354
 
351
- /** The CSS variables to feed Astro's `<Font>` component in the document head. */
352
- export const configuredCssVars = (fonts: FontsConfig): string[] =>
353
- buildFontEntries(fonts).map((entry) => entry.cssVariable);
355
+ /**
356
+ * Weights worth preloading per role — the faces above-the-fold text actually
357
+ * renders in: body copy and UI chrome at 400/500, headings at 500/600, code at
358
+ * 400. Every other face still loads on demand through its `@font-face` rule
359
+ * (and `font-display: swap` never blocks text on it), so preloading the long
360
+ * tail only competes with the critical CSS for bandwidth and pushes LCP out.
361
+ */
362
+ const PRELOAD_WEIGHTS = {
363
+ body: [400, 500],
364
+ display: [500, 600],
365
+ mono: [400],
366
+ } satisfies Record<FontSlot, number[]>;
367
+
368
+ /** One `<Font>` render in the head: its CSS variable + weights to preload. */
369
+ export interface FontHead {
370
+ cssVariable: string;
371
+ preloadWeights: number[];
372
+ }
373
+
374
+ /** The weights an entry's faces declare (`undefined` = inferred from files). */
375
+ const entryWeights = (entry: FontEntry): (number | string | undefined)[] =>
376
+ entry.kind === "remote"
377
+ ? entry.weights
378
+ : entry.variants.map((variant) => variant.weight);
379
+
380
+ /**
381
+ * The role's preferred preload weights, narrowed to faces the family loads.
382
+ * Variable ranges (`"100..900"`) and weight-inferred local files can serve any
383
+ * weight, so they keep the preferred list; a family whose numeric weights miss
384
+ * the preferred ones entirely preloads all of its faces instead — those are
385
+ * what its text renders in.
386
+ */
387
+ const preloadWeightsFor = (slot: FontSlot, entry: FontEntry): number[] => {
388
+ const preferred = PRELOAD_WEIGHTS[slot];
389
+ const weights = entryWeights(entry);
390
+ const numeric = weights.filter(
391
+ (weight): weight is number => typeof weight === "number"
392
+ );
393
+ const hits = preferred.filter((weight) => numeric.includes(weight));
394
+ if (hits.length > 0) {
395
+ return hits;
396
+ }
397
+ return numeric.length === weights.length ? numeric : preferred;
398
+ };
399
+
400
+ /**
401
+ * The fonts to feed Astro's `<Font>` component in the document head, deduped
402
+ * by CSS variable with preload weights unioned across the roles that share a
403
+ * family (so `display` and `body` both set to Inter preload 400/500/600 once).
404
+ */
405
+ export const configuredFonts = (fonts: FontsConfig): FontHead[] => {
406
+ if (!fonts) {
407
+ return [];
408
+ }
409
+ const heads = new Map<string, Set<number>>();
410
+ for (const slot of SLOTS) {
411
+ const value = fonts[slot];
412
+ if (value === undefined) {
413
+ continue;
414
+ }
415
+ const entry = resolveFontValue(slot, value);
416
+ if (!entry) {
417
+ continue;
418
+ }
419
+ const weights = heads.get(entry.cssVariable) ?? new Set();
420
+ for (const weight of preloadWeightsFor(slot, entry)) {
421
+ weights.add(weight);
422
+ }
423
+ heads.set(entry.cssVariable, weights);
424
+ }
425
+ return [...heads].map(([cssVariable, weights]) => ({
426
+ cssVariable,
427
+ preloadWeights: [...weights].toSorted((a, b) => a - b),
428
+ }));
429
+ };
@@ -17,9 +17,11 @@ import { getIconData, iconToSVG } from "@iconify/utils";
17
17
  // strips it when externalizing) and then rejects the module, whereas `require`
18
18
  // of a JSON file needs no attribute and works under both Node and Bun.
19
19
  const requireJson = createRequire(import.meta.url);
20
+ // SAFETY: the required file is the Iconify-published icon-set JSON, whose
21
+ // shape is exactly `IconifyJSON`.
20
22
  const loadSet = (pkg: string): IconifyJSON => requireJson(pkg) as IconifyJSON;
21
23
 
22
- const SETS: Record<string, IconifyJSON> = {
24
+ const SETS = {
23
25
  lucide: loadSet("@iconify-json/lucide/icons.json"),
24
26
  };
25
27
 
@@ -27,7 +29,7 @@ const SETS: Record<string, IconifyJSON> = {
27
29
  const DEFAULT_SET = "lucide";
28
30
 
29
31
  /** Explicit `prefix:name` prefixes. Lucide is the only bundled set. */
30
- const PREFIX_SETS: Record<string, string> = {
32
+ const PREFIX_SETS = {
31
33
  lucide: "lucide",
32
34
  };
33
35
 
@@ -7,7 +7,7 @@ const FALLBACK_ACCENT = "oklch(0.62 0.16 250)";
7
7
  * preset colors: the theme CSS and the OG card (og/card.ts) both resolve from
8
8
  * this table, so a site and its social cards can't disagree about "blue".
9
9
  */
10
- export const ACCENTS: Record<string, string> = {
10
+ export const ACCENTS = {
11
11
  blue: FALLBACK_ACCENT,
12
12
  green: "oklch(0.6 0.16 150)",
13
13
  orange: "oklch(0.68 0.17 50)",
@@ -15,7 +15,16 @@ export const ACCENTS: Record<string, string> = {
15
15
  purple: "oklch(0.58 0.2 290)",
16
16
  red: "oklch(0.58 0.22 25)",
17
17
  teal: "oklch(0.6 0.12 195)",
18
- };
18
+ } satisfies Record<string, string>;
19
+
20
+ /**
21
+ * Whether a raw config value names an accent preset. `hasOwn` keeps a value
22
+ * like "constructor" from resolving an Object.prototype member — which would
23
+ * stringify a function into the generated CSS, breaking the rule (the exact
24
+ * breakout {@link safeColor} exists to prevent).
25
+ */
26
+ export const isAccentPreset = (value: string): value is keyof typeof ACCENTS =>
27
+ Object.hasOwn(ACCENTS, value);
19
28
 
20
29
  // Characters valid in a CSS color value (hex, rgb/hsl/oklch functions, named
21
30
  // colors). Anything else — notably `;`, `{`, `}` — could break out of the
@@ -26,27 +35,20 @@ const CSS_COLOR = /^[\w\s#%.,()/+-]+$/u;
26
35
  const safeColor = (value: string, fallback: string): string =>
27
36
  CSS_COLOR.test(value.trim()) ? value.trim() : fallback;
28
37
 
29
- /**
30
- * Resolve a named preset or fall back to {@link safeColor}. `hasOwn` keeps a
31
- * value like "constructor" from resolving an Object.prototype member — which
32
- * would stringify a function into the generated CSS, breaking the rule (the
33
- * exact breakout safeColor exists to prevent).
34
- */
38
+ /** Resolve a named preset or fall back to {@link safeColor}. */
35
39
  const presetOrColor = (value: string): string =>
36
- Object.hasOwn(ACCENTS, value)
37
- ? (ACCENTS[value] as string)
38
- : safeColor(value, FALLBACK_ACCENT);
40
+ isAccentPreset(value) ? ACCENTS[value] : safeColor(value, FALLBACK_ACCENT);
39
41
 
40
42
  /** Like {@link safeColor} but drops an unsafe/absent value to `null`. */
41
43
  const safeColorOrNull = (value: string | undefined): string | null =>
42
44
  value && CSS_COLOR.test(value.trim()) ? value.trim() : null;
43
45
 
44
- const RADII: Record<ResolvedConfig["theme"]["radius"], string> = {
46
+ const RADII = {
45
47
  lg: "0.75rem",
46
48
  md: "0.5rem",
47
49
  none: "0",
48
50
  sm: "0.25rem",
49
- };
51
+ } satisfies Record<ResolvedConfig["theme"]["radius"], string>;
50
52
 
51
53
  const cssString = (value: string): string => JSON.stringify(value);
52
54
 
@@ -116,6 +118,12 @@ ${tokens.join("\n")}
116
118
  `;
117
119
  };
118
120
 
121
+ /** Per-mode accent CSS colors. */
122
+ export interface AccentColors {
123
+ dark: string;
124
+ light: string;
125
+ }
126
+
119
127
  /**
120
128
  * Resolve the configured accent to per-mode CSS colors. A named accent
121
129
  * resolves to its preset; any other value is treated as a raw CSS color so
@@ -125,7 +133,7 @@ ${tokens.join("\n")}
125
133
  */
126
134
  export const resolveAccent = (
127
135
  theme: ResolvedConfig["theme"]
128
- ): { dark: string; light: string } => ({
136
+ ): AccentColors => ({
129
137
  dark: presetOrColor(theme.accent.dark),
130
138
  light: presetOrColor(theme.accent.light),
131
139
  });
@@ -39,6 +39,15 @@ export interface TranslatableMeta {
39
39
 
40
40
  const META_FILES = ["**/meta.ts", "**/meta.js", "**/meta.mjs"];
41
41
 
42
+ /**
43
+ * Whether a loaded meta module default-exports a factory function. Generic so
44
+ * it can decode the loader's untyped module value at this boundary.
45
+ */
46
+ const isFactoryModule = <T>(
47
+ value: T
48
+ ): value is T & ((...args: never[]) => FolderMeta) =>
49
+ typeof value === "function";
50
+
42
51
  /** Where a locale's generated meta module lives (always written as `meta.ts`). */
43
52
  export const metaTargetPath = (
44
53
  meta: TranslatableMeta,
@@ -92,7 +101,7 @@ export const discoverTranslatableMeta = async (
92
101
  readFile(file, "utf-8"),
93
102
  load(file),
94
103
  ]);
95
- if (typeof mod === "function") {
104
+ if (isFactoryModule(mod)) {
96
105
  diagnostics.push({
97
106
  code: "BLUME_TRANSLATE_META_FACTORY",
98
107
  file,
@@ -130,14 +139,14 @@ export const generateMetaModule = (
130
139
  meta: FolderMeta,
131
140
  translatedTitle: string
132
141
  ): string => {
133
- const data: Record<string, unknown> = {
142
+ const data = {
134
143
  ...meta,
135
144
  title: translatedTitle,
136
145
  };
137
- const lines = Object.keys(data)
138
- .toSorted()
139
- .filter((key) => data[key] !== undefined)
140
- .map((key) => ` ${key}: ${JSON.stringify(data[key])},`);
146
+ const lines = Object.entries(data)
147
+ .toSorted(([a], [b]) => (a < b ? -1 : 1))
148
+ .filter(([, value]) => value !== undefined)
149
+ .map(([key, value]) => ` ${key}: ${JSON.stringify(value)},`);
141
150
  return [
142
151
  "// Generated by `blume translate` — edit the default locale's meta file",
143
152
  "// and rerun the translation instead of editing this copy.",
@@ -29,17 +29,17 @@ import type {
29
29
 
30
30
  const ESC = String.fromCodePoint(27);
31
31
 
32
- const GLYPH: Record<TranslateItemStatus, string> = {
32
+ const GLYPH = {
33
33
  failed: "✖",
34
34
  partial: "!",
35
35
  translated: "✔",
36
- };
36
+ } satisfies Record<TranslateItemStatus, string>;
37
37
 
38
- const STATUS_COLOR: Record<TranslateItemStatus, ColorFunction> = {
38
+ const STATUS_COLOR = {
39
39
  failed: colors.red,
40
40
  partial: colors.yellow,
41
41
  translated: colors.green,
42
- };
42
+ } satisfies Record<TranslateItemStatus, ColorFunction>;
43
43
 
44
44
  export const SPINNER_FRAMES = [
45
45
  "⠋",
@@ -76,8 +76,12 @@ export const spinnerLine = (
76
76
  total: number,
77
77
  frame: number
78
78
  ): string => {
79
+ // SAFETY: the renderer only paints while at least one item is active, so the
80
+ // oldest active entry exists.
79
81
  const first = active[0] as WorkItem;
80
82
  const more = active.length > 1 ? ` (+${active.length - 1} more)` : "";
83
+ // SAFETY: `frame % SPINNER_FRAMES.length` is always an index into the
84
+ // non-empty frames array.
81
85
  return ` ${colors.cyan(SPINNER_FRAMES[frame % SPINNER_FRAMES.length] as string)} ${itemLabel(first)}${more} ${colors.dim(`${done}/${total}`)}`;
82
86
  };
83
87
 
@@ -216,7 +220,7 @@ export const checkReportJson = (workList: TranslateWorkList): string => {
216
220
  };
217
221
 
218
222
  /** One run result lowered to JSON-friendly, root-relative fields. */
219
- const resultJson = (result: TranslateItemResult): Record<string, unknown> => ({
223
+ const resultJson = (result: TranslateItemResult) => ({
220
224
  costUsd: result.costUsd,
221
225
  detail: result.detail,
222
226
  durationMs: result.durationMs,
@@ -58,8 +58,9 @@ export interface TranslateRunOptions {
58
58
  * Called after each finished item to flush the ledger to disk, so an
59
59
  * interrupted run keeps everything already translated. Calls are serialized
60
60
  * here — concurrent workers finishing together never race the same file.
61
+ * The flush's result (`writeLedger`'s wrote-or-not boolean) is ignored.
61
62
  */
62
- persistLedger?: () => Promise<unknown>;
63
+ persistLedger?: () => Promise<boolean | undefined>;
63
64
  project: BlumeProject;
64
65
  /** The spawn function — injectable so tests never launch a real agent. */
65
66
  run?: HeadlessRunner;
@@ -129,6 +130,8 @@ const runPageItem = async (
129
130
  });
130
131
 
131
132
  const sourceText = await readFile(item.sourcePath, "utf-8");
133
+ // SAFETY: `targets` maps every configured locale, and work items only carry
134
+ // configured locale codes.
132
135
  const target = context.targets.get(item.locale) as LocaleConfig;
133
136
  // A hand-authored translation can live at a non-canonical name (see
134
137
  // WorkStatus); the disk probe finds only canonical targets, and a miss just
@@ -175,6 +178,8 @@ const runMetaItem = async (
175
178
  const titles = Object.fromEntries(
176
179
  item.entries.map((entry) => [metaDirKey(entry.meta.dir), entry.meta.title])
177
180
  );
181
+ // SAFETY: `targets` maps every configured locale, and work items only carry
182
+ // configured locale codes.
178
183
  const target = context.targets.get(item.locale) as LocaleConfig;
179
184
  const output = await invokeAgent(
180
185
  context,
@@ -257,6 +262,7 @@ export const runTranslate = async (
257
262
  if (!i18n) {
258
263
  throw new Error("blume translate requires i18n to be configured");
259
264
  }
265
+ // SAFETY: the config schema requires `defaultLocale` to be one of `locales`.
260
266
  const source = i18n.locales.find(
261
267
  (locale) => locale.code === i18n.defaultLocale
262
268
  ) as LocaleConfig;
@@ -280,7 +286,7 @@ export const runTranslate = async (
280
286
  // The persist mutex: ledger flushes from concurrent lanes are serialized so
281
287
  // two lanes never write the ledger file at the same time.
282
288
  const persistLimit = pLimit(1);
283
- const persist = (): Promise<unknown> =>
289
+ const persist = (): Promise<boolean | undefined> =>
284
290
  persistLimit(() => options.persistLedger?.());
285
291
 
286
292
  const results = await pMap(
@@ -316,11 +322,11 @@ export const runTranslate = async (
316
322
  }
317
323
  }
318
324
 
319
- const counts: Record<TranslateItemStatus, number> = {
325
+ const counts = {
320
326
  failed: 0,
321
327
  partial: 0,
322
328
  translated: 0,
323
- };
329
+ } satisfies Record<TranslateItemStatus, number>;
324
330
  for (const result of results) {
325
331
  counts[result.status] += 1;
326
332
  }
@@ -13,6 +13,24 @@ export type ValidationResult =
13
13
  | { ok: true; text: string }
14
14
  | { ok: false; reason: string };
15
15
 
16
+ /**
17
+ * A parsed YAML frontmatter value (agent meta replies parse from JSON into the
18
+ * same shape). js-yaml can also mint Dates and other rich scalars; the
19
+ * traversal below only ever distinguishes "keyed object" from "string", so
20
+ * they ride along as the object arm.
21
+ */
22
+ type FrontmatterValue =
23
+ | string
24
+ | number
25
+ | boolean
26
+ | null
27
+ | FrontmatterValue[]
28
+ | FrontmatterData;
29
+
30
+ interface FrontmatterData {
31
+ [key: string]: FrontmatterValue;
32
+ }
33
+
16
34
  const FRONTMATTER_OPEN = /^---\r?\n/u;
17
35
  const FENCE_LINE = /^\s*(?:```|~~~)/u;
18
36
 
@@ -33,27 +51,41 @@ export const stripOuterFence = (text: string): string => {
33
51
  const countFenceLines = (text: string): number =>
34
52
  text.split("\n").filter((line) => FENCE_LINE.test(line)).length;
35
53
 
36
- const getPath = (data: unknown, path: readonly string[]): unknown => {
37
- let value: unknown = data;
54
+ const isKeyedObject = (
55
+ value: FrontmatterValue | undefined
56
+ ): value is FrontmatterData => typeof value === "object" && value !== null;
57
+
58
+ const isString = (value: FrontmatterValue | undefined): value is string =>
59
+ typeof value === "string";
60
+
61
+ const getPath = (
62
+ data: FrontmatterValue,
63
+ path: readonly string[]
64
+ ): FrontmatterValue | undefined => {
65
+ let value: FrontmatterValue | undefined = data;
38
66
  for (const key of path) {
39
- if (typeof value !== "object" || value === null) {
67
+ if (!isKeyedObject(value)) {
40
68
  return;
41
69
  }
42
- value = (value as Record<string, unknown>)[key];
70
+ value = value[key];
43
71
  }
44
72
  return value;
45
73
  };
46
74
 
47
75
  /** Set `path` on `data`; only called for paths whose parents exist in `data`. */
48
76
  const setPath = (
49
- data: Record<string, unknown>,
77
+ data: FrontmatterData,
50
78
  path: readonly string[],
51
79
  value: string
52
80
  ): void => {
53
81
  let parent = data;
54
82
  for (const key of path.slice(0, -1)) {
55
- parent = parent[key] as Record<string, unknown>;
83
+ // SAFETY: callers only set paths that getPath already resolved to a string
84
+ // on this same (cloned) data, so every intermediate step is a keyed object.
85
+ parent = parent[key] as FrontmatterData;
56
86
  }
87
+ // SAFETY: every TRANSLATABLE_KEY_PATHS entry is a non-empty tuple, so the
88
+ // path always has a final key.
57
89
  parent[path.at(-1) as string] = value;
58
90
  };
59
91
 
@@ -84,7 +116,7 @@ export const validateTranslation = (
84
116
  };
85
117
  }
86
118
 
87
- let parsed: { content: string; data: Record<string, unknown> };
119
+ let parsed: { content: string; data: FrontmatterData };
88
120
  try {
89
121
  parsed = matter(candidate);
90
122
  } catch {
@@ -114,13 +146,13 @@ export const validateTranslation = (
114
146
  // Reconciliation by reconstruction: start from the SOURCE data and overlay
115
147
  // only the translatable key paths where both sides hold a string and the
116
148
  // translation is non-empty.
117
- const data = structuredClone(source.data) as Record<string, unknown>;
149
+ const data: FrontmatterData = structuredClone(source.data);
118
150
  for (const path of TRANSLATABLE_KEY_PATHS) {
119
151
  const original = getPath(source.data, path);
120
152
  const translated = getPath(parsed.data, path);
121
153
  if (
122
- typeof original === "string" &&
123
- typeof translated === "string" &&
154
+ isString(original) &&
155
+ isString(translated) &&
124
156
  translated.trim() !== ""
125
157
  ) {
126
158
  setPath(data, path, translated);
@@ -133,6 +165,12 @@ export const validateTranslation = (
133
165
  };
134
166
  };
135
167
 
168
+ /** A meta-batch parse: recovered titles by key, plus the keys still missing. */
169
+ export interface MetaTitlesResult {
170
+ titles: Record<string, string>;
171
+ missing: string[];
172
+ }
173
+
136
174
  /**
137
175
  * Extract translated sidebar titles from a meta reply: tolerant first-`{`
138
176
  * to-last-`}` extraction (the eval `parseVerdict` idiom). Keys missing or
@@ -141,10 +179,10 @@ export const validateTranslation = (
141
179
  export const parseMetaTitles = (
142
180
  agentText: string,
143
181
  expectedKeys: readonly string[]
144
- ): { titles: Record<string, string>; missing: string[] } => {
182
+ ): MetaTitlesResult => {
145
183
  const start = agentText.indexOf("{");
146
184
  const end = agentText.lastIndexOf("}");
147
- let parsed: unknown;
185
+ let parsed: FrontmatterValue | undefined;
148
186
  if (start !== -1 && end > start) {
149
187
  try {
150
188
  parsed = JSON.parse(agentText.slice(start, end + 1));
@@ -152,16 +190,13 @@ export const parseMetaTitles = (
152
190
  parsed = undefined;
153
191
  }
154
192
  }
155
- const record =
156
- typeof parsed === "object" && parsed !== null
157
- ? (parsed as Record<string, unknown>)
158
- : {};
193
+ const record: FrontmatterData = isKeyedObject(parsed) ? parsed : {};
159
194
 
160
195
  const titles: Record<string, string> = {};
161
196
  const missing: string[] = [];
162
197
  for (const key of expectedKeys) {
163
198
  const value = record[key];
164
- if (typeof value === "string" && value.trim() !== "") {
199
+ if (isString(value) && value.trim() !== "") {
165
200
  titles[key] = value;
166
201
  } else {
167
202
  missing.push(key);
Binary file