@i18n-micro/cli 0.0.0-stage → 2.0.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/LICENSE +21 -0
- package/README.md +29 -2
- package/bin/i18n-micro.mjs +3 -0
- package/dist/chunks/check-BaP_H-mS.mjs +175 -0
- package/dist/chunks/clean-Do9z6D5j.mjs +68 -0
- package/dist/chunks/clean-bQVjG_2M.mjs +1 -0
- package/dist/chunks/export-C2J-pagB.mjs +219 -0
- package/dist/chunks/export-usBaNIpp.mjs +1 -0
- package/dist/chunks/extract-CJ8UfrVJ.mjs +30 -0
- package/dist/chunks/format-6CtMgO-M.mjs +26 -0
- package/dist/chunks/http-whB7a5ye.mjs +100 -0
- package/dist/chunks/import-CnL8U6Za.mjs +99 -0
- package/dist/chunks/info-rYas4Yw3.mjs +32 -0
- package/dist/chunks/json-BV_apCMu.mjs +439 -0
- package/dist/chunks/lint-BaEIQGMl.mjs +125 -0
- package/dist/chunks/nuxt-dKxG087V.mjs +366 -0
- package/dist/chunks/package-f9OTcDxl.mjs +3 -0
- package/dist/chunks/project-DgWgr7pF.mjs +206 -0
- package/dist/chunks/protect-D1HIuxmJ.mjs +36 -0
- package/dist/chunks/pseudo-oXBaONgF.mjs +32 -0
- package/dist/chunks/rename-uJnBX86A.mjs +117 -0
- package/dist/chunks/runner-wHXdhRdA.mjs +200 -0
- package/dist/chunks/search-Dj_Ynq_x.mjs +67 -0
- package/dist/chunks/source-Bx2lHSW7.mjs +744 -0
- package/dist/chunks/spec-BwZlT28L.mjs +507 -0
- package/dist/chunks/stats-BU-SmrVR.mjs +156 -0
- package/dist/chunks/sync-BY2EFEda.mjs +1 -0
- package/dist/chunks/sync-C0NVDabK.mjs +56 -0
- package/dist/chunks/sync-remote-y3ZATbF2.mjs +148 -0
- package/dist/chunks/text-to-i18n-DweavFLm.mjs +404 -0
- package/dist/chunks/translate-CmnmgAHX.mjs +543 -0
- package/dist/cli.mjs +4 -0
- package/dist/index.mjs +3 -0
- package/dist/src/cli.d.ts +1 -0
- package/dist/src/commands/check.d.ts +28 -0
- package/dist/src/commands/clean.d.ts +26 -0
- package/dist/src/commands/export.d.ts +10 -0
- package/dist/src/commands/extract.d.ts +6 -0
- package/dist/src/commands/format.d.ts +8 -0
- package/dist/src/commands/import.d.ts +8 -0
- package/dist/src/commands/info.d.ts +5 -0
- package/dist/src/commands/lint.d.ts +16 -0
- package/dist/src/commands/pseudo.d.ts +9 -0
- package/dist/src/commands/rename.d.ts +9 -0
- package/dist/src/commands/search.d.ts +20 -0
- package/dist/src/commands/stats.d.ts +20 -0
- package/dist/src/commands/sync-remote.d.ts +30 -0
- package/dist/src/commands/sync.d.ts +20 -0
- package/dist/src/commands/text-to-i18n.d.ts +10 -0
- package/dist/src/commands/translate.d.ts +14 -0
- package/dist/src/csv.d.ts +3 -0
- package/dist/src/errors.d.ts +22 -0
- package/dist/src/fs.d.ts +45 -0
- package/dist/src/glossary.d.ts +12 -0
- package/dist/src/help.d.ts +3 -0
- package/dist/src/http.d.ts +10 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/json.d.ts +34 -0
- package/dist/src/lint-rules.d.ts +9 -0
- package/dist/src/log.d.ts +88 -0
- package/dist/src/nuxt.d.ts +66 -0
- package/dist/src/po.d.ts +14 -0
- package/dist/src/project.d.ts +64 -0
- package/dist/src/runner.d.ts +6 -0
- package/dist/src/source.d.ts +109 -0
- package/dist/src/spec.d.ts +57 -0
- package/dist/src/text-to-i18n.d.ts +46 -0
- package/dist/src/translate/ai.d.ts +2 -0
- package/dist/src/translate/drivers.d.ts +26 -0
- package/dist/src/translate/engine.d.ts +33 -0
- package/dist/src/translate/protect.d.ts +11 -0
- package/package.json +71 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 s00d
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,30 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @i18n-micro/cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Command-line tools for [nuxt-i18n-micro](https://github.com/s00d/nuxt-i18n-micro): a CI gate for your translations, safe removal of unused keys, translation of what is missing, and a rename that follows the code.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install -D @i18n-micro/cli
|
|
7
|
+
npx i18n-micro check # the CI gate: exit 0, 1 on findings, 2 on failure
|
|
8
|
+
npx i18n-micro translate --dryRun # what a run would send
|
|
9
|
+
I18N_TRANSLATE_TOKEN=... npx i18n-micro translate --service deepl
|
|
10
|
+
npx i18n-micro clean --dryRun # keys nothing in the project uses
|
|
11
|
+
npx i18n-micro rename --from common.save --to actions.save
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
| Group | Commands |
|
|
15
|
+
| -------------------- | -------------------------------------------- |
|
|
16
|
+
| Inspect | `info`, `check`, `stats`, `lint`, `search` |
|
|
17
|
+
| Translation workflow | `extract`, `translate`, `sync`, `clean` |
|
|
18
|
+
| Refactoring | `text-to-i18n`, `rename`, `pseudo`, `format` |
|
|
19
|
+
| Exchange | `export`, `import`, `sync-remote` |
|
|
20
|
+
|
|
21
|
+
It runs inside a Nuxt project and reads `locales`, `defaultLocale`, `translationDir` and layers from `nuxt.config` through the project's own Nuxt. Requires Node.js 20 or newer.
|
|
22
|
+
|
|
23
|
+
- [Guide](https://s00d.github.io/nuxt-i18n-micro/guide/cli)
|
|
24
|
+
- [All commands and flags](https://s00d.github.io/nuxt-i18n-micro/api/cli), also available as `i18n-micro <command> --help`
|
|
25
|
+
|
|
26
|
+
This package replaces `nuxt-i18n-micro-cli`. The [guide](https://s00d.github.io/nuxt-i18n-micro/guide/cli#moving-from-nuxt-i18n-micro-cli) maps the old commands to the new ones.
|
|
27
|
+
|
|
28
|
+
## License
|
|
29
|
+
|
|
30
|
+
MIT
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { A as log, _ as findingsError, a as flattenStrings, s as groupBy, x as usageError } from "./json-BV_apCMu.mjs";
|
|
2
|
+
import { d as splitList, u as relativePath } from "./nuxt-dKxG087V.mjs";
|
|
3
|
+
import { n as relativeFile, t as Project } from "./project-DgWgr7pF.mjs";
|
|
4
|
+
import { f as usageDoubts, u as scanProject } from "./source-Bx2lHSW7.mjs";
|
|
5
|
+
import { t as CHECK_TYPES } from "./spec-BwZlT28L.mjs";
|
|
6
|
+
import { r as findUnused } from "./clean-Do9z6D5j.mjs";
|
|
7
|
+
import { t as extraKeys } from "./sync-C0NVDabK.mjs";
|
|
8
|
+
// src/commands/check.ts
|
|
9
|
+
/** What the runtime fills in: any run without braces or spaces. */
|
|
10
|
+
var PLACEHOLDER = /\{([^{}\s]+)\}/g;
|
|
11
|
+
function placeholdersOf(text) {
|
|
12
|
+
const counts = /* @__PURE__ */ new Map();
|
|
13
|
+
for (const [, name] of text.matchAll(PLACEHOLDER)) counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
14
|
+
return counts;
|
|
15
|
+
}
|
|
16
|
+
/** Japanese and the like have one form, and their translation of `a | b` lost nothing. */
|
|
17
|
+
function hasPluralForms(code) {
|
|
18
|
+
try {
|
|
19
|
+
return new Intl.PluralRules(code).resolvedOptions().pluralCategories.length > 1;
|
|
20
|
+
} catch {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function placeholderDrift(source, translated, code) {
|
|
25
|
+
const expected = placeholdersOf(source);
|
|
26
|
+
const actual = placeholdersOf(translated);
|
|
27
|
+
const exact = !source.includes("|") && !translated.includes("|");
|
|
28
|
+
const short = (a, b) => [...a].filter(([name, count]) => (b.get(name) ?? 0) < (exact ? count : 1)).map(([name]) => name);
|
|
29
|
+
const lost = short(expected, actual).map((name) => `lost {${name}}`);
|
|
30
|
+
const added = short(actual, expected).map((name) => `added {${name}}`);
|
|
31
|
+
const plural = source.includes("|") && !translated.includes("|") && hasPluralForms(code) ? ["lost the plural forms"] : [];
|
|
32
|
+
const drift = [
|
|
33
|
+
...lost,
|
|
34
|
+
...added,
|
|
35
|
+
...plural
|
|
36
|
+
];
|
|
37
|
+
return drift.length > 0 ? drift.join(", ") : null;
|
|
38
|
+
}
|
|
39
|
+
function missingAndEmpty(reference, locale, code) {
|
|
40
|
+
return reference.scopes.flatMap((scope) => Object.entries(reference.leaves(scope)).flatMap(([key, value]) => {
|
|
41
|
+
const file = relativeFile(code, scope);
|
|
42
|
+
const current = locale.resolve(key, scope);
|
|
43
|
+
if (current === void 0) return [{
|
|
44
|
+
type: "missing",
|
|
45
|
+
locale: code,
|
|
46
|
+
file,
|
|
47
|
+
key,
|
|
48
|
+
detail: String(value)
|
|
49
|
+
}];
|
|
50
|
+
if (current === "") return [{
|
|
51
|
+
type: "empty",
|
|
52
|
+
locale: code,
|
|
53
|
+
file,
|
|
54
|
+
key,
|
|
55
|
+
detail: String(value)
|
|
56
|
+
}];
|
|
57
|
+
if (typeof value !== "string" || typeof current !== "string") return [];
|
|
58
|
+
const detail = placeholderDrift(value, current, code);
|
|
59
|
+
return detail ? [{
|
|
60
|
+
type: "placeholders",
|
|
61
|
+
locale: code,
|
|
62
|
+
file,
|
|
63
|
+
key,
|
|
64
|
+
detail
|
|
65
|
+
}] : [];
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
68
|
+
function emptyInReference(reference, code) {
|
|
69
|
+
return reference.scopes.flatMap((scope) => Object.entries(reference.leaves(scope)).flatMap(([key, value]) => value === "" ? [{
|
|
70
|
+
type: "empty",
|
|
71
|
+
locale: code,
|
|
72
|
+
file: relativeFile(code, scope),
|
|
73
|
+
key
|
|
74
|
+
}] : []));
|
|
75
|
+
}
|
|
76
|
+
/** A component can render on any page, so a page's keys answer for it too. */
|
|
77
|
+
var isDefined = (reference, ref) => ref.page === null ? reference.scopes.some((scope) => reference.get(ref.key, scope) !== void 0) : reference.resolve(ref.key, ref.page) !== void 0;
|
|
78
|
+
function undefinedKeys(project, scan) {
|
|
79
|
+
const seen = /* @__PURE__ */ new Set();
|
|
80
|
+
return scan.refs.flatMap((ref) => {
|
|
81
|
+
if (ref.guard || ref.test || seen.has(ref.key) || isDefined(project.reference, ref)) return [];
|
|
82
|
+
seen.add(ref.key);
|
|
83
|
+
return [{
|
|
84
|
+
type: "undefined",
|
|
85
|
+
file: `${relativePath(project.config.rootDir, ref.file)}:${ref.line}`,
|
|
86
|
+
key: ref.key
|
|
87
|
+
}];
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
function duplicates(project) {
|
|
91
|
+
return project.codes.flatMap((code) => {
|
|
92
|
+
const locale = project.locale(code);
|
|
93
|
+
return [...groupBy(locale.scopes.flatMap((scope) => Object.entries(flattenStrings(locale.ownTree(scope))).map(([key, value]) => ({
|
|
94
|
+
scope,
|
|
95
|
+
key,
|
|
96
|
+
value
|
|
97
|
+
}))).filter((item) => item.value !== ""), (item) => item.value)].flatMap(([value, places]) => places.length < 2 ? [] : places.map(({ scope, key }) => ({
|
|
98
|
+
type: "duplicates",
|
|
99
|
+
locale: code,
|
|
100
|
+
file: relativeFile(code, scope),
|
|
101
|
+
key,
|
|
102
|
+
detail: value
|
|
103
|
+
})));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
function selectedTypes(args) {
|
|
107
|
+
const only = splitList(args.only);
|
|
108
|
+
const unknown = only.filter((type) => !CHECK_TYPES.includes(type));
|
|
109
|
+
if (unknown.length > 0) throw usageError(`Unknown check: ${unknown.join(", ")}`, [`Accepted: ${CHECK_TYPES.join(", ")}`]);
|
|
110
|
+
if (only.length > 0) return new Set(only);
|
|
111
|
+
return new Set(CHECK_TYPES.filter((type) => type !== "duplicates" || args.duplicates));
|
|
112
|
+
}
|
|
113
|
+
var FIX_HINTS = {
|
|
114
|
+
missing: "Translate what is missing: i18n-micro translate.",
|
|
115
|
+
empty: "Translate what is missing: i18n-micro translate.",
|
|
116
|
+
extra: "Review extra keys: i18n-micro sync --dryRun lists what sync would remove.",
|
|
117
|
+
placeholders: "Correct the placeholders in the listed translations.",
|
|
118
|
+
undefined: "Add the listed keys to the default locale.",
|
|
119
|
+
unused: "Remove unused keys: i18n-micro clean.",
|
|
120
|
+
duplicates: "Give each key its own text, or use one key in both places."
|
|
121
|
+
};
|
|
122
|
+
function runChecks(project, scan, types, askedForUnused = false) {
|
|
123
|
+
const { reference, config } = project;
|
|
124
|
+
const doubts = usageDoubts(scan);
|
|
125
|
+
const skipUnused = types.has("unused") && doubts.length > 0;
|
|
126
|
+
const findings = [
|
|
127
|
+
...emptyInReference(reference, config.defaultLocale),
|
|
128
|
+
...project.targetCodes.flatMap((code) => missingAndEmpty(reference, project.locale(code), code)),
|
|
129
|
+
...project.targetCodes.flatMap((code) => extraKeys(reference, project.locale(code)).map(({ scope, key }) => ({
|
|
130
|
+
type: "extra",
|
|
131
|
+
locale: code,
|
|
132
|
+
file: relativeFile(code, scope),
|
|
133
|
+
key
|
|
134
|
+
}))),
|
|
135
|
+
...undefinedKeys(project, scan),
|
|
136
|
+
...types.has("duplicates") ? duplicates(project) : [],
|
|
137
|
+
...types.has("unused") && !skipUnused ? findUnused(project, scan).map(({ code, scope, key }) => ({
|
|
138
|
+
type: "unused",
|
|
139
|
+
locale: code,
|
|
140
|
+
file: relativeFile(code, scope),
|
|
141
|
+
key
|
|
142
|
+
})) : []
|
|
143
|
+
].filter((finding) => types.has(finding.type));
|
|
144
|
+
const summary = Object.fromEntries([...types].map((type) => [type, findings.filter((finding) => finding.type === type).length]));
|
|
145
|
+
return {
|
|
146
|
+
ok: findings.length === 0 && !(askedForUnused && skipUnused),
|
|
147
|
+
summary,
|
|
148
|
+
findings,
|
|
149
|
+
...skipUnused ? { unusedSkipped: doubts } : {}
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function print(report) {
|
|
153
|
+
for (const [type, items] of groupBy(report.findings, (finding) => finding.type)) log.list(type, items.map(({ locale, file, key, detail }) => [
|
|
154
|
+
locale,
|
|
155
|
+
key,
|
|
156
|
+
file,
|
|
157
|
+
detail
|
|
158
|
+
].filter((cell) => Boolean(cell))));
|
|
159
|
+
if (report.unusedSkipped) log.warn(`Unused keys not checked:\n ${report.unusedSkipped.join("\n ")}`);
|
|
160
|
+
}
|
|
161
|
+
async function run(args) {
|
|
162
|
+
const types = selectedTypes(args);
|
|
163
|
+
const project = await Project.load(args);
|
|
164
|
+
const report = runChecks(project, scanProject(project.config, { ignore: splitList(args.ignore) }), types, splitList(args.only).includes("unused"));
|
|
165
|
+
if (args.json) log.json(report);
|
|
166
|
+
else print(report);
|
|
167
|
+
if (report.ok) {
|
|
168
|
+
if (!args.json) log.success("All checks passed.");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const counts = Object.entries(report.summary).filter(([, count]) => count > 0);
|
|
172
|
+
const summary = counts.map(([type, count]) => `${count} ${type}`).join(", ") || "unused keys could not be checked";
|
|
173
|
+
throw findingsError(`Check failed: ${summary}.`, args.json ? [] : [...new Set(counts.map(([type]) => FIX_HINTS[type]))]);
|
|
174
|
+
}
|
|
175
|
+
export { run as default, runChecks };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { A as log, M as plural, S as validationError, _ as findingsError, g as commandFailed, o as getPath } from "./json-BV_apCMu.mjs";
|
|
2
|
+
import { d as splitList, m as walkFiles, r as isUnder } from "./nuxt-dKxG087V.mjs";
|
|
3
|
+
import { t as Project } from "./project-DgWgr7pF.mjs";
|
|
4
|
+
import { f as usageDoubts, p as usageIndex, u as scanProject } from "./source-Bx2lHSW7.mjs";
|
|
5
|
+
import { realpathSync } from "node:fs";
|
|
6
|
+
// src/commands/clean.ts
|
|
7
|
+
function regexFrom(pattern, flag) {
|
|
8
|
+
if (!pattern) return null;
|
|
9
|
+
try {
|
|
10
|
+
return new RegExp(pattern);
|
|
11
|
+
} catch {
|
|
12
|
+
throw validationError(`Invalid regular expression for --${flag}: ${pattern}`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Keys of this project's own files that nothing in the project can read. A key used anywhere
|
|
17
|
+
* stays in every scope: a page's route name is not always knowable from its file.
|
|
18
|
+
*/
|
|
19
|
+
function findUnused(project, scan) {
|
|
20
|
+
const isUsed = usageIndex(scan);
|
|
21
|
+
return project.codes.flatMap((code) => {
|
|
22
|
+
const locale = project.locale(code);
|
|
23
|
+
return locale.scopes.flatMap((scope) => Object.keys(locale.leaves(scope)).filter((key) => !isUsed(key) && getPath(locale.inheritedTree(scope), key) === void 0).map((key) => ({
|
|
24
|
+
code,
|
|
25
|
+
scope,
|
|
26
|
+
key
|
|
27
|
+
})));
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/** Why removing keys cannot be trusted here. Empty when it can. */
|
|
31
|
+
function deletionDoubts(project, scan) {
|
|
32
|
+
const shared = sharedLocaleFiles(project.config).map((file) => `Locale file outside the project: ${file}`);
|
|
33
|
+
return [...usageDoubts(scan), ...shared];
|
|
34
|
+
}
|
|
35
|
+
/** A locale file that lives outside the project may feed another app too, whose code was not scanned. */
|
|
36
|
+
function sharedLocaleFiles(config) {
|
|
37
|
+
const root = realpathSync(config.rootDir);
|
|
38
|
+
return walkFiles(config.translationDir, (path) => path.endsWith(".json")).filter((file) => !isUnder(root, realpathSync(file)));
|
|
39
|
+
}
|
|
40
|
+
/** Nothing scanned proves nothing: no flag changes that. */
|
|
41
|
+
function assertScanned(scan) {
|
|
42
|
+
if (scan.files === 0) throw commandFailed("No source files found: nothing proves a key unused or extra, so none is removed.", ["Run from the app root, or pass --cwd."]);
|
|
43
|
+
}
|
|
44
|
+
var describe = ({ code, scope, key }) => [
|
|
45
|
+
code,
|
|
46
|
+
key,
|
|
47
|
+
...scope === "" ? [] : [scope]
|
|
48
|
+
];
|
|
49
|
+
async function run(args) {
|
|
50
|
+
const project = await Project.load(args);
|
|
51
|
+
const include = regexFrom(args.include, "include");
|
|
52
|
+
const exclude = regexFrom(args.exclude, "exclude");
|
|
53
|
+
const scan = scanProject(project.config, { ignore: splitList(args.ignore) });
|
|
54
|
+
assertScanned(scan);
|
|
55
|
+
const doubts = deletionDoubts(project, scan);
|
|
56
|
+
for (const doubt of doubts) log.warn(doubt);
|
|
57
|
+
if (doubts.length > 0 && !args.force && !args.dryRun) throw findingsError("Cannot prove which keys are unused, so nothing was removed.", ["Review the list with --dryRun, protect keys with --exclude, then rerun with --force."]);
|
|
58
|
+
const unused = findUnused(project, scan).filter(({ key }) => (!include || include.test(key)) && !exclude?.test(key));
|
|
59
|
+
if (args.dryRun) {
|
|
60
|
+
log.list("Would remove", unused.map(describe));
|
|
61
|
+
log.note(`Dry run: ${plural(unused.length, "key")} would be removed.${doubts.length > 0 ? " Without --force, none would be." : ""}`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
for (const { code, scope, key } of unused) project.locale(code).delete(key, scope);
|
|
65
|
+
project.save();
|
|
66
|
+
log.success(`Removed ${plural(unused.length, "unused key")}.`);
|
|
67
|
+
}
|
|
68
|
+
export { run as i, deletionDoubts as n, findUnused as r, assertScanned as t };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { t as assertScanned, i as default, n as deletionDoubts, r as findUnused } from "./clean-Do9z6D5j.mjs";
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { A as log, S as validationError, _ as findingsError, j as pick } from "./json-BV_apCMu.mjs";
|
|
2
|
+
import { a as readJsonIfExists, f as stringifyJson, g as writeFilesAtomic, s as readLoose, u as relativePath } from "./nuxt-dKxG087V.mjs";
|
|
3
|
+
import { n as relativeFile, t as Project } from "./project-DgWgr7pF.mjs";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
7
|
+
// src/csv.ts
|
|
8
|
+
/** RFC 4180: enough for the files the CLI reads and writes. */
|
|
9
|
+
var quote = (cell) => /[",\r\n]/.test(cell) ? `"${cell.replace(/"/g, "\"\"")}"` : cell;
|
|
10
|
+
var stringifyCsv = (rows) => rows.map((row) => `${row.map(quote).join(",")}\n`).join("");
|
|
11
|
+
/** A quote out of place makes the rest of the file mean something else, so it stops the read. */
|
|
12
|
+
function parseCsv(content, name = "the CSV") {
|
|
13
|
+
const rows = [];
|
|
14
|
+
let row = [];
|
|
15
|
+
let cell = "";
|
|
16
|
+
let quoted = false;
|
|
17
|
+
let openedAt = 0;
|
|
18
|
+
const malformed = (index) => validationError(`Quote out of place in ${name}, line ${content.slice(0, index).split("\n").length}.`, ["A quote inside a field is written \"a \"\"b\"\" c\"."]);
|
|
19
|
+
const endCell = () => {
|
|
20
|
+
row.push(cell);
|
|
21
|
+
cell = "";
|
|
22
|
+
};
|
|
23
|
+
const endRow = () => {
|
|
24
|
+
endCell();
|
|
25
|
+
if (row.length > 1 || row[0] !== "") rows.push(row);
|
|
26
|
+
row = [];
|
|
27
|
+
};
|
|
28
|
+
for (let index = 0; index < content.length; index += 1) {
|
|
29
|
+
const char = content[index];
|
|
30
|
+
if (quoted) {
|
|
31
|
+
if (char !== "\"") cell += char;
|
|
32
|
+
else if (content[index + 1] === "\"") {
|
|
33
|
+
cell += "\"";
|
|
34
|
+
index += 1;
|
|
35
|
+
} else {
|
|
36
|
+
quoted = false;
|
|
37
|
+
const next = content[index + 1];
|
|
38
|
+
if (next !== void 0 && next !== "," && next !== "\r" && next !== "\n") throw malformed(index);
|
|
39
|
+
}
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (char === "\"") {
|
|
43
|
+
if (cell !== "") throw malformed(index);
|
|
44
|
+
quoted = true;
|
|
45
|
+
openedAt = index;
|
|
46
|
+
} else if (char === ",") endCell();
|
|
47
|
+
else if (char === "\n") endRow();
|
|
48
|
+
else if (char !== "\r") cell += char;
|
|
49
|
+
}
|
|
50
|
+
if (quoted) throw validationError(`Unclosed quote in ${name}, line ${content.slice(0, openedAt).split("\n").length}.`, ["Every quoted field needs a closing quote."]);
|
|
51
|
+
if (cell !== "" || row.length > 0) endRow();
|
|
52
|
+
return rows;
|
|
53
|
+
}
|
|
54
|
+
// src/po.ts
|
|
55
|
+
/** The small part of gettext the CLI exchanges: a flat catalog of msgid -> msgstr, with msgctxt as a key prefix. */
|
|
56
|
+
var ESCAPES = {
|
|
57
|
+
n: "\n",
|
|
58
|
+
t: " ",
|
|
59
|
+
r: "\r",
|
|
60
|
+
"\"": "\"",
|
|
61
|
+
"\\": "\\"
|
|
62
|
+
};
|
|
63
|
+
var escapePo = (text) => text.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\t/g, "\\t").replace(/\r/g, "\\r");
|
|
64
|
+
var unescapePo = (text) => text.replace(/\\(.)/g, (_match, char) => ESCAPES[char] ?? char);
|
|
65
|
+
var sourceComment = (source) => source ? `#. ${source.split("\n").join("\n#. ")}\n` : "";
|
|
66
|
+
function compilePo(messages) {
|
|
67
|
+
return ["msgid \"\"\nmsgstr \"Content-Type: text/plain; charset=utf-8\\n\"\n", ...messages.map(({ key, value, source }) => `${sourceComment(source)}msgid "${escapePo(key)}"\nmsgstr "${escapePo(value)}"\n`)].join("\n");
|
|
68
|
+
}
|
|
69
|
+
var QUOTED = /^"((?:[^"\\]|\\.)*)"$/;
|
|
70
|
+
var FIELD = /^(msgctxt|msgid_plural|msgid|msgstr)(?:\[(\d+)\])?\s+(".*")$/;
|
|
71
|
+
var emptyEntry = (fuzzy = false) => ({
|
|
72
|
+
msgctxt: "",
|
|
73
|
+
msgid: "",
|
|
74
|
+
forms: [],
|
|
75
|
+
fuzzy
|
|
76
|
+
});
|
|
77
|
+
function finish(entry, catalog) {
|
|
78
|
+
if (entry.msgid === "") return;
|
|
79
|
+
const key = entry.msgctxt ? `${entry.msgctxt}.${entry.msgid}` : entry.msgid;
|
|
80
|
+
const value = entry.forms.every((form) => form === "") ? "" : entry.forms.join("|");
|
|
81
|
+
(entry.fuzzy ? catalog.fuzzy : catalog.messages)[key] = value;
|
|
82
|
+
}
|
|
83
|
+
function parsePo(content) {
|
|
84
|
+
const catalog = {
|
|
85
|
+
messages: {},
|
|
86
|
+
fuzzy: {}
|
|
87
|
+
};
|
|
88
|
+
let entry = emptyEntry();
|
|
89
|
+
let write = null;
|
|
90
|
+
let inTranslation = false;
|
|
91
|
+
let nextFuzzy = false;
|
|
92
|
+
const assign = (name, form, first) => {
|
|
93
|
+
const current = entry;
|
|
94
|
+
const apply = {
|
|
95
|
+
msgctxt: (text) => current.msgctxt += text,
|
|
96
|
+
msgid: (text) => current.msgid += text,
|
|
97
|
+
msgstr: (text) => current.forms[form] = (current.forms[form] ?? "") + text,
|
|
98
|
+
msgid_plural: () => ""
|
|
99
|
+
}[name];
|
|
100
|
+
write = apply;
|
|
101
|
+
apply(first);
|
|
102
|
+
};
|
|
103
|
+
for (const raw of content.split(/\r?\n/)) {
|
|
104
|
+
const line = raw.trim();
|
|
105
|
+
if (line.startsWith("#,") && line.includes("fuzzy")) nextFuzzy = true;
|
|
106
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
107
|
+
const match = line.match(FIELD);
|
|
108
|
+
if (match) {
|
|
109
|
+
const name = match[1];
|
|
110
|
+
if (name !== "msgstr" && name !== "msgid_plural" && inTranslation) {
|
|
111
|
+
finish(entry, catalog);
|
|
112
|
+
entry = emptyEntry();
|
|
113
|
+
inTranslation = false;
|
|
114
|
+
}
|
|
115
|
+
if (nextFuzzy) entry.fuzzy = true;
|
|
116
|
+
nextFuzzy = false;
|
|
117
|
+
if (name === "msgstr") inTranslation = true;
|
|
118
|
+
assign(name, Number(match[2] ?? 0), unescapePo(match[3].slice(1, -1)));
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const continuation = line.match(QUOTED);
|
|
122
|
+
if (continuation) write?.(unescapePo(continuation[1]));
|
|
123
|
+
}
|
|
124
|
+
finish(entry, catalog);
|
|
125
|
+
return catalog;
|
|
126
|
+
}
|
|
127
|
+
// src/commands/export.ts
|
|
128
|
+
var defaultExchangeDir = (format) => format === "csv" ? "csv_exports" : "pots";
|
|
129
|
+
/** Every key of the scope in either locale, untranslated ones too: they are what a translator is here for. */
|
|
130
|
+
function messagesOf(project, code, scope) {
|
|
131
|
+
const strings = project.locale(code).strings(scope);
|
|
132
|
+
const source = project.reference.strings(scope);
|
|
133
|
+
return [.../* @__PURE__ */ new Set([...Object.keys(source), ...Object.keys(strings)])].map((key) => ({
|
|
134
|
+
key,
|
|
135
|
+
value: strings[key] ?? "",
|
|
136
|
+
source: source[key]
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
var scopesOf = (project, code) => [.../* @__PURE__ */ new Set([...project.reference.scopes, ...project.locale(code).scopes])];
|
|
140
|
+
function poFiles(project, dir) {
|
|
141
|
+
return project.codes.flatMap((code) => scopesOf(project, code).map((scope) => [join(dir, ...relativeFile(code, scope, "po").split("/")), compilePo(messagesOf(project, code, scope))]));
|
|
142
|
+
}
|
|
143
|
+
function csvRows(project, code) {
|
|
144
|
+
return [[
|
|
145
|
+
"File",
|
|
146
|
+
"Key",
|
|
147
|
+
"Source",
|
|
148
|
+
"Translation"
|
|
149
|
+
], ...scopesOf(project, code).flatMap((scope) => messagesOf(project, code, scope).map(({ key, value, source }) => [
|
|
150
|
+
relativeFile(code, scope),
|
|
151
|
+
key,
|
|
152
|
+
source ?? "",
|
|
153
|
+
value
|
|
154
|
+
]))];
|
|
155
|
+
}
|
|
156
|
+
var csvFiles = (project, dir) => project.codes.map((code) => [join(dir, `${code}.csv`), stringifyCsv(csvRows(project, code))]);
|
|
157
|
+
/** What the last export wrote, by file: the one way to tell a translator's edit from a changed locale file. */
|
|
158
|
+
var MANIFEST = ".i18n-export.json";
|
|
159
|
+
var digest = (text) => createHash("sha256").update(text).digest("hex");
|
|
160
|
+
function readManifest(dir) {
|
|
161
|
+
try {
|
|
162
|
+
return readJsonIfExists(join(dir, MANIFEST));
|
|
163
|
+
} catch {
|
|
164
|
+
return {};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** Translations in a catalog by key, unconfirmed ones too. `null`: not a file this command wrote. */
|
|
168
|
+
function catalogOf(text, format) {
|
|
169
|
+
if (format === "po") {
|
|
170
|
+
const { messages, fuzzy } = parsePo(text);
|
|
171
|
+
return new Map(Object.entries({
|
|
172
|
+
...messages,
|
|
173
|
+
...fuzzy
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
let parsed;
|
|
177
|
+
try {
|
|
178
|
+
parsed = parseCsv(text);
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
const [header, ...rows] = parsed;
|
|
183
|
+
const [file, key, translation] = [
|
|
184
|
+
"File",
|
|
185
|
+
"Key",
|
|
186
|
+
"Translation"
|
|
187
|
+
].map((name) => header?.indexOf(name) ?? -1);
|
|
188
|
+
if (file === -1 || key === -1 || translation === -1) return null;
|
|
189
|
+
return new Map(rows.map((row) => [`${row[file]} ${row[key]}`, row[translation] ?? ""]));
|
|
190
|
+
}
|
|
191
|
+
/** A translator's work this run would write over: the file changed since the last export, and not by an import. */
|
|
192
|
+
function holdsUnimportedWork(path, next, format, recorded) {
|
|
193
|
+
if (!existsSync(path)) return false;
|
|
194
|
+
const text = readLoose(path);
|
|
195
|
+
if (digest(text) === recorded) return false;
|
|
196
|
+
const before = catalogOf(text, format);
|
|
197
|
+
const after = catalogOf(next, format);
|
|
198
|
+
return !before || [...before].some(([key, value]) => value !== "" && value !== after?.get(key));
|
|
199
|
+
}
|
|
200
|
+
async function run(args) {
|
|
201
|
+
const project = await Project.load(args);
|
|
202
|
+
const dir = resolve(project.config.rootDir, args.dir ?? defaultExchangeDir(args.format));
|
|
203
|
+
const files = args.format === "csv" ? csvFiles(project, dir) : poFiles(project, dir);
|
|
204
|
+
const manifest = readManifest(dir);
|
|
205
|
+
const nameOf = (path) => relativePath(dir, path);
|
|
206
|
+
const edited = files.filter(([path, text]) => holdsUnimportedWork(path, text, args.format, manifest[nameOf(path)]));
|
|
207
|
+
if (edited.length > 0 && !args.force) {
|
|
208
|
+
log.list("Changed since the last export, and not imported", edited.map(([path]) => relativePath(project.config.rootDir, path)));
|
|
209
|
+
throw findingsError("Nothing was exported.", ["Import them first, or overwrite with --force."]);
|
|
210
|
+
}
|
|
211
|
+
const written = Object.fromEntries(files.map(([path, text]) => [nameOf(path), digest(text)]));
|
|
212
|
+
const others = Object.fromEntries(Object.entries(manifest).filter(([name]) => !name.endsWith(`.${args.format}`)));
|
|
213
|
+
writeFilesAtomic([...files, [join(dir, MANIFEST), stringifyJson({
|
|
214
|
+
...others,
|
|
215
|
+
...written
|
|
216
|
+
})]], { overwrite: true });
|
|
217
|
+
log.success(`Exported ${files.length} ${args.format.toUpperCase()} ${pick(files.length, "file", "files")} to ${dir}`);
|
|
218
|
+
}
|
|
219
|
+
export { parseCsv as a, parsePo as i, readManifest as n, run as r, defaultExchangeDir as t };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { r as default, t as defaultExchangeDir, n as readManifest } from "./export-C2J-pagB.mjs";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { d as splitList, u as relativePath } from "./nuxt-dKxG087V.mjs";
|
|
2
|
+
import { A as log, M as plural } from "./json-BV_apCMu.mjs";
|
|
3
|
+
import { t as Project } from "./project-DgWgr7pF.mjs";
|
|
4
|
+
import { i as isOwnSource, u as scanProject } from "./source-Bx2lHSW7.mjs";
|
|
5
|
+
// src/commands/extract.ts
|
|
6
|
+
var wanted = (ref) => !ref.guard && !ref.test;
|
|
7
|
+
/**
|
|
8
|
+
* Keys with a text of their own go to the default locale. A key without one stays absent:
|
|
9
|
+
* `''` is a defined value, it would show a blank instead of the fallback locale.
|
|
10
|
+
*/
|
|
11
|
+
function addDefaultText(project, ref) {
|
|
12
|
+
const scope = ref.page && !project.config.disablePageLocales ? ref.page : "";
|
|
13
|
+
const conflict = project.reference.set(ref.key, ref.defaultText, scope);
|
|
14
|
+
if (conflict) log.warn(`Skipping ${ref.key}: ${conflict} already holds a value.`);
|
|
15
|
+
return !conflict;
|
|
16
|
+
}
|
|
17
|
+
async function run(args) {
|
|
18
|
+
const project = await Project.load(args);
|
|
19
|
+
const { reference, config } = project;
|
|
20
|
+
const scan = scanProject(config, { ignore: splitList(args.ignore) });
|
|
21
|
+
for (const { file, line, expression, group } of scan.dynamic) if (!group) log.warn(`Dynamic i18n key in ${file}:${line}: ${expression}`);
|
|
22
|
+
const absent = (ref) => reference.resolve(ref.key, ref.page ?? "") === void 0;
|
|
23
|
+
const writable = (ref) => Boolean(ref.defaultText) && isOwnSource(config, ref.file);
|
|
24
|
+
const added = scan.refs.filter((ref) => wanted(ref) && writable(ref) && absent(ref) && addDefaultText(project, ref));
|
|
25
|
+
const untexted = scan.refs.filter((ref) => wanted(ref) && !writable(ref) && absent(ref));
|
|
26
|
+
const written = project.save();
|
|
27
|
+
log.list(`Need a text in ${config.defaultLocale}`, untexted.map((ref) => [ref.key, `${relativePath(config.rootDir, ref.file)}:${ref.line}`]));
|
|
28
|
+
log.success(`Scanned ${plural(scan.files, "file")}: ${plural(added.length, "key")} added, ${untexted.length} without text, ${plural(written.length, "file")} updated.`);
|
|
29
|
+
}
|
|
30
|
+
export { run as default };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { A as log, M as plural, _ as findingsError, j as pick } from "./json-BV_apCMu.mjs";
|
|
2
|
+
import { t as Project } from "./project-DgWgr7pF.mjs";
|
|
3
|
+
import { u as relativePath } from "./nuxt-dKxG087V.mjs";
|
|
4
|
+
// src/commands/format.ts
|
|
5
|
+
async function run(args) {
|
|
6
|
+
const project = await Project.load(args);
|
|
7
|
+
for (const code of project.codes) project.locale(code).modified = true;
|
|
8
|
+
const options = {
|
|
9
|
+
indent: args.indent,
|
|
10
|
+
sort: args.sortKeys,
|
|
11
|
+
rewrite: true
|
|
12
|
+
};
|
|
13
|
+
if (args.check) {
|
|
14
|
+
const files = project.pendingFiles(options).map(([path]) => relativePath(project.config.rootDir, path));
|
|
15
|
+
if (files.length === 0) {
|
|
16
|
+
log.success("All translation files are formatted.");
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
log.list("Not formatted", files);
|
|
20
|
+
const flags = `${args.indent === 2 ? "" : ` --indent ${args.indent}`}${args.sortKeys === false ? " --no-sortKeys" : ""}`;
|
|
21
|
+
throw findingsError(`${plural(files.length, "file")} ${pick(files.length, "needs", "need")} formatting.`, [`Run i18n-micro format${flags}.`]);
|
|
22
|
+
}
|
|
23
|
+
const written = project.save(options);
|
|
24
|
+
log.success(`Formatted translation files: ${written.length} changed.`);
|
|
25
|
+
}
|
|
26
|
+
export { run as default };
|