@i18n-micro/cli 0.0.0-stage → 2.0.1

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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +29 -2
  3. package/bin/i18n-micro.mjs +3 -0
  4. package/dist/chunks/check-CwSzATDI.mjs +175 -0
  5. package/dist/chunks/clean-Do9z6D5j.mjs +68 -0
  6. package/dist/chunks/clean-bQVjG_2M.mjs +1 -0
  7. package/dist/chunks/export-C2J-pagB.mjs +219 -0
  8. package/dist/chunks/export-usBaNIpp.mjs +1 -0
  9. package/dist/chunks/extract-CJ8UfrVJ.mjs +30 -0
  10. package/dist/chunks/format-6CtMgO-M.mjs +26 -0
  11. package/dist/chunks/http-whB7a5ye.mjs +100 -0
  12. package/dist/chunks/import-CnL8U6Za.mjs +99 -0
  13. package/dist/chunks/info-Lom_wHos.mjs +32 -0
  14. package/dist/chunks/json-BV_apCMu.mjs +439 -0
  15. package/dist/chunks/lint-DDGSQAXd.mjs +125 -0
  16. package/dist/chunks/nuxt-dKxG087V.mjs +366 -0
  17. package/dist/chunks/package-BejjFS6s.mjs +3 -0
  18. package/dist/chunks/project-DgWgr7pF.mjs +206 -0
  19. package/dist/chunks/protect-D1HIuxmJ.mjs +36 -0
  20. package/dist/chunks/pseudo-oXBaONgF.mjs +32 -0
  21. package/dist/chunks/rename-uJnBX86A.mjs +117 -0
  22. package/dist/chunks/runner-CsPPffV1.mjs +200 -0
  23. package/dist/chunks/search-Dj_Ynq_x.mjs +67 -0
  24. package/dist/chunks/source-Bx2lHSW7.mjs +744 -0
  25. package/dist/chunks/spec-DwveYr8X.mjs +507 -0
  26. package/dist/chunks/stats-BU-SmrVR.mjs +156 -0
  27. package/dist/chunks/sync-BY2EFEda.mjs +1 -0
  28. package/dist/chunks/sync-C0NVDabK.mjs +56 -0
  29. package/dist/chunks/sync-remote-y3ZATbF2.mjs +148 -0
  30. package/dist/chunks/text-to-i18n-DweavFLm.mjs +404 -0
  31. package/dist/chunks/translate-CmnmgAHX.mjs +543 -0
  32. package/dist/cli.mjs +4 -0
  33. package/dist/index.mjs +3 -0
  34. package/dist/src/cli.d.ts +1 -0
  35. package/dist/src/commands/check.d.ts +28 -0
  36. package/dist/src/commands/clean.d.ts +26 -0
  37. package/dist/src/commands/export.d.ts +10 -0
  38. package/dist/src/commands/extract.d.ts +6 -0
  39. package/dist/src/commands/format.d.ts +8 -0
  40. package/dist/src/commands/import.d.ts +8 -0
  41. package/dist/src/commands/info.d.ts +5 -0
  42. package/dist/src/commands/lint.d.ts +16 -0
  43. package/dist/src/commands/pseudo.d.ts +9 -0
  44. package/dist/src/commands/rename.d.ts +9 -0
  45. package/dist/src/commands/search.d.ts +20 -0
  46. package/dist/src/commands/stats.d.ts +20 -0
  47. package/dist/src/commands/sync-remote.d.ts +30 -0
  48. package/dist/src/commands/sync.d.ts +20 -0
  49. package/dist/src/commands/text-to-i18n.d.ts +10 -0
  50. package/dist/src/commands/translate.d.ts +14 -0
  51. package/dist/src/csv.d.ts +3 -0
  52. package/dist/src/errors.d.ts +22 -0
  53. package/dist/src/fs.d.ts +45 -0
  54. package/dist/src/glossary.d.ts +12 -0
  55. package/dist/src/help.d.ts +3 -0
  56. package/dist/src/http.d.ts +10 -0
  57. package/dist/src/index.d.ts +3 -0
  58. package/dist/src/json.d.ts +34 -0
  59. package/dist/src/lint-rules.d.ts +9 -0
  60. package/dist/src/log.d.ts +88 -0
  61. package/dist/src/nuxt.d.ts +66 -0
  62. package/dist/src/po.d.ts +14 -0
  63. package/dist/src/project.d.ts +64 -0
  64. package/dist/src/runner.d.ts +6 -0
  65. package/dist/src/source.d.ts +109 -0
  66. package/dist/src/spec.d.ts +57 -0
  67. package/dist/src/text-to-i18n.d.ts +46 -0
  68. package/dist/src/translate/ai.d.ts +2 -0
  69. package/dist/src/translate/drivers.d.ts +26 -0
  70. package/dist/src/translate/engine.d.ts +33 -0
  71. package/dist/src/translate/protect.d.ts +11 -0
  72. package/package.json +71 -3
@@ -0,0 +1,56 @@
1
+ import { A as log, M as plural, _ as findingsError, o as getPath } from "./json-BV_apCMu.mjs";
2
+ import { d as splitList } from "./nuxt-dKxG087V.mjs";
3
+ import { n as relativeFile, t as Project } from "./project-DgWgr7pF.mjs";
4
+ import { p as usageIndex, u as scanProject } from "./source-Bx2lHSW7.mjs";
5
+ import { n as deletionDoubts, t as assertScanned } from "./clean-Do9z6D5j.mjs";
6
+ // src/commands/sync.ts
7
+ /** Keys of `locale` the default locale cannot answer, compared by path: `"a.b"` and `a: { b }` are the same key. */
8
+ function extraKeys(reference, locale) {
9
+ return locale.scopes.flatMap((scope) => Object.keys(locale.leaves(scope)).filter((key) => reference.resolve(key, scope) === void 0).map((key) => ({
10
+ scope,
11
+ key
12
+ })));
13
+ }
14
+ /** A scope with nothing to compare against says nothing about the keys of its translations. */
15
+ function missingReferences(project, extras) {
16
+ const empty = extras.filter(({ scope }) => Object.keys(project.reference.leaves(scope)).length === 0);
17
+ return [...new Set(empty.map(({ code, scope }) => relativeFile(code, scope)))].map((file) => `No keys in ${project.config.defaultLocale} to compare ${file} with.`);
18
+ }
19
+ var overridden = (project) => project.config.defaultLocaleOverridden ? [`DEFAULT_LOCALE made ${project.config.defaultLocale} the reference, not the default locale of nuxt.config.`] : [];
20
+ /**
21
+ * Removes what the default locale does not have. Missing keys stay missing: `''` would hide
22
+ * them from `fallbackLocale` and from `check`. A delete-only command, so it has the brakes of
23
+ * `clean`, and one more: a reference that is empty proves nothing.
24
+ */
25
+ async function run(args) {
26
+ const project = await Project.load(args);
27
+ const { reference } = project;
28
+ const scan = scanProject(project.config, { ignore: splitList(args.ignore) });
29
+ const isUsed = usageIndex(scan);
30
+ const extras = project.targetCodes.flatMap((code) => extraKeys(reference, project.locale(code)).map(({ scope, key }) => ({
31
+ code,
32
+ scope,
33
+ key
34
+ })));
35
+ for (const { code, scope, key } of extras.filter((extra) => isUsed(extra.key))) log.warn(`Keeping ${key} in ${relativeFile(code, scope)}: the code uses it, but ${project.config.defaultLocale} has no text for it.`);
36
+ const inLayer = ({ code, scope, key }) => getPath(project.locale(code).inheritedTree(scope), key) !== void 0;
37
+ const removable = extras.filter((extra) => !isUsed(extra.key) && !inLayer(extra));
38
+ if (removable.length > 0) assertScanned(scan);
39
+ const doubts = removable.length === 0 ? [] : [
40
+ ...overridden(project),
41
+ ...missingReferences(project, removable),
42
+ ...deletionDoubts(project, scan)
43
+ ];
44
+ for (const doubt of doubts) log.warn(doubt);
45
+ if (doubts.length > 0 && !args.force && !args.dryRun) throw findingsError("Cannot prove these keys are extra, so nothing was removed.", ["Review the list with --dryRun, then rerun with --force."]);
46
+ const removed = removable.map(({ code, scope, key }) => [relativeFile(code, scope), key]);
47
+ log.list(args.dryRun ? "Would remove" : "Removed", removed);
48
+ if (args.dryRun) {
49
+ log.note(`Dry run: ${plural(removed.length, "key")} would be removed.${doubts.length > 0 ? " Without --force, none would be." : ""}`);
50
+ return;
51
+ }
52
+ for (const { code, scope, key } of removable) project.locale(code).delete(key, scope);
53
+ project.save();
54
+ log.success(`Translations have been synchronized: ${plural(removed.length, "extra key")} removed.`);
55
+ }
56
+ export { run as n, extraKeys as t };
@@ -0,0 +1,148 @@
1
+ import { A as log, C as addSecret, M as plural, _ as findingsError, f as pathConflict, h as CliError, i as flatten, j as pick, l as isObject, o as getPath, p as setPath, w as addUrlSecrets, y as remoteConfigError } from "./json-BV_apCMu.mjs";
2
+ import { o as readJsonObject } from "./nuxt-dKxG087V.mjs";
3
+ import { n as requestJson, t as jsonBody } from "./http-whB7a5ye.mjs";
4
+ import { t as Project } from "./project-DgWgr7pF.mjs";
5
+ import { trimLeadingSlashes, trimTrailingSlashes } from "@i18n-micro/utils/app-path";
6
+ import { existsSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ // src/commands/sync-remote.ts
9
+ function loadConfig(rootDir) {
10
+ const path = join(rootDir, ".i18n-remote.json");
11
+ if (!existsSync(path)) throw remoteConfigError("Remote configuration file not found.", ["Create .i18n-remote.json with the url of your endpoint."], { Path: path });
12
+ const config = readJsonObject(path, { private: true });
13
+ if (config.type && config.type !== "custom") throw remoteConfigError(`Remote type "${config.type}" was removed: only an HTTP endpoint of your own is supported.`, ["For a translation platform, use its own CLI: see https://s00d.github.io/nuxt-i18n-micro/guide/crowdin"]);
14
+ if (!config.url) throw remoteConfigError(".i18n-remote.json needs \"url\".");
15
+ const token = config.token ?? process.env.I18N_REMOTE_TOKEN;
16
+ addSecret(token);
17
+ addSecret(config.auth?.password);
18
+ addUrlSecrets(config.url);
19
+ return {
20
+ ...config,
21
+ token
22
+ };
23
+ }
24
+ function requestOptions(config) {
25
+ const { username, password } = config.auth ?? {};
26
+ const encoded = username ? Buffer.from(`${username}:${password ?? ""}`).toString("base64") : void 0;
27
+ addSecret(encoded);
28
+ const authorization = encoded ? `Basic ${encoded}` : config.token ? `Bearer ${config.token}` : void 0;
29
+ addSecret(authorization);
30
+ return { headers: authorization ? { Authorization: authorization } : {} };
31
+ }
32
+ var remoteUrl = (config) => `${trimTrailingSlashes(config.url)}/${trimLeadingSlashes(config.path ?? "translations")}`;
33
+ function planLocale(project, code, tree, force, plan) {
34
+ const local = project.locale(code);
35
+ const planned = structuredClone(local.global);
36
+ const apply = (key, value) => {
37
+ setPath(planned, key, value);
38
+ plan.apply.push([
39
+ code,
40
+ key,
41
+ value
42
+ ]);
43
+ };
44
+ for (const [key, value] of Object.entries(flatten(tree))) {
45
+ const mine = local.get(key);
46
+ const label = `${code} ${key}`;
47
+ const blocker = pathConflict(planned, key);
48
+ if (value === "" || JSON.stringify(mine) === JSON.stringify(value)) continue;
49
+ if (key.split(".").includes("__proto__")) plan.conflicts.push(`${label}: a key that cannot be stored`);
50
+ else if (blocker) plan.conflicts.push(`${label}: ${blocker} already holds a value`);
51
+ else if (mine === void 0 || mine === "") {
52
+ plan.added.push(label);
53
+ apply(key, value);
54
+ } else if (force) {
55
+ plan.updated.push(label);
56
+ apply(key, value);
57
+ } else plan.conflicts.push(`${label}\n local: ${JSON.stringify(mine)}\n remote: ${JSON.stringify(value)}`);
58
+ }
59
+ }
60
+ function planPull(project, remote, force) {
61
+ const plan = {
62
+ added: [],
63
+ updated: [],
64
+ conflicts: [],
65
+ apply: []
66
+ };
67
+ for (const [code, tree] of Object.entries(remote)) {
68
+ if (!project.codes.includes(code) || !isObject(tree)) {
69
+ log.warn(`Skipping remote locale ${code}: not a configured locale.`);
70
+ continue;
71
+ }
72
+ planLocale(project, code, tree, force, plan);
73
+ }
74
+ return plan;
75
+ }
76
+ async function pull(args, project, config) {
77
+ const remote = await requestJson(remoteUrl(config), requestOptions(config));
78
+ if (!isObject(remote)) throw remoteConfigError("Invalid response from the remote: expected an object of locales.");
79
+ const plan = planPull(project, remote, Boolean(args.force));
80
+ log.list("New", plan.added);
81
+ log.list("Replaced", plan.updated);
82
+ log.list("Conflicts", plan.conflicts);
83
+ if (args.dryRun) {
84
+ log.note(`Dry run: ${plan.added.length} new, ${plan.updated.length} replaced, ${plural(plan.conflicts.length, "conflict")}. Nothing was written.`);
85
+ return;
86
+ }
87
+ if (plan.conflicts.length > 0) throw findingsError("Conflicts found between local and remote translations. Nothing was written.", ["Resolve them. --force takes the remote value where two values differ; a blocked path needs one of the two keys removed.", "Preview without writing: i18n-micro sync-remote --dryRun"]);
88
+ for (const [code, key, value] of plan.apply) project.locale(code).set(key, value);
89
+ project.save();
90
+ log.success(`Pulled ${plan.added.length} new and ${plan.updated.length} replaced ${pick(plan.added.length + plan.updated.length, "value", "values")}.`);
91
+ }
92
+ /** A blank local value is a gap, not a text: pushed, it could clear what a translator wrote. */
93
+ function withoutBlanks(tree) {
94
+ const entries = Object.entries(tree).flatMap(([key, value]) => {
95
+ if (value === "") return [];
96
+ return [[key, isObject(value) ? withoutBlanks(value) : value]];
97
+ });
98
+ return Object.fromEntries(entries);
99
+ }
100
+ /** What the endpoint holds. A 404 is an endpoint nothing was pushed to yet. */
101
+ async function remoteDocument(config, options) {
102
+ const remote = await requestJson(remoteUrl(config), options).catch((error) => {
103
+ if (error instanceof CliError && error.details["HTTP status"] === "404") return void 0;
104
+ throw error;
105
+ });
106
+ if (remote !== void 0 && !isObject(remote)) throw remoteConfigError("Invalid response from the remote: expected an object of locales.");
107
+ return remote ?? {};
108
+ }
109
+ /** A PUT replaces the document, so this text would be gone: it is on the remote, and not in what goes up or in any layer. */
110
+ function lostOnPush(project, remote, body) {
111
+ return Object.entries(remote).flatMap(([code, tree]) => {
112
+ if (!isObject(tree)) return [];
113
+ const local = project.codes.includes(code) ? project.locale(code).global : {};
114
+ return Object.entries(flatten(tree)).filter(([key, value]) => value !== "" && getPath(body[code] ?? {}, key) === void 0 && JSON.stringify(value) !== JSON.stringify(getPath(local, key))).map(([key]) => `${code} ${key}`);
115
+ });
116
+ }
117
+ async function push(args, project, config) {
118
+ if (args.dryRun) {
119
+ log.note(`Dry run: would push ${project.codes.join(", ")}. Nothing was sent.`);
120
+ return;
121
+ }
122
+ const body = Object.fromEntries(project.codes.map((code) => [code, withoutBlanks(project.locale(code).fileTree(""))]));
123
+ const options = requestOptions(config);
124
+ if (!args.force) {
125
+ const lost = lostOnPush(project, await remoteDocument(config, options), body);
126
+ log.list("Only on the remote", lost);
127
+ if (lost.length > 0) throw findingsError("A push would drop this text. Nothing was sent.", ["Pull it first, or overwrite it with --force."]);
128
+ }
129
+ await requestJson(remoteUrl(config), {
130
+ ...options,
131
+ method: "PUT",
132
+ ...jsonBody(body),
133
+ headers: {
134
+ ...options.headers,
135
+ "Content-Type": "application/json"
136
+ }
137
+ });
138
+ log.success(`Pushed ${plural(project.codes.length, "locale")}.`);
139
+ }
140
+ async function run(args) {
141
+ const project = await Project.load(args);
142
+ const config = loadConfig(project.config.rootDir);
143
+ const pages = new Set(project.codes.flatMap((code) => project.locale(code).pageScopes)).size;
144
+ if (pages > 0) log.warn(`Global files only: ${plural(pages, "page scope")} ${pick(pages, "is", "are")} not exchanged.`);
145
+ if (args.pull) await pull(args, project, config);
146
+ if (args.push) await push(args, project, config);
147
+ }
148
+ export { run as default, planPull };
@@ -0,0 +1,404 @@
1
+ import { A as log, E as choose, M as plural, S as validationError, T as ask, a as flattenStrings, f as pathConflict, p as setPath, u as isWritableKey, v as notFoundError, x as usageError } from "./json-BV_apCMu.mjs";
2
+ import { a as readJsonIfExists, d as splitList, g as writeFilesAtomic, l as readUtf8, p as stringifyLike, r as isUnder, t as loadProjectConfig, u as relativePath } from "./nuxt-dKxG087V.mjs";
3
+ import { a as isTestFile, c as projectFiles, d as staticString, i as isOwnSource, l as scanFile, o as lineOf, r as isCodeFile, s as parseCode, t as NODE_TYPES } from "./source-Bx2lHSW7.mjs";
4
+ import { forwardSlashes } from "@i18n-micro/utils/app-path";
5
+ import { existsSync, readFileSync, statSync } from "node:fs";
6
+ import { extname, join, parse, resolve } from "node:path";
7
+ import { MagicString, parse as parse$1, walk } from "@vue/compiler-sfc";
8
+ // src/text-to-i18n.ts
9
+ var TEXT_ATTRIBUTES = /* @__PURE__ */ new Set([
10
+ "title",
11
+ "label",
12
+ "placeholder",
13
+ "alt",
14
+ "aria-label"
15
+ ]);
16
+ var NON_TEXT_CALLS = /* @__PURE__ */ new Set([
17
+ "emit",
18
+ "$emit",
19
+ "useFetch",
20
+ "$fetch",
21
+ "useAsyncData",
22
+ "definePageMeta",
23
+ "defineProps",
24
+ "defineEmits",
25
+ "navigateTo",
26
+ "useRoute",
27
+ "import",
28
+ "require"
29
+ ]);
30
+ var SKIPPED_PARENTS = /* @__PURE__ */ new Set([
31
+ "ImportDeclaration",
32
+ "ExportNamedDeclaration",
33
+ "ExportAllDeclaration",
34
+ "TSLiteralType",
35
+ "TSEnumMember",
36
+ "TSExternalModuleReference"
37
+ ]);
38
+ var NON_DISPLAY_TAGS = /* @__PURE__ */ new Set([
39
+ "script",
40
+ "style",
41
+ "code",
42
+ "pre"
43
+ ]);
44
+ var KEY_ROOTS = [
45
+ "pages",
46
+ "components",
47
+ "plugins",
48
+ "layouts"
49
+ ];
50
+ var CYRILLIC = {
51
+ а: "a",
52
+ б: "b",
53
+ в: "v",
54
+ г: "g",
55
+ д: "d",
56
+ е: "e",
57
+ ё: "e",
58
+ ж: "zh",
59
+ з: "z",
60
+ и: "i",
61
+ й: "y",
62
+ к: "k",
63
+ л: "l",
64
+ м: "m",
65
+ н: "n",
66
+ о: "o",
67
+ п: "p",
68
+ р: "r",
69
+ с: "s",
70
+ т: "t",
71
+ у: "u",
72
+ ф: "f",
73
+ х: "h",
74
+ ц: "ts",
75
+ ч: "ch",
76
+ ш: "sh",
77
+ щ: "sch",
78
+ ъ: "",
79
+ ы: "y",
80
+ ь: "",
81
+ э: "e",
82
+ ю: "yu",
83
+ я: "ya"
84
+ };
85
+ function toSlug(text) {
86
+ return text.replace(/\p{Script=Cyrillic}/gu, (letter) => CYRILLIC[letter.toLowerCase()] ?? letter).replaceAll("&", " and ").replaceAll("$", " dollar ").replaceAll("%", " percent ").replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
87
+ }
88
+ var keywordsOf = (text) => text.replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, " ").split(/\s+/).filter((word) => word.length >= 2).slice(0, 3);
89
+ /** `pages/blog/index.vue` -> `pages.blog.index`: keys say where their text came from. */
90
+ function filePrefix(file) {
91
+ const { dir, name } = parse(file);
92
+ const segments = forwardSlashes(dir).split("/");
93
+ const rootIndex = segments.findIndex((segment) => KEY_ROOTS.includes(segment));
94
+ if (rootIndex === -1) return "common";
95
+ return [...segments.slice(rootIndex), name].map(toSlug).join(".");
96
+ }
97
+ /** Shared across files: known text reuses its key, generated keys stay unique. */
98
+ var KeyRegistry = class {
99
+ options;
100
+ created = /* @__PURE__ */ new Map();
101
+ /** Text left alone because its key would overwrite a value of the translation file. */
102
+ blocked = [];
103
+ byText = /* @__PURE__ */ new Map();
104
+ used;
105
+ /** The file with this run's keys in it: `pages.user` and `pages.user.save` cannot both be text. */
106
+ planned;
107
+ constructor(existing, tree = {}, options = {}) {
108
+ this.options = options;
109
+ this.planned = structuredClone(tree);
110
+ this.used = new Set(Object.keys(existing));
111
+ for (const [key, value] of Object.entries(existing)) this.byText.set(value, key);
112
+ }
113
+ generate(text, file) {
114
+ const slug = toSlug(keywordsOf(text).join(" ")) || toSlug(text.slice(0, 40)) || `key-${this.used.size + 1}`;
115
+ const base = [
116
+ filePrefix(file),
117
+ this.options.context,
118
+ slug
119
+ ].filter(Boolean).join(".");
120
+ let key = base;
121
+ for (let counter = 1; this.used.has(key); counter += 1) key = `${base}_${counter}`;
122
+ this.used.add(key);
123
+ return key;
124
+ }
125
+ block(text, file, line) {
126
+ this.byText.set(text, "");
127
+ this.blocked.push({
128
+ file,
129
+ line,
130
+ text
131
+ });
132
+ return null;
133
+ }
134
+ /** Key for `text`, or `null` when it stays as it is. */
135
+ keyFor(text, file, line) {
136
+ const known = this.byText.get(text);
137
+ if (known === "") return null;
138
+ if (known !== void 0) return isWritableKey(known) ? known : this.block(text, file, line);
139
+ const generated = this.generate(text, file);
140
+ if (this.options.skippedKeys?.includes(generated)) {
141
+ this.byText.set(text, "");
142
+ return null;
143
+ }
144
+ const key = this.options.keyOverrides?.[generated] ?? generated;
145
+ if (pathConflict(this.planned, key)) return this.block(text, file, line);
146
+ setPath(this.planned, key, text);
147
+ this.created.set(key, {
148
+ key,
149
+ value: text,
150
+ file,
151
+ line
152
+ });
153
+ this.byText.set(text, key);
154
+ return key;
155
+ }
156
+ /** Take back keys of a conversion that was thrown away. */
157
+ forget(keep) {
158
+ for (const [key, { value }] of this.created) {
159
+ if (keep.has(key)) continue;
160
+ this.created.delete(key);
161
+ this.byText.delete(value);
162
+ }
163
+ }
164
+ };
165
+ var hasInterpolation = (text) => /\{\{[^}]*\}\}|\$\{/.test(text);
166
+ var isTranslatable = (text) => /\p{L}/u.test(text) && !hasInterpolation(text);
167
+ /** Identifiers, paths, URLs: data that happens to be a string. */
168
+ function isTechnical(value) {
169
+ const text = value.trim();
170
+ if (text.includes("://")) return true;
171
+ if (/\s/.test(text)) return false;
172
+ return /^[./]/.test(text) || /^[a-z0-9_.:-]+$/.test(text) || /^[A-Z0-9_.-]+$/.test(text);
173
+ }
174
+ /** The compiler's text: entities decoded, so `&` does not reach the locale file as five characters. */
175
+ var displayText = (node) => (node.content ?? "").replace(/\s+/g, " ").trim();
176
+ function convertTextNode(node, run) {
177
+ const text = displayText(node);
178
+ if (!isTranslatable(text)) return;
179
+ const source = node.loc.source;
180
+ const start = node.loc.start.offset + (source.length - source.trimStart().length);
181
+ const key = run.keys.keyFor(text, run.file, lineOf(run.content, start));
182
+ if (key) run.magic.overwrite(start, start + source.trim().length, `{{ $t('${key}') }}`);
183
+ }
184
+ /** `title="x" :title="y"`: binding the first would make the attribute a duplicate. */
185
+ var isBound = (element, name) => Boolean(element.props?.some((prop) => prop.type === NODE_TYPES.DIRECTIVE && prop.arg?.content === name));
186
+ function convertAttribute(prop, element, run) {
187
+ const text = prop.value?.content;
188
+ if (prop.type !== NODE_TYPES.ATTRIBUTE || !text || !TEXT_ATTRIBUTES.has(prop.name ?? "") || !isTranslatable(text)) return;
189
+ if (isBound(element, prop.name)) return;
190
+ const key = run.keys.keyFor(text, run.file, lineOf(run.content, prop.loc.start.offset));
191
+ if (key) run.magic.overwrite(prop.loc.start.offset, prop.loc.end.offset, `:${prop.name}="$t('${key}')"`);
192
+ }
193
+ var isBlank = (node) => node.type === NODE_TYPES.COMMENT || node.type === NODE_TYPES.TEXT && displayText(node) === "";
194
+ /** Text next to markup or an interpolation: `<p>Hello <b>world</b> today</p>`. */
195
+ var isMixed = (children) => {
196
+ const present = children.filter((child) => !isBlank(child));
197
+ return present.some((child) => child.type === NODE_TYPES.TEXT) && present.some((child) => child.type !== NODE_TYPES.TEXT);
198
+ };
199
+ var isSkippedElement = (node) => NON_DISPLAY_TAGS.has(node.tag ?? "") || /^i18n-?t$/i.test(node.tag ?? "") || /^<[^>]*\sv-pre[\s/>]/.test(node.loc.source);
200
+ function reportMixed(node, run) {
201
+ const text = displayText({
202
+ ...node,
203
+ content: node.loc.source.replace(/<[^>]*>/g, " ")
204
+ });
205
+ if (isTranslatable(text.replace(/\{\{[^}]*\}\}/g, ""))) run.out.mixed.push({
206
+ file: run.file,
207
+ line: lineOf(run.content, node.loc.start.offset),
208
+ text
209
+ });
210
+ }
211
+ function convertTemplate(node, run, textAllowed = true) {
212
+ if (node.type === NODE_TYPES.ELEMENT) {
213
+ if (isSkippedElement(node)) return;
214
+ for (const prop of node.props ?? []) convertAttribute(prop, node, run);
215
+ }
216
+ const children = node.children ?? [];
217
+ const mixed = isMixed(children);
218
+ if (mixed && textAllowed) reportMixed(node, run);
219
+ for (const child of children) if (child.type !== NODE_TYPES.TEXT) convertTemplate(child, run, textAllowed && !mixed);
220
+ else if (textAllowed && !mixed) convertTextNode(child, run);
221
+ }
222
+ function calleeName(call) {
223
+ const callee = call.callee;
224
+ return String(callee.name ?? callee.property?.name ?? callee.type);
225
+ }
226
+ function isDataLiteral(node, parent, key) {
227
+ if (!parent) return false;
228
+ if (SKIPPED_PARENTS.has(parent.type)) return true;
229
+ if (parent.type === "ObjectProperty" && key === "key" && !parent.computed) return true;
230
+ if (parent.type === "MemberExpression" && key === "property") return true;
231
+ if (parent.type === "BinaryExpression" || parent.type === "TaggedTemplateExpression") return true;
232
+ if (parent.type === "JSXAttribute") return true;
233
+ if (parent.type !== "CallExpression" && parent.type !== "OptionalCallExpression" && parent.type !== "NewExpression") return false;
234
+ return NON_TEXT_CALLS.has(calleeName(parent)) || node === parent.callee;
235
+ }
236
+ /** Text a person would read, in code. The strings the scanner took for keys or arguments of `$t` are not. */
237
+ function reportCode(content, file, lang = extname(file).slice(1), firstLine = 1) {
238
+ const ast = parseCode(content, lang);
239
+ if (!ast) return [];
240
+ const found = [];
241
+ const keyArguments = new Set(scanFile(content, `${file}.${lang}`).refs.map((ref) => ref.start - 1));
242
+ walk(ast, { enter(raw, parent, key) {
243
+ const node = raw;
244
+ const text = node.type === "JSXText" ? node.value.trim() : staticString(node);
245
+ if (!text || !isTranslatable(text) || isTechnical(text) || keyArguments.has(node.start)) return;
246
+ if (!isDataLiteral(node, parent, key)) found.push({
247
+ file,
248
+ line: lineOf(content, node.start) + firstLine - 1,
249
+ text
250
+ });
251
+ } });
252
+ return found;
253
+ }
254
+ function convertVue(content, file, keys) {
255
+ const out = {
256
+ content,
257
+ mixed: [],
258
+ code: []
259
+ };
260
+ if (content.trim() === "") return out;
261
+ const { descriptor, errors } = parse$1(content, { filename: file });
262
+ if (errors.length > 0) throw new Error(`it does not parse: ${String(errors[0].message)}`);
263
+ out.code = [descriptor.script, descriptor.scriptSetup].flatMap((block) => block ? reportCode(block.content, file, block.lang ?? "js", block.loc.start.line) : []);
264
+ const ast = descriptor.template?.ast;
265
+ if (!ast) return out;
266
+ const before = new Set(keys.created.keys());
267
+ const run = {
268
+ magic: new MagicString(content),
269
+ keys,
270
+ file,
271
+ content,
272
+ out
273
+ };
274
+ convertTemplate({
275
+ ...ast,
276
+ loc: descriptor.template.loc
277
+ }, run);
278
+ const converted = run.magic.toString();
279
+ if (parse$1(converted, { filename: file }).errors.length > 0) {
280
+ keys.forget(before);
281
+ throw new Error("the rewritten file would not parse");
282
+ }
283
+ out.content = converted;
284
+ return out;
285
+ }
286
+ /** One source file: `.vue` templates are rewritten, anything else is only read. */
287
+ function convertSource(content, file, keys) {
288
+ if (extname(file).toLowerCase() === ".vue") return convertVue(content, file, keys);
289
+ return {
290
+ content,
291
+ mixed: [],
292
+ code: reportCode(content, file)
293
+ };
294
+ }
295
+ // src/commands/text-to-i18n.ts
296
+ var VALID_KEY = /^[\p{L}\p{N}_.-]+$/u;
297
+ /** A layer or an alias target serves other apps too, and the new keys would land in this one only. */
298
+ function ownFiles(config, files) {
299
+ const own = files.filter((file) => isOwnSource(config, file));
300
+ if (own.length < files.length) log.warn(`Left ${plural(files.length - own.length, "file")} of layers and folders outside the project alone.`);
301
+ return own;
302
+ }
303
+ function filesToProcess(config, args) {
304
+ const sources = projectFiles(config, { ignore: splitList(args.ignore) }).filter((file) => isCodeFile(file) && !isTestFile(config, file) && !/\bnuxt\.config\.\w+$/.test(file));
305
+ if (!args.path) return sources;
306
+ const target = resolve(config.rootDir, args.path);
307
+ if (!existsSync(target)) throw notFoundError(`Path does not exist: ${target}`);
308
+ if (statSync(target).isDirectory()) return sources.filter((file) => isUnder(target, file));
309
+ if (!isCodeFile(target)) throw validationError(`Unsupported file type: ${target}`, ["Supported: .vue, .js, .ts, .jsx, .tsx"]);
310
+ return [target];
311
+ }
312
+ function convertAll(files, args, tree, decisions) {
313
+ const keys = new KeyRegistry(flattenStrings(tree), tree, {
314
+ context: args.context,
315
+ ...decisions
316
+ });
317
+ const changes = [];
318
+ const conversions = [];
319
+ for (const file of files) try {
320
+ const content = readUtf8(file);
321
+ const conversion = convertSource(content, file, keys);
322
+ conversions.push(conversion);
323
+ if (conversion.content !== content) changes.push([file, conversion.content]);
324
+ } catch (error) {
325
+ log.warn(`Skipping ${file}: ${error.message}`);
326
+ }
327
+ return {
328
+ changes,
329
+ created: keys.created,
330
+ mixed: conversions.flatMap((item) => item.mixed),
331
+ code: conversions.flatMap((item) => item.code),
332
+ blocked: keys.blocked
333
+ };
334
+ }
335
+ async function askDecision(entry, decisions, reserved) {
336
+ log.info(`\n[${entry.file}:${entry.line}] "${entry.value}" -> ${entry.key}`);
337
+ const action = await choose("Action", [
338
+ "accept",
339
+ "edit",
340
+ "skip"
341
+ ]);
342
+ if (!action) throw usageError("No answer: stopping without changing anything.");
343
+ if (action === "skip") {
344
+ decisions.skippedKeys.push(entry.key);
345
+ reserved.delete(entry.key);
346
+ return;
347
+ }
348
+ if (action !== "edit") return;
349
+ const edited = await ask(`New key for "${entry.value}":`);
350
+ if (!edited || edited === entry.key) return;
351
+ if (reserved.has(edited) || !VALID_KEY.test(edited) || !isWritableKey(edited)) {
352
+ log.warn(`Key "${edited}" is taken or not a valid key; keeping ${entry.key}.`);
353
+ return;
354
+ }
355
+ decisions.keyOverrides[entry.key] = edited;
356
+ reserved.delete(entry.key);
357
+ reserved.add(edited);
358
+ }
359
+ async function collectDecisions(created, existing) {
360
+ const decisions = {
361
+ keyOverrides: {},
362
+ skippedKeys: []
363
+ };
364
+ const reserved = /* @__PURE__ */ new Set([...created.keys(), ...Object.keys(existing)]);
365
+ for (const entry of created.values()) await askDecision(entry, decisions, reserved);
366
+ return decisions;
367
+ }
368
+ function report(title, items, rootDir) {
369
+ log.list(title, items.map(({ file, line, text }) => [`${relativePath(rootDir, file)}:${line}`, text]));
370
+ }
371
+ async function run(args) {
372
+ const config = await loadProjectConfig(args.cwd ?? ".");
373
+ const translationFile = args.translationFile ? resolve(config.rootDir, args.translationFile) : join(config.translationDir, `${config.defaultLocale}.json`);
374
+ const translations = readJsonIfExists(translationFile);
375
+ const files = ownFiles(config, filesToProcess(config, args));
376
+ let result = convertAll(files, args, translations, {
377
+ keyOverrides: {},
378
+ skippedKeys: []
379
+ });
380
+ if (args.interactive && result.created.size > 0) result = convertAll(files, args, translations, await collectDecisions(result.created, flattenStrings(translations)));
381
+ log.note(`Found ${plural(files.length, "file")} to process.`);
382
+ report("Mixed text, not rewritten: wrap the sentence in one key by hand", result.mixed, config.rootDir);
383
+ report("Text in code, not rewritten: `$t` may not be in scope there", result.code, config.rootDir);
384
+ report("Skipped: the key would overwrite a value, or cannot be written in a template", result.blocked, config.rootDir);
385
+ if (result.created.size === 0 && result.changes.length === 0) {
386
+ log.note("No new translations found.");
387
+ return;
388
+ }
389
+ log.note(`Found ${plural(result.created.size, "new translation")}.`);
390
+ if (args.dryRun) {
391
+ log.list("Would add", [...result.created.values()].map(({ key, value, file, line }) => [
392
+ key,
393
+ `"${value}"`,
394
+ `${relativePath(config.rootDir, file)}:${line}`
395
+ ]));
396
+ log.note("Dry run: no files were modified.");
397
+ return;
398
+ }
399
+ for (const { key, value } of result.created.values()) setPath(translations, key, value);
400
+ const previous = existsSync(translationFile) ? readFileSync(translationFile, "utf8") : void 0;
401
+ writeFilesAtomic([...result.changes, [translationFile, stringifyLike(previous, translations)]]);
402
+ log.success(`Updated ${plural(result.changes.length, "file")} and ${relativePath(config.rootDir, translationFile)}.`);
403
+ }
404
+ export { run as default };