@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
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { A as log, M as plural, _ as findingsError, a as flattenStrings, x as usageError } from "./json-BV_apCMu.mjs";
|
|
2
|
+
import { a as readJsonIfExists, d as splitList } from "./nuxt-dKxG087V.mjs";
|
|
3
|
+
import { n as relativeFile, t as Project } from "./project-DgWgr7pF.mjs";
|
|
4
|
+
import { r as LINT_RULES } from "./spec-BwZlT28L.mjs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
// src/lint-rules.ts
|
|
7
|
+
/** Rules for what is wrong in any language. Whether a text ends in a period is not one of them. */
|
|
8
|
+
var lintRules = {
|
|
9
|
+
"no-trailing-spaces": {
|
|
10
|
+
isValid: (value) => !/[ \t]$/m.test(value),
|
|
11
|
+
fix: (value) => value.replace(/[ \t]+$/gm, "")
|
|
12
|
+
},
|
|
13
|
+
"no-multiple-spaces": {
|
|
14
|
+
isValid: (value) => !value.includes(" "),
|
|
15
|
+
fix: (value) => value.replace(/ {2,}/g, " ")
|
|
16
|
+
},
|
|
17
|
+
"no-empty-translation": { isValid: (value) => value.trim().length > 0 }
|
|
18
|
+
};
|
|
19
|
+
// src/commands/lint.ts
|
|
20
|
+
var stringList = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
21
|
+
function customRule(item) {
|
|
22
|
+
const { name, pattern, flags, description, replacement } = item;
|
|
23
|
+
if (typeof name !== "string" || typeof pattern !== "string") return [];
|
|
24
|
+
let matcher;
|
|
25
|
+
try {
|
|
26
|
+
matcher = new RegExp(pattern, typeof flags === "string" ? flags : void 0);
|
|
27
|
+
} catch {
|
|
28
|
+
log.warn(`Skipping lint rule "${name}": invalid pattern ${pattern}`);
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
const everywhere = new RegExp(matcher, matcher.global ? matcher.flags : `${matcher.flags}g`);
|
|
32
|
+
return [{
|
|
33
|
+
name,
|
|
34
|
+
description: typeof description === "string" ? description : `Regex rule: ${pattern}`,
|
|
35
|
+
isValid: (value) => {
|
|
36
|
+
matcher.lastIndex = 0;
|
|
37
|
+
return !matcher.test(value);
|
|
38
|
+
},
|
|
39
|
+
fix: typeof replacement === "string" ? (value) => value.replace(everywhere, replacement) : void 0
|
|
40
|
+
}];
|
|
41
|
+
}
|
|
42
|
+
function loadConfig(rootDir) {
|
|
43
|
+
const raw = readJsonIfExists(join(rootDir, ".i18n-lintrc.json"));
|
|
44
|
+
const custom = Array.isArray(raw.customRegexRules) ? raw.customRegexRules : [];
|
|
45
|
+
return {
|
|
46
|
+
enabledRules: stringList(raw.enabledRules),
|
|
47
|
+
disabledRules: stringList(raw.disabledRules),
|
|
48
|
+
customRules: custom.filter((item) => item !== null && typeof item === "object").flatMap((item) => customRule(item))
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function selectRules(args, config) {
|
|
52
|
+
const available = [...LINT_RULES.map(({ name, description }) => ({
|
|
53
|
+
name,
|
|
54
|
+
description,
|
|
55
|
+
isValid: lintRules[name].isValid,
|
|
56
|
+
fix: lintRules[name].fix
|
|
57
|
+
})), ...config.customRules];
|
|
58
|
+
const requested = splitList(args.rules);
|
|
59
|
+
const selected = requested.length > 0 ? requested : config.enabledRules.length > 0 ? config.enabledRules : available.map((rule) => rule.name);
|
|
60
|
+
const unknown = selected.filter((name) => !available.some((rule) => rule.name === name));
|
|
61
|
+
if (unknown.length > 0) throw usageError(`Unknown lint rules: ${unknown.join(", ")}`, [`Available: ${available.map((rule) => rule.name).join(", ")}`]);
|
|
62
|
+
return available.filter((rule) => selected.includes(rule.name) && !config.disabledRules.includes(rule.name));
|
|
63
|
+
}
|
|
64
|
+
/** Own files only: a layer's text is the layer's to fix. */
|
|
65
|
+
function lintProject(project, rules) {
|
|
66
|
+
return project.codes.flatMap((code) => {
|
|
67
|
+
const locale = project.locale(code);
|
|
68
|
+
return locale.scopes.flatMap((scope) => Object.entries(flattenStrings(locale.fileTree(scope))).flatMap(([key, value]) => rules.filter((rule) => !rule.isValid(value)).map((rule) => ({
|
|
69
|
+
file: relativeFile(code, scope),
|
|
70
|
+
locale: code,
|
|
71
|
+
scope,
|
|
72
|
+
key,
|
|
73
|
+
value,
|
|
74
|
+
rule: rule.name
|
|
75
|
+
}))));
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
/** Fixes stack: two rules on one value both apply, in rule order. */
|
|
79
|
+
function applyFixes(project, results, rules) {
|
|
80
|
+
const fixers = new Map(rules.map((rule) => [rule.name, rule.fix]));
|
|
81
|
+
let fixed = 0;
|
|
82
|
+
for (const { locale, scope, key, rule } of results) {
|
|
83
|
+
const set = project.locale(locale);
|
|
84
|
+
const before = set.get(key, scope);
|
|
85
|
+
const after = fixers.get(rule)?.(before) ?? before;
|
|
86
|
+
if (after === before) continue;
|
|
87
|
+
set.set(key, after, scope);
|
|
88
|
+
fixed += 1;
|
|
89
|
+
}
|
|
90
|
+
project.save();
|
|
91
|
+
return fixed;
|
|
92
|
+
}
|
|
93
|
+
async function run(args) {
|
|
94
|
+
const project = await Project.load(args);
|
|
95
|
+
const rules = selectRules(args, loadConfig(project.config.rootDir));
|
|
96
|
+
if (rules.length === 0) {
|
|
97
|
+
log.warn("No rules to check");
|
|
98
|
+
if (args.json) log.json({
|
|
99
|
+
rulesChecked: [],
|
|
100
|
+
issueCount: 0,
|
|
101
|
+
fixedCount: 0,
|
|
102
|
+
fixApplied: false,
|
|
103
|
+
issues: []
|
|
104
|
+
});
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const results = lintProject(project, rules);
|
|
108
|
+
const fixedCount = args.fix ? applyFixes(project, results, rules) : 0;
|
|
109
|
+
const remaining = args.fix ? lintProject(project, rules).length : results.length;
|
|
110
|
+
if (args.json) log.json({
|
|
111
|
+
rulesChecked: rules.map((rule) => rule.name),
|
|
112
|
+
issueCount: results.length,
|
|
113
|
+
fixedCount,
|
|
114
|
+
fixApplied: Boolean(args.fix),
|
|
115
|
+
issues: results
|
|
116
|
+
});
|
|
117
|
+
else {
|
|
118
|
+
log.note(`Active rules: ${rules.map((rule) => rule.name).join(", ")}`);
|
|
119
|
+
for (const { rule, file, key, locale } of results) log.warn(`[${rule}] ${key} (${locale}) in ${file}`);
|
|
120
|
+
if (fixedCount > 0) log.success(`Fixed ${plural(fixedCount, "issue")}.`);
|
|
121
|
+
if (remaining === 0) log.success("Check completed. No issues remain.");
|
|
122
|
+
}
|
|
123
|
+
if (remaining > 0) throw findingsError(`Found ${plural(remaining, "issue")} that need${remaining === 1 ? "s" : ""} to be fixed`, ["Review the issues above, or run with --json for machine-readable output.", "Apply automatic fixes where available: i18n-micro lint --fix"]);
|
|
124
|
+
}
|
|
125
|
+
export { run as default };
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { c as hasProtoKey, h as CliError, n as findDuplicateKey, r as findUnsafeInteger } from "./json-BV_apCMu.mjs";
|
|
2
|
+
import { forwardSlashes } from "@i18n-micro/utils/app-path";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { accessSync, chmodSync, constants, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
// src/fs.ts
|
|
8
|
+
var SKIPPED_DIRS = /* @__PURE__ */ new Set([
|
|
9
|
+
"node_modules",
|
|
10
|
+
".git",
|
|
11
|
+
".nuxt",
|
|
12
|
+
".output"
|
|
13
|
+
]);
|
|
14
|
+
var statOf = (path) => {
|
|
15
|
+
try {
|
|
16
|
+
return statSync(path, { throwIfNoEntry: false });
|
|
17
|
+
} catch {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
function visit(dir, accept, skipDir, seen, out) {
|
|
22
|
+
const real = realpathSync(dir);
|
|
23
|
+
if (seen.has(real)) return;
|
|
24
|
+
seen.add(real);
|
|
25
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
26
|
+
const full = join(dir, entry.name);
|
|
27
|
+
const stat = entry.isSymbolicLink() ? statOf(full) : entry;
|
|
28
|
+
if (stat?.isDirectory() && !skipDir(full)) visit(full, accept, skipDir, seen, out);
|
|
29
|
+
else if (stat?.isFile() && accept(full)) out.push(full);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** `path` from `from`, as a report, a key or a glob reads it: `/` on every platform. */
|
|
33
|
+
var relativePath = (from, path) => forwardSlashes(relative(from, path));
|
|
34
|
+
/** Whether `path` is `dir` or below it. `relative` gives an absolute path across Windows drives. */
|
|
35
|
+
function isUnder(dir, path) {
|
|
36
|
+
const rel = relative(dir, path);
|
|
37
|
+
return !rel.startsWith("..") && !isAbsolute(rel);
|
|
38
|
+
}
|
|
39
|
+
/** Every file under `dir` that `accept` wants, sorted, symlinks followed. A missing directory is an empty result. */
|
|
40
|
+
function walkFiles(dir, accept = () => true, skipDir = (path) => SKIPPED_DIRS.has(basename(path))) {
|
|
41
|
+
const out = [];
|
|
42
|
+
if (statOf(dir)?.isDirectory()) visit(dir, accept, skipDir, /* @__PURE__ */ new Set(), out);
|
|
43
|
+
return out.sort();
|
|
44
|
+
}
|
|
45
|
+
/** Where a write has to land: replacing a symlink with a file would silently unshare it. */
|
|
46
|
+
function realTarget(path) {
|
|
47
|
+
if (existsSync(path)) return realpathSync(path);
|
|
48
|
+
return lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink() ? resolve(dirname(path), readlinkSync(path)) : path;
|
|
49
|
+
}
|
|
50
|
+
var tempName = (path) => join(dirname(path), `.${basename(path)}.${randomBytes(4).toString("hex")}.tmp`);
|
|
51
|
+
/** A rename needs only the directory, so it would replace a file somebody made read-only. */
|
|
52
|
+
function assertWritable(target) {
|
|
53
|
+
try {
|
|
54
|
+
if (existsSync(target)) accessSync(target, constants.W_OK);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
throw new CliError(`Permission denied: ${target}`, {
|
|
57
|
+
code: "PERMISSION_DENIED",
|
|
58
|
+
cause: error
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** The tail of `tempName`. Anything else with a similar name is somebody else's. */
|
|
63
|
+
var TEMP_SUFFIX = /^[0-9a-f]{8}\.tmp$/;
|
|
64
|
+
/** What a killed run left next to `target`. */
|
|
65
|
+
function clearStaleTemps(target) {
|
|
66
|
+
const prefix = `.${basename(target)}.`;
|
|
67
|
+
for (const name of readdirSync(dirname(target))) if (name.startsWith(prefix) && TEMP_SUFFIX.test(name.slice(prefix.length))) rmSync(join(dirname(target), name), { force: true });
|
|
68
|
+
}
|
|
69
|
+
function writeTemps(files, options) {
|
|
70
|
+
const pending = [];
|
|
71
|
+
for (const [path] of files) {
|
|
72
|
+
const target = realTarget(path);
|
|
73
|
+
assertWritable(target);
|
|
74
|
+
if (!options.overwrite && statOf(target)?.isFile()) readUtf8(target);
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
for (const [path, content] of files) {
|
|
78
|
+
const target = realTarget(path);
|
|
79
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
80
|
+
clearStaleTemps(target);
|
|
81
|
+
const temp = tempName(target);
|
|
82
|
+
pending.push([temp, target]);
|
|
83
|
+
writeFileSync(temp, content);
|
|
84
|
+
if (existsSync(target)) chmodSync(temp, statSync(target).mode);
|
|
85
|
+
}
|
|
86
|
+
} catch (error) {
|
|
87
|
+
for (const [temp] of pending) rmSync(temp, { force: true });
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
return pending;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Hard links share an inode: a rename would leave the other name behind with the old text.
|
|
94
|
+
* So a hard-linked file is written in place, and a process killed during that write can leave
|
|
95
|
+
* it cut short. Every other target is replaced whole.
|
|
96
|
+
*/
|
|
97
|
+
function commit(temp, target) {
|
|
98
|
+
if ((statOf(target)?.nlink ?? 1) > 1) {
|
|
99
|
+
writeFileSync(target, readFileSync(temp));
|
|
100
|
+
rmSync(temp, { force: true });
|
|
101
|
+
} else renameSync(temp, target);
|
|
102
|
+
}
|
|
103
|
+
/** Through a temp file too: a target that turned read-only must not stop the rollback. */
|
|
104
|
+
function restore(target, original) {
|
|
105
|
+
if (!original) {
|
|
106
|
+
rmSync(target, { force: true });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const temp = tempName(target);
|
|
110
|
+
writeFileSync(temp, original);
|
|
111
|
+
chmodSync(temp, statSync(target).mode);
|
|
112
|
+
commit(temp, target);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Write a set of files all-or-nothing.
|
|
116
|
+
*
|
|
117
|
+
* Everything lands in temp files first, so a full disk, a bad path or a read-only file fails
|
|
118
|
+
* before the first real file changes. A rename that fails later puts back what the earlier ones
|
|
119
|
+
* replaced. Only killing the process can leave a mix: of whole files between two renames, and
|
|
120
|
+
* a cut one in a hard-linked target, see `commit`. The next save clears its temp files.
|
|
121
|
+
*/
|
|
122
|
+
function writeFilesAtomic(files, options = {}) {
|
|
123
|
+
const pending = writeTemps(files, options);
|
|
124
|
+
const originals = [];
|
|
125
|
+
let done = 0;
|
|
126
|
+
try {
|
|
127
|
+
for (const [, target] of pending) originals.push(statOf(target)?.isFile() ? readFileSync(target) : null);
|
|
128
|
+
for (; done < pending.length; done += 1) commit(pending[done][0], pending[done][1]);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
for (const [temp] of pending) rmSync(temp, { force: true });
|
|
131
|
+
for (let index = 0; index < done; index += 1) restore(pending[index][1], originals[index]);
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
var writeFileAtomic = (path, content) => writeFilesAtomic([[path, content]]);
|
|
136
|
+
var stringifyJson = (value, indent = 2) => `${JSON.stringify(value, null, indent)}\n`;
|
|
137
|
+
/** JSON text has no raw newline in a string, so every one of them is a line ending. */
|
|
138
|
+
var matchLineEnding = (previous, text) => previous?.includes("\r\n") ? text.split("\n").join("\r\n") : text;
|
|
139
|
+
/** Saved the way the file was written, so an edit shows up as an edit and not as a whole-file diff. */
|
|
140
|
+
function stringifyLike(previous, value) {
|
|
141
|
+
const text = stringifyJson(value, /^([ \t]+)"/m.exec(previous ?? "")?.[1] ?? 2);
|
|
142
|
+
return matchLineEnding(previous, previous && !previous.endsWith("\n") ? text.trimEnd() : text);
|
|
143
|
+
}
|
|
144
|
+
/** Text that survives a save. Bytes that are not UTF-8 would come back as replacement characters. */
|
|
145
|
+
function readUtf8(path) {
|
|
146
|
+
const raw = readFileSync(path);
|
|
147
|
+
const text = raw.toString("utf8");
|
|
148
|
+
if (!Buffer.from(text).equals(raw)) throw new CliError(`${path} is not valid UTF-8: a save would corrupt it.`, { code: "INVALID_ENCODING" });
|
|
149
|
+
return text;
|
|
150
|
+
}
|
|
151
|
+
var withoutBom = (text) => text.charCodeAt(0) === 65279 ? text.slice(1) : text;
|
|
152
|
+
/** For what is only read: a stray byte in a data file or a locale file costs nothing until a save. */
|
|
153
|
+
var readLoose = (path) => withoutBom(readFileSync(path, "utf8"));
|
|
154
|
+
/** A BOM comes from Windows editors and Excel; `JSON.parse` stops at it. */
|
|
155
|
+
var readText = (path) => withoutBom(readUtf8(path));
|
|
156
|
+
/**
|
|
157
|
+
* Read a JSON object strictly.
|
|
158
|
+
*
|
|
159
|
+
* A broken file must stop the command: treating it as empty is how the next save would
|
|
160
|
+
* replace somebody's translations with `{}`.
|
|
161
|
+
*/
|
|
162
|
+
function readJsonObject(path, options = {}) {
|
|
163
|
+
const content = readLoose(path);
|
|
164
|
+
if (content.trim() === "") return {};
|
|
165
|
+
let parsed;
|
|
166
|
+
try {
|
|
167
|
+
parsed = JSON.parse(content);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
const details = options.private ? {} : { Reason: String(error.message) };
|
|
170
|
+
throw new CliError(`Invalid JSON in ${path}`, {
|
|
171
|
+
code: "INVALID_JSON",
|
|
172
|
+
details,
|
|
173
|
+
cause: options.private ? void 0 : error
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new CliError(`Expected a JSON object in ${path}`, { code: "INVALID_JSON" });
|
|
177
|
+
const duplicate = findDuplicateKey(content);
|
|
178
|
+
if (duplicate !== null) throw new CliError(`Duplicate key in ${path}: ${duplicate}`, { code: "INVALID_JSON" });
|
|
179
|
+
if (hasProtoKey(parsed)) throw new CliError(`A key called __proto__ in ${path} would be lost on a save.`, { code: "INVALID_JSON" });
|
|
180
|
+
const unsafe = findUnsafeInteger(parsed);
|
|
181
|
+
if (unsafe !== null) throw new CliError(`Integer too large in ${path}: ${unsafe}. A save would change it; store it as a string.`, { code: "INVALID_JSON" });
|
|
182
|
+
return parsed;
|
|
183
|
+
}
|
|
184
|
+
var readJsonIfExists = (path) => existsSync(path) ? readJsonObject(path) : {};
|
|
185
|
+
/** `tests/**`, `*.spec.ts`. A glob without a slash matches at any depth. */
|
|
186
|
+
function globToRegExp(glob) {
|
|
187
|
+
const source = (glob.includes("/") ? glob : `**/${glob}`).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*\//g, "\0").replace(/\*\*/g, "").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]").replaceAll("\0", "(?:.*/)?").replaceAll("", ".*");
|
|
188
|
+
return new RegExp(`^${source}(?:/.*)?$`);
|
|
189
|
+
}
|
|
190
|
+
var splitList = (value) => (value ?? "").split(",").map((part) => part.trim()).filter(Boolean);
|
|
191
|
+
// src/nuxt.ts
|
|
192
|
+
var NUXT_CONFIG_FILES = [
|
|
193
|
+
"ts",
|
|
194
|
+
"js",
|
|
195
|
+
"mjs",
|
|
196
|
+
"cjs",
|
|
197
|
+
"mts",
|
|
198
|
+
"cts"
|
|
199
|
+
].map((extension) => `nuxt.config.${extension}`);
|
|
200
|
+
function findNuxtRoot(startDir) {
|
|
201
|
+
const start = resolve(startDir);
|
|
202
|
+
if (NUXT_CONFIG_FILES.some((file) => existsSync(join(start, file)))) return start;
|
|
203
|
+
const parent = dirname(start);
|
|
204
|
+
return parent === start ? null : findNuxtRoot(parent);
|
|
205
|
+
}
|
|
206
|
+
function findPackageDir(name, fromDir) {
|
|
207
|
+
const candidate = join(fromDir, "node_modules", name);
|
|
208
|
+
if (existsSync(join(candidate, "package.json"))) return realpathSync(candidate);
|
|
209
|
+
const parent = dirname(fromDir);
|
|
210
|
+
return parent === fromDir ? null : findPackageDir(name, parent);
|
|
211
|
+
}
|
|
212
|
+
function exportEntry(target) {
|
|
213
|
+
if (typeof target === "string") return target;
|
|
214
|
+
if (typeof target !== "object" || target === null) return null;
|
|
215
|
+
const conditions = target;
|
|
216
|
+
return exportEntry(conditions["."]) ?? exportEntry(conditions.import) ?? exportEntry(conditions.default);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The Nuxt Kit of the project, never one of our own.
|
|
220
|
+
*
|
|
221
|
+
* The config has to be read by the Nuxt version that will run it. pnpm does not hoist `@nuxt/kit`
|
|
222
|
+
* to the app, so when it is not next to the project it is taken from next to `nuxt`.
|
|
223
|
+
*/
|
|
224
|
+
async function importProjectKit(rootDir) {
|
|
225
|
+
const nuxtDir = findPackageDir("nuxt", rootDir);
|
|
226
|
+
const kitDir = findPackageDir("@nuxt/kit", rootDir) ?? (nuxtDir && findPackageDir("@nuxt/kit", nuxtDir));
|
|
227
|
+
if (!kitDir) throw new CliError("Cannot find Nuxt in this project.", {
|
|
228
|
+
code: "NUXT_LOAD_FAILED",
|
|
229
|
+
hints: ["Install the project dependencies first (npm install, pnpm install).", "Run from the app root, or pass --cwd."],
|
|
230
|
+
details: { "Project root": rootDir }
|
|
231
|
+
});
|
|
232
|
+
const manifest = JSON.parse(readFileSync(join(kitDir, "package.json"), "utf8"));
|
|
233
|
+
const entry = exportEntry(manifest.exports) ?? manifest.module ?? manifest.main ?? "index.js";
|
|
234
|
+
return await import(pathToFileURL(join(kitDir, entry)).href);
|
|
235
|
+
}
|
|
236
|
+
function moduleEnabled(modules = []) {
|
|
237
|
+
return modules.some((entry) => String(Array.isArray(entry) ? entry[0] : entry).includes("nuxt-i18n-micro"));
|
|
238
|
+
}
|
|
239
|
+
/** `modules: [['nuxt-i18n-micro', { ... }]]`. */
|
|
240
|
+
function inlineOptions(modules = []) {
|
|
241
|
+
return modules.find((item) => Array.isArray(item) && String(item[0]).includes("nuxt-i18n-micro"))?.[1];
|
|
242
|
+
}
|
|
243
|
+
/** Layers concatenate `locales`, project first, so the first entry of a code wins, as in the module. */
|
|
244
|
+
function normalizeLocales(locales = []) {
|
|
245
|
+
const byCode = /* @__PURE__ */ new Map();
|
|
246
|
+
for (const locale of locales) {
|
|
247
|
+
const entry = typeof locale === "string" ? { code: locale } : locale;
|
|
248
|
+
if (!byCode.has(entry.code)) byCode.set(entry.code, entry);
|
|
249
|
+
}
|
|
250
|
+
return [...byCode.values()].filter((entry) => !entry.disabled).map(({ code }) => ({ code }));
|
|
251
|
+
}
|
|
252
|
+
var layersOf = (config, rootDir) => {
|
|
253
|
+
const layers = (config._layers ?? []).flatMap((layer) => layer.config?.rootDir ? [layer.config] : []);
|
|
254
|
+
return layers.some((layer) => layer.rootDir === rootDir) ? layers : [{
|
|
255
|
+
rootDir,
|
|
256
|
+
srcDir: config.srcDir,
|
|
257
|
+
dir: config.dir
|
|
258
|
+
}, ...layers];
|
|
259
|
+
};
|
|
260
|
+
/** Like the module: each additional directory is relative to every layer, and sits under the layers' own files. */
|
|
261
|
+
function additionalDirsOf(roots, i18n) {
|
|
262
|
+
const names = (i18n.additionalTranslationDirs ?? []).map((entry) => typeof entry === "string" ? entry : entry.path).filter(Boolean);
|
|
263
|
+
return [...new Set(names.flatMap((name) => roots.map((root) => resolve(root, name))))];
|
|
264
|
+
}
|
|
265
|
+
var ALIAS_PREFIX = /^(~~|@@|~|@)(?=\/)/;
|
|
266
|
+
/** `../shared/*`: the folder a glob starts from. Reading all of it only finds more evidence. */
|
|
267
|
+
var globBase = (path) => path.replace(/[/\\][^/\\]*[*?[{(!].*$/, "");
|
|
268
|
+
/** Strings out of `components` and `imports.dirs`, in every shape Nuxt accepts them. */
|
|
269
|
+
function dirNames(value) {
|
|
270
|
+
if (typeof value === "string") return [value];
|
|
271
|
+
if (Array.isArray(value)) return value.flatMap(dirNames);
|
|
272
|
+
if (typeof value !== "object" || value === null) return [];
|
|
273
|
+
const { path, dirs } = value;
|
|
274
|
+
return [...dirNames(path), ...dirNames(dirs)];
|
|
275
|
+
}
|
|
276
|
+
/** A folder outside the root is still this app's code when an alias or auto-import points at it. */
|
|
277
|
+
function extraSourceDirsOf(config, rootDir) {
|
|
278
|
+
const srcDir = config.srcDir ?? rootDir;
|
|
279
|
+
const expand = (dir) => resolve(rootDir, dir.replace(ALIAS_PREFIX, (prefix) => prefix.length === 2 ? rootDir : srcDir));
|
|
280
|
+
const outside = [...Object.values(config.alias ?? {}).filter((name) => typeof name === "string"), ...[...dirNames(config.components), ...dirNames(config.imports?.dirs)].map(globBase)].map(expand).filter((dir) => !isUnder(rootDir, dir) && !forwardSlashes(dir).includes("/node_modules/") && existsSync(dir));
|
|
281
|
+
return [...new Set(outside.filter((dir) => statSync(dir).isDirectory()))];
|
|
282
|
+
}
|
|
283
|
+
async function readNuxtConfig(rootDir) {
|
|
284
|
+
const kit = await importProjectKit(rootDir);
|
|
285
|
+
try {
|
|
286
|
+
return await kit.loadNuxtConfig({
|
|
287
|
+
cwd: rootDir,
|
|
288
|
+
dotenv: { cwd: rootDir }
|
|
289
|
+
});
|
|
290
|
+
} catch (error) {
|
|
291
|
+
throw new CliError("Failed to load Nuxt configuration.", {
|
|
292
|
+
code: "NUXT_LOAD_FAILED",
|
|
293
|
+
hints: ["Verify that nuxt.config.* is valid and dependencies are installed.", "Run with --debug to see the full stack trace."],
|
|
294
|
+
details: {
|
|
295
|
+
"Project root": rootDir,
|
|
296
|
+
Reason: String(error.message)
|
|
297
|
+
},
|
|
298
|
+
cause: error
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Resolved i18n configuration of the project at or above `cwd`.
|
|
304
|
+
*
|
|
305
|
+
* `loadNuxtConfig`, not `loadNuxt`: the CLI needs options and layers, and booting Nuxt runs
|
|
306
|
+
* every module of the app just to read them.
|
|
307
|
+
*/
|
|
308
|
+
async function loadProjectConfig(cwd) {
|
|
309
|
+
const requested = resolve(cwd);
|
|
310
|
+
const rootDir = findNuxtRoot(requested);
|
|
311
|
+
if (!rootDir) throw new CliError("No Nuxt project found.", {
|
|
312
|
+
code: "NOT_NUXT_PROJECT",
|
|
313
|
+
hints: ["Run inside a Nuxt app (where nuxt.config.ts exists), or pass --cwd /path/to/app."],
|
|
314
|
+
details: { "Working directory": requested }
|
|
315
|
+
});
|
|
316
|
+
const config = await readNuxtConfig(rootDir);
|
|
317
|
+
const inline = inlineOptions(config.modules);
|
|
318
|
+
const i18n = config.i18n || inline ? {
|
|
319
|
+
...config.i18n,
|
|
320
|
+
...inline
|
|
321
|
+
} : void 0;
|
|
322
|
+
if (!i18n) {
|
|
323
|
+
const enabled = moduleEnabled(config.modules);
|
|
324
|
+
throw new CliError(enabled ? "nuxt-i18n-micro is enabled, but has no i18n options." : "nuxt-i18n-micro is not enabled in this project.", {
|
|
325
|
+
code: enabled ? "I18N_NOT_CONFIGURED" : "I18N_MODULE_MISSING",
|
|
326
|
+
hints: [`Example: modules: ['nuxt-i18n-micro'], i18n: { locales: [{ code: 'en' }], defaultLocale: 'en' }`],
|
|
327
|
+
details: { "Project root": rootDir }
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
const locales = normalizeLocales(i18n.locales);
|
|
331
|
+
if (locales.length === 0) throw new CliError("i18n configuration has no locales.", {
|
|
332
|
+
code: "NO_LOCALES",
|
|
333
|
+
hints: [`Add at least one locale, e.g. i18n: { locales: [{ code: 'en' }], defaultLocale: 'en' }`],
|
|
334
|
+
details: { "Project root": rootDir }
|
|
335
|
+
});
|
|
336
|
+
const defaultLocale = process.env.DEFAULT_LOCALE ?? i18n.defaultLocale ?? "en";
|
|
337
|
+
if (!locales.some((locale) => locale.code === defaultLocale)) throw new CliError(`The default locale "${defaultLocale}" is not among the enabled locales.`, {
|
|
338
|
+
code: "NO_LOCALES",
|
|
339
|
+
hints: ["Set i18n.defaultLocale in nuxt.config."],
|
|
340
|
+
details: {
|
|
341
|
+
"Project root": rootDir,
|
|
342
|
+
Locales: locales.map((locale) => locale.code).join(", ")
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
const dirName = i18n.translationDir || "locales";
|
|
346
|
+
const layers = layersOf(config, rootDir);
|
|
347
|
+
const roots = layers.map((layer) => layer.rootDir).reverse();
|
|
348
|
+
const translationDir = resolve(rootDir, dirName);
|
|
349
|
+
const primaryDirs = [...roots.map((root) => resolve(root, dirName)), translationDir];
|
|
350
|
+
const additionalDirs = additionalDirsOf(roots, i18n).filter((dir) => !primaryDirs.includes(dir));
|
|
351
|
+
return {
|
|
352
|
+
rootDir,
|
|
353
|
+
srcDir: config.srcDir ?? rootDir,
|
|
354
|
+
translationDir,
|
|
355
|
+
translationDirs: [.../* @__PURE__ */ new Set([...additionalDirs, ...primaryDirs])].sort((a, b) => Number(a === translationDir) - Number(b === translationDir)),
|
|
356
|
+
additionalDirs,
|
|
357
|
+
layerRoots: roots,
|
|
358
|
+
pagesDirs: [...new Set(layers.map((layer) => resolve(layer.srcDir ?? layer.rootDir, layer.dir?.pages ?? "pages")))],
|
|
359
|
+
extraSourceDirs: extraSourceDirsOf(config, rootDir),
|
|
360
|
+
defaultLocale,
|
|
361
|
+
defaultLocaleOverridden: defaultLocale !== (i18n.defaultLocale ?? "en"),
|
|
362
|
+
locales,
|
|
363
|
+
disablePageLocales: Boolean(i18n.disablePageLocales)
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
export { readJsonIfExists as a, readText as c, splitList as d, stringifyJson as f, writeFilesAtomic as g, writeFileAtomic as h, matchLineEnding as i, readUtf8 as l, walkFiles as m, globToRegExp as n, readJsonObject as o, stringifyLike as p, isUnder as r, readLoose as s, loadProjectConfig as t, relativePath as u };
|