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
@@ -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
  };
@@ -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);
@@ -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