blume 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/cli/index.js +2260 -1100
  3. package/dist/cli/index.js.map +123 -117
  4. package/dist/types/ai/component-markdown.d.ts +14 -4
  5. package/dist/types/core/base-path.d.ts +8 -0
  6. package/dist/types/core/config-input.d.ts +87 -27
  7. package/dist/types/core/config.d.ts +2 -1
  8. package/dist/types/core/data.d.ts +16 -1
  9. package/dist/types/core/diagnostics.d.ts +5 -1
  10. package/dist/types/core/i18n-ui.d.ts +12 -0
  11. package/dist/types/core/schema.d.ts +116 -15
  12. package/dist/types/core/sources/types.d.ts +11 -1
  13. package/dist/types/core/standard-schema.d.ts +7 -3
  14. package/dist/types/core/types.d.ts +43 -2
  15. package/dist/types/core/ui-packs/index.d.ts +9 -1
  16. package/dist/types/openapi/references.d.ts +14 -7
  17. package/dist/types/seo/x-handle.d.ts +3 -2
  18. package/docs/advanced/api-reference.mdx +8 -6
  19. package/docs/configuration/ai.mdx +26 -8
  20. package/docs/configuration/search.mdx +2 -0
  21. package/docs/configuration/seo.mdx +1 -1
  22. package/docs/content/i18n.mdx +1 -1
  23. package/docs/content/meta.mdx +2 -1
  24. package/docs/content/meta.ts +1 -0
  25. package/docs/content/navigation.mdx +35 -1
  26. package/docs/content/sources.mdx +1 -1
  27. package/docs/content/versioning.mdx +106 -0
  28. package/docs/reference/cli.mdx +1 -0
  29. package/docs/reference/frontmatter.mdx +3 -0
  30. package/package.json +13 -1
  31. package/skills/blume-migrate/SKILL.md +2 -2
  32. package/skills/blume-migrate/references/docusaurus.md +1 -1
  33. package/skills/blume-migrate/references/fumadocs.md +1 -1
  34. package/skills/blume-migrate/references/mintlify.md +1 -1
  35. package/src/ai/agent-readability.ts +40 -12
  36. package/src/ai/api-catalog.ts +2 -2
  37. package/src/ai/ask-context.ts +49 -12
  38. package/src/ai/ask.ts +10 -1
  39. package/src/ai/component-markdown.ts +80 -43
  40. package/src/ai/llms.ts +42 -17
  41. package/src/ai/mcp/data.ts +48 -12
  42. package/src/ai/mcp/discovery.ts +52 -16
  43. package/src/ai/mcp/server.ts +280 -125
  44. package/src/ai/mcp/tools.ts +3 -3
  45. package/src/ai/skills.ts +32 -9
  46. package/src/ai/tar.ts +29 -70
  47. package/src/ai/visibility.ts +2 -2
  48. package/src/astro/component-slots.ts +2 -0
  49. package/src/astro/examples.ts +13 -5
  50. package/src/astro/generate.ts +113 -63
  51. package/src/astro/integration.ts +13 -2
  52. package/src/astro/islands.ts +23 -12
  53. package/src/astro/templates.ts +185 -41
  54. package/src/audit/agent.ts +16 -31
  55. package/src/audit/checks/content.ts +26 -11
  56. package/src/audit/checks/dns-aid.ts +3 -0
  57. package/src/audit/checks/indexability.ts +24 -6
  58. package/src/audit/checks/llms.ts +9 -4
  59. package/src/audit/checks/network.ts +2 -0
  60. package/src/audit/checks/social.ts +18 -10
  61. package/src/audit/crawl.ts +78 -25
  62. package/src/audit/report.ts +20 -19
  63. package/src/audit/run.ts +15 -5
  64. package/src/audit/snapshot.ts +29 -6
  65. package/src/audit/types.ts +25 -3
  66. package/src/blume-modules.d.ts +5 -1
  67. package/src/cli/commands/audit.ts +21 -21
  68. package/src/cli/commands/build.ts +30 -16
  69. package/src/cli/commands/dev.ts +15 -15
  70. package/src/cli/commands/doctor.ts +2 -0
  71. package/src/cli/commands/eject.ts +4 -4
  72. package/src/cli/commands/eval.ts +24 -30
  73. package/src/cli/commands/init.ts +9 -9
  74. package/src/cli/commands/mcp-stdio.ts +3 -0
  75. package/src/cli/commands/translate.ts +14 -3
  76. package/src/cli/commands/version.ts +85 -0
  77. package/src/cli/dev-lock.ts +31 -10
  78. package/src/cli/eject-scripts.ts +17 -2
  79. package/src/cli/env.ts +13 -30
  80. package/src/cli/index.ts +2 -0
  81. package/src/cli/init/questions.ts +1 -1
  82. package/src/cli/init/scaffold.ts +41 -13
  83. package/src/cli/internal-error.ts +1 -0
  84. package/src/cli/report-format.ts +22 -0
  85. package/src/components/content/AccordionItem.astro +2 -9
  86. package/src/components/content/ColorItem.astro +5 -13
  87. package/src/components/content/Component.astro +12 -8
  88. package/src/components/content/Frame.astro +2 -12
  89. package/src/components/content/Prompt.astro +12 -31
  90. package/src/components/content/Tab.astro +2 -9
  91. package/src/components/content/Tooltip.astro +1 -9
  92. package/src/components/content/Update.astro +2 -9
  93. package/src/components/content/auto-type-table.ts +3 -0
  94. package/src/components/content/diff.ts +9 -5
  95. package/src/components/content/github-info.ts +2 -0
  96. package/src/components/content/inline-markdown.ts +28 -0
  97. package/src/components/copy-feedback.ts +96 -0
  98. package/src/components/islands/ask-ai.tsx +111 -34
  99. package/src/components/islands/hooks.ts +5 -1
  100. package/src/components/islands/webmcp.ts +49 -12
  101. package/src/components/layout/Header.astro +25 -1
  102. package/src/components/layout/NavSelector.astro +11 -2
  103. package/src/components/layout/NavTree.astro +4 -2
  104. package/src/components/layout/PageActions.astro +20 -32
  105. package/src/components/layout/PageLayout.astro +8 -28
  106. package/src/components/layout/RootLayout.astro +24 -48
  107. package/src/components/layout/Search.astro +133 -22
  108. package/src/components/layout/VersionBanner.astro +39 -0
  109. package/src/components/layout/analytics-client.ts +8 -5
  110. package/src/components/layout/drawer-inert.ts +31 -0
  111. package/src/components/layout/hydration-hint.ts +1 -1
  112. package/src/components/layout/nav-utils.ts +1 -4
  113. package/src/components/layout/overrides.ts +25 -12
  114. package/src/components/layout/search/algolia.ts +18 -5
  115. package/src/components/layout/search/endpoint.ts +3 -0
  116. package/src/components/layout/search/flexsearch.ts +23 -7
  117. package/src/components/layout/search/orama-cloud.ts +1 -1
  118. package/src/components/layout/search/orama.ts +4 -1
  119. package/src/components/layout/search/pagefind.ts +8 -5
  120. package/src/components/layout/search/types.ts +45 -1
  121. package/src/components/layout/search/typesense.ts +19 -3
  122. package/src/components/openapi/ApiOverview.astro +32 -6
  123. package/src/components/openapi/AsyncApiOperation.astro +237 -0
  124. package/src/components/openapi/Bindings.astro +89 -0
  125. package/src/components/openapi/MethodBadge.astro +3 -0
  126. package/src/components/openapi/Operation.astro +7 -2
  127. package/src/components/openapi/PanelTabs.astro +131 -0
  128. package/src/components/openapi/ParametersTable.astro +2 -0
  129. package/src/components/openapi/RequestPanel.astro +12 -119
  130. package/src/components/openapi/async-snippets.ts +174 -0
  131. package/src/components/openapi/async.ts +348 -0
  132. package/src/components/openapi/helpers.ts +52 -20
  133. package/src/components/openapi/panel.ts +11 -8
  134. package/src/components/openapi/security.ts +102 -29
  135. package/src/components/openapi/snippets.ts +11 -11
  136. package/src/components/raf-throttle.ts +21 -0
  137. package/src/components/slug.ts +14 -0
  138. package/src/core/base-path.ts +18 -1
  139. package/src/core/component-overrides.ts +28 -23
  140. package/src/core/config-input.ts +96 -27
  141. package/src/core/config.ts +20 -7
  142. package/src/core/content.ts +3 -1
  143. package/src/core/data.ts +16 -1
  144. package/src/core/define-components.ts +5 -0
  145. package/src/core/diagnostics.ts +46 -38
  146. package/src/core/frontmatter.ts +74 -4
  147. package/src/core/graph.ts +137 -53
  148. package/src/core/i18n-ui.ts +15 -0
  149. package/src/core/i18n.ts +16 -8
  150. package/src/core/load-module.ts +1 -0
  151. package/src/core/manifest.ts +92 -3
  152. package/src/core/meta.ts +44 -14
  153. package/src/core/nav-diagnostics.ts +3 -3
  154. package/src/core/navigation.ts +247 -67
  155. package/src/core/probe.ts +7 -19
  156. package/src/core/project-graph.ts +27 -4
  157. package/src/core/schema.ts +219 -67
  158. package/src/core/site-url.ts +27 -0
  159. package/src/core/sources/assets.ts +2 -0
  160. package/src/core/sources/cache.ts +16 -8
  161. package/src/core/sources/github-releases.ts +39 -11
  162. package/src/core/sources/mdx-remote.ts +4 -0
  163. package/src/core/sources/normalize.ts +93 -22
  164. package/src/core/sources/notion.ts +76 -22
  165. package/src/core/sources/portable-text.ts +48 -12
  166. package/src/core/sources/resolve.ts +1 -0
  167. package/src/core/sources/sanity.ts +68 -14
  168. package/src/core/sources/types.ts +17 -1
  169. package/src/core/sources/watch.ts +1 -1
  170. package/src/core/standard-schema.ts +9 -3
  171. package/src/core/text-width.ts +26 -0
  172. package/src/core/tsconfig-aliases.ts +9 -5
  173. package/src/core/types.ts +45 -2
  174. package/src/core/ui-packs/index.ts +9 -1
  175. package/src/core/version-cut.ts +301 -0
  176. package/src/core/version.ts +2 -0
  177. package/src/core/versions.ts +170 -0
  178. package/src/deploy/adapter-output.ts +5 -2
  179. package/src/deploy/cloudflare-negotiation.ts +40 -11
  180. package/src/deploy/robots.ts +2 -1
  181. package/src/deploy/rss.ts +2 -1
  182. package/src/deploy/sitemap.ts +89 -8
  183. package/src/deploy/vercel-negotiation.ts +11 -4
  184. package/src/eval/agents.ts +13 -10
  185. package/src/eval/report.ts +5 -18
  186. package/src/eval/run.ts +2 -2
  187. package/src/eval/schema.ts +1 -1
  188. package/src/markdown/base-links.ts +6 -6
  189. package/src/markdown/directives.ts +7 -1
  190. package/src/markdown/heading-anchors.ts +17 -6
  191. package/src/markdown/index.ts +73 -24
  192. package/src/markdown/inline-code.ts +14 -2
  193. package/src/markdown/language-icon.ts +6 -2
  194. package/src/markdown/mdast.ts +18 -4
  195. package/src/markdown/package-commands.ts +63 -58
  196. package/src/markdown/table-wrap.ts +4 -1
  197. package/src/markdown/twoslash.ts +2 -0
  198. package/src/og/card.ts +50 -33
  199. package/src/og/derive.ts +43 -27
  200. package/src/openapi/asyncapi.ts +366 -0
  201. package/src/openapi/model.ts +135 -66
  202. package/src/openapi/parse.ts +166 -33
  203. package/src/openapi/references.ts +47 -22
  204. package/src/openapi/render-mdx.ts +137 -59
  205. package/src/openapi/scalar.ts +8 -10
  206. package/src/openapi/source.ts +126 -29
  207. package/src/registry/eject.ts +7 -2
  208. package/src/search/documents.ts +103 -39
  209. package/src/search/facets.ts +7 -5
  210. package/src/search/orama-index.ts +117 -32
  211. package/src/search/popular.ts +10 -5
  212. package/src/search/providers.ts +2 -2
  213. package/src/search/sync/index.ts +2 -0
  214. package/src/search/sync/typesense.ts +4 -2
  215. package/src/seo/jsonld.ts +24 -6
  216. package/src/seo/x-handle.ts +8 -3
  217. package/src/theme/chrome-icons.ts +7 -2
  218. package/src/theme/fonts.ts +8 -4
  219. package/src/theme/icons.ts +4 -2
  220. package/src/theme/palette.ts +27 -15
  221. package/src/translate/ledger.ts +4 -2
  222. package/src/translate/meta.ts +15 -6
  223. package/src/translate/report.ts +10 -19
  224. package/src/translate/run.ts +29 -38
  225. package/src/translate/validate.ts +52 -17
  226. package/src/translate/work-list.ts +0 -0
  227. package/src/cli/coalesce.ts +0 -43
@@ -3,6 +3,7 @@ import type { ColorFunction } from "consola/utils";
3
3
 
4
4
  import { AGENTS } from "../audit/agent.ts";
5
5
  import type { AgentKind } from "../audit/agent.ts";
6
+ import { duration, money, seconds } from "../cli/report-format.ts";
6
7
  import { countBySeverity } from "../core/diagnostics.ts";
7
8
  import type { Diagnostic } from "../core/types.ts";
8
9
  import type {
@@ -28,17 +29,17 @@ import type {
28
29
 
29
30
  const ESC = String.fromCodePoint(27);
30
31
 
31
- const GLYPH: Record<TranslateItemStatus, string> = {
32
+ const GLYPH = {
32
33
  failed: "✖",
33
34
  partial: "!",
34
35
  translated: "✔",
35
- };
36
+ } satisfies Record<TranslateItemStatus, string>;
36
37
 
37
- const STATUS_COLOR: Record<TranslateItemStatus, ColorFunction> = {
38
+ const STATUS_COLOR = {
38
39
  failed: colors.red,
39
40
  partial: colors.yellow,
40
41
  translated: colors.green,
41
- };
42
+ } satisfies Record<TranslateItemStatus, ColorFunction>;
42
43
 
43
44
  export const SPINNER_FRAMES = [
44
45
  "⠋",
@@ -59,20 +60,6 @@ export const SPINNER_INTERVAL_MS = 80;
59
60
  /** The clear-to-start-of-line prefix every TTY rewrite uses. */
60
61
  const REWRITE = `\r${ESC}[K`;
61
62
 
62
- const seconds = (ms: number): string => `${(ms / 1000).toFixed(1)}s`;
63
-
64
- const money = (cost: number | undefined): string =>
65
- cost === undefined ? "" : `$${cost.toFixed(2)}`;
66
-
67
- const duration = (ms: number): string => {
68
- if (ms < 60_000) {
69
- return seconds(ms);
70
- }
71
- const minutes = Math.floor(ms / 60_000);
72
- const rest = Math.round((ms % 60_000) / 1000);
73
- return `${minutes}m ${rest}s`;
74
- };
75
-
76
63
  /** `docs/guides/install.mdx → fr`, or the batched meta call's label. */
77
64
  export const itemLabel = (item: WorkItem): string =>
78
65
  item.kind === "page"
@@ -89,8 +76,12 @@ export const spinnerLine = (
89
76
  total: number,
90
77
  frame: number
91
78
  ): string => {
79
+ // SAFETY: the renderer only paints while at least one item is active, so the
80
+ // oldest active entry exists.
92
81
  const first = active[0] as WorkItem;
93
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.
94
85
  return ` ${colors.cyan(SPINNER_FRAMES[frame % SPINNER_FRAMES.length] as string)} ${itemLabel(first)}${more} ${colors.dim(`${done}/${total}`)}`;
95
86
  };
96
87
 
@@ -229,7 +220,7 @@ export const checkReportJson = (workList: TranslateWorkList): string => {
229
220
  };
230
221
 
231
222
  /** One run result lowered to JSON-friendly, root-relative fields. */
232
- const resultJson = (result: TranslateItemResult): Record<string, unknown> => ({
223
+ const resultJson = (result: TranslateItemResult) => ({
233
224
  costUsd: result.costUsd,
234
225
  detail: result.detail,
235
226
  durationMs: result.durationMs,
@@ -2,9 +2,11 @@ import { existsSync } from "node:fs";
2
2
  import { mkdtemp, readFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
 
5
+ import pLimit from "p-limit";
6
+ import pMap from "p-map";
5
7
  import { join } from "pathe";
6
8
 
7
- import { AGENTS, WINDOWS_COMMAND_NOT_FOUND } from "../audit/agent.ts";
9
+ import { AGENTS } from "../audit/agent.ts";
8
10
  import type { AgentKind } from "../audit/agent.ts";
9
11
  import { writeTextAtomic } from "../core/fs-atomic.ts";
10
12
  import type { BlumeProject } from "../core/project-graph.ts";
@@ -56,8 +58,9 @@ export interface TranslateRunOptions {
56
58
  * Called after each finished item to flush the ledger to disk, so an
57
59
  * interrupted run keeps everything already translated. Calls are serialized
58
60
  * here — concurrent workers finishing together never race the same file.
61
+ * The flush's result (`writeLedger`'s wrote-or-not boolean) is ignored.
59
62
  */
60
- persistLedger?: () => Promise<unknown>;
63
+ persistLedger?: () => Promise<boolean | undefined>;
61
64
  project: BlumeProject;
62
65
  /** The spawn function — injectable so tests never launch a real agent. */
63
66
  run?: HeadlessRunner;
@@ -89,9 +92,9 @@ interface RunContext {
89
92
  const metaDirKey = (dir: string): string => (dir === "" ? "." : dir);
90
93
 
91
94
  /**
92
- * One headless agent call. A Windows shell launch reports a missing executable
93
- * through exit code 9009 instead of a spawn error, so that is normalized to
94
- * the ENOENT rejection the command layer already turns into an install hint.
95
+ * One headless agent call. A missing executable rejects with ENOENT on every
96
+ * platform (the runner spawns without a shell), which the command layer turns
97
+ * into an install hint.
95
98
  */
96
99
  const invokeAgent = async (
97
100
  context: RunContext,
@@ -104,13 +107,6 @@ const invokeAgent = async (
104
107
  translateAgentArgs(context.kind, messagePath),
105
108
  { cwd: context.dir, prompt, timeoutMs: context.timeoutMs }
106
109
  );
107
- if (!result.timedOut && result.code === WINDOWS_COMMAND_NOT_FOUND) {
108
- const missing = new Error(
109
- `${context.bin} was not found on PATH`
110
- ) as NodeJS.ErrnoException;
111
- missing.code = "ENOENT";
112
- throw missing;
113
- }
114
110
  return await readAgentOutput(context.kind, result, messagePath);
115
111
  };
116
112
 
@@ -134,6 +130,8 @@ const runPageItem = async (
134
130
  });
135
131
 
136
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.
137
135
  const target = context.targets.get(item.locale) as LocaleConfig;
138
136
  // A hand-authored translation can live at a non-canonical name (see
139
137
  // WorkStatus); the disk probe finds only canonical targets, and a miss just
@@ -180,6 +178,8 @@ const runMetaItem = async (
180
178
  const titles = Object.fromEntries(
181
179
  item.entries.map((entry) => [metaDirKey(entry.meta.dir), entry.meta.title])
182
180
  );
181
+ // SAFETY: `targets` maps every configured locale, and work items only carry
182
+ // configured locale codes.
183
183
  const target = context.targets.get(item.locale) as LocaleConfig;
184
184
  const output = await invokeAgent(
185
185
  context,
@@ -262,6 +262,7 @@ export const runTranslate = async (
262
262
  if (!i18n) {
263
263
  throw new Error("blume translate requires i18n to be configured");
264
264
  }
265
+ // SAFETY: the config schema requires `defaultLocale` to be one of `locales`.
265
266
  const source = i18n.locales.find(
266
267
  (locale) => locale.code === i18n.defaultLocale
267
268
  ) as LocaleConfig;
@@ -277,42 +278,31 @@ export const runTranslate = async (
277
278
  };
278
279
 
279
280
  const { items } = options.workList;
280
- const results: TranslateItemResult[] = Array.from({ length: items.length });
281
281
  const concurrency = Math.max(
282
282
  1,
283
283
  Math.min(options.concurrency ?? 1, items.length || 1)
284
284
  );
285
285
 
286
- // The persist chain: whichever lane finishes next appends its flush after
287
- // the previous one, so two lanes never write the ledger file concurrently.
288
- let persisting: Promise<unknown> = Promise.resolve();
289
- const persist = (): Promise<unknown> => {
290
- // The chain is the mutex: appending with .then() serializes flushes.
291
- // oxlint-disable-next-line promise/prefer-await-to-then
292
- persisting = persisting.then(() => options.persistLedger?.());
293
- return persisting;
294
- };
286
+ // The persist mutex: ledger flushes from concurrent lanes are serialized so
287
+ // two lanes never write the ledger file at the same time.
288
+ const persistLimit = pLimit(1);
289
+ const persist = (): Promise<boolean | undefined> =>
290
+ persistLimit(() => options.persistLedger?.());
295
291
 
296
- let nextIndex = 0;
297
- const worker = async (): Promise<void> => {
298
- while (nextIndex < items.length) {
299
- const index = nextIndex;
300
- nextIndex += 1;
301
- const item = items[index] as WorkItem;
292
+ const results = await pMap(
293
+ items,
294
+ async (item: WorkItem, index): Promise<TranslateItemResult> => {
302
295
  options.onProgress?.({
303
296
  index,
304
297
  item,
305
298
  kind: "item-start",
306
299
  total: items.length,
307
300
  });
308
- // oxlint-disable-next-line no-await-in-loop -- each worker is a serial lane
309
301
  const result = await (item.kind === "page"
310
302
  ? runPageItem(item, index, context, options.ledger)
311
303
  : runMetaItem(item, index, context, options.ledger));
312
- results[index] = result;
313
- // Flush this item's stamps before claiming the next one, so a kill
314
- // loses at most the in-flight items.
315
- // oxlint-disable-next-line no-await-in-loop
304
+ // Flush this item's stamps before the slot frees for the next item, so
305
+ // a kill loses at most the in-flight items.
316
306
  await persist();
317
307
  options.onProgress?.({
318
308
  index,
@@ -320,9 +310,10 @@ export const runTranslate = async (
320
310
  result,
321
311
  total: items.length,
322
312
  });
323
- }
324
- };
325
- await Promise.all(Array.from({ length: concurrency }, () => worker()));
313
+ return result;
314
+ },
315
+ { concurrency }
316
+ );
326
317
 
327
318
  const diagnostics: Diagnostic[] = [];
328
319
  for (const result of results) {
@@ -331,11 +322,11 @@ export const runTranslate = async (
331
322
  }
332
323
  }
333
324
 
334
- const counts: Record<TranslateItemStatus, number> = {
325
+ const counts = {
335
326
  failed: 0,
336
327
  partial: 0,
337
328
  translated: 0,
338
- };
329
+ } satisfies Record<TranslateItemStatus, number>;
339
330
  for (const result of results) {
340
331
  counts[result.status] += 1;
341
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
@@ -1,43 +0,0 @@
1
- /**
2
- * Wrap an async task so it never runs concurrently with itself. Triggering the
3
- * returned function while a run is in flight coalesces into a single trailing
4
- * run after the current one settles.
5
- *
6
- * Dev regeneration (`scanProject` + `generateRuntime`) is expensive on a large
7
- * project — a full content re-scan that allocates big strings. A plain debounce
8
- * still lets a fast burst of watch events (or, before it was fixed, a `.blume/`
9
- * watch storm) start a new scan before the previous finished, piling up
10
- * overlapping scans until the heap is exhausted (observed as an OOM after
11
- * minutes of looping). Single-flighting bounds it to one scan at a time while
12
- * still guaranteeing a final run reflects the latest change.
13
- *
14
- * The task must not reject: a rejection would surface as an unhandled promise
15
- * rejection, so callers handle their own errors and always resolve.
16
- */
17
- export const coalescedRunner = (task: () => Promise<void>): (() => void) => {
18
- let inFlight: Promise<void> | null = null;
19
- let pending = false;
20
-
21
- // Drain any run requested during the current run, then release the lock. The
22
- // `inFlight` promise is assigned synchronously by the caller below, so a
23
- // re-entrant trigger sees the lock immediately and only sets `pending`.
24
- const cycle = async (): Promise<void> => {
25
- try {
26
- do {
27
- pending = false;
28
- // oxlint-disable-next-line no-await-in-loop -- serialized by design
29
- await task();
30
- } while (pending);
31
- } finally {
32
- inFlight = null;
33
- }
34
- };
35
-
36
- return () => {
37
- if (inFlight) {
38
- pending = true;
39
- return;
40
- }
41
- inFlight = cycle();
42
- };
43
- };