localewarden 0.2.0 → 0.3.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 (53) hide show
  1. package/README.md +108 -4
  2. package/dist/budget.d.ts +25 -0
  3. package/dist/budget.js +74 -0
  4. package/dist/checks.d.ts +15 -4
  5. package/dist/checks.js +17 -3
  6. package/dist/cli.js +44 -9
  7. package/dist/config.d.ts +27 -1
  8. package/dist/config.js +105 -3
  9. package/dist/engine/context.d.ts +81 -0
  10. package/dist/engine/context.js +75 -0
  11. package/dist/engine/copies.d.ts +9 -0
  12. package/dist/engine/copies.js +31 -0
  13. package/dist/engine/planner.d.ts +66 -0
  14. package/dist/engine/planner.js +167 -0
  15. package/dist/engine/repair.d.ts +16 -0
  16. package/dist/engine/repair.js +61 -0
  17. package/dist/engine/sources.d.ts +13 -0
  18. package/dist/engine/sources.js +40 -0
  19. package/dist/engine/translator.d.ts +29 -0
  20. package/dist/engine/translator.js +156 -0
  21. package/dist/engine/writer.d.ts +13 -0
  22. package/dist/engine/writer.js +63 -0
  23. package/dist/files.d.ts +9 -0
  24. package/dist/files.js +76 -14
  25. package/dist/index.d.ts +6 -3
  26. package/dist/index.js +6 -3
  27. package/dist/llm.d.ts +5 -8
  28. package/dist/llm.js +7 -15
  29. package/dist/lock.d.ts +13 -0
  30. package/dist/lock.js +91 -0
  31. package/dist/output.d.ts +21 -0
  32. package/dist/output.js +76 -0
  33. package/dist/plugins.d.ts +72 -0
  34. package/dist/plugins.js +71 -0
  35. package/dist/project.d.ts +18 -5
  36. package/dist/project.js +70 -57
  37. package/dist/prompt.d.ts +3 -1
  38. package/dist/prompt.js +2 -2
  39. package/dist/review.d.ts +1 -1
  40. package/dist/review.js +41 -35
  41. package/dist/state.d.ts +10 -3
  42. package/dist/state.js +30 -11
  43. package/dist/translate.d.ts +9 -49
  44. package/dist/translate.js +118 -429
  45. package/dist/ui/data.d.ts +50 -0
  46. package/dist/ui/data.js +178 -0
  47. package/dist/ui/page.d.ts +5 -0
  48. package/dist/ui/page.js +277 -0
  49. package/dist/ui/server.d.ts +24 -0
  50. package/dist/ui/server.js +194 -0
  51. package/dist/util.d.ts +6 -2
  52. package/dist/util.js +17 -5
  53. package/package.json +1 -1
package/dist/lock.js ADDED
@@ -0,0 +1,91 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ export class LockError extends Error {
5
+ }
6
+ const alive = (pid) => {
7
+ try {
8
+ process.kill(pid, 0);
9
+ return true;
10
+ }
11
+ catch (error) {
12
+ // EPERM: the process exists but belongs to someone else.
13
+ return error.code === 'EPERM';
14
+ }
15
+ };
16
+ const readHolder = (file) => {
17
+ try {
18
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ };
24
+ /**
25
+ * Takes <stateDir>/run.lock so two runs (say, CI and a local run), or a run and an edit in the
26
+ * web interface, cannot write the same state at once. Only one holder at a time, including
27
+ * within one process. A lock is stale when its process no longer exists on this machine, or
28
+ * when it was written on another machine (a lock file committed by mistake); a stale lock is
29
+ * taken over with an atomic rename, so two processes cannot both take it. Returns the
30
+ * function that releases it.
31
+ */
32
+ export function acquireLock(stateDir) {
33
+ const file = path.join(stateDir, 'run.lock');
34
+ fs.mkdirSync(stateDir, { recursive: true });
35
+ const me = { pid: process.pid, host: os.hostname(), started: new Date().toISOString() };
36
+ for (let attempt = 0; attempt < 3; attempt++) {
37
+ try {
38
+ const fd = fs.openSync(file, 'wx');
39
+ fs.writeSync(fd, JSON.stringify(me));
40
+ fs.closeSync(fd);
41
+ let released = false;
42
+ return () => {
43
+ if (released)
44
+ return;
45
+ released = true;
46
+ const holder = readHolder(file);
47
+ if (holder && holder.pid === me.pid && holder.started === me.started)
48
+ fs.rmSync(file, { force: true });
49
+ };
50
+ }
51
+ catch (error) {
52
+ if (error.code !== 'EEXIST')
53
+ throw error;
54
+ const holder = readHolder(file);
55
+ const sameHost = !holder?.host || holder.host === os.hostname();
56
+ if (holder && holder.pid && sameHost && alive(holder.pid)) {
57
+ throw new LockError(`Another localewarden run is active (pid ${holder.pid}, started ${holder.started ?? 'at an unknown time'}). ` +
58
+ `Wait for it to finish; if no run is active, delete ${file}.`);
59
+ }
60
+ // Stale: move it aside atomically. If another process got there first, the rename fails
61
+ // and the next attempt sees their fresh lock.
62
+ const aside = `${file}.stale-${process.pid}-${attempt}`;
63
+ try {
64
+ fs.renameSync(file, aside);
65
+ const moved = readHolder(aside);
66
+ if (moved && holder && (moved.pid !== holder.pid || moved.started !== holder.started)) {
67
+ // We moved someone's fresh lock: put it back and stop.
68
+ fs.renameSync(aside, file);
69
+ throw new LockError(`Another localewarden run just started (pid ${moved.pid}).`);
70
+ }
71
+ fs.rmSync(aside, { force: true });
72
+ }
73
+ catch (renameError) {
74
+ if (renameError instanceof LockError)
75
+ throw renameError;
76
+ // ENOENT: someone else took it over; try again.
77
+ }
78
+ }
79
+ }
80
+ throw new LockError(`Could not take the lock ${file}.`);
81
+ }
82
+ /** Runs `fn` while holding the lock (for short writes like review approvals and edits). */
83
+ export function withLock(stateDir, fn) {
84
+ const release = acquireLock(stateDir);
85
+ try {
86
+ return fn();
87
+ }
88
+ finally {
89
+ release();
90
+ }
91
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Cleaning model answers. Models sometimes wrap a translation in commentary ("Here is the
3
+ * translation:"), code fences or quotes. That is stripped before the checks run; anything
4
+ * left over is caught by them.
5
+ */
6
+ /** A model answer with lead-in commentary, code fences and wrapping quotes removed. */
7
+ export declare function cleanOutput(source: string, text: string): string;
8
+ /**
9
+ * A JSON array of `expected` elements from a model answer, or null. An element that is not a
10
+ * string or number (null, an object) is returned as null: that string is translated again on
11
+ * its own instead of becoming the text "null".
12
+ */
13
+ export declare function parseArray(raw: string, expected: number): (string | null)[] | null;
14
+ /**
15
+ * Splits a long text into chunks of at most `max` characters at paragraph breaks (or line
16
+ * breaks, if a paragraph is longer). The separators are kept so joining restores the layout.
17
+ */
18
+ export declare function splitLongText(text: string, max: number): {
19
+ parts: string[];
20
+ separators: string[];
21
+ };
package/dist/output.js ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Cleaning model answers. Models sometimes wrap a translation in commentary ("Here is the
3
+ * translation:"), code fences or quotes. That is stripped before the checks run; anything
4
+ * left over is caught by them.
5
+ */
6
+ // "Here is the translation:" anywhere at the start; a bare "Translation:" only when it stands on
7
+ // its own line, so a string that really starts with "Translation: …" is left alone.
8
+ const LEAD_IN = /^\s*(?:(?:sure|certainly|of course)[,!.]?\s*)?(?:here(?:'s| is) (?:the|your|my) (?:translation|translated text)(?: in [^:\n]+)?\s*:\s*\n*|(?:translation|translated text)\s*:[ \t]*\n+)/i;
9
+ /** A model answer with lead-in commentary, code fences and wrapping quotes removed. */
10
+ export function cleanOutput(source, text) {
11
+ let out = text.trim();
12
+ const fenced = /^```[\w-]*\n([\s\S]*?)\n```$/.exec(out);
13
+ if (fenced && !source.includes('```'))
14
+ out = fenced[1].trim();
15
+ // Never strip what the source itself says.
16
+ if (!LEAD_IN.test(source))
17
+ out = out.replace(LEAD_IN, '');
18
+ const quoted = /^(["“«„「])([\s\S]*)(["”»“」])$/.exec(out);
19
+ const sourceQuoted = /^\s*["“«„「]/.test(source) && /["”»“」]\s*$/.test(source);
20
+ if (quoted && !sourceQuoted && !quoted[2].includes(quoted[1]))
21
+ out = quoted[2].trim();
22
+ return out;
23
+ }
24
+ /**
25
+ * A JSON array of `expected` elements from a model answer, or null. An element that is not a
26
+ * string or number (null, an object) is returned as null: that string is translated again on
27
+ * its own instead of becoming the text "null".
28
+ */
29
+ export function parseArray(raw, expected) {
30
+ const json = raw.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
31
+ try {
32
+ const parsed = JSON.parse(json);
33
+ if (!Array.isArray(parsed) || parsed.length !== expected)
34
+ return null;
35
+ return parsed.map(value => (typeof value === 'string' ? value : typeof value === 'number' ? String(value) : null));
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ /**
42
+ * Splits a long text into chunks of at most `max` characters at paragraph breaks (or line
43
+ * breaks, if a paragraph is longer). The separators are kept so joining restores the layout.
44
+ */
45
+ export function splitLongText(text, max) {
46
+ // Paragraph breaks first; a single paragraph longer than `max` is split at line breaks.
47
+ const byParagraph = text.split(/(\n\s*\n)/);
48
+ const pieces = [];
49
+ for (let i = 0; i < byParagraph.length; i += 2) {
50
+ const paragraph = byParagraph[i];
51
+ const lines = paragraph.length > max ? paragraph.split(/(\n)/) : [paragraph];
52
+ pieces.push(...lines);
53
+ if (i + 1 < byParagraph.length)
54
+ pieces.push(byParagraph[i + 1]);
55
+ }
56
+ const parts = [];
57
+ const separators = [];
58
+ let current = '';
59
+ let pendingSeparator = '';
60
+ for (let i = 0; i < pieces.length; i += 2) {
61
+ const paragraph = pieces[i];
62
+ const separator = pieces[i + 1] ?? '';
63
+ if (current && (current + pendingSeparator + paragraph).length > max) {
64
+ parts.push(current);
65
+ separators.push(pendingSeparator);
66
+ current = paragraph;
67
+ }
68
+ else {
69
+ current = current ? current + pendingSeparator + paragraph : paragraph;
70
+ }
71
+ pendingSeparator = separator;
72
+ }
73
+ if (current)
74
+ parts.push(current);
75
+ return { parts, separators };
76
+ }
@@ -0,0 +1,72 @@
1
+ import type { Config } from './config.js';
2
+ /**
3
+ * Plugins add project rules without forking localewarden: extra checks, prompt notes,
4
+ * post-processing of model output, and the order in which files are translated.
5
+ * A plugin is an ES module whose default export is a plugin object, or a function that
6
+ * receives the options from the config and returns one.
7
+ *
8
+ * "plugins": ["./tools/my-rules.mjs", { "module": "./tools/blog.mjs", "options": { "today": "2026-10-09" } }]
9
+ *
10
+ * This interface is experimental in 0.x: it may change in a minor version.
11
+ */
12
+ export interface StringContext {
13
+ lang: string;
14
+ key: string;
15
+ /** File id: the path pattern with {lang}, e.g. "locales/{lang}/common.json". */
16
+ file: string;
17
+ source: string;
18
+ text: string;
19
+ }
20
+ export interface PluginIssue {
21
+ /** Check name, shown in `localewarden check` (e.g. "regional-term"). */
22
+ check: string;
23
+ note?: string;
24
+ /** "error" blocks writing a translation and fails `check`; default "warning". */
25
+ severity?: 'error' | 'warning';
26
+ /** May `--fix-flagged` ask the model to fix it? Default false. */
27
+ fixable?: boolean;
28
+ }
29
+ export interface FileInfo {
30
+ /** File id with {lang}. */
31
+ id: string;
32
+ /** Path of the source-language file, relative to the project. */
33
+ sourcePath: string;
34
+ }
35
+ export interface Plugin {
36
+ name: string;
37
+ /** Extra findings for one translated string. */
38
+ checks?(ctx: StringContext): PluginIssue[] | void;
39
+ /** Extra prompt text for a batch of strings (e.g. meanings of terms they contain). */
40
+ promptNotes?(ctx: {
41
+ lang: string;
42
+ items: {
43
+ key: string;
44
+ source: string;
45
+ }[];
46
+ }): string | void;
47
+ /** Rewrites a model answer before it is checked and written (e.g. number formats). */
48
+ postProcess?(ctx: StringContext): string | void;
49
+ /** Reorders or filters the source files of a group (e.g. newest blog posts first). */
50
+ order?(files: FileInfo[], ctx: {
51
+ group?: string;
52
+ }): FileInfo[] | void;
53
+ }
54
+ export type PluginSpec = string | {
55
+ module: string;
56
+ options?: unknown;
57
+ };
58
+ /** Loads the plugins listed in the config, relative to the config file. */
59
+ export declare function loadPlugins(config: Pick<Config, 'plugins' | 'root'>): Promise<Plugin[]>;
60
+ /** Calls plugin hooks; an exception names the plugin that threw it. */
61
+ export declare class PluginHost {
62
+ readonly plugins: Plugin[];
63
+ constructor(plugins?: Plugin[]);
64
+ private call;
65
+ checks(ctx: StringContext): PluginIssue[];
66
+ promptNotes(lang: string, items: {
67
+ key: string;
68
+ source: string;
69
+ }[]): string;
70
+ postProcess(ctx: StringContext): string;
71
+ order(files: FileInfo[], group?: string): FileInfo[];
72
+ }
@@ -0,0 +1,71 @@
1
+ import path from 'node:path';
2
+ import { pathToFileURL } from 'node:url';
3
+ /** Loads the plugins listed in the config, relative to the config file. */
4
+ export async function loadPlugins(config) {
5
+ const plugins = [];
6
+ for (const spec of config.plugins ?? []) {
7
+ const modulePath = typeof spec === 'string' ? spec : spec.module;
8
+ const options = typeof spec === 'string' ? undefined : spec.options;
9
+ const file = path.resolve(config.root, modulePath);
10
+ let exported;
11
+ try {
12
+ exported = (await import(pathToFileURL(file).href)).default;
13
+ }
14
+ catch (error) {
15
+ throw new Error(`Could not load plugin ${modulePath}: ${error.message}`);
16
+ }
17
+ const plugin = (typeof exported === 'function' ? await exported(options) : exported);
18
+ if (!plugin || typeof plugin !== 'object' || typeof plugin.name !== 'string') {
19
+ throw new Error(`Plugin ${modulePath} must export a plugin object with a "name" (or a function returning one).`);
20
+ }
21
+ plugins.push(plugin);
22
+ }
23
+ return plugins;
24
+ }
25
+ /** Calls plugin hooks; an exception names the plugin that threw it. */
26
+ export class PluginHost {
27
+ plugins;
28
+ constructor(plugins = []) {
29
+ this.plugins = plugins;
30
+ }
31
+ call(plugin, hook, fn) {
32
+ try {
33
+ return fn();
34
+ }
35
+ catch (error) {
36
+ throw new Error(`Plugin "${plugin.name}" failed in ${hook}: ${error.message}`);
37
+ }
38
+ }
39
+ checks(ctx) {
40
+ return this.plugins.flatMap(p => (p.checks ? this.call(p, 'checks', () => p.checks(ctx)) ?? [] : []));
41
+ }
42
+ promptNotes(lang, items) {
43
+ return this.plugins
44
+ .map(p => (p.promptNotes ? this.call(p, 'promptNotes', () => p.promptNotes({ lang, items })) : undefined))
45
+ .filter((note) => Boolean(note))
46
+ .map(note => ` ${note.trim()}`)
47
+ .join('');
48
+ }
49
+ postProcess(ctx) {
50
+ let text = ctx.text;
51
+ for (const p of this.plugins) {
52
+ if (!p.postProcess)
53
+ continue;
54
+ const next = this.call(p, 'postProcess', () => p.postProcess({ ...ctx, text }));
55
+ if (typeof next === 'string')
56
+ text = next;
57
+ }
58
+ return text;
59
+ }
60
+ order(files, group) {
61
+ let result = files;
62
+ for (const p of this.plugins) {
63
+ if (!p.order)
64
+ continue;
65
+ const next = this.call(p, 'order', () => p.order(result, { group }));
66
+ if (Array.isArray(next))
67
+ result = next;
68
+ }
69
+ return result;
70
+ }
71
+ }
package/dist/project.d.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  import { type CheckName } from './checks.js';
2
- import type { Config } from './config.js';
2
+ import { type Config } from './config.js';
3
+ import { type Plugin } from './plugins.js';
3
4
  export interface Finding {
4
5
  lang: string;
5
- check: CheckName;
6
+ /** Built-in check name or a plugin's check name. */
7
+ check: CheckName | (string & {});
8
+ /** Group name, when the config has groups. */
9
+ group?: string;
6
10
  severity: 'error' | 'warning';
7
11
  file: string;
8
12
  key: string;
@@ -17,11 +21,20 @@ export declare function collapsedSiblings(source: Map<string, string>, target: M
17
21
  key: string;
18
22
  note: string;
19
23
  }[];
24
+ export interface CheckOptions {
25
+ /** Subset of the target languages. */
26
+ languages?: string[];
27
+ /** Only these groups (by name). */
28
+ groups?: string[];
29
+ /** Loaded plugins (see loadPlugins); their checks run too. */
30
+ plugins?: Plugin[];
31
+ }
20
32
  /**
21
- * Runs the quality checks over every translated string. Approved hand edits are skipped
22
- * except for placeholder and script errors, which break the app either way.
33
+ * Runs the quality checks over every translated string of every group. Approved strings are
34
+ * skipped except for errors (placeholder, unsafe, script, plugin errors), which break the
35
+ * software either way.
23
36
  */
24
- export declare function checkProject(config: Config, languages?: string[]): Finding[];
37
+ export declare function checkProject(config: Config, optionsOrLanguages?: CheckOptions | string[]): Finding[];
25
38
  /**
26
39
  * Repairs placeholder findings that have exactly one possible fix: the source has one
27
40
  * placeholder and the translation one brace token with another name ("{heures}" for
package/dist/project.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import path from 'node:path';
2
- import { CHECKS, Checker, ERROR_CHECKS } from './checks.js';
2
+ import { CHECKS, Checker, isError } from './checks.js';
3
+ import { groupsOf, selectGroups } from './config.js';
4
+ import { listFiles } from './engine/sources.js';
3
5
  import { findSourceFiles, flatten, isArbMetadata, parseDoc, readText, writeText } from './files.js';
6
+ import { PluginHost } from './plugins.js';
4
7
  import { reviewId, State } from './state.js';
5
8
  import { hash } from './util.js';
6
9
  /**
@@ -43,58 +46,63 @@ function readJson(file) {
43
46
  return text === null ? null : parseDoc(file, text);
44
47
  }
45
48
  /**
46
- * Runs the quality checks over every translated string. Approved hand edits are skipped
47
- * except for placeholder and script errors, which break the app either way.
49
+ * Runs the quality checks over every translated string of every group. Approved strings are
50
+ * skipped except for errors (placeholder, unsafe, script, plugin errors), which break the
51
+ * software either way.
48
52
  */
49
- export function checkProject(config, languages = config.targetLanguages) {
50
- const checker = new Checker(config);
53
+ export function checkProject(config, optionsOrLanguages = {}) {
54
+ const options = Array.isArray(optionsOrLanguages) ? { languages: optionsOrLanguages } : optionsOrLanguages;
55
+ const host = new PluginHost(options.plugins ?? []);
51
56
  const state = new State(path.join(config.root, config.stateDir));
52
57
  const findings = [];
53
- for (const file of findSourceFiles(config.root, config.files, config.sourceLanguage)) {
54
- if (checker.scope.isExcluded(file))
55
- continue;
56
- const sourceDoc = readJson(path.join(config.root, file.pathFor(config.sourceLanguage)));
57
- if (sourceDoc === null)
58
- continue;
59
- const source = flatten(sourceDoc);
60
- for (const lang of languages) {
61
- const rel = file.pathFor(lang);
62
- let doc;
63
- try {
64
- doc = readJson(path.join(config.root, rel));
65
- }
66
- catch (error) {
67
- findings.push({ lang, check: 'markup', severity: 'error', file: rel, key: '(file)', text: '', note: `invalid JSON: ${error.message}` });
58
+ for (const group of selectGroups(config, options.groups, options.languages)) {
59
+ const checker = new Checker(group, host);
60
+ const languages = group.targetLanguages.filter(lang => !options.languages?.length || options.languages.includes(lang));
61
+ for (const file of listFiles(group, checker.scope, host)) {
62
+ const sourceDoc = readJson(path.join(group.root, file.pathFor(group.sourceLanguage)));
63
+ if (sourceDoc === null)
68
64
  continue;
69
- }
70
- if (doc === null)
71
- continue;
72
- const translated = flatten(doc);
73
- for (const { key, note } of collapsedSiblings(source, translated)) {
74
- const review = state.review[reviewId(lang, file.id, key)];
75
- if (review?.status === 'approved')
65
+ const source = flatten(sourceDoc);
66
+ for (const lang of languages) {
67
+ const rel = file.pathFor(lang);
68
+ let doc;
69
+ try {
70
+ doc = readJson(path.join(group.root, rel));
71
+ }
72
+ catch (error) {
73
+ findings.push({ lang, check: 'markup', severity: 'error', file: rel, key: '(file)', text: '', note: `invalid JSON: ${error.message}`, group: group.name });
76
74
  continue;
77
- findings.push({ lang, check: 'partial', severity: 'warning', file: rel, key, text: translated.get(key) ?? '', note });
78
- }
79
- for (const [key, text] of translated) {
80
- // Plural forms only the target language has are checked against the source "_other".
81
- const sourceText = source.get(key) ?? (/_(zero|one|two|few|many)$/.test(key) ? source.get(key.replace(/_(zero|one|two|few|many)$/, '_other')) : undefined);
82
- if (sourceText === undefined || sourceText.trim() === '' || isArbMetadata(rel, key) || checker.scope.isLiteral(key, sourceText))
75
+ }
76
+ if (doc === null)
83
77
  continue;
84
- const review = state.review[reviewId(lang, file.id, key)];
85
- const approved = review?.status === 'approved' && review.valueHash === hash(text);
86
- for (const issue of checker.checkString(lang, key, sourceText, text)) {
87
- if (approved && !ERROR_CHECKS.has(issue.check))
78
+ const translated = flatten(doc);
79
+ for (const { key, note } of collapsedSiblings(source, translated)) {
80
+ const review = state.review[reviewId(lang, file.id, key)];
81
+ if (review?.status === 'approved')
88
82
  continue;
89
- findings.push({
90
- lang,
91
- check: issue.check,
92
- severity: ERROR_CHECKS.has(issue.check) ? 'error' : 'warning',
93
- file: rel,
94
- key,
95
- text,
96
- note: issue.note,
97
- });
83
+ findings.push({ lang, check: 'partial', severity: 'warning', file: rel, key, text: translated.get(key) ?? '', note, group: group.name });
84
+ }
85
+ for (const [key, text] of translated) {
86
+ // Plural forms only the target language has are checked against the source "_other".
87
+ const sourceText = source.get(key) ?? (/_(zero|one|two|few|many)$/.test(key) ? source.get(key.replace(/_(zero|one|two|few|many)$/, '_other')) : undefined);
88
+ if (sourceText === undefined || sourceText.trim() === '' || isArbMetadata(rel, key) || checker.scope.isLiteral(key, sourceText))
89
+ continue;
90
+ const review = state.review[reviewId(lang, file.id, key)];
91
+ const approved = review?.status === 'approved' && review.valueHash === hash(text);
92
+ for (const issue of checker.checkString(lang, key, sourceText, text, file.id)) {
93
+ if (approved && !isError(issue))
94
+ continue;
95
+ findings.push({
96
+ lang,
97
+ check: issue.check,
98
+ severity: isError(issue) ? 'error' : 'warning',
99
+ file: rel,
100
+ key,
101
+ text,
102
+ note: issue.note,
103
+ group: group.name,
104
+ });
105
+ }
98
106
  }
99
107
  }
100
108
  }
@@ -110,18 +118,21 @@ export function checkProject(config, languages = config.targetLanguages) {
110
118
  export function fixPlaceholders(config, findings) {
111
119
  const fixed = [];
112
120
  const sources = new Map();
113
- for (const file of findSourceFiles(config.root, config.files, config.sourceLanguage)) {
114
- const doc = readJson(path.join(config.root, file.pathFor(config.sourceLanguage)));
115
- if (doc)
116
- for (const lang of config.targetLanguages)
117
- sources.set(file.pathFor(lang), flatten(doc));
121
+ for (const group of groupsOf(config)) {
122
+ const checker = new Checker(group);
123
+ for (const file of findSourceFiles(group.root, group.files, group.sourceLanguage)) {
124
+ const doc = readJson(path.join(group.root, file.pathFor(group.sourceLanguage)));
125
+ if (doc)
126
+ for (const lang of group.targetLanguages)
127
+ sources.set(file.pathFor(lang), { values: flatten(doc), checker });
128
+ }
118
129
  }
119
- const checker = new Checker(config);
120
130
  for (const finding of findings.filter(f => f.check === 'placeholder')) {
121
- const source = sources.get(finding.file)?.get(finding.key);
122
- if (source === undefined)
131
+ const entry = sources.get(finding.file);
132
+ const source = entry?.values.get(finding.key);
133
+ if (!entry || source === undefined)
123
134
  continue;
124
- const expected = source.match(checker.placeholderRe) ?? [];
135
+ const expected = source.match(entry.checker.placeholderRe) ?? [];
125
136
  const tokens = finding.text.match(/\{\{?[^{}]+\}?\}/g) ?? [];
126
137
  const [want, got] = [expected[0], tokens[0]];
127
138
  if (new Set(expected).size !== 1 || tokens.length !== 1 || !want || !got || got === want)
@@ -139,8 +150,10 @@ export function fixPlaceholders(config, findings) {
139
150
  }
140
151
  /** Table of finding counts per language and check. */
141
152
  export function summaryTable(findings, languages) {
142
- const header = ['lang', ...CHECKS];
143
- const rows = languages.map(lang => [lang, ...CHECKS.map(check => String(findings.filter(f => f.lang === lang && f.check === check).length))]);
153
+ // Built-in checks always; plugin checks when they found something.
154
+ const checks = [...CHECKS, ...new Set(findings.map(f => f.check).filter(c => !CHECKS.includes(c)))];
155
+ const header = ['lang', ...checks];
156
+ const rows = languages.map(lang => [lang, ...checks.map(check => String(findings.filter(f => f.lang === lang && f.check === check).length))]);
144
157
  const widths = header.map((h, i) => Math.max(h.length, ...rows.map(row => row[i].length)));
145
158
  const line = (cells) => cells.map((cell, i) => cell.padEnd(widths[i])).join(' ').trimEnd();
146
159
  return [line(header), ...rows.map(line)].join('\n');
package/dist/prompt.d.ts CHANGED
@@ -8,9 +8,11 @@ export interface PromptItem {
8
8
  maxLength?: number;
9
9
  /** Set when --fix-flagged revises a translation that lost content. */
10
10
  completing?: boolean;
11
+ /** File id ("locales/{lang}/common.json"), for plugins. */
12
+ file?: string;
11
13
  }
12
14
  /** System prompt for translating a JSON array of strings. */
13
- export declare function batchPrompt(config: Config, lang: string, items: PromptItem[]): string;
15
+ export declare function batchPrompt(config: Config, lang: string, items: PromptItem[], notes?: string): string;
14
16
  /** System prompt for translating one string as plain text. */
15
17
  export declare function singlePrompt(config: Config, lang: string, item: PromptItem, extra?: string): string;
16
18
  /** Turns a translation request into a minimal correction of an existing translation. */
package/dist/prompt.js CHANGED
@@ -36,7 +36,7 @@ function rules(config, lang, texts) {
36
36
  }
37
37
  const REVISION_RULE = 'start from the existing translation: keep its wording, terms and sentence structure wherever it still says what the source now says, and change only the parts where the source differs. Remove anything the existing translation says that the source no longer says, and add what is new. Do not reword text that is still correct.';
38
38
  /** System prompt for translating a JSON array of strings. */
39
- export function batchPrompt(config, lang, items) {
39
+ export function batchPrompt(config, lang, items, notes = '') {
40
40
  const keys = ` CONTEXT: each element is a UI string. Its key (by 1-based position) hints at the screen and role; use it to pick the right meaning, never translate or output it: ${items
41
41
  .map((item, i) => `${i + 1}=${item.key}`)
42
42
  .join(', ')}.`;
@@ -53,7 +53,7 @@ export function batchPrompt(config, lang, items) {
53
53
  const plural = items.some(item => /_(zero|one|two|few|many|other)$/.test(item.key))
54
54
  ? ` PLURALS: keys ending in _zero, _one, _two, _few, _many or _other are plural forms (Unicode CLDR categories) of ${languageName(lang)}. Write the form of that category, even when the source text given is the English plural (_zero is used when the count is 0, _two when it is 2; keep the {{count}} placeholder).`
55
55
  : '';
56
- return `${intro(config, lang)} Translate each string in the JSON array. Return ONLY a valid JSON array of strings with the same number of elements in the same order. No explanations, no code fences.${rules(config, lang, items.map(i => i.source))}${keys}${lengths}${plural}${revision}`;
56
+ return `${intro(config, lang)} Translate each string in the JSON array. Return ONLY a valid JSON array of strings with the same number of elements in the same order. No explanations, no code fences.${rules(config, lang, items.map(i => i.source))}${keys}${lengths}${plural}${revision}${notes}`;
57
57
  }
58
58
  /** System prompt for translating one string as plain text. */
59
59
  export function singlePrompt(config, lang, item, extra = '') {
package/dist/review.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Config } from './config.js';
1
+ import { type Config } from './config.js';
2
2
  export interface ReviewItem {
3
3
  id: string;
4
4
  lang: string;