blume 1.3.1 → 1.4.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 (104) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/cli/index.js +3221 -201
  3. package/dist/cli/index.js.map +73 -61
  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 +1 -1
  21. package/src/ai/agent-readability.ts +7 -4
  22. package/src/ai/ask-context.ts +3 -6
  23. package/src/ai/mcp/data.ts +10 -4
  24. package/src/ai/mcp/server.ts +74 -3
  25. package/src/ai/mcp/tools.ts +2 -2
  26. package/src/astro/integration.ts +3 -1
  27. package/src/astro/markdown-negotiation.ts +5 -0
  28. package/src/astro/templates.ts +66 -18
  29. package/src/audit/url.ts +5 -10
  30. package/src/cli/commands/build.ts +129 -24
  31. package/src/cli/commands/translate.ts +300 -0
  32. package/src/cli/index.ts +2 -0
  33. package/src/components/Icon.astro +2 -7
  34. package/src/components/content/Step.astro +3 -8
  35. package/src/components/content/Tab.astro +20 -1
  36. package/src/components/layout/LanguageSwitcher.astro +2 -1
  37. package/src/components/layout/Logo.astro +4 -4
  38. package/src/components/layout/PageActions.astro +12 -7
  39. package/src/components/layout/Search.astro +15 -20
  40. package/src/components/layout/search/orama.ts +3 -1
  41. package/src/core/base-path.ts +9 -0
  42. package/src/core/config-input.ts +84 -6
  43. package/src/core/graph.ts +46 -2
  44. package/src/core/i18n-ui.ts +2 -0
  45. package/src/core/i18n.ts +31 -0
  46. package/src/core/nav-diagnostics.ts +13 -34
  47. package/src/core/project-graph.ts +13 -2
  48. package/src/core/schema.ts +174 -74
  49. package/src/core/sources/normalize.ts +25 -12
  50. package/src/core/sources/types.ts +5 -0
  51. package/src/core/types.ts +4 -3
  52. package/src/core/ui-packs/ar.ts +42 -1
  53. package/src/core/ui-packs/bg.ts +42 -1
  54. package/src/core/ui-packs/bn.ts +42 -1
  55. package/src/core/ui-packs/ca.ts +44 -1
  56. package/src/core/ui-packs/cs.ts +42 -1
  57. package/src/core/ui-packs/da.ts +42 -1
  58. package/src/core/ui-packs/de.ts +42 -1
  59. package/src/core/ui-packs/el.ts +44 -1
  60. package/src/core/ui-packs/es.ts +44 -1
  61. package/src/core/ui-packs/fa.ts +42 -1
  62. package/src/core/ui-packs/fi.ts +42 -1
  63. package/src/core/ui-packs/fr.ts +44 -1
  64. package/src/core/ui-packs/he.ts +42 -1
  65. package/src/core/ui-packs/hi.ts +42 -1
  66. package/src/core/ui-packs/hr.ts +42 -1
  67. package/src/core/ui-packs/hu.ts +42 -1
  68. package/src/core/ui-packs/id.ts +42 -1
  69. package/src/core/ui-packs/it.ts +44 -1
  70. package/src/core/ui-packs/ja.ts +44 -1
  71. package/src/core/ui-packs/ko.ts +44 -1
  72. package/src/core/ui-packs/nl.ts +42 -1
  73. package/src/core/ui-packs/no.ts +42 -1
  74. package/src/core/ui-packs/pl.ts +42 -1
  75. package/src/core/ui-packs/pt-br.ts +44 -1
  76. package/src/core/ui-packs/pt.ts +44 -1
  77. package/src/core/ui-packs/ro.ts +42 -1
  78. package/src/core/ui-packs/ru.ts +42 -1
  79. package/src/core/ui-packs/sk.ts +42 -1
  80. package/src/core/ui-packs/sr.ts +42 -1
  81. package/src/core/ui-packs/sv.ts +42 -1
  82. package/src/core/ui-packs/th.ts +44 -1
  83. package/src/core/ui-packs/tr.ts +42 -1
  84. package/src/core/ui-packs/uk.ts +42 -1
  85. package/src/core/ui-packs/vi.ts +44 -1
  86. package/src/core/ui-packs/zh-tw.ts +44 -1
  87. package/src/core/ui-packs/zh.ts +44 -1
  88. package/src/deploy/adapter-output.ts +44 -5
  89. package/src/deploy/cloudflare-negotiation.ts +527 -0
  90. package/src/deploy/redirects.ts +13 -0
  91. package/src/eval/agents.ts +1 -1
  92. package/src/search/documents.ts +11 -0
  93. package/src/search/facets.ts +33 -0
  94. package/src/search/orama-index.ts +48 -6
  95. package/src/search/popular-icon.ts +33 -0
  96. package/src/theme/icon-kind.ts +20 -0
  97. package/src/translate/agents.ts +51 -0
  98. package/src/translate/ledger.ts +148 -0
  99. package/src/translate/meta.ts +149 -0
  100. package/src/translate/prompts.ts +95 -0
  101. package/src/translate/report.ts +360 -0
  102. package/src/translate/run.ts +376 -0
  103. package/src/translate/validate.ts +171 -0
  104. package/src/translate/work-list.ts +0 -0
@@ -13,6 +13,10 @@ export interface OramaDoc {
13
13
  title: string;
14
14
  /** Locale code; indexed as an enum so queries can filter to one language. */
15
15
  locale?: string;
16
+ /** Resolved page `type`; indexed as an enum so queries can filter by type. */
17
+ contentType?: string;
18
+ /** Declared facet values (`content.types.<type>.facets`), key → value. */
19
+ facets?: Record<string, string>;
16
20
  /** Carried through for the search dialog's breadcrumb + filter pills. Stored
17
21
  * but not indexed, so they ride along on the returned document untouched. */
18
22
  breadcrumb?: string[];
@@ -21,13 +25,22 @@ export interface OramaDoc {
21
25
 
22
26
  const SCHEMA = {
23
27
  content: "string",
28
+ // Enums (not full-text "string") so `where` does an exact-match filter.
29
+ contentType: "enum",
24
30
  description: "string",
25
- // Enum (not full-text "string") so `where` does an exact-match filter.
31
+ // Facets, flattened to `key:value` terms — enum[] so one static schema
32
+ // serves every project's facet keys, with `containsAll` matching a filter
33
+ // set. Derived from `facets` at insert time.
34
+ facetTerms: "enum[]",
26
35
  locale: "enum",
27
36
  route: "string",
28
37
  title: "string",
29
38
  } as const;
30
39
 
40
+ /** Flatten a facet map to the `key:value` terms the `facetTerms` enum holds. */
41
+ const toFacetTerms = (facets: Record<string, string>): string[] =>
42
+ Object.entries(facets).map(([key, value]) => `${key}:${value}`);
43
+
31
44
  /** Title and description outrank body text, matching the search dialog. */
32
45
  const BOOST = { description: 2, title: 3 };
33
46
 
@@ -169,17 +182,36 @@ export const buildOramaIndex = async (
169
182
  schema: SCHEMA,
170
183
  ...(tokenizer ? { components: { tokenizer } } : {}),
171
184
  });
172
- await insertMultiple(db, documents);
185
+ await insertMultiple(
186
+ db,
187
+ documents.map((doc) =>
188
+ doc.facets ? { ...doc, facetTerms: toFacetTerms(doc.facets) } : doc
189
+ )
190
+ );
173
191
  return db;
174
192
  };
175
193
 
176
194
  /** Orama keeps only documents matching every token at a threshold of 0. */
177
195
  const ALL_TOKENS = 0;
178
196
 
197
+ /** Optional exact-match filters applied to a query via Orama's `where`. */
198
+ export interface OramaQueryFilters {
199
+ /** Keep only documents whose `contentType` is in this list. */
200
+ contentTypes?: string[];
201
+ /**
202
+ * Keep only documents matching every facet, key → required value. Facet
203
+ * keys and values come from the `facets` field on the indexed documents.
204
+ */
205
+ facets?: Record<string, string>;
206
+ /** Keep only documents in this locale. */
207
+ locale?: string;
208
+ }
209
+
179
210
  /**
180
211
  * Query the index, returning the matching documents (highest-ranked first).
181
- * When `locale` is given, results are filtered to that language via an exact
182
- * `where` match on the `locale` enum.
212
+ * `filters` narrows results by exact `where` matches on the enum fields:
213
+ * `locale` to one language, `contentTypes` to a set of page types, `facets`
214
+ * to documents carrying every requested `key:value` term.
183
215
  *
184
216
  * On a bigrammed index the strict pass runs first: a term is only meant to
185
217
  * match where its bigrams sit together, and scoring them independently lets a
@@ -191,14 +223,24 @@ export const queryOramaIndex = async (
191
223
  db: AnyOrama,
192
224
  term: string,
193
225
  limit: number,
194
- locale?: string
226
+ filters?: OramaQueryFilters
195
227
  ): Promise<OramaDoc[]> => {
228
+ const facetTerms = filters?.facets ? toFacetTerms(filters.facets) : [];
229
+ const where = {
230
+ ...(filters?.locale ? { locale: { eq: filters.locale } } : {}),
231
+ ...(filters?.contentTypes && filters.contentTypes.length > 0
232
+ ? { contentType: { in: filters.contentTypes } }
233
+ : {}),
234
+ ...(facetTerms.length > 0
235
+ ? { facetTerms: { containsAll: facetTerms } }
236
+ : {}),
237
+ };
196
238
  const params = {
197
239
  boost: BOOST,
198
240
  limit,
199
241
  properties: ["title", "description", "content"],
200
242
  term,
201
- ...(locale ? { where: { locale: { eq: locale } } } : {}),
243
+ ...(Object.keys(where).length > 0 ? { where } : {}),
202
244
  };
203
245
  const bigrammed = BIGRAM_LANGUAGES.has(db.tokenizer?.language ?? "");
204
246
  const strict = bigrammed
@@ -0,0 +1,33 @@
1
+ import { prefixBase } from "../components/islands/base-path.ts";
2
+ import { isImageIcon, isInlineSvg } from "../theme/icon-kind.ts";
3
+ import { resolveIcon } from "../theme/icons.ts";
4
+
5
+ /**
6
+ * Resolve a `search.popular` icon to markup for the Cmd+K island. Accepts the
7
+ * same *inputs* as nav `<Icon>` (built-in name, image path/URL, inline SVG);
8
+ * resolved on the server so the island never loads the icon set.
9
+ */
10
+ export const resolvePopularIconMarkup = (
11
+ icon?: string,
12
+ /** `deployment.base` / `import.meta.env.BASE_URL` for root-relative image paths. */
13
+ base = "/"
14
+ ): string | undefined => {
15
+ if (!icon) {
16
+ return undefined;
17
+ }
18
+ if (isInlineSvg(icon)) {
19
+ // Author SVG carries no guaranteed width/height (a viewBox-only <svg>
20
+ // defaults to 300x150), so size it the same way Icon.astro's wrapper does.
21
+ return `<span aria-hidden="true" style="display:inline-flex;width:16px;height:16px">${icon.trim()}</span>`;
22
+ }
23
+ if (isImageIcon(icon)) {
24
+ const src = prefixBase(base, icon.trim())
25
+ .replaceAll("&", "&amp;")
26
+ .replaceAll('"', "&quot;");
27
+ return `<img src="${src}" width="16" height="16" alt="" aria-hidden="true" class="size-4" />`;
28
+ }
29
+ const resolved = resolveIcon(icon);
30
+ return resolved
31
+ ? `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="${resolved.viewBox}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${resolved.body}</svg>`
32
+ : undefined;
33
+ };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Classify author-supplied icon strings — built-in Lucide names vs image
3
+ * paths/URLs vs inline SVG. Shared by nav diagnostics, `<Icon>`, and the
4
+ * Cmd+K popular-icon resolver so the three shapes stay in sync.
5
+ */
6
+
7
+ const IMAGE_ICON =
8
+ /^(?:https?:\/\/|data:image\/|\/|\.{1,2}\/)|\.(?:avif|gif|jpe?g|png|svg|webp)$/iu;
9
+
10
+ const INLINE_SVG = /^\s*<svg[\s\S]*<\/svg>\s*$/u;
11
+
12
+ /** Image path, remote URL, or data URI — not a Lucide name or inline SVG. */
13
+ export const isImageIcon = (value: string): boolean => IMAGE_ICON.test(value);
14
+
15
+ /** Full `<svg>…</svg>` markup (whitespace-tolerant). */
16
+ export const isInlineSvg = (value: string): boolean => INLINE_SVG.test(value);
17
+
18
+ /** Image or inline SVG — anything that is not a built-in icon name. */
19
+ export const isAssetIcon = (value: string): boolean =>
20
+ isInlineSvg(value) || isImageIcon(value);
@@ -0,0 +1,51 @@
1
+ import type { AgentKind } from "../audit/agent.ts";
2
+ import { DISALLOWED_TOOLS } from "../eval/agents.ts";
3
+
4
+ /**
5
+ * argv builders for the translator role. The subprocess machinery
6
+ * (`runAgentHeadless`, `readAgentOutput`, the `HeadlessRunner` test seam) is
7
+ * shared with `blume eval` — only the argument surface differs: a translator
8
+ * is a pure text→text call with no MCP servers and no tools at all, so the
9
+ * agent can neither read the repo nor write files (Blume owns every write).
10
+ */
11
+
12
+ /**
13
+ * Wall-clock ceiling per file. Generous by design: translating a large page
14
+ * means regenerating the whole file token by token, and a 20KB+ reference
15
+ * page comfortably exceeds the eval reader's 3-minute precedent. The ceiling
16
+ * exists to catch hung agents, not to police slow-but-progressing ones.
17
+ */
18
+ export const DEFAULT_TRANSLATE_TIMEOUT_MS = 600_000;
19
+
20
+ const claudeArgs = (): string[] => [
21
+ "-p",
22
+ "--output-format",
23
+ "json",
24
+ "--strict-mcp-config",
25
+ "--disallowedTools",
26
+ DISALLOWED_TOOLS.join(","),
27
+ "--max-turns",
28
+ "1",
29
+ ];
30
+
31
+ const codexArgs = (lastMessagePath: string): string[] => [
32
+ "exec",
33
+ "--skip-git-repo-check",
34
+ "--ignore-user-config",
35
+ "--ephemeral",
36
+ "--sandbox",
37
+ "read-only",
38
+ "--output-last-message",
39
+ lastMessagePath,
40
+ "-",
41
+ ];
42
+
43
+ /**
44
+ * Build the argv for one headless translation call. `lastMessagePath` is where
45
+ * codex writes its final message (its stdout interleaves progress); claude
46
+ * ignores it and answers as JSON on stdout.
47
+ */
48
+ export const translateAgentArgs = (
49
+ kind: AgentKind,
50
+ lastMessagePath: string
51
+ ): string[] => (kind === "claude" ? claudeArgs() : codexArgs(lastMessagePath));
@@ -0,0 +1,148 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+
4
+ import { dirname, join } from "pathe";
5
+ import { z } from "zod";
6
+
7
+ /**
8
+ * The committed translation ledger: which source files have been translated
9
+ * into which locales, and at what source content. Named "ledger" to avoid
10
+ * colliding with the route manifest (`core/manifest.ts`). It lives at the
11
+ * project root — never inside `.blume/` (init gitignores that dir wholesale,
12
+ * and the whole point is that the ledger is committed alongside the docs).
13
+ */
14
+ export const LEDGER_FILE = "blume.translations.json";
15
+
16
+ /**
17
+ * `files` maps a POSIX root-relative source path to, per locale, the hash of
18
+ * the raw source text at the moment that locale's translation was written.
19
+ */
20
+ export interface TranslationLedger {
21
+ version: 1;
22
+ files: Record<string, Record<string, string>>;
23
+ }
24
+
25
+ const ledgerSchema = z.object({
26
+ files: z.record(z.string(), z.record(z.string(), z.string())),
27
+ version: z.literal(1),
28
+ });
29
+
30
+ export const emptyLedger = (): TranslationLedger => ({
31
+ files: {},
32
+ version: 1,
33
+ });
34
+
35
+ /**
36
+ * Hash the raw source text (frontmatter included), so any edit invalidates
37
+ * every locale's stamp. sha256-16 like the audit snapshot's content hash —
38
+ * never `hashText` (djb2), which is an ephemeral-cache-only hash.
39
+ */
40
+ export const hashSource = (text: string): string =>
41
+ createHash("sha256").update(text).digest("hex").slice(0, 16);
42
+
43
+ /**
44
+ * Read the ledger at `root`, tolerantly: a missing file, unparseable JSON, or
45
+ * an unknown shape/version all resolve to an empty ledger rather than an error
46
+ * (same posture as the dev lock's `parseLock`) — the worst outcome of a
47
+ * corrupt ledger is retranslating files that were already up to date.
48
+ */
49
+ export const readLedger = async (root: string): Promise<TranslationLedger> => {
50
+ let raw: string;
51
+ try {
52
+ raw = await readFile(join(root, LEDGER_FILE), "utf-8");
53
+ } catch {
54
+ return emptyLedger();
55
+ }
56
+ let data: unknown;
57
+ try {
58
+ data = JSON.parse(raw);
59
+ } catch {
60
+ return emptyLedger();
61
+ }
62
+ const parsed = ledgerSchema.safeParse(data);
63
+ return parsed.success ? parsed.data : emptyLedger();
64
+ };
65
+
66
+ /** Deterministic serialization: keys sorted at both levels, 2-space indent. */
67
+ export const serializeLedger = (ledger: TranslationLedger): string => {
68
+ const files: Record<string, Record<string, string>> = {};
69
+ for (const source of Object.keys(ledger.files).toSorted()) {
70
+ const locales = ledger.files[source] ?? {};
71
+ files[source] = Object.fromEntries(
72
+ Object.keys(locales)
73
+ .toSorted()
74
+ .map((locale) => [locale, locales[locale] ?? ""])
75
+ );
76
+ }
77
+ return `${JSON.stringify({ files, version: ledger.version }, null, 2)}\n`;
78
+ };
79
+
80
+ /**
81
+ * Write the ledger at `root`, returning whether anything changed on disk. A
82
+ * byte-identical ledger is left untouched (no mtime churn, no git noise);
83
+ * a changed one lands via temp-file-plus-rename so a concurrent reader never
84
+ * observes a half-written file.
85
+ */
86
+ export const writeLedger = async (
87
+ root: string,
88
+ ledger: TranslationLedger
89
+ ): Promise<boolean> => {
90
+ const path = join(root, LEDGER_FILE);
91
+ const content = serializeLedger(ledger);
92
+ let existing: string | null = null;
93
+ try {
94
+ existing = await readFile(path, "utf-8");
95
+ } catch {
96
+ existing = null;
97
+ }
98
+ if (existing === content) {
99
+ return false;
100
+ }
101
+ await mkdir(dirname(path), { recursive: true });
102
+ const tmp = `${path}.${process.pid}.tmp`;
103
+ await writeFile(tmp, content, "utf-8");
104
+ try {
105
+ await rename(tmp, path);
106
+ } catch (error) {
107
+ await rm(tmp, { force: true });
108
+ throw error;
109
+ }
110
+ return true;
111
+ };
112
+
113
+ /** Record that `sourceRel` is translated into `locale` at source hash `hash`. */
114
+ export const stampLedger = (
115
+ ledger: TranslationLedger,
116
+ sourceRel: string,
117
+ locale: string,
118
+ hash: string
119
+ ): void => {
120
+ const locales = ledger.files[sourceRel] ?? {};
121
+ locales[locale] = hash;
122
+ ledger.files[sourceRel] = locales;
123
+ };
124
+
125
+ /**
126
+ * Drop entries for sources that no longer exist and locales that are no longer
127
+ * configured, so deleted pages and removed locales don't linger in the ledger
128
+ * forever. Returns a new ledger.
129
+ */
130
+ export const pruneLedger = (
131
+ ledger: TranslationLedger,
132
+ knownSources: ReadonlySet<string>,
133
+ knownLocales: ReadonlySet<string>
134
+ ): TranslationLedger => {
135
+ const files: Record<string, Record<string, string>> = {};
136
+ for (const [source, locales] of Object.entries(ledger.files)) {
137
+ if (!knownSources.has(source)) {
138
+ continue;
139
+ }
140
+ const kept = Object.fromEntries(
141
+ Object.entries(locales).filter(([locale]) => knownLocales.has(locale))
142
+ );
143
+ if (Object.keys(kept).length > 0) {
144
+ files[source] = kept;
145
+ }
146
+ }
147
+ return { files, version: ledger.version };
148
+ };
@@ -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)}`;