@wbcom/i18n-ai 0.1.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.
@@ -0,0 +1,21 @@
1
+ {
2
+ "domain": "jetonomy",
3
+ "potFile": "languages/jetonomy.pot",
4
+ "languagesDir": "languages",
5
+ "engine": "claude",
6
+ "model": "haiku",
7
+ "batch": 40,
8
+ "makeJson": true,
9
+ "protect": ["Jetonomy", "Pro", "WordPress", "BuddyPress", "REST", "API", "URL", "CSV", "AI"],
10
+ "glossary": {
11
+ "Space": "",
12
+ "Trust Level": ""
13
+ },
14
+ "locales": [
15
+ { "locale": "de_DE", "name": "German", "register": "informal (du)" },
16
+ { "locale": "fr_FR", "name": "French", "register": "informal (tu)" },
17
+ { "locale": "es_ES", "name": "Spanish", "register": "informal (tú)" },
18
+ { "locale": "nl_NL", "name": "Dutch", "register": "informal (je)" },
19
+ { "locale": "ko_KR", "name": "Korean", "register": "polite (해요체)", "model": "sonnet" }
20
+ ]
21
+ }
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # @wbcom/i18n-ai
2
+
3
+ AI-translate WordPress plugin/theme `.po` files as part of your build/release — **sync new strings, translate them, validate, compile**. Wires into `grunt` and `bin/build-release.sh`; reusable across every plugin via one `devDependency`.
4
+
5
+ It does **not** replace human translators where you want them — it produces solid first-pass translations (Claude) so locales ship populated; site owners refine per-site with **Loco Translate**.
6
+
7
+ ## How it works
8
+
9
+ ```
10
+ grunt makepot # your existing step → fresh .pot
11
+ wbcom-i18n sync # msgmerge .pot → each locale .po (NEW → untranslated, CHANGED → fuzzy)
12
+ wbcom-i18n translate # AI-fill ONLY untranslated + fuzzy entries (incremental, cheap)
13
+ wbcom-i18n compile # msgfmt -c → .mo + wp i18n make-json → JS/block .json
14
+ # or: wbcom-i18n all
15
+ ```
16
+
17
+ - **Incremental** — `sync` (msgmerge) means each release only translates *new/changed* strings; existing translations are preserved.
18
+ - **Crash-safe** — every translation's printf placeholders (`%s`, `%1$s`…) are validated against the source. A mismatch marks the entry `fuzzy`, so gettext won't compile it → the string **falls back to English instead of crashing**. No human review required for safety.
19
+ - **Brand-aware** — `protect` terms (Jetonomy, Pro, WordPress…) and an optional `glossary` keep terminology consistent.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ npm i -D @wbcom/i18n-ai # plus system tools: gettext (msginit/msgmerge/msgfmt), wp-cli, and the `claude` CLI
25
+ ```
26
+
27
+ Requirements: Node ≥18, GNU **gettext**, **wp-cli** (for `make-json`), and an engine — the **`claude` CLI** (default, no API key) or `ANTHROPIC_API_KEY` (`--engine=api`).
28
+
29
+ ## Configure — `.wbcom-i18n.json` (plugin root)
30
+
31
+ See `.wbcom-i18n.example.json`. Per-locale `model` lets you use Haiku for European languages and Sonnet for Korean/CJK.
32
+
33
+ ## CLI
34
+
35
+ ```bash
36
+ wbcom-i18n all # sync → translate → compile, all locales
37
+ wbcom-i18n translate --locales=de_DE # one locale
38
+ wbcom-i18n sync # just merge new strings
39
+ wbcom-i18n compile # just build .mo + .json
40
+ wbcom-i18n all --engine=api --model=sonnet
41
+ ```
42
+
43
+ ## Grunt
44
+
45
+ ```js
46
+ // Gruntfile.js
47
+ require('@wbcom/i18n-ai/grunt')(grunt);
48
+ grunt.registerTask('build', ['makepot', 'i18n', 'rtlcss', 'cssmin', 'uglify']);
49
+ ```
50
+
51
+ `grunt i18n` runs sync → translate → compile, so `grunt build` (and your release script) keeps all locales current automatically.
52
+
53
+ ## License
54
+
55
+ GPL-2.0-or-later.
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ const { loadConfig } = require('../lib/config');
4
+ const { syncLocales } = require('../lib/sync');
5
+ const { translatePo } = require('../lib/translate');
6
+ const { compileLocales } = require('../lib/compile');
7
+
8
+ const log = (m) => console.log(m);
9
+
10
+ function parseArgs(argv) {
11
+ const a = { _: [] };
12
+ for (const t of argv) {
13
+ if (t.startsWith('--')) { const [k, v] = t.slice(2).split('='); a[k] = v === undefined ? true : v; }
14
+ else a._.push(t);
15
+ }
16
+ return a;
17
+ }
18
+
19
+ const USAGE = `wbcom-i18n <command> [options]
20
+ Commands: sync | translate | compile | all (default)
21
+ Options:
22
+ --config=<file> config file (default .wbcom-i18n.json)
23
+ --locales=de_DE,.. only these locales
24
+ --engine=claude|api override engine
25
+ --model=haiku|... override default model
26
+ `;
27
+
28
+ async function main() {
29
+ const args = parseArgs(process.argv.slice(2));
30
+ if (args.help || args.h) { process.stdout.write(USAGE); return; }
31
+ const cmd = args._[0] || 'all';
32
+ const cfg = loadConfig(process.cwd(), typeof args.config === 'string' ? args.config : '.wbcom-i18n.json');
33
+ if (args.locales) { const want = String(args.locales).split(','); cfg.locales = cfg.locales.filter((l) => want.includes(l.locale)); }
34
+ if (typeof args.engine === 'string') cfg.engine = args.engine;
35
+ if (typeof args.model === 'string') cfg.model = args.model;
36
+ if (args.limit) cfg.limit = parseInt(String(args.limit), 10);
37
+ if (!cfg.locales.length) throw new Error('no locales selected');
38
+
39
+ if (cmd === 'sync' || cmd === 'all') { log('▶ sync'); syncLocales(cfg, log); }
40
+ if (cmd === 'translate' || cmd === 'all') {
41
+ log('▶ translate');
42
+ for (const loc of cfg.locales) await translatePo(loc.poFile, loc, cfg, log);
43
+ }
44
+ if (cmd === 'compile' || cmd === 'all') { log('▶ compile'); compileLocales(cfg, log); }
45
+ log('✓ done');
46
+ }
47
+ main().catch((e) => { console.error('ERROR:', e.message); process.exit(1); });
package/grunt/index.js ADDED
@@ -0,0 +1,18 @@
1
+ 'use strict';
2
+ // Register a `grunt i18n` task. In your Gruntfile.js:
3
+ // require('@wbcom/i18n-ai/grunt')(grunt);
4
+ // grunt.registerTask('build', ['makepot', 'i18n', 'rtlcss', 'cssmin', 'uglify']);
5
+ module.exports = function (grunt) {
6
+ const { loadConfig, syncLocales, translatePo, compileLocales } = require('../lib');
7
+ grunt.registerTask('i18n', 'Sync + AI-translate + compile locale .po files', function () {
8
+ const done = this.async();
9
+ (async () => {
10
+ const cfg = loadConfig(process.cwd());
11
+ const w = (m) => grunt.log.writeln(m);
12
+ grunt.log.subhead('i18n: sync'); syncLocales(cfg, w);
13
+ grunt.log.subhead('i18n: translate');
14
+ for (const loc of cfg.locales) await translatePo(loc.poFile, loc, cfg, w);
15
+ grunt.log.subhead('i18n: compile'); compileLocales(cfg, w);
16
+ })().then(() => done(), (e) => { grunt.log.error(e.message); done(false); });
17
+ });
18
+ };
package/lib/compile.js ADDED
@@ -0,0 +1,28 @@
1
+ 'use strict';
2
+ const { execFileSync } = require('child_process');
3
+
4
+ function sh(cmd, args) {
5
+ return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
6
+ }
7
+
8
+ /**
9
+ * Compile each locale .po -> .mo (with `-c` = the crash-safety check), then emit
10
+ * the JSON files block/JS i18n needs (wp i18n make-json). msgfmt -c fails loudly
11
+ * on any format/placeholder error a fuzzy flag didn't already neutralise.
12
+ */
13
+ function compileLocales(cfg, log = () => {}) {
14
+ for (const loc of cfg.locales) {
15
+ sh('msgfmt', ['-c', '-o', loc.moFile, loc.poFile]);
16
+ log(` compile ${loc.locale}: ${loc.moFile}`);
17
+ }
18
+ if (cfg.makeJson) {
19
+ try {
20
+ sh('wp', ['i18n', 'make-json', cfg.languagesDir, '--no-purge', `--domain=${cfg.domain}`]);
21
+ log(' make-json: emitted JS/block translations');
22
+ } catch (e) {
23
+ log(` make-json skipped (wp-cli i18n unavailable): ${String(e.message).split('\n')[0]}`);
24
+ }
25
+ }
26
+ }
27
+
28
+ module.exports = { compileLocales };
package/lib/config.js ADDED
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ const DEFAULTS = {
6
+ engine: 'claude', // 'claude' (CLI, no key) | 'api' (ANTHROPIC_API_KEY)
7
+ model: 'haiku',
8
+ batch: 40,
9
+ languagesDir: 'languages',
10
+ makeJson: true, // also emit .json for block/JS i18n
11
+ protect: ['Jetonomy', 'Pro', 'WordPress', 'BuddyPress', 'REST', 'API', 'URL', 'CSV', 'AI', 'HTML', 'CSS'],
12
+ glossary: {},
13
+ locales: [],
14
+ };
15
+
16
+ /** Load .wbcom-i18n.json from cwd (plugin root), merge defaults, resolve paths. */
17
+ function loadConfig(cwd = process.cwd(), file = '.wbcom-i18n.json') {
18
+ const p = path.resolve(cwd, file);
19
+ if (!fs.existsSync(p)) throw new Error(`Config not found: ${p}`);
20
+ const cfg = { ...DEFAULTS, ...JSON.parse(fs.readFileSync(p, 'utf8')) };
21
+ cfg.cwd = cwd;
22
+ if (!cfg.domain) throw new Error('config.domain is required (the text domain / slug)');
23
+ cfg.languagesDir = path.resolve(cwd, cfg.languagesDir);
24
+ cfg.potFile = path.resolve(cwd, cfg.potFile || path.join('languages', `${cfg.domain}.pot`));
25
+ cfg.locales = cfg.locales.map((l) => ({
26
+ register: 'neutral', ...l,
27
+ poFile: path.join(cfg.languagesDir, `${cfg.domain}-${l.locale}.po`),
28
+ moFile: path.join(cfg.languagesDir, `${cfg.domain}-${l.locale}.mo`),
29
+ }));
30
+ return cfg;
31
+ }
32
+
33
+ module.exports = { loadConfig, DEFAULTS };
package/lib/index.js ADDED
@@ -0,0 +1,7 @@
1
+ 'use strict';
2
+ module.exports = {
3
+ loadConfig: require('./config').loadConfig,
4
+ syncLocales: require('./sync').syncLocales,
5
+ translatePo: require('./translate').translatePo,
6
+ compileLocales: require('./compile').compileLocales,
7
+ };
package/lib/sync.js ADDED
@@ -0,0 +1,31 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const { execFileSync } = require('child_process');
4
+
5
+ function sh(cmd, args) {
6
+ return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
7
+ }
8
+
9
+ /**
10
+ * Bring every locale .po in lockstep with the current .pot.
11
+ * - missing .po -> msginit (fresh, all untranslated)
12
+ * - existing .po -> msgmerge (NEW strings appear untranslated, CHANGED ones fuzzy)
13
+ * This is the "sync new strings" step: only new/changed entries become work for
14
+ * the translate step; existing translations are preserved.
15
+ */
16
+ function syncLocales(cfg, log = () => {}) {
17
+ if (!fs.existsSync(cfg.potFile)) {
18
+ throw new Error(`POT not found: ${cfg.potFile} (run 'grunt makepot' / 'wp i18n make-pot' first)`);
19
+ }
20
+ for (const loc of cfg.locales) {
21
+ if (fs.existsSync(loc.poFile)) {
22
+ sh('msgmerge', ['--update', '--backup=none', '--no-fuzzy-matching', loc.poFile, cfg.potFile]);
23
+ log(` sync ${loc.locale}: merged new/changed strings`);
24
+ } else {
25
+ sh('msginit', ['--no-translator', `--locale=${loc.locale}`, `--input=${cfg.potFile}`, `--output=${loc.poFile}`]);
26
+ log(` sync ${loc.locale}: created ${loc.poFile}`);
27
+ }
28
+ }
29
+ }
30
+
31
+ module.exports = { syncLocales };
@@ -0,0 +1,103 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const { po } = require('gettext-parser');
4
+ const { samePlaceholders, translateBatch, npluralsOf } = require('./util');
5
+
6
+ const DEFAULT_PROTECT = ['Jetonomy', 'Pro', 'WordPress', 'BuddyPress', 'REST', 'API', 'URL', 'CSV', 'AI', 'HTML', 'CSS'];
7
+
8
+ function buildPrompt(cfg, loc, input) {
9
+ const protect = (cfg.protect || DEFAULT_PROTECT).join(', ');
10
+ const gloss = cfg.glossary && Object.keys(cfg.glossary).length
11
+ ? ` Use this glossary (English -> ${loc.name}) consistently: ${JSON.stringify(cfg.glossary)}.`
12
+ : '';
13
+ return (
14
+ `Translate these WordPress plugin UI strings to ${loc.name} (${loc.locale}), ${loc.register || 'neutral'} register. ` +
15
+ `Return ONLY a compact JSON object mapping each input key to its ${loc.name} translation - no markdown, no commentary. ` +
16
+ `PRESERVE EXACTLY, never translate or reorder: printf placeholders (%s, %d, %1$s, %2$d ...), HTML tags and entities, ` +
17
+ `and these terms: ${protect}. Keep trailing punctuation/ellipsis and any leading/trailing spaces.${gloss} ` +
18
+ `Input: ${JSON.stringify(input)}`
19
+ );
20
+ }
21
+
22
+ function flagSet(entry) {
23
+ return new Set((entry.comments && entry.comments.flag ? entry.comments.flag : '').split(',').map(s => s.trim()).filter(Boolean));
24
+ }
25
+ function setFuzzy(entry, on) {
26
+ const flags = flagSet(entry);
27
+ if (on) flags.add('fuzzy'); else flags.delete('fuzzy');
28
+ entry.comments = entry.comments || {};
29
+ entry.comments.flag = [...flags].join(', ');
30
+ }
31
+ function isFuzzy(entry) { return flagSet(entry).has('fuzzy'); }
32
+
33
+ /** Fill one locale .po in place. Returns {total, filled, fuzzy}. */
34
+ async function translatePo(poPath, loc, cfg, log = () => {}) {
35
+ const data = po.parse(fs.readFileSync(poPath));
36
+ const nplurals = npluralsOf(data.headers['plural-forms'] || data.headers['Plural-Forms']);
37
+ const model = loc.model || cfg.model || 'haiku';
38
+ const engine = cfg.engine || 'claude';
39
+ const batchSize = cfg.batch || 40;
40
+
41
+ // Collect slots needing translation: empty, or fuzzy (changed source via msgmerge).
42
+ const items = [];
43
+ const touched = new Set();
44
+ for (const ctx of Object.keys(data.translations)) {
45
+ for (const key of Object.keys(data.translations[ctx])) {
46
+ const e = data.translations[ctx][key];
47
+ if (e.msgid === '') continue; // header
48
+ const fuzzy = isFuzzy(e);
49
+ if (e.msgid_plural) {
50
+ if (fuzzy || !e.msgstr[0]) items.push({ e, slot: 0, src: e.msgid });
51
+ for (let n = 1; n < nplurals; n++) if (fuzzy || !e.msgstr[n]) items.push({ e, slot: n, src: e.msgid_plural });
52
+ } else if (fuzzy || !e.msgstr[0]) {
53
+ items.push({ e, slot: 0, src: e.msgid });
54
+ }
55
+ }
56
+ }
57
+
58
+ if (cfg.limit && items.length > cfg.limit) items.length = cfg.limit; // test cap
59
+ const total = items.length;
60
+ log(`${loc.locale}: ${total} strings to fill (model=${model}, engine=${engine}, nplurals=${nplurals})`);
61
+ let filled = 0;
62
+ for (let i = 0; i < total; i += batchSize) {
63
+ const chunk = items.slice(i, i + batchSize);
64
+ const input = {};
65
+ chunk.forEach((it, j) => { input[j] = it.src; });
66
+ let res;
67
+ try {
68
+ res = await translateBatch(buildPrompt(cfg, loc, input), model, engine);
69
+ } catch (err) {
70
+ log(` ${loc.locale}: batch ${i} error: ${err.message}`);
71
+ continue;
72
+ }
73
+ chunk.forEach((it, j) => {
74
+ const t = res[j] != null ? String(res[j]) : '';
75
+ if (!t) return;
76
+ it.e.msgstr[it.slot] = t;
77
+ touched.add(it.e);
78
+ filled++;
79
+ });
80
+ log(` ${loc.locale}: ${Math.min(i + batchSize, total)}/${total}`);
81
+ }
82
+
83
+ // Crash-safety validation: any msgstr whose placeholders differ from its source
84
+ // marks the WHOLE entry fuzzy -> gettext won't compile it -> English fallback.
85
+ let fuzzy = 0;
86
+ for (const e of touched) {
87
+ let bad = false;
88
+ if (e.msgid_plural) {
89
+ if (e.msgstr[0] && !samePlaceholders(e.msgid, e.msgstr[0])) bad = true;
90
+ for (let n = 1; n < nplurals; n++) if (e.msgstr[n] && !samePlaceholders(e.msgid_plural, e.msgstr[n])) bad = true;
91
+ } else if (e.msgstr[0] && !samePlaceholders(e.msgid, e.msgstr[0])) {
92
+ bad = true;
93
+ }
94
+ setFuzzy(e, bad);
95
+ if (bad) fuzzy++;
96
+ }
97
+
98
+ fs.writeFileSync(poPath, po.compile(data));
99
+ log(`DONE ${loc.locale}: filled=${filled} fuzzy(placeholder-fallback)=${fuzzy}`);
100
+ return { total, filled, fuzzy };
101
+ }
102
+
103
+ module.exports = { translatePo };
package/lib/util.js ADDED
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+ const { execFileSync } = require('child_process');
3
+
4
+ // printf placeholders WordPress uses: %s %d %1$s %2$d ... and literal %%
5
+ const PH = /%%|%(?:\d+\$)?[bcdeEfFgGosuxX]/g;
6
+
7
+ function placeholders(s) {
8
+ return ((s || '').match(PH) || []).slice().sort();
9
+ }
10
+ function samePlaceholders(a, b) {
11
+ const x = placeholders(a), y = placeholders(b);
12
+ return x.length === y.length && x.every((v, i) => v === y[i]);
13
+ }
14
+
15
+ // Strip markdown fences / prose and isolate the JSON object the model returned.
16
+ function extractJson(out) {
17
+ let s = String(out).trim();
18
+ s = s.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '');
19
+ const m = s.match(/\{[\s\S]*\}/);
20
+ return JSON.parse(m ? m[0] : s);
21
+ }
22
+
23
+ // Engine: claude CLI (default, no key) — model is an alias like "haiku"/"sonnet".
24
+ function callClaude(prompt, model) {
25
+ const out = execFileSync('claude', ['-p', '--model', model, prompt], {
26
+ encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, timeout: 300000,
27
+ });
28
+ return extractJson(out);
29
+ }
30
+
31
+ // Engine: Anthropic API (needs ANTHROPIC_API_KEY). Aliases mapped to model IDs.
32
+ const API_MODELS = {
33
+ haiku: 'claude-haiku-4-5-20251001',
34
+ sonnet: 'claude-sonnet-4-6',
35
+ opus: 'claude-opus-4-8',
36
+ };
37
+ async function callApi(prompt, model) {
38
+ const key = process.env.ANTHROPIC_API_KEY;
39
+ if (!key) throw new Error('ANTHROPIC_API_KEY not set (engine=api)');
40
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
41
+ method: 'POST',
42
+ headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
43
+ body: JSON.stringify({ model: API_MODELS[model] || model, max_tokens: 8000, messages: [{ role: 'user', content: prompt }] }),
44
+ });
45
+ const j = await res.json();
46
+ if (!j.content) throw new Error('API error: ' + JSON.stringify(j).slice(0, 200));
47
+ return extractJson(j.content[0].text);
48
+ }
49
+
50
+ function translateBatch(prompt, model, engine) {
51
+ return engine === 'api' ? callApi(prompt, model) : Promise.resolve(callClaude(prompt, model));
52
+ }
53
+
54
+ function npluralsOf(pluralForms) {
55
+ const m = /nplurals\s*=\s*(\d+)/.exec(pluralForms || '');
56
+ return m ? parseInt(m[1], 10) : 2;
57
+ }
58
+
59
+ module.exports = { placeholders, samePlaceholders, extractJson, translateBatch, npluralsOf };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@wbcom/i18n-ai",
3
+ "version": "0.1.0",
4
+ "description": "AI-translate WordPress plugin/theme .po files: sync new strings (msgmerge), translate (Claude), validate placeholders, compile .mo + .json. Wires into grunt/release.",
5
+ "keywords": [
6
+ "wordpress",
7
+ "i18n",
8
+ "l10n",
9
+ "translation",
10
+ "gettext",
11
+ "po",
12
+ "pot",
13
+ "ai",
14
+ "claude",
15
+ "grunt"
16
+ ],
17
+ "homepage": "https://github.com/vapvarun/wbcom-i18n-ai#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/vapvarun/wbcom-i18n-ai/issues"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/vapvarun/wbcom-i18n-ai.git"
24
+ },
25
+ "bin": {
26
+ "wbcom-i18n": "bin/wbcom-i18n.js"
27
+ },
28
+ "main": "lib/index.js",
29
+ "files": [
30
+ "bin/",
31
+ "lib/",
32
+ "grunt/",
33
+ "README.md",
34
+ ".wbcom-i18n.example.json"
35
+ ],
36
+ "engines": {
37
+ "node": ">=18"
38
+ },
39
+ "type": "commonjs",
40
+ "dependencies": {
41
+ "gettext-parser": "^8.0.0"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "license": "GPL-2.0-or-later",
47
+ "author": "Wbcom Designs"
48
+ }