champollion 0.3.3
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/LICENSE +133 -0
- package/README.md +387 -0
- package/bin/cli.js +278 -0
- package/index.js +135 -0
- package/lib/api-key.js +127 -0
- package/lib/autofix.js +432 -0
- package/lib/bridge/method_bridge.py +430 -0
- package/lib/card-source-resolution.mjs +284 -0
- package/lib/cards/cache.js +169 -0
- package/lib/cards/env.js +82 -0
- package/lib/cards/fetch-card-child.js +38 -0
- package/lib/cards/reader.js +435 -0
- package/lib/cards/refresh.js +111 -0
- package/lib/cards/remote.js +387 -0
- package/lib/cldf-export.mjs +540 -0
- package/lib/cldf-terms.mjs +62 -0
- package/lib/command-help.js +790 -0
- package/lib/commands/audit.js +49 -0
- package/lib/commands/card.js +454 -0
- package/lib/commands/doctor.js +559 -0
- package/lib/commands/fonts.js +489 -0
- package/lib/commands/help.js +91 -0
- package/lib/commands/init.js +1259 -0
- package/lib/commands/integrity.js +148 -0
- package/lib/commands/leaderboard.js +478 -0
- package/lib/commands/lint.js +30 -0
- package/lib/commands/models.js +177 -0
- package/lib/commands/plugin.js +103 -0
- package/lib/commands/provenance.js +45 -0
- package/lib/commands/recommend.js +75 -0
- package/lib/commands/register-corpus.js +678 -0
- package/lib/commands/repair-script.js +42 -0
- package/lib/commands/seal-corpus.js +355 -0
- package/lib/commands/seo.js +72 -0
- package/lib/commands/serve.js +147 -0
- package/lib/commands/status.js +265 -0
- package/lib/commands/submit.js +332 -0
- package/lib/commands/sync.js +89 -0
- package/lib/commands/tm.js +573 -0
- package/lib/commands/verify.js +39 -0
- package/lib/commands/watch.js +20 -0
- package/lib/commands/wrap.js +138 -0
- package/lib/commands/xliff.js +327 -0
- package/lib/commercial-eligibility.js +235 -0
- package/lib/concurrent.js +87 -0
- package/lib/config.js +523 -0
- package/lib/contamination-lane.js +76 -0
- package/lib/content-sync.js +731 -0
- package/lib/content.js +733 -0
- package/lib/corpus-registration.mjs +608 -0
- package/lib/cost-report.js +346 -0
- package/lib/diff.js +155 -0
- package/lib/docusaurus-sync.js +1256 -0
- package/lib/flatten.js +55 -0
- package/lib/format.js +954 -0
- package/lib/hash.js +159 -0
- package/lib/icu.js +473 -0
- package/lib/integrity.js +689 -0
- package/lib/license-gate.mjs +478 -0
- package/lib/license-identify.mjs +229 -0
- package/lib/lint.js +629 -0
- package/lib/method-manifest.js +60 -0
- package/lib/methods/anthropic.js +140 -0
- package/lib/methods/apertium.js +163 -0
- package/lib/methods/api.js +316 -0
- package/lib/methods/base.js +184 -0
- package/lib/methods/content-separator.js +45 -0
- package/lib/methods/deepl.js +426 -0
- package/lib/methods/direct-llm.js +586 -0
- package/lib/methods/external.js +332 -0
- package/lib/methods/fetch-with-retry.js +124 -0
- package/lib/methods/gemini.js +147 -0
- package/lib/methods/google-translate.js +402 -0
- package/lib/methods/http-utils.js +122 -0
- package/lib/methods/libretranslate.js +314 -0
- package/lib/methods/llm-coached.js +670 -0
- package/lib/methods/llm.js +592 -0
- package/lib/methods/local.js +76 -0
- package/lib/methods/microsoft-translator.js +331 -0
- package/lib/methods/openai.js +131 -0
- package/lib/methods/openrouter-client.js +327 -0
- package/lib/methods/openrouter-pricing.js +156 -0
- package/lib/methods/provider-env.js +115 -0
- package/lib/methods/provider-pricing.js +310 -0
- package/lib/methods/tilde.js +150 -0
- package/lib/methods/translated.js +229 -0
- package/lib/methods/translation-error.js +80 -0
- package/lib/models.js +258 -0
- package/lib/no-translate.js +233 -0
- package/lib/output.js +238 -0
- package/lib/pairs.js +547 -0
- package/lib/plugins.js +447 -0
- package/lib/provenance.js +323 -0
- package/lib/recommend.js +648 -0
- package/lib/registers.js +1185 -0
- package/lib/repair-script.js +266 -0
- package/lib/scripts.js +994 -0
- package/lib/seal.mjs +464 -0
- package/lib/sealed-qualifier.mjs +211 -0
- package/lib/security.js +59 -0
- package/lib/segment.js +369 -0
- package/lib/seo.js +275 -0
- package/lib/serve.js +854 -0
- package/lib/string-classify.js +85 -0
- package/lib/submit.mjs +344 -0
- package/lib/sync.js +969 -0
- package/lib/tags/bcp47.js +202 -0
- package/lib/tags/resolve.js +314 -0
- package/lib/terminology.js +111 -0
- package/lib/tm-seed.js +294 -0
- package/lib/tm.js +515 -0
- package/lib/translate-pair.js +197 -0
- package/lib/translate.js +203 -0
- package/lib/types.js +230 -0
- package/lib/validate.js +510 -0
- package/lib/verify.js +451 -0
- package/lib/watch.js +145 -0
- package/lib/xliff.js +184 -0
- package/package.json +93 -0
- package/shared/ATTRIBUTION.md +145 -0
- package/shared/CORPORA-CARDS.md +288 -0
- package/shared/DATA-SOVEREIGNTY.md +500 -0
- package/shared/LANGUAGE-CARD-FIELDS.md +532 -0
- package/shared/card-lint-baseline.json +3189 -0
- package/shared/cards-fallback.json +1 -0
- package/shared/catalogue/card-config.json +6091 -0
- package/shared/catalogue/external-results.json +3888 -0
- package/shared/catalogue/gender-guidance.json +1038 -0
- package/shared/catalogue/method-coverage.json +1751 -0
- package/shared/catalogue/metric-coverage.json +170 -0
- package/shared/catalogue/metric-reliability.json +1 -0
- package/shared/catalogue/register-presets.json +3180 -0
- package/shared/catalogue/vitality-scales.json +55 -0
- package/shared/cldr-index.json +1115 -0
- package/shared/code-bridge.json +253 -0
- package/shared/corpora-cards-v1-reference.md +281 -0
- package/shared/curated-dictionary-flags.json +35 -0
- package/shared/curated-endonyms.json +35 -0
- package/shared/curated-fsts.json +51 -0
- package/shared/curated-orthography-conventions.json +26 -0
- package/shared/curated-sil-resources.json +374 -0
- package/shared/curated-tools.json +41 -0
- package/shared/docent/corpus.json +11333 -0
- package/shared/docent/faq.en.json +564 -0
- package/shared/docent/register-blocks.json +60 -0
- package/shared/docent/system-prompt.md +144 -0
- package/shared/domain-taxonomy.json +35 -0
- package/shared/explainers/glossary.json +2975 -0
- package/shared/explainers/tc-features.json +20112 -0
- package/shared/explainers/term-watchlist.json +147 -0
- package/shared/human-services.json +59 -0
- package/shared/license-corrections.json +261 -0
- package/shared/license-evidence.json +13452 -0
- package/shared/licenses.json +6781 -0
- package/shared/method-registry.json +236 -0
- package/shared/metric-registry.json +620 -0
- package/shared/model-aliases.json +7 -0
- package/shared/schemas/champollion-plugin.schema.json +206 -0
- package/shared/schemas/corpora-card.schema.json +957 -0
- package/shared/schemas/domain-taxonomy.schema.json +64 -0
- package/shared/schemas/external-results.schema.json +314 -0
- package/shared/schemas/human-services.schema.json +90 -0
- package/shared/schemas/language-card.schema.json +1308 -0
- package/shared/schemas/licenses.schema.json +155 -0
- package/shared/schemas/method-card.schema.json +412 -0
- package/shared/schemas/method-registry.schema.json +85 -0
- package/shared/schemas/metric-registry.schema.json +96 -0
- package/shared/schemas/metric-reliability.schema.json +178 -0
- package/shared/schemas/model-aliases.schema.json +27 -0
- package/shared/schemas/source-snapshot.schema.json +96 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command: wrap
|
|
3
|
+
*
|
|
4
|
+
* Auto-wraps hardcoded user-facing strings in t() calls.
|
|
5
|
+
* Includes safety gates:
|
|
6
|
+
* 1. Git-clean check (skip in dry-run)
|
|
7
|
+
* 2. Automatic backup to .champollion-backup/
|
|
8
|
+
* 3. Diff preview before each file write
|
|
9
|
+
* 4. --undo support to restore from backup
|
|
10
|
+
*
|
|
11
|
+
* After wrapping, adds the extracted keys to locale files.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { resolveConfig } from '../config.js';
|
|
17
|
+
import { detectFramework, walkDir } from '../lint.js';
|
|
18
|
+
import {
|
|
19
|
+
checkGitClean, createBackup, restoreFromBackup,
|
|
20
|
+
processFile, generateDiff, addKeysToLocales,
|
|
21
|
+
} from '../autofix.js';
|
|
22
|
+
import { output } from '../output.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {import('../types.js').CLIArgs} args - Parsed CLI arguments
|
|
26
|
+
* @param {string} cwd - Working directory
|
|
27
|
+
* @returns {Promise<number>} Exit code (0 = success, 1 = error)
|
|
28
|
+
*/
|
|
29
|
+
async function run(args, cwd) {
|
|
30
|
+
// Gate: Undo mode — restore from backup and exit
|
|
31
|
+
if (args.undo) {
|
|
32
|
+
const { restored, errors } = restoreFromBackup(cwd);
|
|
33
|
+
if (errors.length > 0) {
|
|
34
|
+
for (const err of errors) output.error(err);
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
output.ok(`Restored ${restored} file(s) from .champollion-backup/`);
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const isDry = !!args.dry;
|
|
42
|
+
|
|
43
|
+
// Gate: Git-clean check (skip in dry-run mode)
|
|
44
|
+
if (!isDry) {
|
|
45
|
+
const { clean, status } = checkGitClean(cwd);
|
|
46
|
+
if (!clean) {
|
|
47
|
+
output.error('Git working tree is not clean. Commit or stash first.');
|
|
48
|
+
output.raw(` ${status.split('\n').slice(0, 5).join('\n ')}`);
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const config = resolveConfig(args, cwd);
|
|
54
|
+
const framework = detectFramework(cwd);
|
|
55
|
+
const minLength = parseInt(args['min-length'] || 2, 10);
|
|
56
|
+
|
|
57
|
+
output.raw(`\n champollion wrap${isDry ? ' (dry run)' : ''}`);
|
|
58
|
+
output.raw(` Framework: ${framework.name}`);
|
|
59
|
+
output.raw('');
|
|
60
|
+
|
|
61
|
+
// Find source files
|
|
62
|
+
let sourceFiles = [];
|
|
63
|
+
const srcDir = args.src || null;
|
|
64
|
+
if (srcDir) {
|
|
65
|
+
sourceFiles = walkDir(path.resolve(cwd, srcDir), framework.extensions, ['node_modules', '.next', 'dist', 'build', '.git']);
|
|
66
|
+
} else {
|
|
67
|
+
for (const dir of framework.srcDirs) {
|
|
68
|
+
sourceFiles.push(...walkDir(path.resolve(cwd, dir), framework.extensions, ['node_modules', '.next', 'dist', 'build', '.git']));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (sourceFiles.length === 0) {
|
|
73
|
+
output.info('No source files found to process.');
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Gate: Backup (only if not dry-run)
|
|
78
|
+
if (!isDry) {
|
|
79
|
+
createBackup(sourceFiles, cwd);
|
|
80
|
+
output.info('Backup created at .champollion-backup/');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let totalFixes = 0;
|
|
84
|
+
let totalAmbiguous = 0;
|
|
85
|
+
const allFixes = [];
|
|
86
|
+
|
|
87
|
+
for (const filePath of sourceFiles) {
|
|
88
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
89
|
+
const relPath = path.relative(cwd, filePath);
|
|
90
|
+
|
|
91
|
+
const { modified, fixes, ambiguous } = processFile(
|
|
92
|
+
content, framework.name, framework, minLength
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
if (fixes.length === 0 && ambiguous.length === 0) continue;
|
|
96
|
+
|
|
97
|
+
// Show diff for applied fixes
|
|
98
|
+
if (fixes.length > 0) {
|
|
99
|
+
const diff = generateDiff(content, modified, relPath);
|
|
100
|
+
if (diff) output.raw(diff);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Report ambiguous cases that need human review
|
|
104
|
+
for (const item of ambiguous) {
|
|
105
|
+
output.warn(`${relPath}:${item.line} — "${item.text}" (${item.reason})`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Write only if not dry-run
|
|
109
|
+
if (!isDry && fixes.length > 0) {
|
|
110
|
+
fs.writeFileSync(filePath, modified, 'utf-8');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
totalFixes += fixes.length;
|
|
114
|
+
totalAmbiguous += ambiguous.length;
|
|
115
|
+
allFixes.push(...fixes);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Add extracted keys to locale files (only if not dry-run)
|
|
119
|
+
if (!isDry && allFixes.length > 0) {
|
|
120
|
+
const targetLocales = config.languages || [];
|
|
121
|
+
addKeysToLocales(allFixes, config.localesDir, config.inputLocale, targetLocales);
|
|
122
|
+
output.info(`Added ${allFixes.length} key(s) to locale files`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
output.raw('');
|
|
126
|
+
output.ok(`${totalFixes} fix(es) applied${isDry ? ' (dry run)' : ''}`);
|
|
127
|
+
if (totalAmbiguous > 0) {
|
|
128
|
+
output.warn(`${totalAmbiguous} ambiguous case(s) flagged for review`);
|
|
129
|
+
}
|
|
130
|
+
if (!isDry && totalFixes > 0) {
|
|
131
|
+
output.raw(' Run `champollion wrap --undo` to revert');
|
|
132
|
+
}
|
|
133
|
+
output.raw('');
|
|
134
|
+
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export { run };
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command: xliff
|
|
3
|
+
*
|
|
4
|
+
* Exports and imports XLIFF 1.2 files for professional translator review.
|
|
5
|
+
*
|
|
6
|
+
* Subcommands:
|
|
7
|
+
* export Generate a .xliff file from source + target locale files.
|
|
8
|
+
* import Merge reviewed translations from a .xliff file back into locale files.
|
|
9
|
+
*
|
|
10
|
+
* WHY THIS EXISTS:
|
|
11
|
+
* XLIFF is the industry-standard exchange format between translation tools
|
|
12
|
+
* and CAT (Computer-Assisted Translation) platforms. This command lets users:
|
|
13
|
+
* 1. Export translations for professional review in memoQ/SDL Trados/Phrase
|
|
14
|
+
* 2. Import reviewed translations back, then run sync to fill gaps
|
|
15
|
+
* 3. Integrate champollion into existing localization workflows
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { resolveConfig } from '../config.js';
|
|
21
|
+
import { exportXLIFF, importXLIFF } from '../xliff.js';
|
|
22
|
+
import { compileNoTranslate } from '../no-translate.js';
|
|
23
|
+
import { flattenKeys, setNestedValue } from '../flatten.js';
|
|
24
|
+
import { readLocaleFile, writeLocaleFile, detectFormatFromDir, detectYAMLStyle, getExtension } from '../format.js';
|
|
25
|
+
import { output } from '../output.js';
|
|
26
|
+
|
|
27
|
+
/** Default output directory for exported XLIFF files */
|
|
28
|
+
const XLIFF_DIR = '.champollion/xliff';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {import('../types.js').CLIArgs} args - Parsed CLI arguments
|
|
32
|
+
* @param {string} cwd - Working directory
|
|
33
|
+
* @returns {Promise<number>} Exit code (0 = success, 1 = error)
|
|
34
|
+
*/
|
|
35
|
+
async function run(args, cwd) {
|
|
36
|
+
const sub = args._[1];
|
|
37
|
+
|
|
38
|
+
// --json: stdout carries exactly one JSON document. Quiet mode keeps the
|
|
39
|
+
// human raws off stdout; warnings/errors still reach stderr.
|
|
40
|
+
if (args.json) output.setMode('quiet');
|
|
41
|
+
|
|
42
|
+
if (!sub || sub === 'help') {
|
|
43
|
+
printUsage();
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (sub === 'export') {
|
|
48
|
+
return runExport(args, cwd);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (sub === 'import') {
|
|
52
|
+
return runImport(args, cwd);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
output.error(`Unknown subcommand: "${sub}". Run "champollion xliff --help" for usage.`);
|
|
56
|
+
return 1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// -----------------------------------------------------------------
|
|
60
|
+
// xliff export
|
|
61
|
+
// -----------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Export source + target locale as XLIFF 1.2.
|
|
65
|
+
*
|
|
66
|
+
* Reads the source locale and the specified target locale,
|
|
67
|
+
* generates XLIFF, and writes it to .champollion/xliff/<locale>.xliff
|
|
68
|
+
* (or a custom path via --out).
|
|
69
|
+
*/
|
|
70
|
+
function runExport(args, cwd) {
|
|
71
|
+
const locale = args.locale;
|
|
72
|
+
if (!locale) {
|
|
73
|
+
output.error('Missing required --locale flag. Example: champollion xliff export --locale fr');
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const config = resolveConfig(args, cwd);
|
|
78
|
+
const format = config.format !== 'auto'
|
|
79
|
+
? config.format
|
|
80
|
+
: detectFormatFromDir(config.localesDir);
|
|
81
|
+
const ext = getExtension(format);
|
|
82
|
+
|
|
83
|
+
// Load source locale
|
|
84
|
+
const sourcePath = path.join(config.localesDir, `${config.inputLocale}${ext}`);
|
|
85
|
+
if (!fs.existsSync(sourcePath)) {
|
|
86
|
+
output.error(`Source locale file not found: ${sourcePath}`);
|
|
87
|
+
return 1;
|
|
88
|
+
}
|
|
89
|
+
const sourceRaw = readLocaleFile(sourcePath, format);
|
|
90
|
+
const sourceFlat = format === 'json' ? flattenKeys(sourceRaw) : { ...sourceRaw };
|
|
91
|
+
|
|
92
|
+
// Load target locale (may not exist yet — that's fine, all targets will be empty)
|
|
93
|
+
const targetPath = path.join(config.localesDir, `${locale}${ext}`);
|
|
94
|
+
let targetFlat = {};
|
|
95
|
+
if (fs.existsSync(targetPath)) {
|
|
96
|
+
const targetRaw = readLocaleFile(targetPath, format);
|
|
97
|
+
targetFlat = format === 'json' ? flattenKeys(targetRaw) : { ...targetRaw };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Generate XLIFF
|
|
101
|
+
const xliff = exportXLIFF({
|
|
102
|
+
sourceLocale: config.inputLocale,
|
|
103
|
+
targetLocale: locale,
|
|
104
|
+
sourceFlat,
|
|
105
|
+
targetFlat,
|
|
106
|
+
original: `${config.inputLocale}${ext}`,
|
|
107
|
+
noTranslate: compileNoTranslate(config),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Determine output path
|
|
111
|
+
const outDir = args.out || path.join(cwd, XLIFF_DIR);
|
|
112
|
+
const outPath = args.out
|
|
113
|
+
? (args.out.endsWith('.xliff') ? args.out : path.join(args.out, `${locale}.xliff`))
|
|
114
|
+
: path.join(outDir, `${locale}.xliff`);
|
|
115
|
+
|
|
116
|
+
// Write
|
|
117
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
118
|
+
fs.writeFileSync(outPath, xliff, 'utf-8');
|
|
119
|
+
|
|
120
|
+
const keyCount = Object.keys(sourceFlat).filter(k => typeof sourceFlat[k] === 'string').length;
|
|
121
|
+
const translatedCount = Object.keys(targetFlat).filter(k =>
|
|
122
|
+
typeof targetFlat[k] === 'string' && targetFlat[k].length > 0
|
|
123
|
+
).length;
|
|
124
|
+
|
|
125
|
+
if (args.json) {
|
|
126
|
+
console.log(JSON.stringify({
|
|
127
|
+
command: 'xliff',
|
|
128
|
+
action: 'export',
|
|
129
|
+
locale,
|
|
130
|
+
path: outPath,
|
|
131
|
+
exported: keyCount,
|
|
132
|
+
translated: translatedCount,
|
|
133
|
+
pending: keyCount - translatedCount,
|
|
134
|
+
}, null, 2));
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
output.raw(`\n ✓ Exported XLIFF 1.2 for ${config.inputLocale} → ${locale}`);
|
|
139
|
+
output.raw(` Keys: ${keyCount}`);
|
|
140
|
+
output.raw(` Translated: ${translatedCount}`);
|
|
141
|
+
output.raw(` Pending: ${keyCount - translatedCount}`);
|
|
142
|
+
output.raw(` Written to: ${path.relative(cwd, outPath)}`);
|
|
143
|
+
output.raw('');
|
|
144
|
+
output.raw(' Send this file to your translator or open it in a CAT tool');
|
|
145
|
+
output.raw(' (memoQ, SDL Trados, Phrase, etc.). When reviewed, import it back:');
|
|
146
|
+
output.raw(` champollion xliff import ${path.relative(cwd, outPath)}\n`);
|
|
147
|
+
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// -----------------------------------------------------------------
|
|
152
|
+
// xliff import
|
|
153
|
+
// -----------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Import reviewed translations from an XLIFF file back into the locale file.
|
|
157
|
+
*
|
|
158
|
+
* Reads the XLIFF, extracts target translations, and merges them
|
|
159
|
+
* into the corresponding locale file. Only keys present in the
|
|
160
|
+
* XLIFF are overwritten — existing translations for other keys
|
|
161
|
+
* are preserved.
|
|
162
|
+
*/
|
|
163
|
+
function runImport(args, cwd) {
|
|
164
|
+
const xliffPath = args._[2];
|
|
165
|
+
if (!xliffPath) {
|
|
166
|
+
output.error('Missing XLIFF file path. Example: champollion xliff import .champollion/xliff/fr.xliff');
|
|
167
|
+
return 1;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const resolvedPath = path.resolve(cwd, xliffPath);
|
|
171
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
172
|
+
output.error(`XLIFF file not found: ${resolvedPath}`);
|
|
173
|
+
return 1;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const xliffContent = fs.readFileSync(resolvedPath, 'utf-8');
|
|
177
|
+
const { translations, metadata } = importXLIFF(xliffContent);
|
|
178
|
+
|
|
179
|
+
if (Object.keys(translations).length === 0) {
|
|
180
|
+
if (args.json) {
|
|
181
|
+
console.log(JSON.stringify({
|
|
182
|
+
command: 'xliff', action: 'import', locale: metadata.targetLocale || null,
|
|
183
|
+
path: resolvedPath, imported: 0, updated: 0, added: 0, unchanged: 0,
|
|
184
|
+
note: 'No translated entries found in the XLIFF file.',
|
|
185
|
+
}, null, 2));
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
188
|
+
output.raw('\n No translated entries found in the XLIFF file.');
|
|
189
|
+
output.raw(' Make sure the XLIFF contains <target> elements with content.\n');
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const locale = metadata.targetLocale;
|
|
194
|
+
if (!locale) {
|
|
195
|
+
output.error('Could not determine target locale from XLIFF metadata.');
|
|
196
|
+
output.error('The <file> element must have a target-language attribute.');
|
|
197
|
+
return 1;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const config = resolveConfig(args, cwd);
|
|
201
|
+
const format = config.format !== 'auto'
|
|
202
|
+
? config.format
|
|
203
|
+
: detectFormatFromDir(config.localesDir);
|
|
204
|
+
const ext = getExtension(format);
|
|
205
|
+
const targetPath = path.join(config.localesDir, `${locale}${ext}`);
|
|
206
|
+
|
|
207
|
+
const dryRun = args.dry || false;
|
|
208
|
+
|
|
209
|
+
// Load existing target locale (or start fresh)
|
|
210
|
+
let existingFlat = {};
|
|
211
|
+
let existingRaw = {};
|
|
212
|
+
if (fs.existsSync(targetPath)) {
|
|
213
|
+
existingRaw = readLocaleFile(targetPath, format);
|
|
214
|
+
existingFlat = format === 'json' ? flattenKeys(existingRaw) : { ...existingRaw };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Merge: XLIFF translations overwrite existing values
|
|
218
|
+
let updated = 0;
|
|
219
|
+
let added = 0;
|
|
220
|
+
for (const [key, value] of Object.entries(translations)) {
|
|
221
|
+
if (key in existingFlat) {
|
|
222
|
+
if (existingFlat[key] !== value) {
|
|
223
|
+
updated++;
|
|
224
|
+
}
|
|
225
|
+
} else {
|
|
226
|
+
added++;
|
|
227
|
+
}
|
|
228
|
+
existingFlat[key] = value;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const skipped = Object.keys(translations).length - updated - added;
|
|
232
|
+
|
|
233
|
+
if (dryRun) {
|
|
234
|
+
if (args.json) {
|
|
235
|
+
console.log(JSON.stringify({
|
|
236
|
+
command: 'xliff', action: 'import', dryRun: true, locale, path: targetPath,
|
|
237
|
+
imported: Object.keys(translations).length, updated, added, unchanged: skipped,
|
|
238
|
+
}, null, 2));
|
|
239
|
+
return 0;
|
|
240
|
+
}
|
|
241
|
+
output.raw(`\n [DRY RUN] Would import ${Object.keys(translations).length} translations for ${locale}`);
|
|
242
|
+
output.raw(` Updated: ${updated} (changed from existing)`);
|
|
243
|
+
output.raw(` Added: ${added} (new keys)`);
|
|
244
|
+
output.raw(` Unchanged: ${skipped}`);
|
|
245
|
+
output.raw(` Target: ${path.relative(cwd, targetPath)}\n`);
|
|
246
|
+
return 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Write back
|
|
250
|
+
if (format === 'json') {
|
|
251
|
+
// Re-nest the flat map into JSON structure using setNestedValue
|
|
252
|
+
const nested = {};
|
|
253
|
+
for (const [key, value] of Object.entries(existingFlat)) {
|
|
254
|
+
setNestedValue(nested, key, value);
|
|
255
|
+
}
|
|
256
|
+
fs.writeFileSync(targetPath, JSON.stringify(nested, null, 2) + '\n', 'utf-8');
|
|
257
|
+
} else {
|
|
258
|
+
// TOML/YAML — write the merged flat map back through the exact same
|
|
259
|
+
// writer sync uses (lib/format.js writeLocaleFile), so an imported file
|
|
260
|
+
// keeps the serialization style of a synced project. YAML needs the
|
|
261
|
+
// style probe sync performs on the SOURCE locale file (Hugo plural
|
|
262
|
+
// sub-keys vs standard nesting); fall back to the target file when the
|
|
263
|
+
// source is missing.
|
|
264
|
+
let yamlStyle = null;
|
|
265
|
+
if (format === 'yaml') {
|
|
266
|
+
const sourcePath = path.join(config.localesDir, `${config.inputLocale}${ext}`);
|
|
267
|
+
const stylePath = fs.existsSync(sourcePath) ? sourcePath
|
|
268
|
+
: (fs.existsSync(targetPath) ? targetPath : null);
|
|
269
|
+
yamlStyle = stylePath ? detectYAMLStyle(fs.readFileSync(stylePath, 'utf-8')) : null;
|
|
270
|
+
}
|
|
271
|
+
writeLocaleFile(targetPath, existingFlat, format, existingFlat, yamlStyle);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (args.json) {
|
|
275
|
+
console.log(JSON.stringify({
|
|
276
|
+
command: 'xliff', action: 'import', dryRun: false, locale, path: targetPath,
|
|
277
|
+
imported: Object.keys(translations).length, updated, added, unchanged: skipped,
|
|
278
|
+
}, null, 2));
|
|
279
|
+
return 0;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
output.raw(`\n ✓ Imported ${Object.keys(translations).length} translations for ${locale}`);
|
|
283
|
+
output.raw(` Updated: ${updated} (changed from existing)`);
|
|
284
|
+
output.raw(` Added: ${added} (new keys)`);
|
|
285
|
+
output.raw(` Unchanged: ${skipped}`);
|
|
286
|
+
output.raw(` Written to: ${path.relative(cwd, targetPath)}\n`);
|
|
287
|
+
|
|
288
|
+
return 0;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// -----------------------------------------------------------------
|
|
292
|
+
// Usage
|
|
293
|
+
// -----------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
function printUsage() {
|
|
296
|
+
output.raw(`
|
|
297
|
+
champollion xliff — XLIFF 1.2 export/import for professional review
|
|
298
|
+
|
|
299
|
+
SUBCOMMANDS
|
|
300
|
+
export Generate a .xliff file for a target locale
|
|
301
|
+
import Merge reviewed .xliff translations back into locale files
|
|
302
|
+
|
|
303
|
+
OPTIONS
|
|
304
|
+
--locale <code> Target locale for export (required for export)
|
|
305
|
+
--out <path> Custom output path or directory (export)
|
|
306
|
+
--dry Preview import without writing files
|
|
307
|
+
--json Single JSON document output (export/import)
|
|
308
|
+
--config <path> Path to config file
|
|
309
|
+
|
|
310
|
+
Import writes back in the project's locale format (JSON, TOML, or YAML)
|
|
311
|
+
using the same serializers sync uses.
|
|
312
|
+
|
|
313
|
+
WORKFLOW
|
|
314
|
+
1. Export: champollion xliff export --locale fr
|
|
315
|
+
2. Review: Send .champollion/xliff/fr.xliff to translator or CAT tool
|
|
316
|
+
3. Import: champollion xliff import .champollion/xliff/fr.xliff
|
|
317
|
+
4. Sync: champollion sync (fills remaining gaps)
|
|
318
|
+
|
|
319
|
+
EXAMPLES
|
|
320
|
+
champollion xliff export --locale fr
|
|
321
|
+
champollion xliff export --locale ja --out ./review/
|
|
322
|
+
champollion xliff import .champollion/xliff/fr.xliff
|
|
323
|
+
champollion xliff import ./reviewed.xliff --dry
|
|
324
|
+
`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export { run };
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commercial-eligibility.js — the ONE answer to "may this method be routed to
|
|
3
|
+
* in a COMMERCIAL lane?", enforced at routing time.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS. `commercialReady` was declared in three places that had
|
|
6
|
+
* already drifted: the cross-runtime SSOT (`shared/method-registry.json`),
|
|
7
|
+
* a hardcoded table in `lib/provenance.js`, and each method loader's own
|
|
8
|
+
* `getProvenance()`. LibreTranslate was `AGPL-3.0` + `commercialReady: true`
|
|
9
|
+
* in two of them and `false` in the SSOT — i.e. the AGPL boundary CLAUDE.md
|
|
10
|
+
* calls a never-cross rule was crossable by reading the wrong table. This
|
|
11
|
+
* module makes the SSOT win everywhere and makes the answer enforceable
|
|
12
|
+
* instead of advisory.
|
|
13
|
+
*
|
|
14
|
+
* THE RULES, in precedence order:
|
|
15
|
+
*
|
|
16
|
+
* 1. **The shared registry wins.** If `shared/method-registry.json` has an
|
|
17
|
+
* entry for the method (by canonical key OR `cli_name`), its
|
|
18
|
+
* `commercialReady` is the answer. Nothing overrides it upward — a
|
|
19
|
+
* plugin cannot declare itself commercial-ready past an SSOT `false`.
|
|
20
|
+
* 2. **A plugin may only ever restrict.** `pluginProvenance.commercialReady
|
|
21
|
+
* === false` blocks even when the registry says true (a coached plugin
|
|
22
|
+
* can carry NC coaching data the engine knows nothing about).
|
|
23
|
+
* 3. **Methods the registry deliberately does not model** — CLI-only
|
|
24
|
+
* pseudo-methods with no cross-runtime adapter — are declared in
|
|
25
|
+
* CLI_ONLY below, each with its reason.
|
|
26
|
+
* 4. **Unknown is INELIGIBLE.** No registry entry, no declaration, no
|
|
27
|
+
* plugin provenance → not routable commercially. Fail-safe, matching
|
|
28
|
+
* `license-gate.mjs`'s doctrine for sources ("an unknown / unstated /
|
|
29
|
+
* mixed source is treated as RESTRICTED").
|
|
30
|
+
*
|
|
31
|
+
* SCOPE. This gates the COMMERCIAL lane only. Champollion's default lane is
|
|
32
|
+
* non-commercial, where NC and copyleft engines are perfectly usable — that
|
|
33
|
+
* is the open project, and nothing here restricts it. Same orthogonality
|
|
34
|
+
* `license-gate.mjs` already applies to corpora: use context is a descriptor,
|
|
35
|
+
* not a single bucket.
|
|
36
|
+
*
|
|
37
|
+
* @module commercial-eligibility
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { manifestEntries, cliNameFor } from './method-manifest.js';
|
|
41
|
+
import { USE_CONTEXTS } from './license-gate.mjs';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Methods with no entry in the shared registry, because they are CLI-only
|
|
45
|
+
* constructs rather than engines with a cross-runtime adapter.
|
|
46
|
+
*
|
|
47
|
+
* Every entry states WHY. An engine that belongs in the registry must go in
|
|
48
|
+
* the registry — this table is not a bypass for it.
|
|
49
|
+
*/
|
|
50
|
+
const CLI_ONLY = {
|
|
51
|
+
// Plain LLM translation through the user's own provider key. The engine
|
|
52
|
+
// itself carries no external data dependency; the provider's ToS governs,
|
|
53
|
+
// and every LLM provider in the registry is commercialReady.
|
|
54
|
+
'llm-coached': {
|
|
55
|
+
eligible: true,
|
|
56
|
+
license: 'Provider ToS (per LLM provider)',
|
|
57
|
+
reason: 'the method carries no data dependency of its own — its COACHING '
|
|
58
|
+
+ 'data does, and that rides pluginProvenance (rule 2), which can only '
|
|
59
|
+
+ 'restrict',
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
// Human-in-the-loop review. No external resource at all.
|
|
63
|
+
'human-review': {
|
|
64
|
+
eligible: true,
|
|
65
|
+
license: 'n/a',
|
|
66
|
+
reason: 'no external resource — human review of output already produced',
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
// Points at a remote champollion-serve endpoint. Whatever is BEHIND it is
|
|
70
|
+
// declared by the plugin manifest, so with no declaration there is nothing
|
|
71
|
+
// to stand on.
|
|
72
|
+
api: {
|
|
73
|
+
eligible: false,
|
|
74
|
+
license: 'unknown (declared by the plugin manifest)',
|
|
75
|
+
reason: 'a remote endpoint\'s provenance is only knowable from its plugin '
|
|
76
|
+
+ 'manifest — supply one declaring commercialReady, or route it in the '
|
|
77
|
+
+ 'non-commercial lane',
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
// Arbitrary user-supplied Python method directory.
|
|
81
|
+
external: {
|
|
82
|
+
eligible: false,
|
|
83
|
+
license: 'unknown (user-supplied plugin)',
|
|
84
|
+
reason: 'a user-supplied plugin\'s dependencies are unknown to us',
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
// The Plains Cree FST pipeline: AGPL FST invoked as a separate tool, plus
|
|
88
|
+
// the Wolvengrey dictionary, which is index-only and permanently
|
|
89
|
+
// non-redistributable (founder ruling 2026-07-19).
|
|
90
|
+
'fst-gated': {
|
|
91
|
+
eligible: false,
|
|
92
|
+
license: 'AGPL-3.0-or-later + PROPRIETARY dictionary',
|
|
93
|
+
reason: 'depends on an AGPL FST and a proprietary dictionary under a '
|
|
94
|
+
+ 'pending agreement',
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Look the method up in the shared registry by canonical key or CLI alias.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} methodName
|
|
102
|
+
* @returns {{ name: string, entry: object } | null} null when the registry is
|
|
103
|
+
* absent (standalone package with no bundled shared/) or the name is unknown
|
|
104
|
+
*/
|
|
105
|
+
function registryEntryFor(methodName) {
|
|
106
|
+
for (const [name, entry] of Object.entries(manifestEntries())) {
|
|
107
|
+
if (name === methodName || cliNameFor(name, entry) === methodName) {
|
|
108
|
+
return { name, entry };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Resolve whether a method may be routed to commercially.
|
|
116
|
+
*
|
|
117
|
+
* Pure and offline — reads the bundled registry only. Never throws; callers
|
|
118
|
+
* that want an exception use assertRoutable().
|
|
119
|
+
*
|
|
120
|
+
* @param {string} methodName - CLI method name or canonical registry key
|
|
121
|
+
* @param {object} [opts]
|
|
122
|
+
* @param {object|null} [opts.pluginProvenance] - The plugin manifest's own
|
|
123
|
+
* provenance declaration, when the pair config carries one. May only restrict.
|
|
124
|
+
* @returns {{ eligible: boolean, source: string, license: string|null,
|
|
125
|
+
* reason: string }} `source` is where the verdict came from, so a report can
|
|
126
|
+
* cite it: 'registry' | 'cli-only' | 'plugin' | 'unknown'
|
|
127
|
+
*/
|
|
128
|
+
export function resolveCommercialEligibility(methodName, { pluginProvenance = null } = {}) {
|
|
129
|
+
const pluginBlocks = !!pluginProvenance
|
|
130
|
+
&& typeof pluginProvenance === 'object'
|
|
131
|
+
&& pluginProvenance.commercialReady === false;
|
|
132
|
+
|
|
133
|
+
const found = registryEntryFor(methodName);
|
|
134
|
+
if (found) {
|
|
135
|
+
const { name, entry } = found;
|
|
136
|
+
const eligible = entry.commercialReady === true && !pluginBlocks;
|
|
137
|
+
let reason;
|
|
138
|
+
if (pluginBlocks) {
|
|
139
|
+
reason = 'the installed plugin declares itself not commercial-ready';
|
|
140
|
+
} else if (entry.commercialReady === true) {
|
|
141
|
+
reason = `cleared in the shared method registry (${name})`;
|
|
142
|
+
} else {
|
|
143
|
+
reason = `not commercial-ready in the shared method registry (${name})`;
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
eligible,
|
|
147
|
+
source: pluginBlocks ? 'plugin' : 'registry',
|
|
148
|
+
license: entry.license || null,
|
|
149
|
+
reason,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const declared = CLI_ONLY[methodName];
|
|
154
|
+
if (declared) {
|
|
155
|
+
const eligible = declared.eligible && !pluginBlocks;
|
|
156
|
+
return {
|
|
157
|
+
eligible,
|
|
158
|
+
source: pluginBlocks ? 'plugin' : 'cli-only',
|
|
159
|
+
license: declared.license,
|
|
160
|
+
reason: pluginBlocks
|
|
161
|
+
? 'the installed plugin declares itself not commercial-ready'
|
|
162
|
+
: declared.reason,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Rule 4. A plugin declaring itself ready is NOT enough to clear an
|
|
167
|
+
// otherwise-unknown method — that would let any manifest self-certify.
|
|
168
|
+
return {
|
|
169
|
+
eligible: false,
|
|
170
|
+
source: 'unknown',
|
|
171
|
+
license: null,
|
|
172
|
+
reason: `"${methodName}" has no recorded commercial eligibility — unknown `
|
|
173
|
+
+ 'methods are treated as restricted',
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Error thrown when a commercial route is refused. Carries the structured
|
|
179
|
+
* verdict so an API layer can render it without re-deriving anything.
|
|
180
|
+
*/
|
|
181
|
+
export class CommercialRouteBlockedError extends Error {
|
|
182
|
+
/**
|
|
183
|
+
* @param {string} methodName
|
|
184
|
+
* @param {{eligible:boolean, source:string, license:string|null, reason:string}} verdict
|
|
185
|
+
*/
|
|
186
|
+
constructor(methodName, verdict) {
|
|
187
|
+
super(
|
|
188
|
+
`Method "${methodName}" is not cleared for the commercial lane: `
|
|
189
|
+
+ `${verdict.reason}`
|
|
190
|
+
+ (verdict.license ? ` (license: ${verdict.license})` : '')
|
|
191
|
+
+ '. Route it in the non-commercial lane, or use a method whose license '
|
|
192
|
+
+ 'permits commercial use (`champollion recommend <src> <tgt> '
|
|
193
|
+
+ '--use commercial` lists them).'
|
|
194
|
+
);
|
|
195
|
+
this.name = 'CommercialRouteBlockedError';
|
|
196
|
+
this.methodName = methodName;
|
|
197
|
+
this.verdict = verdict;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Enforcement point. Throws when the lane is commercial and the method is not
|
|
203
|
+
* cleared; returns the verdict otherwise.
|
|
204
|
+
*
|
|
205
|
+
* The non-commercial lane is the default and is never blocked here — NC and
|
|
206
|
+
* copyleft engines are legitimate there, which is the whole point of the open
|
|
207
|
+
* project.
|
|
208
|
+
*
|
|
209
|
+
* @param {string} methodName
|
|
210
|
+
* @param {object} [opts]
|
|
211
|
+
* @param {('commercial'|'non-commercial')} [opts.useContext='non-commercial']
|
|
212
|
+
* @param {object|null} [opts.pluginProvenance]
|
|
213
|
+
* @returns {{eligible:boolean, source:string, license:string|null, reason:string}}
|
|
214
|
+
* @throws {CommercialRouteBlockedError}
|
|
215
|
+
*/
|
|
216
|
+
export function assertRoutable(methodName, { useContext = USE_CONTEXTS.NON_COMMERCIAL, pluginProvenance = null } = {}) {
|
|
217
|
+
const verdict = resolveCommercialEligibility(methodName, { pluginProvenance });
|
|
218
|
+
if (useContext === USE_CONTEXTS.COMMERCIAL && !verdict.eligible) {
|
|
219
|
+
throw new CommercialRouteBlockedError(methodName, verdict);
|
|
220
|
+
}
|
|
221
|
+
return verdict;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Convenience boolean for report surfaces that only need the answer.
|
|
226
|
+
*
|
|
227
|
+
* @param {string} methodName
|
|
228
|
+
* @param {object} [opts]
|
|
229
|
+
* @returns {boolean}
|
|
230
|
+
*/
|
|
231
|
+
export function isCommercialEligible(methodName, opts = {}) {
|
|
232
|
+
return resolveCommercialEligibility(methodName, opts).eligible;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export { CLI_ONLY as CLI_ONLY_ELIGIBILITY };
|