blume 1.3.1 → 1.4.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 (139) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/cli/index.js +3512 -814
  3. package/dist/cli/index.js.map +99 -87
  4. package/dist/types/core/base-path.d.ts +5 -0
  5. package/dist/types/core/config-input.d.ts +82 -6
  6. package/dist/types/core/i18n-ui.d.ts +2 -0
  7. package/dist/types/core/schema.d.ts +19 -2
  8. package/dist/types/core/sources/types.d.ts +5 -0
  9. package/dist/types/core/types.d.ts +4 -3
  10. package/docs/02-deployment.mdx +1 -1
  11. package/docs/configuration/ai.mdx +15 -1
  12. package/docs/configuration/index.mdx +26 -0
  13. package/docs/configuration/search.mdx +1 -3
  14. package/docs/content/i18n.mdx +13 -1
  15. package/docs/content/navigation.mdx +11 -0
  16. package/docs/reference/cli.mdx +4 -0
  17. package/docs/reference/frontmatter.mdx +33 -0
  18. package/docs/reference/meta.ts +1 -1
  19. package/docs/reference/translate.mdx +80 -0
  20. package/package.json +22 -1
  21. package/src/ai/agent-readability.ts +7 -4
  22. package/src/ai/ask-context.ts +3 -6
  23. package/src/ai/component-markdown.ts +7 -6
  24. package/src/ai/mcp/data.ts +10 -4
  25. package/src/ai/mcp/server.ts +74 -3
  26. package/src/ai/mcp/tools.ts +2 -2
  27. package/src/astro/generate.ts +4 -13
  28. package/src/astro/integration.ts +3 -1
  29. package/src/astro/islands.ts +4 -1
  30. package/src/astro/markdown-negotiation.ts +5 -0
  31. package/src/astro/templates.ts +69 -22
  32. package/src/audit/checks/indexability.ts +3 -6
  33. package/src/audit/checks/robots.ts +18 -37
  34. package/src/audit/crawl.ts +49 -49
  35. package/src/audit/image-size.ts +13 -53
  36. package/src/audit/report.ts +22 -33
  37. package/src/audit/types.ts +6 -2
  38. package/src/audit/url.ts +5 -10
  39. package/src/cli/commands/build.ts +129 -24
  40. package/src/cli/commands/dev.ts +9 -21
  41. package/src/cli/commands/doctor.ts +9 -22
  42. package/src/cli/commands/translate.ts +300 -0
  43. package/src/cli/env.ts +6 -52
  44. package/src/cli/index.ts +2 -0
  45. package/src/cli/init/scaffold.ts +15 -28
  46. package/src/cli/internal-error.ts +11 -11
  47. package/src/components/Icon.astro +2 -7
  48. package/src/components/content/Step.astro +3 -8
  49. package/src/components/content/Tab.astro +20 -1
  50. package/src/components/islands/ask-ai.tsx +25 -100
  51. package/src/components/islands/hooks.ts +10 -3
  52. package/src/components/layout/LanguageSwitcher.astro +2 -1
  53. package/src/components/layout/Logo.astro +4 -4
  54. package/src/components/layout/PageActions.astro +12 -7
  55. package/src/components/layout/RootLayout.astro +37 -109
  56. package/src/components/layout/Search.astro +18 -25
  57. package/src/components/layout/search/orama.ts +3 -1
  58. package/src/components/layout/search/types.ts +4 -16
  59. package/src/components/openapi/helpers.ts +21 -75
  60. package/src/core/base-path.ts +9 -0
  61. package/src/core/component-overrides.ts +0 -7
  62. package/src/core/config-input.ts +84 -6
  63. package/src/core/config.ts +3 -3
  64. package/src/core/diagnostics.ts +10 -20
  65. package/src/core/fs-atomic.ts +22 -0
  66. package/src/core/graph.ts +46 -2
  67. package/src/core/i18n-ui.ts +2 -0
  68. package/src/core/i18n.ts +31 -0
  69. package/src/core/nav-diagnostics.ts +13 -34
  70. package/src/core/project-graph.ts +13 -2
  71. package/src/core/schema.ts +174 -74
  72. package/src/core/sources/github-releases.ts +29 -26
  73. package/src/core/sources/mdx-remote.ts +10 -57
  74. package/src/core/sources/normalize.ts +25 -12
  75. package/src/core/sources/notion.ts +17 -23
  76. package/src/core/sources/types.ts +5 -0
  77. package/src/core/tsconfig-aliases.ts +39 -172
  78. package/src/core/types.ts +4 -3
  79. package/src/core/ui-packs/ar.ts +42 -1
  80. package/src/core/ui-packs/bg.ts +42 -1
  81. package/src/core/ui-packs/bn.ts +42 -1
  82. package/src/core/ui-packs/ca.ts +44 -1
  83. package/src/core/ui-packs/cs.ts +42 -1
  84. package/src/core/ui-packs/da.ts +42 -1
  85. package/src/core/ui-packs/de.ts +42 -1
  86. package/src/core/ui-packs/el.ts +44 -1
  87. package/src/core/ui-packs/es.ts +44 -1
  88. package/src/core/ui-packs/fa.ts +42 -1
  89. package/src/core/ui-packs/fi.ts +42 -1
  90. package/src/core/ui-packs/fr.ts +44 -1
  91. package/src/core/ui-packs/he.ts +42 -1
  92. package/src/core/ui-packs/hi.ts +42 -1
  93. package/src/core/ui-packs/hr.ts +42 -1
  94. package/src/core/ui-packs/hu.ts +42 -1
  95. package/src/core/ui-packs/id.ts +42 -1
  96. package/src/core/ui-packs/it.ts +44 -1
  97. package/src/core/ui-packs/ja.ts +44 -1
  98. package/src/core/ui-packs/ko.ts +44 -1
  99. package/src/core/ui-packs/nl.ts +42 -1
  100. package/src/core/ui-packs/no.ts +42 -1
  101. package/src/core/ui-packs/pl.ts +42 -1
  102. package/src/core/ui-packs/pt-br.ts +44 -1
  103. package/src/core/ui-packs/pt.ts +44 -1
  104. package/src/core/ui-packs/ro.ts +42 -1
  105. package/src/core/ui-packs/ru.ts +42 -1
  106. package/src/core/ui-packs/sk.ts +42 -1
  107. package/src/core/ui-packs/sr.ts +42 -1
  108. package/src/core/ui-packs/sv.ts +42 -1
  109. package/src/core/ui-packs/th.ts +44 -1
  110. package/src/core/ui-packs/tr.ts +42 -1
  111. package/src/core/ui-packs/uk.ts +42 -1
  112. package/src/core/ui-packs/vi.ts +44 -1
  113. package/src/core/ui-packs/zh-tw.ts +44 -1
  114. package/src/core/ui-packs/zh.ts +44 -1
  115. package/src/deploy/adapter-output.ts +44 -5
  116. package/src/deploy/cloudflare-negotiation.ts +527 -0
  117. package/src/deploy/redirects.ts +13 -0
  118. package/src/deploy/rss.ts +4 -1
  119. package/src/deploy/sitemap.ts +3 -1
  120. package/src/eval/agents.ts +1 -1
  121. package/src/eval/report.ts +20 -28
  122. package/src/markdown/directives.ts +6 -18
  123. package/src/markdown/index.ts +1 -6
  124. package/src/markdown/package-commands.ts +0 -4
  125. package/src/openapi/parse.ts +11 -9
  126. package/src/search/documents.ts +11 -0
  127. package/src/search/facets.ts +33 -0
  128. package/src/search/orama-index.ts +48 -6
  129. package/src/search/popular-icon.ts +33 -0
  130. package/src/theme/icon-kind.ts +20 -0
  131. package/src/translate/agents.ts +51 -0
  132. package/src/translate/ledger.ts +142 -0
  133. package/src/translate/meta.ts +149 -0
  134. package/src/translate/prompts.ts +95 -0
  135. package/src/translate/report.ts +354 -0
  136. package/src/translate/run.ts +357 -0
  137. package/src/translate/validate.ts +171 -0
  138. package/src/translate/work-list.ts +0 -0
  139. package/src/deploy/xml.ts +0 -8
@@ -0,0 +1,149 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ import { dirname, join, relative } from "pathe";
4
+ import { glob } from "tinyglobby";
5
+
6
+ import { createModuleLoader } from "../core/load-module.ts";
7
+ import type { BlumeProject } from "../core/project-graph.ts";
8
+ import { folderMetaSchema } from "../core/schema.ts";
9
+ import type { FolderMeta } from "../core/schema.ts";
10
+ import type { Diagnostic } from "../core/types.ts";
11
+
12
+ /**
13
+ * Folder-nav `meta.ts` titles are translatable under the `dir` parser ONLY:
14
+ * per-locale meta is a whole-file replacement, not a merge (`applyFolderMeta`
15
+ * looks up `<locale>/<group>` and falls back to shared meta, never to the
16
+ * default locale's file), and the `dot` parser has no per-locale meta
17
+ * mechanism at all. So the generated module must copy EVERY source key
18
+ * (order/pages/icon/collapsed) with only the title translated — otherwise the
19
+ * locale's navigation loses its ordering.
20
+ */
21
+
22
+ /** One default-locale meta file whose title can be translated. */
23
+ export interface TranslatableMeta {
24
+ /** The parsed meta module; copied wholesale into the generated module. */
25
+ data: FolderMeta;
26
+ /** Directory relative to the owning source's content root (`""` = root). */
27
+ dir: string;
28
+ /** Absolute path of the source meta file. */
29
+ file: string;
30
+ /** The owning source's content root (locale directories live under it). */
31
+ contentRoot: string;
32
+ /** Raw source text at discovery time — what the ledger hashes. */
33
+ raw: string;
34
+ /** POSIX root-relative path of the source meta file — the ledger key. */
35
+ sourceRel: string;
36
+ /** The source title to translate. */
37
+ title: string;
38
+ }
39
+
40
+ const META_FILES = ["**/meta.ts", "**/meta.js", "**/meta.mjs"];
41
+
42
+ /** Where a locale's generated meta module lives (always written as `meta.ts`). */
43
+ export const metaTargetPath = (
44
+ meta: TranslatableMeta,
45
+ locale: string
46
+ ): string => join(meta.contentRoot, locale, meta.dir, "meta.ts");
47
+
48
+ /**
49
+ * Discover the default-locale `meta.{ts,js,mjs}` files whose titles a
50
+ * translation run covers. Skips (in order): non-`dir` i18n projects entirely,
51
+ * files inside a configured locale directory (those ARE translations),
52
+ * factory-form modules (a warning — the generator can't re-emit a function),
53
+ * modules that fail meta validation (the scan already errors on those), and
54
+ * modules with no `title` (nothing to translate).
55
+ */
56
+ export const discoverTranslatableMeta = async (
57
+ project: BlumeProject
58
+ ): Promise<{ metas: TranslatableMeta[]; diagnostics: Diagnostic[] }> => {
59
+ const { i18n } = project.config;
60
+ if (!i18n || i18n.parser !== "dir") {
61
+ return { diagnostics: [], metas: [] };
62
+ }
63
+ const localeDirs = new Set(
64
+ i18n.locales.flatMap((locale) =>
65
+ locale.code === i18n.defaultLocale ? [] : [locale.code.toLowerCase()]
66
+ )
67
+ );
68
+
69
+ const load = createModuleLoader();
70
+ const metas: TranslatableMeta[] = [];
71
+ const diagnostics: Diagnostic[] = [];
72
+
73
+ const roots = project.sources.flatMap((source) =>
74
+ source.staged || !source.contentRoot ? [] : [source.contentRoot]
75
+ );
76
+ for (const contentRoot of roots) {
77
+ // oxlint-disable-next-line no-await-in-loop -- a project has O(1) sources
78
+ const files = await glob(META_FILES, {
79
+ absolute: true,
80
+ cwd: contentRoot,
81
+ ignore: ["**/node_modules/**", "**/.blume/**", "**/dist/**"],
82
+ onlyFiles: true,
83
+ });
84
+ for (const file of files.toSorted()) {
85
+ const dir = relative(contentRoot, dirname(file));
86
+ const first = dir.split("/")[0]?.toLowerCase();
87
+ if (first && localeDirs.has(first)) {
88
+ continue;
89
+ }
90
+ // oxlint-disable-next-line no-await-in-loop -- sequential, ordered discovery
91
+ const [raw, mod] = await Promise.all([
92
+ readFile(file, "utf-8"),
93
+ load(file),
94
+ ]);
95
+ if (typeof mod === "function") {
96
+ diagnostics.push({
97
+ code: "BLUME_TRANSLATE_META_FACTORY",
98
+ file,
99
+ message:
100
+ "This meta file default-exports a function, so `blume translate` cannot generate per-locale copies of it.",
101
+ severity: "warning",
102
+ suggestion:
103
+ "Export a plain object, or author the locale's meta file by hand.",
104
+ });
105
+ continue;
106
+ }
107
+ const parsed = folderMetaSchema.safeParse(mod);
108
+ if (!parsed.success || parsed.data.title === undefined) {
109
+ continue;
110
+ }
111
+ metas.push({
112
+ contentRoot,
113
+ data: parsed.data,
114
+ dir: dir === "." ? "" : dir,
115
+ file,
116
+ raw,
117
+ sourceRel: relative(project.context.root, file),
118
+ title: parsed.data.title,
119
+ });
120
+ }
121
+ }
122
+ return { diagnostics, metas };
123
+ };
124
+
125
+ /**
126
+ * Emit the per-locale meta module: every source key copied verbatim, only the
127
+ * title swapped for its translation. Keys alphabetical, values as JSON.
128
+ */
129
+ export const generateMetaModule = (
130
+ meta: FolderMeta,
131
+ translatedTitle: string
132
+ ): string => {
133
+ const data: Record<string, unknown> = {
134
+ ...meta,
135
+ title: translatedTitle,
136
+ };
137
+ const lines = Object.keys(data)
138
+ .toSorted()
139
+ .filter((key) => data[key] !== undefined)
140
+ .map((key) => ` ${key}: ${JSON.stringify(data[key])},`);
141
+ return [
142
+ "// Generated by `blume translate` — edit the default locale's meta file",
143
+ "// and rerun the translation instead of editing this copy.",
144
+ "export default {",
145
+ ...lines,
146
+ "};",
147
+ "",
148
+ ].join("\n");
149
+ };
@@ -0,0 +1,95 @@
1
+ import type { LocaleConfig } from "../core/schema.ts";
2
+
3
+ /**
4
+ * The frontmatter key paths whose *values* an agent may translate. Everything
5
+ * else in the frontmatter is copied from the source verbatim by the validator,
6
+ * so this list is both the prompt's instruction and the reconciliation
7
+ * contract in `validate.ts`.
8
+ */
9
+ export const TRANSLATABLE_KEY_PATHS: readonly (readonly string[])[] = [
10
+ ["title"],
11
+ ["description"],
12
+ ["sidebar", "label"],
13
+ ["sidebar", "badge"],
14
+ ["seo", "title"],
15
+ ["seo", "description"],
16
+ ];
17
+
18
+ /** "French (fr)" — the configured display label plus the code. */
19
+ const localeName = (locale: LocaleConfig): string =>
20
+ `${locale.label} (${locale.code})`;
21
+
22
+ const KEY_LIST = TRANSLATABLE_KEY_PATHS.map((path) => path.join(".")).join(
23
+ ", "
24
+ );
25
+
26
+ /**
27
+ * The page-translation prompt. Delivered over stdin (no argv limits), so the
28
+ * full source file rides along inline. When the page was translated before,
29
+ * the previous translation rides along too: without it, every retranslation
30
+ * is a from-scratch rewrite in which the agent re-decides register, dialect,
31
+ * and terminology (du vs Sie, pt-BR vs pt-PT) and churns the whole page for
32
+ * a one-paragraph source edit.
33
+ */
34
+ export const pagePrompt = (
35
+ sourceText: string,
36
+ target: LocaleConfig,
37
+ source: LocaleConfig,
38
+ previousTranslation?: string
39
+ ): string => {
40
+ const styleRule =
41
+ target.style === undefined
42
+ ? ""
43
+ : `
44
+ - Write the translation in this style: ${target.style}`;
45
+ const previousRule =
46
+ previousTranslation === undefined
47
+ ? ""
48
+ : `
49
+ - A translation of an earlier revision of this page is included below. Match its register, formality, dialect, and terminology exactly; re-translate only what the changed source requires and keep everything else word-for-word identical.${
50
+ target.style === undefined
51
+ ? ""
52
+ : " Where the previous translation disagrees with the style rule above, the style rule wins."
53
+ }`;
54
+ const previousSection =
55
+ previousTranslation === undefined
56
+ ? ""
57
+ : `
58
+ The previous translation begins after this line and ends at the "page source" marker.
59
+ ${previousTranslation}`;
60
+ return `Translate the following documentation page from ${localeName(
61
+ source
62
+ )} into ${localeName(target)}.
63
+
64
+ Rules:
65
+ - Translate the prose: headings, paragraphs, list items, table cells, admonitions, and image alt text.
66
+ - In the YAML frontmatter, translate ONLY the values of these keys: ${KEY_LIST}. Copy every other frontmatter key and value exactly as written.
67
+ - Never translate or alter: code blocks, inline code, import/export statements, JSX/MDX component names and their attributes, URLs, link targets, HTML tags, or frontmatter keys.
68
+ - Preserve the document structure exactly: the same headings, the same lists and tables, and the same number of code fences.${styleRule}${previousRule}
69
+ - Output ONLY the complete translated file. Do not wrap it in a code fence. Do not add commentary before or after it.
70
+ ${previousSection}
71
+ The page source begins after this line.
72
+ ${sourceText}`;
73
+ };
74
+
75
+ /**
76
+ * The sidebar-titles prompt: a JSON object of `{directory: title}` in, the
77
+ * same keys with translated values out.
78
+ */
79
+ export const metaPrompt = (
80
+ titles: Record<string, string>,
81
+ target: LocaleConfig,
82
+ source: LocaleConfig
83
+ ): string => `Translate the following documentation sidebar section titles from ${localeName(
84
+ source
85
+ )} into ${localeName(target)}.
86
+
87
+ The input is a JSON object mapping a directory path to its title. Reply with ONLY a JSON object that has exactly the same keys, where each value is the title translated into ${localeName(
88
+ target
89
+ )}. Do not translate the keys. Do not add commentary.${
90
+ target.style === undefined
91
+ ? ""
92
+ : `\nWrite the titles in this style: ${target.style}`
93
+ }
94
+
95
+ ${JSON.stringify(titles, null, 2)}`;
@@ -0,0 +1,354 @@
1
+ import { colors } from "consola/utils";
2
+ import type { ColorFunction } from "consola/utils";
3
+
4
+ import { AGENTS } from "../audit/agent.ts";
5
+ import type { AgentKind } from "../audit/agent.ts";
6
+ import { countBySeverity } from "../core/diagnostics.ts";
7
+ import type { Diagnostic } from "../core/types.ts";
8
+ import type {
9
+ TranslateItemResult,
10
+ TranslateItemStatus,
11
+ TranslateResult,
12
+ } from "./run.ts";
13
+ import type {
14
+ MetaWorkEntry,
15
+ TranslateWorkList,
16
+ WorkItem,
17
+ WorkStatus,
18
+ } from "./work-list.ts";
19
+
20
+ /**
21
+ * The live progress UI, colored via consola's `colors` (which honors
22
+ * NO_COLOR/FORCE_COLOR and TTY detection), matching the eval report's palette.
23
+ * Render functions are pure; the renderer takes an injectable `write`/`now` so
24
+ * tests never touch a real TTY or clock. The command sends everything here to
25
+ * stderr via the raw `write` — never `logger.info`, which consola drops in
26
+ * test and CI environments.
27
+ */
28
+
29
+ const ESC = String.fromCodePoint(27);
30
+
31
+ const GLYPH: Record<TranslateItemStatus, string> = {
32
+ failed: "✖",
33
+ partial: "!",
34
+ translated: "✔",
35
+ };
36
+
37
+ const STATUS_COLOR: Record<TranslateItemStatus, ColorFunction> = {
38
+ failed: colors.red,
39
+ partial: colors.yellow,
40
+ translated: colors.green,
41
+ };
42
+
43
+ export const SPINNER_FRAMES = [
44
+ "⠋",
45
+ "⠙",
46
+ "⠹",
47
+ "⠸",
48
+ "⠼",
49
+ "⠴",
50
+ "⠦",
51
+ "⠧",
52
+ "⠇",
53
+ "⠏",
54
+ ];
55
+
56
+ /** How often the TTY spinner advances (one frame per interval). */
57
+ export const SPINNER_INTERVAL_MS = 80;
58
+
59
+ /** The clear-to-start-of-line prefix every TTY rewrite uses. */
60
+ const REWRITE = `\r${ESC}[K`;
61
+
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
+ /** `docs/guides/install.mdx → fr`, or the batched meta call's label. */
77
+ export const itemLabel = (item: WorkItem): string =>
78
+ item.kind === "page"
79
+ ? `${item.sourceRel} → ${item.locale}`
80
+ : `meta title${item.entries.length === 1 ? "" : "s"} (${item.entries.length}) → ${item.locale}`;
81
+
82
+ /**
83
+ * The in-flight spinner line a TTY rewrites in place: the oldest active
84
+ * item's label, how many more lanes are running, and the run's progress.
85
+ */
86
+ export const spinnerLine = (
87
+ active: WorkItem[],
88
+ done: number,
89
+ total: number,
90
+ frame: number
91
+ ): string => {
92
+ const first = active[0] as WorkItem;
93
+ const more = active.length > 1 ? ` (+${active.length - 1} more)` : "";
94
+ return ` ${colors.cyan(SPINNER_FRAMES[frame % SPINNER_FRAMES.length] as string)} ${itemLabel(first)}${more} ${colors.dim(`${done}/${total}`)}`;
95
+ };
96
+
97
+ /** The permanent line printed when an item finishes. */
98
+ export const itemEndLine = (result: TranslateItemResult): string => {
99
+ const color = STATUS_COLOR[result.status];
100
+ const glyph = color(GLYPH[result.status]);
101
+ const label = itemLabel(result.item);
102
+ if (result.status === "translated") {
103
+ const cells = [seconds(result.durationMs), money(result.costUsd)]
104
+ .filter((cell) => cell !== "")
105
+ .join(" ");
106
+ return ` ${glyph} ${label} ${colors.dim(cells)}`;
107
+ }
108
+ const word = result.status === "partial" ? "partial" : "failed";
109
+ return ` ${glyph} ${label} ${color(word)}${
110
+ result.detail ? colors.dim(`: ${result.detail}`) : ""
111
+ }`;
112
+ };
113
+
114
+ /** The header line the command prints before the first item runs. */
115
+ export const translateHeaderLine = (
116
+ itemCount: number,
117
+ localeCount: number,
118
+ agent: AgentKind
119
+ ): string =>
120
+ `${colors.bold("blume translate")} ${itemCount} item(s) · ${localeCount} locale(s) · ${AGENTS[agent].name}`;
121
+
122
+ /**
123
+ * `Translated 11 files into 2 locales · 1 failed · 2 adopted · 8 already up to
124
+ * date · 4m 12s · $0.41` (cost only when the agent reports one).
125
+ */
126
+ export const translateSummaryLine = (
127
+ result: TranslateResult,
128
+ workList: TranslateWorkList
129
+ ): string => {
130
+ const { counts } = result;
131
+ const parts = [
132
+ `Translated ${counts.translated} file${counts.translated === 1 ? "" : "s"} into ${workList.targetLocales.length} locale${workList.targetLocales.length === 1 ? "" : "s"}`,
133
+ counts.failed > 0 ? `${counts.failed} failed` : "",
134
+ counts.partial > 0 ? `${counts.partial} partial` : "",
135
+ workList.untracked.length > 0 ? `${workList.untracked.length} adopted` : "",
136
+ workList.upToDate > 0 ? `${workList.upToDate} already up to date` : "",
137
+ duration(result.durationMs),
138
+ money(result.costUsd),
139
+ ].filter((part) => part !== "");
140
+ return parts.join(" · ");
141
+ };
142
+
143
+ /** Dim warnings for work-list diagnostics (e.g. a factory-form meta file). */
144
+ export const diagnosticLines = (diagnostics: Diagnostic[]): string[] =>
145
+ diagnostics.map(
146
+ (diagnostic) => ` ${colors.yellow("⚠")} ${colors.dim(diagnostic.message)}`
147
+ );
148
+
149
+ /** Every (source, locale, status) drift row in a work list, pages then meta. */
150
+ const driftRows = (
151
+ workList: TranslateWorkList
152
+ ): { locale: string; sourceRel: string; status: WorkStatus }[] =>
153
+ workList.items.flatMap((item) =>
154
+ item.kind === "page"
155
+ ? [
156
+ {
157
+ locale: item.locale,
158
+ sourceRel: item.sourceRel,
159
+ status: item.status,
160
+ },
161
+ ]
162
+ : item.entries.map((entry: MetaWorkEntry) => ({
163
+ locale: item.locale,
164
+ sourceRel: entry.meta.sourceRel,
165
+ status: entry.status,
166
+ }))
167
+ );
168
+
169
+ /** One line per missing/stale pair, plus dim lines for untracked adoptions. */
170
+ export const checkLines = (workList: TranslateWorkList): string[] => [
171
+ ...driftRows(workList).map(
172
+ (row) =>
173
+ ` ${colors.red("✖")} ${row.sourceRel} → ${row.locale} ${colors.dim(row.status)}`
174
+ ),
175
+ ...workList.untracked.map(
176
+ (entry) =>
177
+ ` ${colors.dim(`⊘ ${entry.sourceRel} → ${entry.locale} untracked (adopted by the next translate run)`)}`
178
+ ),
179
+ ];
180
+
181
+ /** The `--check` totals: `2 missing · 1 stale · 1 untracked · 14 up to date`. */
182
+ export const checkSummaryLine = (workList: TranslateWorkList): string => {
183
+ const rows = driftRows(workList);
184
+ const missing = rows.filter((row) => row.status === "missing").length;
185
+ const stale = rows.filter((row) => row.status === "stale").length;
186
+ const parts = [
187
+ missing > 0 ? `${missing} missing` : "",
188
+ stale > 0 ? `${stale} stale` : "",
189
+ workList.untracked.length > 0
190
+ ? `${workList.untracked.length} untracked`
191
+ : "",
192
+ `${workList.upToDate} up to date`,
193
+ ].filter((part) => part !== "");
194
+ return parts.join(" · ");
195
+ };
196
+
197
+ /** Whether a work list fails the `--check` gate (untracked never does). */
198
+ export const hasDrift = (workList: TranslateWorkList): boolean =>
199
+ workList.items.length > 0;
200
+
201
+ /**
202
+ * The machine-readable `--check` report. The `diagnostics` + `summary` shape
203
+ * matches `blume validate/audit/eval --json` exactly, with the drift report
204
+ * under `translate`.
205
+ */
206
+ export const checkReportJson = (workList: TranslateWorkList): string => {
207
+ const locales: Record<
208
+ string,
209
+ { missing: string[]; stale: string[]; untracked: string[] }
210
+ > = {};
211
+ for (const locale of workList.targetLocales) {
212
+ locales[locale] = { missing: [], stale: [], untracked: [] };
213
+ }
214
+ for (const row of driftRows(workList)) {
215
+ locales[row.locale]?.[row.status].push(row.sourceRel);
216
+ }
217
+ for (const entry of workList.untracked) {
218
+ locales[entry.locale]?.untracked.push(entry.sourceRel);
219
+ }
220
+ return `${JSON.stringify(
221
+ {
222
+ diagnostics: workList.diagnostics,
223
+ summary: countBySeverity(workList.diagnostics),
224
+ translate: { locales, upToDate: workList.upToDate },
225
+ },
226
+ null,
227
+ 2
228
+ )}\n`;
229
+ };
230
+
231
+ /** One run result lowered to JSON-friendly, root-relative fields. */
232
+ const resultJson = (result: TranslateItemResult): Record<string, unknown> => ({
233
+ costUsd: result.costUsd,
234
+ detail: result.detail,
235
+ durationMs: result.durationMs,
236
+ kind: result.item.kind,
237
+ locale: result.item.locale,
238
+ status: result.status,
239
+ ...(result.item.kind === "page"
240
+ ? { source: result.item.sourceRel, target: result.item.targetRel }
241
+ : {
242
+ sources: result.item.entries.map((entry) => entry.meta.sourceRel),
243
+ }),
244
+ });
245
+
246
+ /** The machine-readable report for a translation run (`--json`). */
247
+ export const translateReportJson = (
248
+ result: TranslateResult,
249
+ workList: TranslateWorkList
250
+ ): string => {
251
+ const diagnostics = [...workList.diagnostics, ...result.diagnostics];
252
+ return `${JSON.stringify(
253
+ {
254
+ diagnostics,
255
+ summary: countBySeverity(diagnostics),
256
+ translate: {
257
+ adopted: workList.untracked.length,
258
+ agent: result.agent,
259
+ costUsd: result.costUsd,
260
+ counts: result.counts,
261
+ durationMs: result.durationMs,
262
+ results: result.results.map(resultJson),
263
+ upToDate: workList.upToDate,
264
+ },
265
+ },
266
+ null,
267
+ 2
268
+ )}\n`;
269
+ };
270
+
271
+ export interface ProgressRenderer {
272
+ onProgress: (
273
+ event:
274
+ | {
275
+ kind: "item-end";
276
+ index: number;
277
+ result: TranslateItemResult;
278
+ total: number;
279
+ }
280
+ | { kind: "item-start"; index: number; item: WorkItem; total: number }
281
+ ) => void;
282
+ stop: () => void;
283
+ }
284
+
285
+ /**
286
+ * Live progress: on a TTY the in-flight items render as one spinner line
287
+ * rewritten in place (`\r\x1B[K`) — a concurrent run shows the oldest active
288
+ * item plus a `(+n more)` count — and each completion prints its permanent
289
+ * line above it; off-TTY (CI) there is no interval and only the permanent
290
+ * per-item lines print.
291
+ */
292
+ export const createProgressRenderer = (options: {
293
+ isTTY: boolean;
294
+ write: (chunk: string) => void;
295
+ now?: () => number;
296
+ }): ProgressRenderer => {
297
+ const now = options.now ?? (() => performance.now());
298
+ let timer: ReturnType<typeof setInterval> | undefined;
299
+ const active = new Map<number, WorkItem>();
300
+ let done = 0;
301
+ let total = 0;
302
+ let startedAt = 0;
303
+
304
+ const paint = (): void => {
305
+ if (active.size > 0) {
306
+ const frame = Math.floor((now() - startedAt) / SPINNER_INTERVAL_MS);
307
+ options.write(
308
+ `${REWRITE}${spinnerLine([...active.values()], done, total, frame)}`
309
+ );
310
+ }
311
+ };
312
+ const clearTimer = (): void => {
313
+ if (timer) {
314
+ clearInterval(timer);
315
+ timer = undefined;
316
+ }
317
+ };
318
+
319
+ return {
320
+ onProgress(event) {
321
+ ({ total } = event);
322
+ if (event.kind === "item-start") {
323
+ active.set(event.index, event.item);
324
+ if (!options.isTTY) {
325
+ return;
326
+ }
327
+ if (!timer) {
328
+ startedAt = now();
329
+ timer = setInterval(paint, SPINNER_INTERVAL_MS);
330
+ timer.unref?.();
331
+ }
332
+ paint();
333
+ return;
334
+ }
335
+ active.delete(event.index);
336
+ done += 1;
337
+ const line = itemEndLine(event.result);
338
+ if (!options.isTTY) {
339
+ options.write(`${line}\n`);
340
+ return;
341
+ }
342
+ options.write(`${REWRITE}${line}\n`);
343
+ if (active.size > 0) {
344
+ paint();
345
+ } else {
346
+ clearTimer();
347
+ }
348
+ },
349
+ stop() {
350
+ clearTimer();
351
+ active.clear();
352
+ },
353
+ };
354
+ };