localewarden 0.1.1 → 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 (56) hide show
  1. package/README.md +161 -12
  2. package/dist/budget.d.ts +25 -0
  3. package/dist/budget.js +74 -0
  4. package/dist/checks.d.ts +34 -6
  5. package/dist/checks.js +111 -21
  6. package/dist/cli.js +66 -10
  7. package/dist/config.d.ts +37 -1
  8. package/dist/config.js +118 -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 +30 -0
  24. package/dist/files.js +125 -12
  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/placeholders.js +2 -1
  34. package/dist/plugins.d.ts +72 -0
  35. package/dist/plugins.js +71 -0
  36. package/dist/project.d.ts +33 -5
  37. package/dist/project.js +135 -42
  38. package/dist/prompt.d.ts +7 -1
  39. package/dist/prompt.js +10 -3
  40. package/dist/review.d.ts +1 -1
  41. package/dist/review.js +49 -23
  42. package/dist/scope.d.ts +15 -0
  43. package/dist/scope.js +26 -0
  44. package/dist/state.d.ts +11 -4
  45. package/dist/state.js +30 -11
  46. package/dist/translate.d.ts +9 -49
  47. package/dist/translate.js +119 -383
  48. package/dist/ui/data.d.ts +50 -0
  49. package/dist/ui/data.js +178 -0
  50. package/dist/ui/page.d.ts +5 -0
  51. package/dist/ui/page.js +277 -0
  52. package/dist/ui/server.d.ts +24 -0
  53. package/dist/ui/server.js +194 -0
  54. package/dist/util.d.ts +6 -2
  55. package/dist/util.js +17 -5
  56. package/package.json +12 -1
package/dist/llm.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { sleep } from './util.js';
2
+ export { BudgetExceededError } from './budget.js';
2
3
  export class ModelError extends Error {
3
4
  status;
4
5
  retryAfterMs;
@@ -79,28 +80,21 @@ export class OpenAICompatibleModel {
79
80
  return { text: content.trim(), tokens: data.usage?.total_tokens ?? 0 };
80
81
  }
81
82
  }
82
- export class BudgetExceededError extends Error {
83
- }
84
83
  /**
85
- * Wraps a Model with retries, spacing between requests and the per-run token budget.
84
+ * Wraps a Model with retries, spacing between requests and the shared token budget.
86
85
  * The budget is checked before each request; the request that crosses it still completes.
87
86
  */
88
87
  export class Client {
89
88
  model;
90
- maxTokens;
89
+ budget;
91
90
  options;
92
- tokens = 0;
93
- requests = 0;
94
91
  lastStart = 0;
95
92
  queue = Promise.resolve();
96
- constructor(model, maxTokens, options = {}) {
93
+ constructor(model, budget, options = {}) {
97
94
  this.model = model;
98
- this.maxTokens = maxTokens;
95
+ this.budget = budget;
99
96
  this.options = options;
100
97
  }
101
- get budgetExceeded() {
102
- return this.tokens >= this.maxTokens;
103
- }
104
98
  /** Enforces a minimum gap between request starts across all parallel workers. */
105
99
  spacing() {
106
100
  const gap = this.options.minSpacingMs ?? 200;
@@ -116,13 +110,11 @@ export class Client {
116
110
  async complete(system, user, timeoutMs) {
117
111
  const retries = this.options.retries ?? 3;
118
112
  for (let attempt = 1;; attempt++) {
119
- if (this.budgetExceeded)
120
- throw new BudgetExceededError(`token budget of ${this.maxTokens.toLocaleString('en')} reached`);
113
+ this.budget.check();
121
114
  await this.spacing();
122
115
  try {
123
- this.requests++;
124
116
  const result = await this.model.complete(system, user, { timeoutMs });
125
- this.tokens += result.tokens;
117
+ this.budget.record(result.tokens);
126
118
  return result.text;
127
119
  }
128
120
  catch (error) {
package/dist/lock.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ export declare class LockError extends Error {
2
+ }
3
+ /**
4
+ * Takes <stateDir>/run.lock so two runs (say, CI and a local run), or a run and an edit in the
5
+ * web interface, cannot write the same state at once. Only one holder at a time, including
6
+ * within one process. A lock is stale when its process no longer exists on this machine, or
7
+ * when it was written on another machine (a lock file committed by mistake); a stale lock is
8
+ * taken over with an atomic rename, so two processes cannot both take it. Returns the
9
+ * function that releases it.
10
+ */
11
+ export declare function acquireLock(stateDir: string): () => void;
12
+ /** Runs `fn` while holding the lock (for short writes like review approvals and edits). */
13
+ export declare function withLock<T>(stateDir: string, fn: () => T): T;
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
+ }
@@ -6,7 +6,8 @@
6
6
  export const DEFAULT_PLACEHOLDER_PATTERNS = [
7
7
  '\\{\\{\\s*[\\w.-]+\\s*\\}\\}', // {{name}} i18next, Handlebars, vue-i18n
8
8
  '\\{\\s*[\\w.-]+\\s*\\}', // {name} ICU, react-intl, i18next (custom)
9
- '%(?:\\d+\\$)?[-+0#]*\\d*(?:\\.\\d+)?[sdifuxXeEgGc@]', // %s %d %1$s %.2f printf, Android, iOS
9
+ // Not followed by a letter: Hungarian writes suffixes after percent signs ("100%-ig").
10
+ '%(?:\\d+\\$)?[-+0#]*\\d*(?:\\.\\d+)?[sdifuxXeEgGc@](?![A-Za-z\\u00C0-\\u024F])', // %s %d %1$s %.2f printf, Android, iOS
10
11
  '%\\([\\w.-]+\\)[sdif]', // %(name)s Python
11
12
  '%\\{[\\w.-]+\\}', // %{name} Ruby, rails-i18n
12
13
  '\\$\\{[\\w.-]+\\}', // ${name} template literals
@@ -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;
@@ -10,9 +14,33 @@ export interface Finding {
10
14
  note?: string;
11
15
  }
12
16
  /**
13
- * Runs the quality checks over every translated string. Approved hand edits are skipped
14
- * except for placeholder and script errors, which break the app either way.
17
+ * Short sibling strings (same parent key) that differ in the source but got the same
18
+ * translation: answer options, tabs or menu items the user can no longer tell apart.
15
19
  */
16
- export declare function checkProject(config: Config, languages?: string[]): Finding[];
20
+ export declare function collapsedSiblings(source: Map<string, string>, target: Map<string, string>): {
21
+ key: string;
22
+ note: string;
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
+ }
32
+ /**
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.
36
+ */
37
+ export declare function checkProject(config: Config, optionsOrLanguages?: CheckOptions | string[]): Finding[];
38
+ /**
39
+ * Repairs placeholder findings that have exactly one possible fix: the source has one
40
+ * placeholder and the translation one brace token with another name ("{heures}" for
41
+ * "{hours}"). The file text is edited in place, so its formatting stays as it is.
42
+ * Returns the repaired findings.
43
+ */
44
+ export declare function fixPlaceholders(config: Config, findings: Finding[]): Finding[];
17
45
  /** Table of finding counts per language and check. */
18
46
  export declare function summaryTable(findings: Finding[], languages: string[]): string;