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.
- package/CHANGELOG.md +50 -0
- package/dist/cli/index.js +3221 -201
- package/dist/cli/index.js.map +73 -61
- package/dist/types/core/base-path.d.ts +5 -0
- package/dist/types/core/config-input.d.ts +82 -6
- package/dist/types/core/i18n-ui.d.ts +2 -0
- package/dist/types/core/schema.d.ts +19 -2
- package/dist/types/core/sources/types.d.ts +5 -0
- package/dist/types/core/types.d.ts +4 -3
- package/docs/02-deployment.mdx +1 -1
- package/docs/configuration/ai.mdx +15 -1
- package/docs/configuration/index.mdx +26 -0
- package/docs/configuration/search.mdx +1 -3
- package/docs/content/i18n.mdx +13 -1
- package/docs/content/navigation.mdx +11 -0
- package/docs/reference/cli.mdx +4 -0
- package/docs/reference/frontmatter.mdx +33 -0
- package/docs/reference/meta.ts +1 -1
- package/docs/reference/translate.mdx +80 -0
- package/package.json +1 -1
- package/src/ai/agent-readability.ts +7 -4
- package/src/ai/ask-context.ts +3 -6
- package/src/ai/mcp/data.ts +10 -4
- package/src/ai/mcp/server.ts +74 -3
- package/src/ai/mcp/tools.ts +2 -2
- package/src/astro/integration.ts +3 -1
- package/src/astro/markdown-negotiation.ts +5 -0
- package/src/astro/templates.ts +66 -18
- package/src/audit/url.ts +5 -10
- package/src/cli/commands/build.ts +129 -24
- package/src/cli/commands/translate.ts +300 -0
- package/src/cli/index.ts +2 -0
- package/src/components/Icon.astro +2 -7
- package/src/components/content/Step.astro +3 -8
- package/src/components/content/Tab.astro +20 -1
- package/src/components/layout/LanguageSwitcher.astro +2 -1
- package/src/components/layout/Logo.astro +4 -4
- package/src/components/layout/PageActions.astro +12 -7
- package/src/components/layout/Search.astro +15 -20
- package/src/components/layout/search/orama.ts +3 -1
- package/src/core/base-path.ts +9 -0
- package/src/core/config-input.ts +84 -6
- package/src/core/graph.ts +46 -2
- package/src/core/i18n-ui.ts +2 -0
- package/src/core/i18n.ts +31 -0
- package/src/core/nav-diagnostics.ts +13 -34
- package/src/core/project-graph.ts +13 -2
- package/src/core/schema.ts +174 -74
- package/src/core/sources/normalize.ts +25 -12
- package/src/core/sources/types.ts +5 -0
- package/src/core/types.ts +4 -3
- package/src/core/ui-packs/ar.ts +42 -1
- package/src/core/ui-packs/bg.ts +42 -1
- package/src/core/ui-packs/bn.ts +42 -1
- package/src/core/ui-packs/ca.ts +44 -1
- package/src/core/ui-packs/cs.ts +42 -1
- package/src/core/ui-packs/da.ts +42 -1
- package/src/core/ui-packs/de.ts +42 -1
- package/src/core/ui-packs/el.ts +44 -1
- package/src/core/ui-packs/es.ts +44 -1
- package/src/core/ui-packs/fa.ts +42 -1
- package/src/core/ui-packs/fi.ts +42 -1
- package/src/core/ui-packs/fr.ts +44 -1
- package/src/core/ui-packs/he.ts +42 -1
- package/src/core/ui-packs/hi.ts +42 -1
- package/src/core/ui-packs/hr.ts +42 -1
- package/src/core/ui-packs/hu.ts +42 -1
- package/src/core/ui-packs/id.ts +42 -1
- package/src/core/ui-packs/it.ts +44 -1
- package/src/core/ui-packs/ja.ts +44 -1
- package/src/core/ui-packs/ko.ts +44 -1
- package/src/core/ui-packs/nl.ts +42 -1
- package/src/core/ui-packs/no.ts +42 -1
- package/src/core/ui-packs/pl.ts +42 -1
- package/src/core/ui-packs/pt-br.ts +44 -1
- package/src/core/ui-packs/pt.ts +44 -1
- package/src/core/ui-packs/ro.ts +42 -1
- package/src/core/ui-packs/ru.ts +42 -1
- package/src/core/ui-packs/sk.ts +42 -1
- package/src/core/ui-packs/sr.ts +42 -1
- package/src/core/ui-packs/sv.ts +42 -1
- package/src/core/ui-packs/th.ts +44 -1
- package/src/core/ui-packs/tr.ts +42 -1
- package/src/core/ui-packs/uk.ts +42 -1
- package/src/core/ui-packs/vi.ts +44 -1
- package/src/core/ui-packs/zh-tw.ts +44 -1
- package/src/core/ui-packs/zh.ts +44 -1
- package/src/deploy/adapter-output.ts +44 -5
- package/src/deploy/cloudflare-negotiation.ts +527 -0
- package/src/deploy/redirects.ts +13 -0
- package/src/eval/agents.ts +1 -1
- package/src/search/documents.ts +11 -0
- package/src/search/facets.ts +33 -0
- package/src/search/orama-index.ts +48 -6
- package/src/search/popular-icon.ts +33 -0
- package/src/theme/icon-kind.ts +20 -0
- package/src/translate/agents.ts +51 -0
- package/src/translate/ledger.ts +148 -0
- package/src/translate/meta.ts +149 -0
- package/src/translate/prompts.ts +95 -0
- package/src/translate/report.ts +360 -0
- package/src/translate/run.ts +376 -0
- package/src/translate/validate.ts +171 -0
- 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
|
-
//
|
|
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(
|
|
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
|
-
*
|
|
182
|
-
* `
|
|
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
|
-
|
|
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
|
-
...(
|
|
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("&", "&")
|
|
26
|
+
.replaceAll('"', """);
|
|
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)}`;
|