localewarden 0.1.1 → 0.2.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/dist/files.js CHANGED
@@ -87,7 +87,7 @@ export function findSourceFiles(root, pattern, sourceLanguage) {
87
87
  };
88
88
  const files = [];
89
89
  for (const rel of all.sort()) {
90
- if (!rel.endsWith('.json'))
90
+ if (!/\.(json|arb|txt)$/.test(rel))
91
91
  continue;
92
92
  const match = matcher.exec(rel);
93
93
  if (!match || match.groups?.lang !== sourceLanguage)
@@ -99,6 +99,38 @@ export function findSourceFiles(root, pattern, sourceLanguage) {
99
99
  }
100
100
  return files;
101
101
  }
102
+ /**
103
+ * Key pattern -> RegExp. "*" matches within one segment, "**" any number of segments. A
104
+ * pattern without a dot matches the last segment anywhere ("id" matches "steps.2.id").
105
+ */
106
+ export function keyPattern(pattern) {
107
+ const body = pattern
108
+ .split(/(\*\*|\*)/)
109
+ .map(part => (part === '**' ? '.*' : part === '*' ? '[^.]*' : escapeRegExp(part)))
110
+ .join('');
111
+ return new RegExp(pattern.includes('.') ? `^${body}$` : `(?:^|\\.)${body}$`);
112
+ }
113
+ /** Path pattern -> RegExp ("*" within a folder, "**" across folders, {lang} as written). */
114
+ export function pathPattern(pattern) {
115
+ const body = pattern
116
+ .replace(/\\/g, '/')
117
+ .replace(/^\.\//, '')
118
+ .split(/(\*\*\/?|\*)/)
119
+ .map(part => (part.startsWith('**') ? '(?:.*/)?' : part === '*' ? '[^/]*' : escapeRegExp(part)))
120
+ .join('');
121
+ return new RegExp(`^${body}$`);
122
+ }
123
+ /**
124
+ * Values that are not text and stay as they are in every language: URLs, email addresses,
125
+ * file paths and plain numbers or codes without spaces.
126
+ */
127
+ export function isLiteralValue(value) {
128
+ const v = value.trim();
129
+ return (/^(?:https?:\/\/|mailto:|tel:)\S+$/i.test(v) ||
130
+ /^[\w.+-]+@[\w-]+(?:\.[\w-]+)+$/.test(v) ||
131
+ /^(?:\.{0,2}\/)?[\w@.-]+(?:\/[\w@.-]+)*\.(?:png|jpe?g|gif|svg|webp|avif|ico|mp3|mp4|webm|wav|pdf|json|css|js|html?)$/i.test(v) ||
132
+ /^[\d\s.,:%+\-–/×x#]+$/.test(v));
133
+ }
102
134
  export const keyOf = (segments) => segments.join('.');
103
135
  /** All string values of a parsed JSON document, in document order. */
104
136
  export function stringLeaves(value, prefix = [], out = []) {
@@ -209,6 +241,25 @@ export function detectFormat(text) {
209
241
  export function serialize(value, format) {
210
242
  return JSON.stringify(value, null, format.indent) + (format.finalNewline ? '\n' : '');
211
243
  }
244
+ const txtKey = (rel) => path.posix.basename(rel.replace(/\\/g, '/'), '.txt');
245
+ /**
246
+ * Parses a locale file. A .txt file (fastlane metadata: description.txt, keywords.txt) is one
247
+ * string, keyed by its file name, so maxLength patterns like "keywords" apply to it.
248
+ */
249
+ export function parseDoc(rel, text) {
250
+ if (rel.endsWith('.txt'))
251
+ return { [txtKey(rel)]: text.replace(/\r?\n$/, '') };
252
+ return JSON.parse(text);
253
+ }
254
+ /** Flutter ARB metadata ("@@locale", "@title": { description, placeholders }): not text. */
255
+ export const isArbMetadata = (rel, key) => rel.endsWith('.arb') && key.startsWith('@');
256
+ /** Text of a locale file, or null when a .txt file has no value to write. */
257
+ export function serializeDoc(rel, doc, format) {
258
+ if (!rel.endsWith('.txt'))
259
+ return serialize(doc, format);
260
+ const value = doc[txtKey(rel)];
261
+ return typeof value === 'string' ? value + (format.finalNewline ? '\n' : '') : null;
262
+ }
212
263
  export function readText(file) {
213
264
  try {
214
265
  return fs.readFileSync(file, 'utf8');
@@ -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
package/dist/project.d.ts CHANGED
@@ -9,10 +9,25 @@ export interface Finding {
9
9
  text: string;
10
10
  note?: string;
11
11
  }
12
+ /**
13
+ * Short sibling strings (same parent key) that differ in the source but got the same
14
+ * translation: answer options, tabs or menu items the user can no longer tell apart.
15
+ */
16
+ export declare function collapsedSiblings(source: Map<string, string>, target: Map<string, string>): {
17
+ key: string;
18
+ note: string;
19
+ }[];
12
20
  /**
13
21
  * Runs the quality checks over every translated string. Approved hand edits are skipped
14
22
  * except for placeholder and script errors, which break the app either way.
15
23
  */
16
24
  export declare function checkProject(config: Config, languages?: string[]): Finding[];
25
+ /**
26
+ * Repairs placeholder findings that have exactly one possible fix: the source has one
27
+ * placeholder and the translation one brace token with another name ("{heures}" for
28
+ * "{hours}"). The file text is edited in place, so its formatting stays as it is.
29
+ * Returns the repaired findings.
30
+ */
31
+ export declare function fixPlaceholders(config: Config, findings: Finding[]): Finding[];
17
32
  /** Table of finding counts per language and check. */
18
33
  export declare function summaryTable(findings: Finding[], languages: string[]): string;
package/dist/project.js CHANGED
@@ -1,11 +1,46 @@
1
1
  import path from 'node:path';
2
2
  import { CHECKS, Checker, ERROR_CHECKS } from './checks.js';
3
- import { findSourceFiles, flatten, readText } from './files.js';
3
+ import { findSourceFiles, flatten, isArbMetadata, parseDoc, readText, writeText } from './files.js';
4
4
  import { reviewId, State } from './state.js';
5
5
  import { hash } from './util.js';
6
+ /**
7
+ * Short sibling strings (same parent key) that differ in the source but got the same
8
+ * translation: answer options, tabs or menu items the user can no longer tell apart.
9
+ */
10
+ export function collapsedSiblings(source, target) {
11
+ const groups = new Map();
12
+ for (const key of target.keys()) {
13
+ // Only nested keys: in a flat file every string would be a "sibling" of every other.
14
+ if (!key.includes('.'))
15
+ continue;
16
+ const parent = key.slice(0, key.lastIndexOf('.'));
17
+ // Plural forms of one string are meant to look alike.
18
+ if (/_(zero|one|two|few|many|other)$/.test(key))
19
+ continue;
20
+ groups.set(parent, [...(groups.get(parent) ?? []), key]);
21
+ }
22
+ const out = [];
23
+ for (const keys of groups.values()) {
24
+ const seen = new Map();
25
+ for (const key of keys) {
26
+ const src = source.get(key)?.trim();
27
+ const text = target.get(key)?.trim().toLocaleLowerCase();
28
+ if (!src || !text || src.length > 40)
29
+ continue;
30
+ const earlier = seen.get(text);
31
+ if (earlier !== undefined && source.get(earlier)?.trim().toLocaleLowerCase() !== src.toLocaleLowerCase()) {
32
+ out.push({ key, note: `same translation as "${earlier}", although the source differs ("${source.get(earlier)}" / "${src}")` });
33
+ }
34
+ else if (earlier === undefined) {
35
+ seen.set(text, key);
36
+ }
37
+ }
38
+ }
39
+ return out;
40
+ }
6
41
  function readJson(file) {
7
42
  const text = readText(file);
8
- return text === null ? null : JSON.parse(text);
43
+ return text === null ? null : parseDoc(file, text);
9
44
  }
10
45
  /**
11
46
  * Runs the quality checks over every translated string. Approved hand edits are skipped
@@ -16,6 +51,8 @@ export function checkProject(config, languages = config.targetLanguages) {
16
51
  const state = new State(path.join(config.root, config.stateDir));
17
52
  const findings = [];
18
53
  for (const file of findSourceFiles(config.root, config.files, config.sourceLanguage)) {
54
+ if (checker.scope.isExcluded(file))
55
+ continue;
19
56
  const sourceDoc = readJson(path.join(config.root, file.pathFor(config.sourceLanguage)));
20
57
  if (sourceDoc === null)
21
58
  continue;
@@ -32,10 +69,17 @@ export function checkProject(config, languages = config.targetLanguages) {
32
69
  }
33
70
  if (doc === null)
34
71
  continue;
35
- for (const [key, text] of flatten(doc)) {
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')
76
+ 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) {
36
80
  // Plural forms only the target language has are checked against the source "_other".
37
81
  const sourceText = source.get(key) ?? (/_(zero|one|two|few|many)$/.test(key) ? source.get(key.replace(/_(zero|one|two|few|many)$/, '_other')) : undefined);
38
- if (sourceText === undefined || sourceText.trim() === '')
82
+ if (sourceText === undefined || sourceText.trim() === '' || isArbMetadata(rel, key) || checker.scope.isLiteral(key, sourceText))
39
83
  continue;
40
84
  const review = state.review[reviewId(lang, file.id, key)];
41
85
  const approved = review?.status === 'approved' && review.valueHash === hash(text);
@@ -57,6 +101,42 @@ export function checkProject(config, languages = config.targetLanguages) {
57
101
  }
58
102
  return findings;
59
103
  }
104
+ /**
105
+ * Repairs placeholder findings that have exactly one possible fix: the source has one
106
+ * placeholder and the translation one brace token with another name ("{heures}" for
107
+ * "{hours}"). The file text is edited in place, so its formatting stays as it is.
108
+ * Returns the repaired findings.
109
+ */
110
+ export function fixPlaceholders(config, findings) {
111
+ const fixed = [];
112
+ 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));
118
+ }
119
+ const checker = new Checker(config);
120
+ for (const finding of findings.filter(f => f.check === 'placeholder')) {
121
+ const source = sources.get(finding.file)?.get(finding.key);
122
+ if (source === undefined)
123
+ continue;
124
+ const expected = source.match(checker.placeholderRe) ?? [];
125
+ const tokens = finding.text.match(/\{\{?[^{}]+\}?\}/g) ?? [];
126
+ const [want, got] = [expected[0], tokens[0]];
127
+ if (new Set(expected).size !== 1 || tokens.length !== 1 || !want || !got || got === want)
128
+ continue;
129
+ const repaired = finding.text.replace(got, want);
130
+ const file = path.join(config.root, finding.file);
131
+ const text = readText(file);
132
+ const before = JSON.stringify(finding.text).slice(1, -1);
133
+ if (text === null || text.split(before).length !== 2)
134
+ continue; // not found exactly once
135
+ writeText(file, text.replace(before, JSON.stringify(repaired).slice(1, -1)));
136
+ fixed.push({ ...finding, text: repaired });
137
+ }
138
+ return fixed;
139
+ }
60
140
  /** Table of finding counts per language and check. */
61
141
  export function summaryTable(findings, languages) {
62
142
  const header = ['lang', ...CHECKS];
package/dist/prompt.d.ts CHANGED
@@ -4,6 +4,10 @@ export interface PromptItem {
4
4
  source: string;
5
5
  /** Existing translation of an older version of the source (revision mode). */
6
6
  previous?: string;
7
+ /** Maximum characters of the translation (store listings, SEO titles, buttons). */
8
+ maxLength?: number;
9
+ /** Set when --fix-flagged revises a translation that lost content. */
10
+ completing?: boolean;
7
11
  }
8
12
  /** System prompt for translating a JSON array of strings. */
9
13
  export declare function batchPrompt(config: Config, lang: string, items: PromptItem[]): string;
package/dist/prompt.js CHANGED
@@ -46,17 +46,24 @@ export function batchPrompt(config, lang, items) {
46
46
  const revision = revised.length > 0
47
47
  ? ` REVISION: the source of some elements was edited after they had been translated. Their existing translation (of the older source) by 1-based position: ${JSON.stringify(Object.fromEntries(revised))}. For these elements, ${REVISION_RULE}`
48
48
  : '';
49
+ const limited = items.map((item, i) => (item.maxLength ? `element ${i + 1} at most ${item.maxLength}` : null)).filter(Boolean);
50
+ const lengths = limited.length > 0
51
+ ? ` LENGTH LIMIT: these elements are cut off past a character limit (counting spaces): ${limited.join(', ')} characters. Stay under it even if that means a shorter, freer phrasing; never pad.`
52
+ : '';
49
53
  const plural = items.some(item => /_(zero|one|two|few|many|other)$/.test(item.key))
50
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).`
51
55
  : '';
52
- 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}${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}`;
53
57
  }
54
58
  /** System prompt for translating one string as plain text. */
55
59
  export function singlePrompt(config, lang, item, extra = '') {
56
60
  const revision = item.previous
57
61
  ? ` REVISION: the source was edited after it had been translated. Existing translation (of the older source): ${JSON.stringify(item.previous)}. ${REVISION_RULE[0].toUpperCase()}${REVISION_RULE.slice(1)}`
58
62
  : '';
59
- return `${intro(config, lang)} The user message is one UI string (key: ${item.key}). Output only the translation, with no explanations, quotes or commentary.${rules(config, lang, [item.source])}${revision}${extra}`;
63
+ const length = item.maxLength
64
+ ? ` LENGTH LIMIT: at most ${item.maxLength} characters including spaces; the text is cut off past that. Prefer a shorter, freer phrasing over a literal one.`
65
+ : '';
66
+ return `${intro(config, lang)}${length} The user message is one UI string (key: ${item.key}). Output only the translation, with no explanations, quotes or commentary.${rules(config, lang, [item.source])}${revision}${extra}`;
60
67
  }
61
68
  /** Turns a translation request into a minimal correction of an existing translation. */
62
69
  export function repairInstruction(existing, problems) {
package/dist/review.js CHANGED
@@ -1,13 +1,14 @@
1
1
  import path from 'node:path';
2
- import { flatten, readText } from './files.js';
3
- import { parseReviewId, State } from './state.js';
4
- import { hash } from './util.js';
2
+ import { findSourceFiles, flatten, parseDoc, readText } from './files.js';
3
+ import { parseReviewId, reviewId, State } from './state.js';
4
+ import { hash, today } from './util.js';
5
5
  function currentValue(config, lang, fileId, key) {
6
- const text = readText(path.join(config.root, fileId.split('{lang}').join(lang)));
6
+ const rel = fileId.split('{lang}').join(lang);
7
+ const text = readText(path.join(config.root, rel));
7
8
  if (text === null)
8
9
  return undefined;
9
10
  try {
10
- return flatten(JSON.parse(text)).get(key);
11
+ return flatten(parseDoc(rel, text)).get(key);
11
12
  }
12
13
  catch {
13
14
  return undefined;
@@ -58,6 +59,25 @@ export function updateReview(config, action, selectors) {
58
59
  }
59
60
  changed++;
60
61
  }
62
+ // Approving a string that is not on the list marks it as checked by a person: it is then
63
+ // protected like a hand edit and the quality check no longer reports warnings for it.
64
+ if (action === 'approve') {
65
+ const exact = selectors.filter(sel => sel !== 'all' && !sel.endsWith(':*') && sel.includes(':'));
66
+ for (const selector of exact) {
67
+ const colon = selector.indexOf(':');
68
+ const [lang, key] = [selector.slice(0, colon), selector.slice(colon + 1)];
69
+ for (const file of findSourceFiles(config.root, config.files, config.sourceLanguage)) {
70
+ const id = reviewId(lang, file.id, key);
71
+ if (state.review[id])
72
+ continue;
73
+ const value = currentValue(config, lang, file.id, key);
74
+ if (value === undefined)
75
+ continue;
76
+ state.review[id] = { status: 'approved', reason: 'approved-by-hand', file: file.pathFor(lang), since: today(), valueHash: hash(value) };
77
+ changed++;
78
+ }
79
+ }
80
+ }
61
81
  if (changed > 0)
62
82
  state.save();
63
83
  return changed;
@@ -0,0 +1,15 @@
1
+ import type { Config } from './config.js';
2
+ import { type LocaleFile } from './files.js';
3
+ /** Which files and strings localewarden translates, and their length limits. */
4
+ export declare class Scope {
5
+ private readonly config;
6
+ private readonly excluded;
7
+ private readonly ignored;
8
+ private readonly limits;
9
+ constructor(config: Config);
10
+ /** Excluded by id ("locales/{lang}/nav.json") or by the source path ("locales/en/nav.json"). */
11
+ isExcluded(file: LocaleFile): boolean;
12
+ /** Not text: an ignored key or a URL, email, file path or number. Copied, never translated. */
13
+ isLiteral(key: string, value: string): boolean;
14
+ maxLength(key: string): number | undefined;
15
+ }
package/dist/scope.js ADDED
@@ -0,0 +1,26 @@
1
+ import { isLiteralValue, keyPattern, pathPattern } from './files.js';
2
+ /** Which files and strings localewarden translates, and their length limits. */
3
+ export class Scope {
4
+ config;
5
+ excluded;
6
+ ignored;
7
+ limits;
8
+ constructor(config) {
9
+ this.config = config;
10
+ this.excluded = config.exclude.map(pathPattern);
11
+ this.ignored = config.ignoreKeys.map(keyPattern);
12
+ this.limits = Object.entries(config.maxLength).map(([pattern, max]) => [keyPattern(pattern), max]);
13
+ }
14
+ /** Excluded by id ("locales/{lang}/nav.json") or by the source path ("locales/en/nav.json"). */
15
+ isExcluded(file) {
16
+ const paths = [file.id, file.pathFor(this.config.sourceLanguage)];
17
+ return this.excluded.some(re => paths.some(p => re.test(p)));
18
+ }
19
+ /** Not text: an ignored key or a URL, email, file path or number. Copied, never translated. */
20
+ isLiteral(key, value) {
21
+ return this.ignored.some(re => re.test(key)) || isLiteralValue(value);
22
+ }
23
+ maxLength(key) {
24
+ return this.limits.find(([re]) => re.test(key))?.[1];
25
+ }
26
+ }
package/dist/state.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * repair-failures.json targeted repairs (--fix-flagged) that were rejected, so the same
10
10
  * value is not sent to the model again.
11
11
  */
12
- export type ReviewReason = 'manual-edit' | 'source-changed' | 'edited-after-approval';
12
+ export type ReviewReason = 'manual-edit' | 'source-changed' | 'edited-after-approval' | 'approved-by-hand';
13
13
  export interface ReviewEntry {
14
14
  status: 'pending' | 'approved';
15
15
  reason: ReviewReason;