@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.
- package/.wbcom-i18n.example.json +21 -0
- package/README.md +55 -0
- package/bin/wbcom-i18n.js +47 -0
- package/grunt/index.js +18 -0
- package/lib/compile.js +28 -0
- package/lib/config.js +33 -0
- package/lib/index.js +7 -0
- package/lib/sync.js +31 -0
- package/lib/translate.js +103 -0
- package/lib/util.js +59 -0
- package/package.json +48 -0
|
@@ -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
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 };
|
package/lib/translate.js
ADDED
|
@@ -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
|
+
}
|