@dualkit/convert 0.0.0-stage → 0.2.0-beta.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 +9 -0
- package/README.md +113 -2
- package/dist/index.cjs +3368 -0
- package/dist/index.d.cts +173 -0
- package/dist/index.d.ts +173 -0
- package/dist/index.js +3332 -0
- package/package.json +46 -4
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,3368 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var src_exports = {};
|
|
22
|
+
__export(src_exports, {
|
|
23
|
+
ConvertFailedError: () => ConvertFailedError,
|
|
24
|
+
OverlapError: () => OverlapError,
|
|
25
|
+
RefusedError: () => RefusedError,
|
|
26
|
+
UnsupportedError: () => UnsupportedError,
|
|
27
|
+
applyEdits: () => applyEdits,
|
|
28
|
+
branchFor: () => branchFor,
|
|
29
|
+
convert: () => convert,
|
|
30
|
+
detectProject: () => detectProject,
|
|
31
|
+
finish: () => finish,
|
|
32
|
+
problems: () => problems
|
|
33
|
+
});
|
|
34
|
+
module.exports = __toCommonJS(src_exports);
|
|
35
|
+
|
|
36
|
+
// src/edits.ts
|
|
37
|
+
var OverlapError = class extends Error {
|
|
38
|
+
constructor(first, second) {
|
|
39
|
+
super(`edits overlap: [${first.start}, ${first.end}) and [${second.start}, ${second.end})`);
|
|
40
|
+
this.first = first;
|
|
41
|
+
this.second = second;
|
|
42
|
+
this.name = "OverlapError";
|
|
43
|
+
}
|
|
44
|
+
first;
|
|
45
|
+
second;
|
|
46
|
+
};
|
|
47
|
+
function checkBounds(text, edit) {
|
|
48
|
+
const span2 = `edit [${edit.start}, ${edit.end})`;
|
|
49
|
+
if (edit.end < edit.start) throw new RangeError(`${span2} ends before it starts`);
|
|
50
|
+
if (edit.start < 0 || edit.end > text.length) throw new RangeError(`${span2} is outside the text, which is ${text.length} characters long`);
|
|
51
|
+
}
|
|
52
|
+
function applyEdits(text, edits2) {
|
|
53
|
+
for (const edit of edits2) checkBounds(text, edit);
|
|
54
|
+
const ordered = edits2.map((edit, index) => ({ edit, index })).sort((a, b) => a.edit.start - b.edit.start || a.index - b.index).map((entry) => entry.edit);
|
|
55
|
+
for (let i = 1; i < ordered.length; i++) {
|
|
56
|
+
const prev = ordered[i - 1];
|
|
57
|
+
const next = ordered[i];
|
|
58
|
+
if (next.start < prev.end) throw new OverlapError(prev, next);
|
|
59
|
+
}
|
|
60
|
+
let out = "";
|
|
61
|
+
let cursor = 0;
|
|
62
|
+
for (const edit of ordered) {
|
|
63
|
+
out += text.slice(cursor, edit.start) + edit.text;
|
|
64
|
+
cursor = edit.end;
|
|
65
|
+
}
|
|
66
|
+
return out + text.slice(cursor);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/project.ts
|
|
70
|
+
var import_node_fs = require("fs");
|
|
71
|
+
var import_node_path = require("path");
|
|
72
|
+
var import_lint = require("@dualkit/lint");
|
|
73
|
+
|
|
74
|
+
// src/guards.ts
|
|
75
|
+
var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
76
|
+
|
|
77
|
+
// src/project.ts
|
|
78
|
+
var UnsupportedError = class extends Error {
|
|
79
|
+
constructor(message) {
|
|
80
|
+
super(message);
|
|
81
|
+
this.name = "UnsupportedError";
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
var SUPPORTED = "Next.js App Router and Vite React apps";
|
|
85
|
+
var REFUSED = [
|
|
86
|
+
["@remix-run/react", "Remix"],
|
|
87
|
+
["@remix-run/dev", "Remix"],
|
|
88
|
+
["react-scripts", "Create React App"],
|
|
89
|
+
["vue", "Vue"],
|
|
90
|
+
["svelte", "Svelte"],
|
|
91
|
+
["@angular/core", "Angular"]
|
|
92
|
+
];
|
|
93
|
+
var LOCKFILES = [
|
|
94
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
95
|
+
["yarn.lock", "yarn"],
|
|
96
|
+
["bun.lockb", "bun"],
|
|
97
|
+
["bun.lock", "bun"],
|
|
98
|
+
["package-lock.json", "npm"]
|
|
99
|
+
];
|
|
100
|
+
var ENTRY_NAMES = ["main", "index"];
|
|
101
|
+
var EXTENSIONS = ["tsx", "jsx", "ts", "js"];
|
|
102
|
+
var unsupported = (what) => new UnsupportedError(`Convert supports ${SUPPORTED}, not ${what}.`);
|
|
103
|
+
function readDeps(root) {
|
|
104
|
+
const path = (0, import_node_path.join)(root, "package.json");
|
|
105
|
+
if (!(0, import_node_fs.existsSync)(path)) throw new UnsupportedError(`No package.json in ${root}. Convert supports ${SUPPORTED}.`);
|
|
106
|
+
let json2;
|
|
107
|
+
try {
|
|
108
|
+
json2 = JSON.parse((0, import_node_fs.readFileSync)(path, "utf8"));
|
|
109
|
+
} catch {
|
|
110
|
+
throw new UnsupportedError(`The package.json in ${root} is not valid JSON. Convert supports ${SUPPORTED}.`);
|
|
111
|
+
}
|
|
112
|
+
if (!isRecord(json2)) throw new UnsupportedError(`The package.json in ${root} must hold a JSON object. Convert supports ${SUPPORTED}.`);
|
|
113
|
+
const deps = {};
|
|
114
|
+
for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
|
|
115
|
+
const list = json2[field];
|
|
116
|
+
if (isRecord(list)) Object.assign(deps, list);
|
|
117
|
+
}
|
|
118
|
+
return deps;
|
|
119
|
+
}
|
|
120
|
+
function withExtension(root, base) {
|
|
121
|
+
const ext = EXTENSIONS.find((e) => (0, import_node_fs.existsSync)((0, import_node_path.join)(root, `${base}.${e}`)));
|
|
122
|
+
return ext ? `${base}.${ext}` : null;
|
|
123
|
+
}
|
|
124
|
+
function nextLayout(root) {
|
|
125
|
+
for (const appDir of ["app", "src/app"]) {
|
|
126
|
+
const entry = withExtension(root, `${appDir}/[locale]/layout`) ?? withExtension(root, `${appDir}/layout`);
|
|
127
|
+
if (entry) return { appDir, entry };
|
|
128
|
+
}
|
|
129
|
+
const hasPages = ["pages", "src/pages"].some((d) => (0, import_node_fs.existsSync)((0, import_node_path.join)(root, d)));
|
|
130
|
+
throw new UnsupportedError(
|
|
131
|
+
hasPages ? `This Next.js app uses the Pages Router. Convert supports the App Router only, so there is no app/layout file to start from.` : `No root layout found in app or src/app. Convert supports the Next.js App Router and Vite React apps.`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
function viteEntry(root) {
|
|
135
|
+
const candidates = ["src", "."].flatMap((dir) => ENTRY_NAMES.map((name) => dir === "." ? name : `${dir}/${name}`));
|
|
136
|
+
for (const base of candidates) {
|
|
137
|
+
const entry = withExtension(root, base);
|
|
138
|
+
if (entry && (0, import_node_fs.readFileSync)((0, import_node_path.join)(root, entry), "utf8").includes("createRoot")) return entry;
|
|
139
|
+
}
|
|
140
|
+
const src = (0, import_node_path.join)(root, "src");
|
|
141
|
+
const files = (0, import_node_fs.existsSync)(src) ? (0, import_node_fs.readdirSync)(src).filter((f) => /\.(tsx|jsx|ts|js)$/.test(f)) : [];
|
|
142
|
+
const found = files.find((f) => (0, import_node_fs.readFileSync)((0, import_node_path.join)(src, f), "utf8").includes("createRoot"));
|
|
143
|
+
if (found) return `src/${found}`;
|
|
144
|
+
throw new UnsupportedError(`No file calling createRoot found in this Vite app. Convert supports ${SUPPORTED}.`);
|
|
145
|
+
}
|
|
146
|
+
function detectPackageManager(root) {
|
|
147
|
+
for (let dir = root; ; dir = (0, import_node_path.dirname)(dir)) {
|
|
148
|
+
const hit = LOCKFILES.find(([file]) => (0, import_node_fs.existsSync)((0, import_node_path.join)(dir, file)));
|
|
149
|
+
if (hit) return hit[1];
|
|
150
|
+
const top = (0, import_node_fs.existsSync)((0, import_node_path.join)(dir, ".git")) || dir !== root && (0, import_node_fs.existsSync)((0, import_node_path.join)(dir, "package.json"));
|
|
151
|
+
if (top || (0, import_node_path.dirname)(dir) === dir) return "npm";
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function detectI18n(deps) {
|
|
155
|
+
if ("next-intl" in deps) return "next-intl";
|
|
156
|
+
if ("react-i18next" in deps) return "react-i18next";
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
function detectProject(root) {
|
|
160
|
+
const deps = readDeps(root);
|
|
161
|
+
const refused = REFUSED.find(([name]) => name in deps);
|
|
162
|
+
if (refused) throw unsupported(refused[1]);
|
|
163
|
+
if (!("react" in deps)) throw new UnsupportedError(`This app does not use React. Convert supports ${SUPPORTED}.`);
|
|
164
|
+
const tailwind = (0, import_lint.createTailwindVersion)()((0, import_node_path.join)(root, "package.json"), root);
|
|
165
|
+
const common = { root, tailwind, i18n: detectI18n(deps), packageManager: detectPackageManager(root) };
|
|
166
|
+
if ("next" in deps) {
|
|
167
|
+
const { appDir, entry } = nextLayout(root);
|
|
168
|
+
return { ...common, framework: "next-app", appDir, entry };
|
|
169
|
+
}
|
|
170
|
+
if ("vite" in deps) return { ...common, framework: "vite-react", appDir: null, entry: viteEntry(root) };
|
|
171
|
+
throw new UnsupportedError(`This React app uses neither Next.js nor Vite. Convert supports ${SUPPORTED}.`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// src/convert.ts
|
|
175
|
+
var import_lint12 = require("@dualkit/lint");
|
|
176
|
+
var import_node_path17 = require("path");
|
|
177
|
+
|
|
178
|
+
// src/apply.ts
|
|
179
|
+
var import_lint2 = require("@dualkit/lint");
|
|
180
|
+
var import_node_fs6 = require("fs");
|
|
181
|
+
var import_node_path7 = require("path");
|
|
182
|
+
|
|
183
|
+
// src/generate/stylesheet.ts
|
|
184
|
+
var import_rules = require("@dualkit/rules");
|
|
185
|
+
var import_node_path2 = require("path");
|
|
186
|
+
var FONT_URL = "https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@400;500;600;700&display=swap";
|
|
187
|
+
var SMALL_TEXT = [".text-xs", ".text-\\[10px\\]", ".text-\\[11px\\]", ".text-\\[12px\\]", ".text-\\[13px\\]"];
|
|
188
|
+
function stylesheetPath(project) {
|
|
189
|
+
return project.appDir ? `${project.appDir}/dualkit.css` : "src/dualkit.css";
|
|
190
|
+
}
|
|
191
|
+
function dualkitCss(project) {
|
|
192
|
+
const blocks = [
|
|
193
|
+
`@import url('${FONT_URL}');`,
|
|
194
|
+
`:lang(ar) {
|
|
195
|
+
font-family: 'IBM Plex Sans Arabic', system-ui, sans-serif;
|
|
196
|
+
line-height: 1.7;
|
|
197
|
+
}`,
|
|
198
|
+
":lang(ar) * {\n letter-spacing: 0;\n}"
|
|
199
|
+
];
|
|
200
|
+
if (project.tailwind) {
|
|
201
|
+
const selectors = SMALL_TEXT.map((s) => `:lang(ar) ${s}`).join(", ");
|
|
202
|
+
blocks.push(`${selectors} {
|
|
203
|
+
font-size: ${import_rules.MIN_ARABIC_SIZE}px;
|
|
204
|
+
}`);
|
|
205
|
+
}
|
|
206
|
+
blocks.push('[dir="rtl"] [data-dk-mirror] {\n scale: -1 1;\n}');
|
|
207
|
+
blocks.push('[dir="rtl"] input[dir="ltr"]:placeholder-shown, [dir="rtl"] textarea[dir="ltr"]:placeholder-shown {\n direction: rtl;\n}');
|
|
208
|
+
return blocks.join("\n\n") + "\n";
|
|
209
|
+
}
|
|
210
|
+
function specifier(entry, cssPath) {
|
|
211
|
+
const rel = import_node_path2.posix.relative(import_node_path2.posix.dirname(entry.path), cssPath);
|
|
212
|
+
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
213
|
+
}
|
|
214
|
+
function importEdit(entry, cssPath) {
|
|
215
|
+
const block = entry.js[0];
|
|
216
|
+
if (!block) return null;
|
|
217
|
+
const spec = specifier(entry, cssPath);
|
|
218
|
+
const body = block.ast.program.body;
|
|
219
|
+
const imports = body.filter((node) => node.type === "ImportDeclaration");
|
|
220
|
+
if (imports.some((node) => node.source?.value === spec)) return null;
|
|
221
|
+
const statement = (at2) => `import '${spec}'${entry.text[at2 - 1] === ";" ? ";" : ""}`;
|
|
222
|
+
const after = imports.at(-1) ?? block.ast.program.directives.at(-1);
|
|
223
|
+
if (after) {
|
|
224
|
+
const at2 = block.offset + after.end;
|
|
225
|
+
return { start: at2, end: at2, text: `
|
|
226
|
+
${statement(at2)}` };
|
|
227
|
+
}
|
|
228
|
+
return { start: block.offset, end: block.offset, text: `import '${spec}'
|
|
229
|
+
` };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// src/git.ts
|
|
233
|
+
var import_node_child_process = require("child_process");
|
|
234
|
+
var import_node_fs3 = require("fs");
|
|
235
|
+
var import_node_path4 = require("path");
|
|
236
|
+
|
|
237
|
+
// src/state.ts
|
|
238
|
+
var import_node_fs2 = require("fs");
|
|
239
|
+
var import_node_path3 = require("path");
|
|
240
|
+
var STATE_DIR = ".dualkit";
|
|
241
|
+
var STATE_FILE = `${STATE_DIR}/convert.json`;
|
|
242
|
+
function isState(value) {
|
|
243
|
+
return isRecord(value) && ["mode", "base", "branch", "framework"].every((k) => typeof value[k] === "string") && typeof value.phase === "string" && Array.isArray(value.generated) && value.generated.every((p) => typeof p === "string");
|
|
244
|
+
}
|
|
245
|
+
function readState(root) {
|
|
246
|
+
const path = (0, import_node_path3.join)(root, STATE_FILE);
|
|
247
|
+
if (!(0, import_node_fs2.existsSync)(path)) return null;
|
|
248
|
+
let parsed;
|
|
249
|
+
try {
|
|
250
|
+
parsed = JSON.parse((0, import_node_fs2.readFileSync)(path, "utf8"));
|
|
251
|
+
} catch (error) {
|
|
252
|
+
throw new Error(`${STATE_FILE} is not valid JSON: ${error.message}`);
|
|
253
|
+
}
|
|
254
|
+
if (!isState(parsed)) throw new Error(`${STATE_FILE} is missing its mode, base, branch, framework, phase or generated files.`);
|
|
255
|
+
return parsed;
|
|
256
|
+
}
|
|
257
|
+
var stateText = (state) => JSON.stringify(state, null, 2) + "\n";
|
|
258
|
+
function saveState(root, state) {
|
|
259
|
+
(0, import_node_fs2.mkdirSync)((0, import_node_path3.join)(root, STATE_DIR), { recursive: true });
|
|
260
|
+
(0, import_node_fs2.writeFileSync)((0, import_node_path3.join)(root, STATE_FILE), stateText(state));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// src/git.ts
|
|
264
|
+
var RefusedError = class extends Error {
|
|
265
|
+
constructor(message) {
|
|
266
|
+
super(message);
|
|
267
|
+
this.name = "RefusedError";
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
var NO_GIT = "git is not installed or not on PATH";
|
|
271
|
+
function git(cwd, args) {
|
|
272
|
+
return new Promise((resolve3, reject) => {
|
|
273
|
+
(0, import_node_child_process.execFile)("git", ["--literal-pathspecs", ...args], { cwd, maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
274
|
+
if (error && error.code === "ENOENT") return reject(new Error((0, import_node_fs3.existsSync)(cwd) ? NO_GIT : `The folder ${cwd} does not exist.`));
|
|
275
|
+
const code = error ? typeof error.code === "number" ? error.code : 1 : 0;
|
|
276
|
+
resolve3({ code, out: `${stdout}${stderr}`.trim(), stdout });
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
async function mustOut(cwd, args) {
|
|
281
|
+
const result = await git(cwd, args);
|
|
282
|
+
if (result.code !== 0) throw new Error(`git ${args[0]} failed${result.out ? `: ${result.out}` : ""}`);
|
|
283
|
+
return result.stdout.trim();
|
|
284
|
+
}
|
|
285
|
+
async function must(cwd, args) {
|
|
286
|
+
const result = await git(cwd, args);
|
|
287
|
+
if (result.code !== 0) throw new Error(`git ${args[0]} failed${result.out ? `: ${result.out}` : ""}`);
|
|
288
|
+
return result.out;
|
|
289
|
+
}
|
|
290
|
+
var isStatePath = (entry, prefix) => entry.slice(3).startsWith(`${prefix}${STATE_DIR}/`);
|
|
291
|
+
async function assertCleanRepo(root) {
|
|
292
|
+
const inside = await git(root, ["rev-parse", "--is-inside-work-tree"]);
|
|
293
|
+
if (inside.code !== 0 || inside.out !== "true") throw new RefusedError(`${root} is not inside a git repo. Convert works on a new branch, so run git init and commit first.`);
|
|
294
|
+
const prefix = await mustOut(root, ["rev-parse", "--show-prefix"]);
|
|
295
|
+
const status = await git(root, ["status", "--porcelain", "-z"]);
|
|
296
|
+
if (status.code !== 0) throw new Error(`git status failed: ${status.out}`);
|
|
297
|
+
const entries = status.stdout.split("\0").filter(Boolean);
|
|
298
|
+
if (entries.some((entry) => !isStatePath(entry, prefix))) throw new RefusedError("This repo has uncommitted changes. Commit or stash them, then run convert again.");
|
|
299
|
+
}
|
|
300
|
+
async function currentBranch(root) {
|
|
301
|
+
const name = await git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
302
|
+
return name.code === 0 ? name.out : must(root, ["rev-parse", "--short", "HEAD"]);
|
|
303
|
+
}
|
|
304
|
+
async function branchExists(root, branch) {
|
|
305
|
+
return (await git(root, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`])).code === 0;
|
|
306
|
+
}
|
|
307
|
+
async function createBranch(root, branch) {
|
|
308
|
+
await must(root, ["checkout", "-q", "-b", branch]);
|
|
309
|
+
}
|
|
310
|
+
async function moveFile(root, from, to) {
|
|
311
|
+
await must(root, ["mv", "--", from, to]);
|
|
312
|
+
}
|
|
313
|
+
async function resetHard(root) {
|
|
314
|
+
await must(root, ["reset", "-q", "--hard"]);
|
|
315
|
+
}
|
|
316
|
+
async function checkout(root, ref) {
|
|
317
|
+
await must(root, ["checkout", "-q", ref]);
|
|
318
|
+
}
|
|
319
|
+
async function deleteBranch(root, branch) {
|
|
320
|
+
await must(root, ["branch", "-q", "-D", branch]);
|
|
321
|
+
}
|
|
322
|
+
async function stage(root, paths) {
|
|
323
|
+
if (paths.length) await must(root, ["add", "-A", "--", ...paths]);
|
|
324
|
+
}
|
|
325
|
+
async function stagedFiles(root) {
|
|
326
|
+
const out = await must(root, ["diff", "--cached", "--name-only", "-z", "-M"]);
|
|
327
|
+
return out.split("\0").filter(Boolean);
|
|
328
|
+
}
|
|
329
|
+
async function commit(root, message) {
|
|
330
|
+
await must(root, ["commit", "-q", "-m", message]);
|
|
331
|
+
}
|
|
332
|
+
async function trackedFiles(root) {
|
|
333
|
+
const out = await must(root, ["ls-files", "-z"]);
|
|
334
|
+
return out.split("\0").filter(Boolean).sort();
|
|
335
|
+
}
|
|
336
|
+
async function cleanAtHead(root, paths) {
|
|
337
|
+
if (!paths.length) return [];
|
|
338
|
+
const inHead = new Set((await must(root, ["ls-tree", "-r", "--name-only", "-z", "HEAD", "--", ...paths])).split("\0").filter(Boolean));
|
|
339
|
+
const changed2 = new Set((await must(root, ["diff", "HEAD", "--relative", "--name-only", "-z", "--", ...paths])).split("\0").filter(Boolean));
|
|
340
|
+
return paths.filter((p) => inHead.has(p) && !changed2.has(p));
|
|
341
|
+
}
|
|
342
|
+
async function restoreFromHead(root, paths) {
|
|
343
|
+
if (paths.length) await must(root, ["checkout", "HEAD", "--", ...paths]);
|
|
344
|
+
}
|
|
345
|
+
async function unstage(root, paths) {
|
|
346
|
+
if (paths.length) await must(root, ["reset", "-q", "--", ...paths]);
|
|
347
|
+
}
|
|
348
|
+
async function excludeStateDir(root) {
|
|
349
|
+
const file = (0, import_node_path4.resolve)(root, await mustOut(root, ["rev-parse", "--git-path", "info/exclude"]));
|
|
350
|
+
const line = `/${await mustOut(root, ["rev-parse", "--show-prefix"])}${STATE_DIR}/*`;
|
|
351
|
+
const text = (0, import_node_fs3.existsSync)(file) ? (0, import_node_fs3.readFileSync)(file, "utf8") : "";
|
|
352
|
+
if (text.split(/\r?\n/).some((l) => l.trim() === line)) return;
|
|
353
|
+
(0, import_node_fs3.mkdirSync)((0, import_node_path4.dirname)(file), { recursive: true });
|
|
354
|
+
(0, import_node_fs3.writeFileSync)(file, `${text}${text === "" || text.endsWith("\n") ? "" : "\n"}${line}
|
|
355
|
+
`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// src/ledger.ts
|
|
359
|
+
var import_node_fs4 = require("fs");
|
|
360
|
+
var import_node_path5 = require("path");
|
|
361
|
+
var LEDGER_FILE = "dualkit-strings.json";
|
|
362
|
+
var STATUSES = /* @__PURE__ */ new Set(["pending", "drafted", "reviewed"]);
|
|
363
|
+
function isEntry(value) {
|
|
364
|
+
return isRecord(value) && ["key", "en", "ar", "file"].every((k) => typeof value[k] === "string") && typeof value.line === "number" && STATUSES.has(value.status);
|
|
365
|
+
}
|
|
366
|
+
var byKey = (a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
|
|
367
|
+
function readLedger(root) {
|
|
368
|
+
const path = (0, import_node_path5.join)(root, LEDGER_FILE);
|
|
369
|
+
if (!(0, import_node_fs4.existsSync)(path)) return [];
|
|
370
|
+
let parsed;
|
|
371
|
+
try {
|
|
372
|
+
parsed = JSON.parse((0, import_node_fs4.readFileSync)(path, "utf8"));
|
|
373
|
+
} catch (error) {
|
|
374
|
+
throw new Error(`${LEDGER_FILE} is not valid JSON: ${error.message}`);
|
|
375
|
+
}
|
|
376
|
+
if (!Array.isArray(parsed)) throw new Error(`${LEDGER_FILE} should hold a list of strings.`);
|
|
377
|
+
const bad = parsed.findIndex((e) => !isEntry(e));
|
|
378
|
+
if (bad !== -1) throw new Error(`${LEDGER_FILE} entry ${bad + 1} is missing a key, en, ar, status, file or line.`);
|
|
379
|
+
return parsed;
|
|
380
|
+
}
|
|
381
|
+
function writeLedger(root, entries) {
|
|
382
|
+
const sorted = [...entries].sort(byKey);
|
|
383
|
+
(0, import_node_fs4.writeFileSync)((0, import_node_path5.join)(root, LEDGER_FILE), JSON.stringify(sorted, null, 2) + "\n");
|
|
384
|
+
}
|
|
385
|
+
function mergeLedger(existing, added) {
|
|
386
|
+
const keys = new Set(existing.map((e) => e.key));
|
|
387
|
+
const fresh = [];
|
|
388
|
+
for (const entry of added) {
|
|
389
|
+
if (keys.has(entry.key)) continue;
|
|
390
|
+
keys.add(entry.key);
|
|
391
|
+
fresh.push(entry);
|
|
392
|
+
}
|
|
393
|
+
return [...existing, ...fresh].sort(byKey);
|
|
394
|
+
}
|
|
395
|
+
var textFor = (entry, locale) => locale === "en" || entry.status === "pending" || entry.ar === "" ? entry.en : entry.ar;
|
|
396
|
+
var group = () => /* @__PURE__ */ Object.create(null);
|
|
397
|
+
function messagesFor(entries, locale) {
|
|
398
|
+
const root = group();
|
|
399
|
+
for (const entry of [...entries].sort(byKey)) {
|
|
400
|
+
const parts = entry.key.split(".");
|
|
401
|
+
let node = root;
|
|
402
|
+
for (const part of parts.slice(0, -1)) {
|
|
403
|
+
const next = Object.hasOwn(node, part) ? node[part] : group();
|
|
404
|
+
if (typeof next !== "object") throw new Error(`Key ${entry.key} sits under a key that is already a message.`);
|
|
405
|
+
node[part] = next;
|
|
406
|
+
node = next;
|
|
407
|
+
}
|
|
408
|
+
const leaf = parts[parts.length - 1];
|
|
409
|
+
if (Object.hasOwn(node, leaf)) throw new Error(`Key ${entry.key} is used twice or is also a group.`);
|
|
410
|
+
node[leaf] = textFor(entry, locale);
|
|
411
|
+
}
|
|
412
|
+
return root;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// src/summary.ts
|
|
416
|
+
var import_node_fs5 = require("fs");
|
|
417
|
+
var import_node_path6 = require("path");
|
|
418
|
+
var SUMMARY_FILE = `${STATE_DIR}/summary.json`;
|
|
419
|
+
function readSummary(root) {
|
|
420
|
+
const path = (0, import_node_path6.join)(root, SUMMARY_FILE);
|
|
421
|
+
if (!(0, import_node_fs5.existsSync)(path)) return null;
|
|
422
|
+
try {
|
|
423
|
+
const parsed = JSON.parse((0, import_node_fs5.readFileSync)(path, "utf8"));
|
|
424
|
+
return isRecord(parsed) && Array.isArray(parsed.flags) && Array.isArray(parsed.changed) && parsed.byKind ? parsed : null;
|
|
425
|
+
} catch {
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
var flagKey = (f) => `${f.file}\0${f.line}\0${f.kind}\0${f.message}`;
|
|
430
|
+
function mergeSummary(earlier, next) {
|
|
431
|
+
if (!earlier) return next;
|
|
432
|
+
const seen = /* @__PURE__ */ new Set();
|
|
433
|
+
const flags = [...next.flags, ...earlier.flags].filter((f) => !seen.has(flagKey(f)) && Boolean(seen.add(flagKey(f))));
|
|
434
|
+
return { ...next, flags };
|
|
435
|
+
}
|
|
436
|
+
var summaryText = (summary) => JSON.stringify(summary, null, 2) + "\n";
|
|
437
|
+
|
|
438
|
+
// src/apply.ts
|
|
439
|
+
var MESSAGES_DIR = "messages";
|
|
440
|
+
var messagesPath = (locale) => `${MESSAGES_DIR}/${locale}.json`;
|
|
441
|
+
var readText = (root, rel) => (0, import_node_fs6.existsSync)((0, import_node_path7.join)(root, rel)) ? (0, import_node_fs6.readFileSync)((0, import_node_path7.join)(root, rel), "utf8") : null;
|
|
442
|
+
function ensureDir(disk, dir) {
|
|
443
|
+
const made = (0, import_node_fs6.mkdirSync)((0, import_node_path7.join)(disk.root, dir), { recursive: true });
|
|
444
|
+
if (made) disk.created.push((0, import_node_path7.relative)(disk.root, made).split(import_node_path7.sep).join("/"));
|
|
445
|
+
}
|
|
446
|
+
function writeText(disk, rel, text) {
|
|
447
|
+
const before = readText(disk.root, rel);
|
|
448
|
+
if (before === text) return false;
|
|
449
|
+
ensureDir(disk, (0, import_node_path7.dirname)(rel));
|
|
450
|
+
(0, import_node_fs6.writeFileSync)((0, import_node_path7.join)(disk.root, rel), text);
|
|
451
|
+
if (before === null) disk.created.push(rel);
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
async function applyPlan(disk, plan) {
|
|
455
|
+
const touched = [];
|
|
456
|
+
for (const [from, to] of plan.moves) {
|
|
457
|
+
ensureDir(disk, (0, import_node_path7.dirname)(to));
|
|
458
|
+
await moveFile(disk.root, from, to);
|
|
459
|
+
disk.created.push(to);
|
|
460
|
+
touched.push(to);
|
|
461
|
+
}
|
|
462
|
+
let writes = 0;
|
|
463
|
+
for (const [path, text] of Object.entries(plan.writes)) {
|
|
464
|
+
if (!writeText(disk, path, text)) continue;
|
|
465
|
+
touched.push(path);
|
|
466
|
+
writes += 1;
|
|
467
|
+
}
|
|
468
|
+
let edits2 = 0;
|
|
469
|
+
const perFile = {};
|
|
470
|
+
for (const [path, list] of Object.entries(plan.edits)) {
|
|
471
|
+
const text = readText(disk.root, path);
|
|
472
|
+
if (text === null) throw new Error(`Convert planned edits to ${path}, but the file is not there.`);
|
|
473
|
+
if (!writeText(disk, path, applyEdits(text, list))) continue;
|
|
474
|
+
touched.push(path);
|
|
475
|
+
edits2 += list.length;
|
|
476
|
+
perFile[path] = list.length;
|
|
477
|
+
}
|
|
478
|
+
return { touched, edits: edits2, writes, perFile };
|
|
479
|
+
}
|
|
480
|
+
var json = (value) => JSON.stringify(value, null, 2) + "\n";
|
|
481
|
+
var isMessages = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
482
|
+
function keepHandAdded(existing, generated) {
|
|
483
|
+
const kept = Object.entries(existing).map(([key, value]) => {
|
|
484
|
+
const fresh = generated[key];
|
|
485
|
+
if (fresh === void 0) return [key, value];
|
|
486
|
+
return [key, isMessages(value) && isMessages(fresh) ? keepHandAdded(value, fresh) : fresh];
|
|
487
|
+
});
|
|
488
|
+
return { ...generated, ...Object.fromEntries(kept) };
|
|
489
|
+
}
|
|
490
|
+
var messagesFiles = (ledger) => ({
|
|
491
|
+
[messagesPath("en")]: json(messagesFor(ledger, "en")),
|
|
492
|
+
[messagesPath("ar")]: json(messagesFor(ledger, "ar"))
|
|
493
|
+
});
|
|
494
|
+
function ownFiles(project, ledger) {
|
|
495
|
+
return { [stylesheetPath(project)]: dualkitCss(project), ...messagesFiles(ledger) };
|
|
496
|
+
}
|
|
497
|
+
function addOwnFiles(disk, project, ledger, entry) {
|
|
498
|
+
const touched = Object.entries(ownFiles(project, ledger)).filter(([path, text2]) => writeText(disk, path, text2)).map(([path]) => path);
|
|
499
|
+
const text = readText(disk.root, entry);
|
|
500
|
+
if (text === null) throw new Error(`Convert could not read the entry file ${entry}.`);
|
|
501
|
+
const outcome = (0, import_lint2.parseSourceOutcome)(entry, text, (0, import_node_path7.join)(disk.root, entry));
|
|
502
|
+
if ("error" in outcome) throw new Error(`The entry file ${entry} does not parse after wiring: ${outcome.error}`);
|
|
503
|
+
const edit = importEdit(outcome.file, stylesheetPath(project));
|
|
504
|
+
if (!edit || !writeText(disk, entry, applyEdits(outcome.file.text, [edit]))) return { touched, edits: 0 };
|
|
505
|
+
return { touched: [...touched, entry], edits: 1 };
|
|
506
|
+
}
|
|
507
|
+
function saveLedger(disk, ledger) {
|
|
508
|
+
const before = readText(disk.root, LEDGER_FILE);
|
|
509
|
+
writeLedger(disk.root, ledger);
|
|
510
|
+
if (before === null) disk.created.push(LEDGER_FILE);
|
|
511
|
+
return readText(disk.root, LEDGER_FILE) !== before;
|
|
512
|
+
}
|
|
513
|
+
function addDependencies(disk, deps) {
|
|
514
|
+
const text = readText(disk.root, "package.json");
|
|
515
|
+
if (text === null) throw new Error("Convert could not read package.json.");
|
|
516
|
+
const pkg = JSON.parse(text);
|
|
517
|
+
const declared = { ...pkg.devDependencies, ...pkg.peerDependencies, ...pkg.dependencies };
|
|
518
|
+
const missing = Object.entries(deps).filter(([name]) => !(name in declared));
|
|
519
|
+
if (missing.length === 0) return false;
|
|
520
|
+
const dependencies = Object.fromEntries([...Object.entries(pkg.dependencies ?? {}), ...missing].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
|
|
521
|
+
const indent = /^([ \t]+)"/m.exec(text)?.[1] ?? " ";
|
|
522
|
+
return writeText(disk, "package.json", JSON.stringify({ ...pkg, dependencies }, null, indent) + "\n");
|
|
523
|
+
}
|
|
524
|
+
var STATE_IGNORE = [`${STATE_DIR}/*`, `!${STATE_FILE}`, `!${SUMMARY_FILE}`];
|
|
525
|
+
var WHOLE_FOLDER = [STATE_DIR, `${STATE_DIR}/`, `/${STATE_DIR}`, `/${STATE_DIR}/`];
|
|
526
|
+
function ignoreStateDir(disk) {
|
|
527
|
+
const text = readText(disk.root, ".gitignore") ?? "";
|
|
528
|
+
const lines = text.split(/\r?\n/);
|
|
529
|
+
const kept = lines.filter((l) => !WHOLE_FOLDER.includes(l.trim()));
|
|
530
|
+
const missing = STATE_IGNORE.filter((l) => !kept.some((k) => k.trim() === l));
|
|
531
|
+
if (missing.length === 0 && kept.length === lines.length) return false;
|
|
532
|
+
const body = kept.join("\n").replace(/\n*$/, "");
|
|
533
|
+
const out = body === "" ? missing : [body, ...missing];
|
|
534
|
+
return writeText(disk, ".gitignore", `${out.join("\n")}
|
|
535
|
+
`);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// src/config-answers.ts
|
|
539
|
+
var CONFIG_FILE = "dualkit.config.json";
|
|
540
|
+
function mergedConfigText(before, answers2, file = CONFIG_FILE) {
|
|
541
|
+
let current = {};
|
|
542
|
+
if (before !== null) {
|
|
543
|
+
let parsed;
|
|
544
|
+
try {
|
|
545
|
+
parsed = JSON.parse(before);
|
|
546
|
+
} catch {
|
|
547
|
+
throw new RefusedError(`${file} is not valid JSON, so convert did not save your answers.`);
|
|
548
|
+
}
|
|
549
|
+
if (!isRecord(parsed)) throw new RefusedError(`${file} must hold a JSON object, so convert did not save your answers.`);
|
|
550
|
+
current = parsed;
|
|
551
|
+
}
|
|
552
|
+
const block = isRecord(current.convert) ? current.convert : {};
|
|
553
|
+
return `${JSON.stringify({ ...current, convert: { ...block, ...answers2 } }, null, 2)}
|
|
554
|
+
`;
|
|
555
|
+
}
|
|
556
|
+
function checkConfigAnswers(root, file = CONFIG_FILE) {
|
|
557
|
+
mergedConfigText(readText(root, file), {}, file);
|
|
558
|
+
}
|
|
559
|
+
function writeConfigAnswers(disk, answers2, file = CONFIG_FILE) {
|
|
560
|
+
return writeText(disk, file, mergedConfigText(readText(disk.root, file), answers2, file));
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// src/baseline.ts
|
|
564
|
+
var import_node_fs8 = require("fs");
|
|
565
|
+
var import_node_path9 = require("path");
|
|
566
|
+
|
|
567
|
+
// src/routes.ts
|
|
568
|
+
var import_node_fs7 = require("fs");
|
|
569
|
+
var import_node_path8 = require("path");
|
|
570
|
+
var NEEDS_PARAMS = "needs sample params in dualkit.config.json";
|
|
571
|
+
var PAGE = /^page\.(tsx|jsx|ts|js)$/;
|
|
572
|
+
var GROUP = /^\([^.)][^)]*\)$/;
|
|
573
|
+
var INTERCEPTING = /^\(\.{1,3}\)/;
|
|
574
|
+
var GENERATED_CATCH_ALL = "[...rest]";
|
|
575
|
+
var DYNAMIC = /^\[.+\]$/;
|
|
576
|
+
function pageFolders(dir, segments = []) {
|
|
577
|
+
const entries = (0, import_node_fs7.readdirSync)(dir, { withFileTypes: true });
|
|
578
|
+
const here = entries.some((e) => e.isFile() && PAGE.test(e.name)) ? [segments] : [];
|
|
579
|
+
const nested = entries.filter((e) => e.isDirectory() && !e.name.startsWith("@") && !e.name.startsWith("_") && !INTERCEPTING.test(e.name) && e.name !== "node_modules").flatMap((e) => pageFolders((0, import_node_path8.join)(dir, e.name), [...segments, e.name]));
|
|
580
|
+
return [...here, ...nested];
|
|
581
|
+
}
|
|
582
|
+
function nextRoutes(project, config) {
|
|
583
|
+
const routes = [];
|
|
584
|
+
const unreachable = [];
|
|
585
|
+
const appPath = (0, import_node_path8.join)(project.root, project.appDir ?? "app");
|
|
586
|
+
if (!(0, import_node_fs7.existsSync)(appPath)) throw new Error(`The app folder ${appPath} does not exist, so there are no pages to list.`);
|
|
587
|
+
for (const folder of pageFolders(appPath)) {
|
|
588
|
+
const visible = folder.filter((s) => !GROUP.test(s));
|
|
589
|
+
if (visible.length === 2 && visible[0] === "[locale]" && visible[1] === GENERATED_CATCH_ALL) continue;
|
|
590
|
+
const first = visible[0];
|
|
591
|
+
const segments = first === "[locale]" ? visible.slice(1) : visible;
|
|
592
|
+
const route = `/${segments.join("/")}`;
|
|
593
|
+
if (!segments.some((s) => DYNAMIC.test(s))) {
|
|
594
|
+
routes.push(route);
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
const sample = config.params?.[route];
|
|
598
|
+
if (sample) routes.push(sample);
|
|
599
|
+
else unreachable.push({ route, reason: NEEDS_PARAMS });
|
|
600
|
+
}
|
|
601
|
+
return { routes: routes.sort(), unreachable: unreachable.sort((a, b) => a.route.localeCompare(b.route)) };
|
|
602
|
+
}
|
|
603
|
+
function listRoutes(project, config) {
|
|
604
|
+
if (project.framework === "vite-react") return { routes: config.routes ?? ["/"], unreachable: [] };
|
|
605
|
+
return nextRoutes(project, config);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// src/server.ts
|
|
609
|
+
var import_node_child_process2 = require("child_process");
|
|
610
|
+
var import_node_net = require("net");
|
|
611
|
+
var ServerError = class extends Error {
|
|
612
|
+
constructor(message) {
|
|
613
|
+
super(message);
|
|
614
|
+
this.name = "ServerError";
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
var OUTPUT_LINES = 20;
|
|
618
|
+
var POLL_MS = 250;
|
|
619
|
+
var KILL_GRACE_MS = 5e3;
|
|
620
|
+
var sleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
621
|
+
function portTaken(port) {
|
|
622
|
+
return new Promise((resolve3) => {
|
|
623
|
+
const socket = (0, import_node_net.connect)({ port, host: "localhost" });
|
|
624
|
+
socket.once("connect", () => {
|
|
625
|
+
socket.destroy();
|
|
626
|
+
resolve3(true);
|
|
627
|
+
});
|
|
628
|
+
socket.once("error", () => resolve3(false));
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
async function answers(url) {
|
|
632
|
+
try {
|
|
633
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(2e3), redirect: "manual" });
|
|
634
|
+
return res.status < 500;
|
|
635
|
+
} catch {
|
|
636
|
+
return false;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
function groupAlive(pid) {
|
|
640
|
+
try {
|
|
641
|
+
process.kill(-pid, 0);
|
|
642
|
+
return true;
|
|
643
|
+
} catch {
|
|
644
|
+
return false;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
function signalGroup(pid, signal) {
|
|
648
|
+
try {
|
|
649
|
+
process.kill(-pid, signal);
|
|
650
|
+
} catch {
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
async function startServer(cmd, port, cwd, timeoutMs = 12e4) {
|
|
654
|
+
if (await portTaken(port)) {
|
|
655
|
+
throw new ServerError(`Port ${port} is already in use. Stop whatever is running there or set another port in dualkit.config.json.`);
|
|
656
|
+
}
|
|
657
|
+
const child = (0, import_node_child_process2.spawn)(cmd, { cwd, shell: true, detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
658
|
+
let output = "";
|
|
659
|
+
let exited = false;
|
|
660
|
+
let spawnError = null;
|
|
661
|
+
child.on("error", (error) => {
|
|
662
|
+
spawnError = error;
|
|
663
|
+
output += `${error.message}
|
|
664
|
+
`;
|
|
665
|
+
});
|
|
666
|
+
const pid = child.pid;
|
|
667
|
+
if (pid === void 0) {
|
|
668
|
+
await new Promise((resolve3) => setImmediate(resolve3));
|
|
669
|
+
throw new ServerError(`Could not start "${cmd}": ${spawnError?.message ?? "no process id"}`);
|
|
670
|
+
}
|
|
671
|
+
const collect = (chunk) => {
|
|
672
|
+
output += chunk.toString();
|
|
673
|
+
};
|
|
674
|
+
child.stdout?.on("data", collect);
|
|
675
|
+
child.stderr?.on("data", collect);
|
|
676
|
+
child.on("exit", () => {
|
|
677
|
+
exited = true;
|
|
678
|
+
});
|
|
679
|
+
const stop = async () => {
|
|
680
|
+
signalGroup(pid, "SIGTERM");
|
|
681
|
+
const deadline2 = Date.now() + KILL_GRACE_MS;
|
|
682
|
+
while ((!exited || groupAlive(pid)) && Date.now() < deadline2) await sleep(50);
|
|
683
|
+
if (!exited || groupAlive(pid)) {
|
|
684
|
+
signalGroup(pid, "SIGKILL");
|
|
685
|
+
while ((!exited || groupAlive(pid)) && Date.now() < deadline2 + KILL_GRACE_MS) await sleep(50);
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
const tail2 = () => output.trimEnd().split("\n").slice(-OUTPUT_LINES).join("\n");
|
|
689
|
+
const url = `http://localhost:${port}`;
|
|
690
|
+
const deadline = Date.now() + timeoutMs;
|
|
691
|
+
while (Date.now() < deadline) {
|
|
692
|
+
if (exited) {
|
|
693
|
+
await stop();
|
|
694
|
+
throw new ServerError(`The server command "${cmd}" exited before it answered.
|
|
695
|
+
${tail2()}`);
|
|
696
|
+
}
|
|
697
|
+
if (await answers(`${url}/`)) return { url, stop };
|
|
698
|
+
await sleep(POLL_MS);
|
|
699
|
+
}
|
|
700
|
+
await stop();
|
|
701
|
+
throw new ServerError(`The server did not answer on port ${port} within ${Math.round(timeoutMs / 1e3)} seconds.
|
|
702
|
+
${tail2()}`);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// src/baseline.ts
|
|
706
|
+
var BASELINE_DIR = `${STATE_DIR}/baseline`;
|
|
707
|
+
var WIDTHS = [390, 1280];
|
|
708
|
+
var DEFAULT_PORTS = { "next-app": 3e3, "vite-react": 5173 };
|
|
709
|
+
var routeSlug = (route) => route.replace(/^\/+|\/+$/g, "").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "") || "home";
|
|
710
|
+
var baselinePath = (route, width) => `${BASELINE_DIR}/${routeSlug(route)}-${width}.png`;
|
|
711
|
+
async function shootBaseline(project, config, shoot, log) {
|
|
712
|
+
const { routes, unreachable } = listRoutes(project, config);
|
|
713
|
+
const port = config.port ?? DEFAULT_PORTS[project.framework];
|
|
714
|
+
(0, import_node_fs8.mkdirSync)((0, import_node_path9.join)(project.root, BASELINE_DIR), { recursive: true });
|
|
715
|
+
const server = await startServer(config.start, port, project.root);
|
|
716
|
+
const shots = [];
|
|
717
|
+
try {
|
|
718
|
+
for (const route of routes) {
|
|
719
|
+
for (const width of WIDTHS) {
|
|
720
|
+
const path = baselinePath(route, width);
|
|
721
|
+
(0, import_node_fs8.writeFileSync)((0, import_node_path9.join)(project.root, path), await shoot(`${server.url}${route}`, width));
|
|
722
|
+
shots.push(path);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
} finally {
|
|
726
|
+
await server.stop();
|
|
727
|
+
}
|
|
728
|
+
log(`Took ${shots.length} English baseline screenshots.`);
|
|
729
|
+
return { shots, unreachable };
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// src/generate/locale.ts
|
|
733
|
+
var LOCALE_COOKIE = "dk-locale";
|
|
734
|
+
var SWITCH_CHOICES = "[\n { code: 'en', label: 'English' },\n { code: 'ar', label: '\u0627\u0644\u0639\u0631\u0628\u064A\u0629' },\n] as const";
|
|
735
|
+
var sourceBase = (project) => project.framework === "vite-react" || project.appDir?.startsWith("src/") ? "src/" : "";
|
|
736
|
+
var localeHelperPath = (project) => `${sourceBase(project)}lib/dualkit-locale.ts`;
|
|
737
|
+
var switchPath = (project) => `${sourceBase(project)}components/dualkit-language-switch.tsx`;
|
|
738
|
+
var LOCALE_HELPER = `/** The Intl locale for an app locale: Arabic uses the UAE region with Latin digits, anything else is unchanged. */
|
|
739
|
+
export function dkIntlLocale(locale: string): string {
|
|
740
|
+
return locale === 'ar' ? 'ar-AE-u-nu-latn' : locale
|
|
741
|
+
}
|
|
742
|
+
`;
|
|
743
|
+
function guardWrites(writes, files, replaced) {
|
|
744
|
+
for (const [path, text] of Object.entries(writes)) {
|
|
745
|
+
const existing = files(path);
|
|
746
|
+
if (existing === null || existing === text || replaced.has(path)) continue;
|
|
747
|
+
throw new UnsupportedError(`This app already has ${path}. Convert cannot merge its own version into it, so move or remove that file and run convert again.`);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// src/generate/next-intl.ts
|
|
752
|
+
var import_node_path13 = require("path");
|
|
753
|
+
|
|
754
|
+
// src/transforms/imports.ts
|
|
755
|
+
var import_node_path10 = require("path");
|
|
756
|
+
var namedOf = (s) => (s.specifiers ?? []).filter((spec) => spec.type === "ImportSpecifier").map((spec) => spec.imported.name ?? spec.imported.value);
|
|
757
|
+
var importsOf = (program) => (program.body ?? []).filter((s) => s.type === "ImportDeclaration" && s.importKind !== "type");
|
|
758
|
+
function addImports(program, wanted, base, style) {
|
|
759
|
+
const imports = importsOf(program);
|
|
760
|
+
const bySource = /* @__PURE__ */ new Map();
|
|
761
|
+
for (const w of wanted) {
|
|
762
|
+
const known = imports.some((s) => s.source.value === w.source && namedOf(s).includes(w.name));
|
|
763
|
+
const list = bySource.get(w.source) ?? [];
|
|
764
|
+
if (!known && !list.includes(w.name)) bySource.set(w.source, [...list, w.name]);
|
|
765
|
+
}
|
|
766
|
+
const edits2 = [];
|
|
767
|
+
const lines = [];
|
|
768
|
+
for (const [source, names] of bySource) {
|
|
769
|
+
if (!names.length) continue;
|
|
770
|
+
const merge2 = imports.find((s) => s.source.value === source && namedOf(s).length);
|
|
771
|
+
if (merge2) {
|
|
772
|
+
const last = (merge2.specifiers ?? []).at(-1);
|
|
773
|
+
edits2.push({ start: base + last.end, end: base + last.end, text: names.map((n) => `, ${n}`).join("") });
|
|
774
|
+
} else lines.push(`import { ${names.join(", ")} } from ${style.quote}${source}${style.quote}${style.semi}`);
|
|
775
|
+
}
|
|
776
|
+
return lines.length ? [...edits2, linesEdit(program, lines, base)] : edits2;
|
|
777
|
+
}
|
|
778
|
+
function linesEdit(program, lines, base) {
|
|
779
|
+
const anchor = (program.body ?? []).filter((s) => s.type === "ImportDeclaration").at(-1) ?? (program.directives ?? []).at(-1);
|
|
780
|
+
if (!anchor) return { start: base, end: base, text: lines.map((l) => `${l}
|
|
781
|
+
`).join("") };
|
|
782
|
+
const at2 = base + anchor.end;
|
|
783
|
+
return { start: at2, end: at2, text: lines.map((l) => `
|
|
784
|
+
${l}`).join("") };
|
|
785
|
+
}
|
|
786
|
+
var sideEffectImport = (program, source, base, style) => linesEdit(program, [`import ${style.quote}${source}${style.quote}${style.semi}`], base);
|
|
787
|
+
function specifierTo(from, to) {
|
|
788
|
+
const rel = import_node_path10.posix.relative(import_node_path10.posix.dirname(from), to).replace(/\.(tsx?|jsx?)$/, "");
|
|
789
|
+
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// src/generate/aliases.ts
|
|
793
|
+
var import_node_path11 = require("path");
|
|
794
|
+
var CONFIGS = ["tsconfig.json", "jsconfig.json"];
|
|
795
|
+
var EXTENSIONS2 = ["", ".tsx", ".ts", ".jsx", ".js", ".mjs", ".cjs", ".mts", ".cts"];
|
|
796
|
+
function stripJsonc(text) {
|
|
797
|
+
let out = "";
|
|
798
|
+
for (let i = 0; i < text.length; i++) {
|
|
799
|
+
const c = text[i];
|
|
800
|
+
if (c === '"') {
|
|
801
|
+
const start2 = i;
|
|
802
|
+
for (i++; i < text.length && text[i] !== '"'; i++) if (text[i] === "\\") i++;
|
|
803
|
+
out += text.slice(start2, i + 1);
|
|
804
|
+
} else if (c === "/" && text[i + 1] === "/") {
|
|
805
|
+
while (i < text.length && text[i] !== "\n") i++;
|
|
806
|
+
out += "\n";
|
|
807
|
+
} else if (c === "/" && text[i + 1] === "*") {
|
|
808
|
+
i = text.indexOf("*/", i + 2);
|
|
809
|
+
if (i === -1) break;
|
|
810
|
+
i++;
|
|
811
|
+
} else out += c;
|
|
812
|
+
}
|
|
813
|
+
return out.replace(/,(\s*[}\]])/g, "$1");
|
|
814
|
+
}
|
|
815
|
+
var prefixLength = (key) => key.includes("*") ? key.indexOf("*") : Number.MAX_SAFE_INTEGER;
|
|
816
|
+
function readAliases(files) {
|
|
817
|
+
for (const name of CONFIGS) {
|
|
818
|
+
const text = files(name);
|
|
819
|
+
if (text === null) continue;
|
|
820
|
+
let options;
|
|
821
|
+
try {
|
|
822
|
+
options = JSON.parse(stripJsonc(text)).compilerOptions ?? {};
|
|
823
|
+
} catch {
|
|
824
|
+
return [];
|
|
825
|
+
}
|
|
826
|
+
const base = typeof options.baseUrl === "string" ? options.baseUrl : ".";
|
|
827
|
+
const paths = options.paths && typeof options.paths === "object" ? options.paths : {};
|
|
828
|
+
return Object.entries(paths).filter((entry) => Array.isArray(entry[1]) && entry[1].every((t) => typeof t === "string")).map(([key, targets]) => ({ key, targets: targets.map((t) => import_node_path11.posix.join(base, t)) })).sort((a, b) => prefixLength(b.key) - prefixLength(a.key));
|
|
829
|
+
}
|
|
830
|
+
return [];
|
|
831
|
+
}
|
|
832
|
+
function capture(pattern, value) {
|
|
833
|
+
const star = pattern.indexOf("*");
|
|
834
|
+
if (star === -1) return pattern === value ? "" : null;
|
|
835
|
+
const [prefix, suffix] = [pattern.slice(0, star), pattern.slice(star + 1)];
|
|
836
|
+
const fits = value.startsWith(prefix) && value.endsWith(suffix) && value.length >= prefix.length + suffix.length;
|
|
837
|
+
return fits ? value.slice(prefix.length, value.length - suffix.length) : null;
|
|
838
|
+
}
|
|
839
|
+
function resolveFile(target, has) {
|
|
840
|
+
const stem = target.replace(/\.(js|jsx|mjs|cjs)$/, "");
|
|
841
|
+
const candidates = [target, ...EXTENSIONS2.map((e) => `${stem}${e}`), ...EXTENSIONS2.slice(1).map((e) => `${target}/index${e}`)];
|
|
842
|
+
return candidates.find(has) ?? null;
|
|
843
|
+
}
|
|
844
|
+
function resolveAlias(spec, aliases, has) {
|
|
845
|
+
const alias = aliases.find((a) => capture(a.key, spec) !== null);
|
|
846
|
+
if (!alias) return null;
|
|
847
|
+
const star = capture(alias.key, spec);
|
|
848
|
+
for (const target of alias.targets) {
|
|
849
|
+
const path = target.replace("*", () => star);
|
|
850
|
+
const file = resolveFile(path, has);
|
|
851
|
+
if (!file) continue;
|
|
852
|
+
const respell = (next) => {
|
|
853
|
+
const middle = target.includes("*") ? capture(target, next) : null;
|
|
854
|
+
return middle === null ? null : alias.key.replace("*", () => middle);
|
|
855
|
+
};
|
|
856
|
+
return { path, file, respell };
|
|
857
|
+
}
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// src/generate/next-config.ts
|
|
862
|
+
var import_lint3 = require("@dualkit/lint");
|
|
863
|
+
|
|
864
|
+
// src/transforms/lines.ts
|
|
865
|
+
function lineOf(text, offset) {
|
|
866
|
+
let line = 1;
|
|
867
|
+
for (let i = text.indexOf("\n"); i !== -1 && i < offset; i = text.indexOf("\n", i + 1)) line++;
|
|
868
|
+
return line;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// src/transforms/scope.ts
|
|
872
|
+
var FUNCTIONS = /* @__PURE__ */ new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
|
|
873
|
+
var isPascal = (name) => typeof name === "string" && /^[A-Z]/.test(name);
|
|
874
|
+
var idName = (node) => node?.type === "Identifier" ? node.name : void 0;
|
|
875
|
+
var WRAPPERS = /* @__PURE__ */ new Set(["memo", "forwardRef"]);
|
|
876
|
+
function isWrapper(call) {
|
|
877
|
+
const callee = call?.type === "CallExpression" ? call.callee : void 0;
|
|
878
|
+
if (callee?.type === "Identifier") return WRAPPERS.has(callee.name);
|
|
879
|
+
return callee?.type === "MemberExpression" && idName(callee.object) === "React" && WRAPPERS.has(idName(callee.property));
|
|
880
|
+
}
|
|
881
|
+
function isComponent(ancestors, at2) {
|
|
882
|
+
const fn = ancestors[at2];
|
|
883
|
+
if (isPascal(idName(fn.id))) return true;
|
|
884
|
+
let above = at2 - 1;
|
|
885
|
+
while (above >= 0 && isWrapper(ancestors[above])) above--;
|
|
886
|
+
const holder = ancestors[above];
|
|
887
|
+
if (holder?.type === "VariableDeclarator" && isPascal(idName(holder.id))) return true;
|
|
888
|
+
return holder?.type === "ExportDefaultDeclaration";
|
|
889
|
+
}
|
|
890
|
+
function componentOf(ancestors) {
|
|
891
|
+
for (let i = ancestors.length - 1; i >= 0; i--) {
|
|
892
|
+
if (FUNCTIONS.has(ancestors[i].type) && isComponent(ancestors, i)) return ancestors[i];
|
|
893
|
+
}
|
|
894
|
+
return null;
|
|
895
|
+
}
|
|
896
|
+
function patternNames(pattern) {
|
|
897
|
+
if (!pattern) return [];
|
|
898
|
+
switch (pattern.type) {
|
|
899
|
+
case "Identifier":
|
|
900
|
+
return [pattern.name];
|
|
901
|
+
case "AssignmentPattern":
|
|
902
|
+
return patternNames(pattern.left);
|
|
903
|
+
case "RestElement":
|
|
904
|
+
return patternNames(pattern.argument);
|
|
905
|
+
case "TSParameterProperty":
|
|
906
|
+
return patternNames(pattern.parameter);
|
|
907
|
+
case "ArrayPattern":
|
|
908
|
+
return (pattern.elements ?? []).flatMap(patternNames);
|
|
909
|
+
case "ObjectPattern":
|
|
910
|
+
return (pattern.properties ?? []).flatMap((p) => patternNames(p.type === "RestElement" ? p : p.value));
|
|
911
|
+
default:
|
|
912
|
+
return [];
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
function isOwnHook(statement) {
|
|
916
|
+
const [d, extra] = statement.declarations ?? [];
|
|
917
|
+
if (statement.type !== "VariableDeclaration" || !d || extra) return false;
|
|
918
|
+
const init = d.init?.type === "AwaitExpression" ? d.init.argument : d.init;
|
|
919
|
+
if (init?.type !== "CallExpression" || (init.arguments ?? []).length) return false;
|
|
920
|
+
const callee = idName(init.callee);
|
|
921
|
+
const id = d.id;
|
|
922
|
+
if (id.type === "Identifier") return id.name === "t" && (callee === "useTranslations" || callee === "getTranslations");
|
|
923
|
+
return callee === "useTranslation" && patternNames(id).join() === "t";
|
|
924
|
+
}
|
|
925
|
+
function statementNames(statement) {
|
|
926
|
+
const s = statement.type === "ExportNamedDeclaration" || statement.type === "ExportDefaultDeclaration" ? statement.declaration : statement;
|
|
927
|
+
if (!s) return [];
|
|
928
|
+
if (s.type === "VariableDeclaration") return isOwnHook(s) ? [] : (s.declarations ?? []).flatMap((d) => patternNames(d.id));
|
|
929
|
+
if (s.type === "FunctionDeclaration" || s.type === "ClassDeclaration") return patternNames(s.id);
|
|
930
|
+
if (s.type === "ImportDeclaration") return (s.specifiers ?? []).map((spec) => spec.local.name);
|
|
931
|
+
return [];
|
|
932
|
+
}
|
|
933
|
+
function scopeNames(node) {
|
|
934
|
+
if (FUNCTIONS.has(node.type)) return [...(node.params ?? []).flatMap(patternNames), ...node.type === "FunctionExpression" ? patternNames(node.id) : []];
|
|
935
|
+
if (node.type === "BlockStatement" || node.type === "Program") return (node.body ?? []).flatMap(statementNames);
|
|
936
|
+
if (node.type === "CatchClause") return patternNames(node.param);
|
|
937
|
+
if (/^For(In|Of)?Statement$/.test(node.type)) {
|
|
938
|
+
const head = node.init ?? node.left;
|
|
939
|
+
return head?.type === "VariableDeclaration" ? statementNames(head) : [];
|
|
940
|
+
}
|
|
941
|
+
return [];
|
|
942
|
+
}
|
|
943
|
+
var tInScope = (ancestors) => ancestors.some((node) => scopeNames(node).includes("t"));
|
|
944
|
+
|
|
945
|
+
// src/transforms/strings.ts
|
|
946
|
+
var SKIP = /* @__PURE__ */ new Set(["loc", "leadingComments", "trailingComments", "innerComments", "extra"]);
|
|
947
|
+
var TEXT_ATTRIBUTES = /* @__PURE__ */ new Set(["alt", "title", "placeholder", "aria-label", "label"]);
|
|
948
|
+
var LETTER = /\p{L}/u;
|
|
949
|
+
var PROSE = /^\p{Lu}\p{Ll}/u;
|
|
950
|
+
var NOT_PROSE = /[/\\_=<>{}@#|]/;
|
|
951
|
+
var ascii = (text) => text.normalize("NFKD").replace(/[̀-ͯ]/g, "");
|
|
952
|
+
var ROUTE_STEMS = /* @__PURE__ */ new Set(["page", "layout", "index", "route", "default"]);
|
|
953
|
+
var kebab = (text) => ascii(text).replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z])([A-Z][a-z])/g, "$1-$2").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
954
|
+
function routeDirs(dirs) {
|
|
955
|
+
const app = dirs.lastIndexOf("app");
|
|
956
|
+
const route = app === -1 ? dirs.slice(-1) : dirs.slice(app + 1);
|
|
957
|
+
return route.filter((d) => !/^\(.*\)$/.test(d) && d !== "[locale]").map((d) => d.replace(/^\[+(\.\.\.)?|\]+$/g, ""));
|
|
958
|
+
}
|
|
959
|
+
function stemOf(path) {
|
|
960
|
+
const parts = path.split(/[\\/]/).filter(Boolean);
|
|
961
|
+
const stem = parts.pop().split(".")[0];
|
|
962
|
+
if (!ROUTE_STEMS.has(stem)) return kebab(stem) || "file";
|
|
963
|
+
return kebab(routeDirs(parts).join("-")) || "home";
|
|
964
|
+
}
|
|
965
|
+
var keyWords = (en) => ascii(en.replace(/\{[^}]*\}/g, " ")).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).slice(0, 4).join("-") || "text";
|
|
966
|
+
function flag(ctx, offset, kind, message, suggestion) {
|
|
967
|
+
ctx.flags.push({ file: ctx.file.path, line: lineOf(ctx.file.text, offset), kind, message, suggestion });
|
|
968
|
+
}
|
|
969
|
+
function keyFor(ctx, en) {
|
|
970
|
+
const known = ctx.byText.get(en);
|
|
971
|
+
if (known) return known;
|
|
972
|
+
const base = `${ctx.stem}.${keyWords(en)}`;
|
|
973
|
+
let key = base;
|
|
974
|
+
for (let n = 2; ctx.used.has(key); n++) key = `${base}-${n}`;
|
|
975
|
+
ctx.used.add(key);
|
|
976
|
+
ctx.byText.set(en, key);
|
|
977
|
+
return key;
|
|
978
|
+
}
|
|
979
|
+
function place(ctx, ancestors, start2, end, en, values) {
|
|
980
|
+
const component = componentOf(ancestors);
|
|
981
|
+
if (!component) {
|
|
982
|
+
flag(
|
|
983
|
+
ctx,
|
|
984
|
+
start2,
|
|
985
|
+
"outside-component",
|
|
986
|
+
`"${en}" sits outside a component, so convert cannot give it a translation hook.`,
|
|
987
|
+
"Move the text into a component, or translate it where it is used."
|
|
988
|
+
);
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
if (ctx.lib === "react-i18next" && component.async) {
|
|
992
|
+
flag(
|
|
993
|
+
ctx,
|
|
994
|
+
start2,
|
|
995
|
+
"unsupported",
|
|
996
|
+
`"${en}" sits in an async component, where the react-i18next hook cannot run, so it was left as it is.`,
|
|
997
|
+
"Move the text into a component that is not async, or translate it there with i18n.t from the i18n setup convert added."
|
|
998
|
+
);
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
if (tInScope(ancestors)) {
|
|
1002
|
+
flag(
|
|
1003
|
+
ctx,
|
|
1004
|
+
start2,
|
|
1005
|
+
"unsupported",
|
|
1006
|
+
`"${en}" was left as it is: a variable named t is already in scope, so a translation hook would clash with it.`,
|
|
1007
|
+
"Rename that variable, then run convert again, or translate this text by hand."
|
|
1008
|
+
);
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
const isNew = !ctx.byText.has(en);
|
|
1012
|
+
const key = keyFor(ctx, en);
|
|
1013
|
+
if (isNew) ctx.entries.push({ key, en, ar: "", status: "pending", file: ctx.file.path, line: lineOf(ctx.file.text, start2) });
|
|
1014
|
+
ctx.edits.push({ start: start2, end, text: `{t('${key}'${values ? `, { ${values} }` : ""})}` });
|
|
1015
|
+
ctx.components.add(component);
|
|
1016
|
+
}
|
|
1017
|
+
var TEXT_METHODS = /^(toLocale(Date|Time)?String|toFixed|toPrecision|toString|toUpperCase|toLowerCase|trim(Start|End)?|padStart|padEnd)$/;
|
|
1018
|
+
function textMethodTarget(expression) {
|
|
1019
|
+
if (expression.type !== "CallExpression" && expression.type !== "OptionalCallExpression") return null;
|
|
1020
|
+
const callee = expression.callee;
|
|
1021
|
+
if (callee.type !== "MemberExpression" && callee.type !== "OptionalMemberExpression" || callee.computed) return null;
|
|
1022
|
+
const method = callee.property;
|
|
1023
|
+
return method.type === "Identifier" && TEXT_METHODS.test(method.name) ? callee.object : null;
|
|
1024
|
+
}
|
|
1025
|
+
function placeholderName(expression) {
|
|
1026
|
+
const target = textMethodTarget(expression);
|
|
1027
|
+
if (target) return placeholderName(target);
|
|
1028
|
+
if (expression.type === "Identifier") return expression.name;
|
|
1029
|
+
if ((expression.type === "MemberExpression" || expression.type === "OptionalMemberExpression") && !expression.computed) {
|
|
1030
|
+
const property = expression.property;
|
|
1031
|
+
return property.type === "Identifier" ? property.name : null;
|
|
1032
|
+
}
|
|
1033
|
+
return null;
|
|
1034
|
+
}
|
|
1035
|
+
function hasText(element) {
|
|
1036
|
+
return (element.children ?? []).some((c) => c.type === "JSXText" ? /\S/.test(String(c.value)) : c.type === "JSXExpressionContainer" ? c.expression.type !== "JSXEmptyExpression" : c.type === "JSXSpreadChild" || hasText(c));
|
|
1037
|
+
}
|
|
1038
|
+
function pieceOf(child, ctx) {
|
|
1039
|
+
const start2 = ctx.base + child.start;
|
|
1040
|
+
const end = ctx.base + child.end;
|
|
1041
|
+
if (child.type === "JSXText") {
|
|
1042
|
+
const raw = ctx.file.text.slice(start2, end);
|
|
1043
|
+
if (!/\S/.test(raw)) return { kind: "space", start: start2, end };
|
|
1044
|
+
const lead = raw.length - raw.trimStart().length;
|
|
1045
|
+
const trail = raw.length - raw.trimEnd().length;
|
|
1046
|
+
return { kind: "text", value: String(child.value), start: start2 + lead, end: end - trail };
|
|
1047
|
+
}
|
|
1048
|
+
if (child.type === "JSXSpreadChild") return { kind: "other", start: start2, end };
|
|
1049
|
+
if (child.type !== "JSXExpressionContainer") return { kind: "element", texty: hasText(child), start: start2, end };
|
|
1050
|
+
const expression = child.expression;
|
|
1051
|
+
if (expression.type === "JSXEmptyExpression") return { kind: "comment", start: start2, end };
|
|
1052
|
+
if (expression.type === "StringLiteral") {
|
|
1053
|
+
const value = String(expression.value);
|
|
1054
|
+
return /\S/.test(value) ? { kind: "text", value, start: start2, end } : { kind: "space", start: start2, end };
|
|
1055
|
+
}
|
|
1056
|
+
const name = placeholderName(expression);
|
|
1057
|
+
if (!name) return { kind: "other", start: start2, end };
|
|
1058
|
+
const source = ctx.file.text.slice(ctx.base + expression.start, ctx.base + expression.end);
|
|
1059
|
+
return { kind: "value", name, source, start: start2, end };
|
|
1060
|
+
}
|
|
1061
|
+
var mixed = (ctx, offset) => flag(
|
|
1062
|
+
ctx,
|
|
1063
|
+
offset,
|
|
1064
|
+
"mixed-text",
|
|
1065
|
+
"This text shares its element with other elements or expressions, so it was left as it is.",
|
|
1066
|
+
"Translate the whole sentence with rich text or a plural message, by hand."
|
|
1067
|
+
);
|
|
1068
|
+
function valuesOf(pieces) {
|
|
1069
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1070
|
+
for (const p of pieces) {
|
|
1071
|
+
if (p.kind !== "value") continue;
|
|
1072
|
+
if (seen.has(p.name) && seen.get(p.name) !== p.source) return null;
|
|
1073
|
+
seen.set(p.name, p.source);
|
|
1074
|
+
}
|
|
1075
|
+
return [...seen].map(([name, source]) => name === source ? name : `${name}: ${source}`).join(", ");
|
|
1076
|
+
}
|
|
1077
|
+
function unitsOf(pieces) {
|
|
1078
|
+
const units = [];
|
|
1079
|
+
let run2 = [];
|
|
1080
|
+
let before;
|
|
1081
|
+
for (const piece of [...pieces, void 0]) {
|
|
1082
|
+
if (piece && piece.kind !== "element") {
|
|
1083
|
+
run2.push(piece);
|
|
1084
|
+
continue;
|
|
1085
|
+
}
|
|
1086
|
+
const texty = (p) => p?.kind === "element" && p.texty;
|
|
1087
|
+
if (run2.length) units.push({ run: run2, touchesText: texty(before) || texty(piece) });
|
|
1088
|
+
run2 = [];
|
|
1089
|
+
before = piece;
|
|
1090
|
+
}
|
|
1091
|
+
return units;
|
|
1092
|
+
}
|
|
1093
|
+
function messageOf(pieces) {
|
|
1094
|
+
if (pieces.some((p) => p.kind === "other")) return null;
|
|
1095
|
+
const edge = (p) => p.kind !== "space" && p.kind !== "comment";
|
|
1096
|
+
const first = pieces.findIndex(edge);
|
|
1097
|
+
const last = pieces.length - 1 - [...pieces].reverse().findIndex(edge);
|
|
1098
|
+
const run2 = pieces.slice(first, last + 1);
|
|
1099
|
+
const values = valuesOf(run2);
|
|
1100
|
+
if (values === null || run2.some((p) => p.kind === "comment")) return null;
|
|
1101
|
+
const en = run2.map((p) => p.kind === "text" ? p.value : p.kind === "value" ? `{${p.name}}` : " ").join("").replace(/\s+/g, " ").trim();
|
|
1102
|
+
return { start: run2[0].start, end: run2[run2.length - 1].end, en, values };
|
|
1103
|
+
}
|
|
1104
|
+
function visitChildren(element, ctx, ancestors) {
|
|
1105
|
+
const pieces = (element.children ?? []).map((c) => pieceOf(c, ctx));
|
|
1106
|
+
const messages = [];
|
|
1107
|
+
for (const { run: run2, touchesText } of unitsOf(pieces)) {
|
|
1108
|
+
const lettered = run2.find((p) => p.kind === "text" && LETTER.test(p.value));
|
|
1109
|
+
if (!lettered) continue;
|
|
1110
|
+
const message = touchesText ? null : messageOf(run2);
|
|
1111
|
+
if (!message) {
|
|
1112
|
+
mixed(ctx, lettered.start);
|
|
1113
|
+
return false;
|
|
1114
|
+
}
|
|
1115
|
+
messages.push(message);
|
|
1116
|
+
}
|
|
1117
|
+
for (const m of messages) place(ctx, ancestors, m.start, m.end, m.en, m.values);
|
|
1118
|
+
return true;
|
|
1119
|
+
}
|
|
1120
|
+
function visitAttribute(attribute, ctx, ancestors) {
|
|
1121
|
+
const name = attribute.name;
|
|
1122
|
+
if (name.type !== "JSXIdentifier" || !TEXT_ATTRIBUTES.has(name.name)) return;
|
|
1123
|
+
const value = attribute.value;
|
|
1124
|
+
const literal = value?.type === "JSXExpressionContainer" ? value.expression : value;
|
|
1125
|
+
if (literal?.type !== "StringLiteral" || !LETTER.test(String(literal.value))) return;
|
|
1126
|
+
const en = String(literal.value).replace(/\s+/g, " ").trim();
|
|
1127
|
+
place(ctx, ancestors, ctx.base + value.start, ctx.base + value.end, en, "");
|
|
1128
|
+
}
|
|
1129
|
+
function walk(node, ctx, ancestors) {
|
|
1130
|
+
if (Array.isArray(node)) return node.forEach((child) => walk(child, ctx, ancestors));
|
|
1131
|
+
if (!node || typeof node !== "object") return;
|
|
1132
|
+
const n = node;
|
|
1133
|
+
if (n.type === "JSXElement" || n.type === "JSXFragment") {
|
|
1134
|
+
ctx.sawJsx = true;
|
|
1135
|
+
if (!visitChildren(n, ctx, ancestors)) return;
|
|
1136
|
+
}
|
|
1137
|
+
if (n.type === "JSXAttribute") visitAttribute(n, ctx, ancestors);
|
|
1138
|
+
const next = typeof n.type === "string" ? [...ancestors, n] : ancestors;
|
|
1139
|
+
for (const [key, child] of Object.entries(n)) if (!SKIP.has(key)) walk(child, ctx, next);
|
|
1140
|
+
}
|
|
1141
|
+
function proseLiteral(node) {
|
|
1142
|
+
if (Array.isArray(node)) return node.map(proseLiteral).find(Boolean) ?? null;
|
|
1143
|
+
if (!node || typeof node !== "object") return null;
|
|
1144
|
+
const n = node;
|
|
1145
|
+
if (FUNCTIONS.has(n.type) || n.type?.startsWith("JSX")) return null;
|
|
1146
|
+
if (n.type === "StringLiteral") {
|
|
1147
|
+
const value = String(n.value);
|
|
1148
|
+
return PROSE.test(value) && !NOT_PROSE.test(value) ? n : null;
|
|
1149
|
+
}
|
|
1150
|
+
for (const [key, child] of Object.entries(n)) {
|
|
1151
|
+
if (SKIP.has(key)) continue;
|
|
1152
|
+
const found = proseLiteral(child);
|
|
1153
|
+
if (found) return found;
|
|
1154
|
+
}
|
|
1155
|
+
return null;
|
|
1156
|
+
}
|
|
1157
|
+
function flagModuleStrings(program, ctx) {
|
|
1158
|
+
for (const statement of program.body ?? []) {
|
|
1159
|
+
const declaration = statement.type?.startsWith("Export") ? statement.declaration : statement;
|
|
1160
|
+
if (declaration?.type !== "VariableDeclaration") continue;
|
|
1161
|
+
for (const declarator of declaration.declarations ?? []) {
|
|
1162
|
+
const literal = proseLiteral(declarator.init);
|
|
1163
|
+
if (!literal) continue;
|
|
1164
|
+
flag(
|
|
1165
|
+
ctx,
|
|
1166
|
+
ctx.base + literal.start,
|
|
1167
|
+
"outside-component",
|
|
1168
|
+
`"${String(literal.value)}" is set at module level, outside any component.`,
|
|
1169
|
+
"Translate it where a component shows it, or move it into the component."
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
function styleOf(program, text, base) {
|
|
1175
|
+
const first = (program.body ?? [])[0];
|
|
1176
|
+
const semi = first && text[base + first.end - 1] === ";" ? ";" : "";
|
|
1177
|
+
const source = (program.body ?? []).find((s) => s.type === "ImportDeclaration")?.source;
|
|
1178
|
+
const quote = String(source?.extra?.raw ?? "'").startsWith('"') ? '"' : "'";
|
|
1179
|
+
return { semi, quote };
|
|
1180
|
+
}
|
|
1181
|
+
var hookFor = (lib, async) => lib === "react-i18next" ? { statement: "const { t } = useTranslation()", import: { name: "useTranslation", source: "react-i18next" } } : async ? { statement: "const t = await getTranslations()", import: { name: "getTranslations", source: "next-intl/server" } } : { statement: "const t = useTranslations()", import: { name: "useTranslations", source: "next-intl" } };
|
|
1182
|
+
var indentAt = (text, offset) => /^[ \t]*/.exec(text.slice(text.lastIndexOf("\n", offset - 1) + 1, offset))[0];
|
|
1183
|
+
function hookEdits(fn, lib, text, base, style) {
|
|
1184
|
+
const hook = hookFor(lib, Boolean(fn.async));
|
|
1185
|
+
const body = fn.body;
|
|
1186
|
+
const statement = hook.statement + style.semi;
|
|
1187
|
+
if (body.type === "BlockStatement") {
|
|
1188
|
+
const statements = body.body ?? [];
|
|
1189
|
+
if (statements.some(isOwnHook)) return { edits: [], imports: [hook.import] };
|
|
1190
|
+
const at2 = base + (statements[0]?.start ?? body.start + 1);
|
|
1191
|
+
const prefix = text.slice(text.lastIndexOf("\n", at2 - 1) + 1, at2);
|
|
1192
|
+
const insert = /^\s*$/.test(prefix) ? `${statement}
|
|
1193
|
+
${prefix}` : `${hook.statement}; `;
|
|
1194
|
+
return { edits: [{ start: at2, end: at2, text: insert }], imports: [hook.import] };
|
|
1195
|
+
}
|
|
1196
|
+
const extra = body.extra;
|
|
1197
|
+
const open = base + (extra?.parenthesized ? extra.parenStart : body.start);
|
|
1198
|
+
const indent = indentAt(text, base + fn.start);
|
|
1199
|
+
return {
|
|
1200
|
+
edits: [
|
|
1201
|
+
{ start: open, end: open, text: `{
|
|
1202
|
+
${indent} ${statement}
|
|
1203
|
+
${indent} return ` },
|
|
1204
|
+
{ start: base + fn.end, end: base + fn.end, text: `
|
|
1205
|
+
${indent}}` }
|
|
1206
|
+
],
|
|
1207
|
+
imports: [hook.import]
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
var hasImport = (program, wanted) => (program.body ?? []).some((s) => s.type === "ImportDeclaration" && s.source.value === wanted.source && (s.specifiers ?? []).some((spec) => {
|
|
1211
|
+
const imported = spec.imported;
|
|
1212
|
+
return spec.type === "ImportSpecifier" && (imported?.name ?? imported?.value) === wanted.name;
|
|
1213
|
+
}));
|
|
1214
|
+
function importEdits(program, wanted, base, style) {
|
|
1215
|
+
const unique = [...new Map(wanted.map((w) => [`${w.source}#${w.name}`, w])).values()].filter((w) => !hasImport(program, w));
|
|
1216
|
+
if (!unique.length) return [];
|
|
1217
|
+
const lines = unique.map((w) => `import { ${w.name} } from ${style.quote}${w.source}${style.quote}${style.semi}`);
|
|
1218
|
+
const body = program.body ?? [];
|
|
1219
|
+
const anchor = [...body].reverse().find((s) => s.type === "ImportDeclaration") ?? (program.directives ?? []).at(-1);
|
|
1220
|
+
if (!anchor) return [{ start: base, end: base, text: lines.map((l) => `${l}
|
|
1221
|
+
`).join("") }];
|
|
1222
|
+
const at2 = base + anchor.end;
|
|
1223
|
+
return [{ start: at2, end: at2, text: lines.map((l) => `
|
|
1224
|
+
${l}`).join("") }];
|
|
1225
|
+
}
|
|
1226
|
+
var isServerFile = (program) => (program.directives ?? []).some((d) => d.value.value === "use server");
|
|
1227
|
+
function extractStrings(file, lib, taken) {
|
|
1228
|
+
const entries = [];
|
|
1229
|
+
const edits2 = [];
|
|
1230
|
+
const flags = [];
|
|
1231
|
+
const used = new Set(taken);
|
|
1232
|
+
for (const block of file.js) {
|
|
1233
|
+
const program = block.ast.program;
|
|
1234
|
+
if (isServerFile(program)) continue;
|
|
1235
|
+
const ctx = { file, base: block.offset, lib, stem: stemOf(file.path), used, byText: /* @__PURE__ */ new Map(), entries, edits: edits2, flags, components: /* @__PURE__ */ new Set(), sawJsx: false };
|
|
1236
|
+
walk(program, ctx, []);
|
|
1237
|
+
if (ctx.sawJsx) flagModuleStrings(program, ctx);
|
|
1238
|
+
const style = styleOf(program, file.text, ctx.base);
|
|
1239
|
+
const hooks = [...ctx.components].map((fn) => hookEdits(fn, lib, file.text, ctx.base, style));
|
|
1240
|
+
edits2.push(...hooks.flatMap((h) => h.edits));
|
|
1241
|
+
edits2.push(...importEdits(program, hooks.flatMap((h) => h.imports), ctx.base, style));
|
|
1242
|
+
}
|
|
1243
|
+
return { edits: edits2, flags, entries };
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// src/generate/next-config.ts
|
|
1247
|
+
var CANDIDATES = ["next.config.ts", "next.config.mts", "next.config.mjs", "next.config.js", "next.config.cjs"];
|
|
1248
|
+
var FUNCTIONS2 = /* @__PURE__ */ new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
|
|
1249
|
+
var PLUGIN = "createNextIntlPlugin";
|
|
1250
|
+
var NEW_CONFIG = `import ${PLUGIN} from 'next-intl/plugin'
|
|
1251
|
+
|
|
1252
|
+
const withNextIntl = ${PLUGIN}()
|
|
1253
|
+
|
|
1254
|
+
/** @type {import('next').NextConfig} */
|
|
1255
|
+
const nextConfig = {}
|
|
1256
|
+
|
|
1257
|
+
export default withNextIntl(nextConfig)
|
|
1258
|
+
`;
|
|
1259
|
+
var refuse = (path) => new UnsupportedError(`Convert could not find the config object ${path} exports, so it cannot add the next-intl plugin. Export the object directly, then run convert again.`);
|
|
1260
|
+
var isModuleExports = (left) => left.type === "MemberExpression" && left.object.name === "module" && left.property.name === "exports";
|
|
1261
|
+
function esmEdits(program, value, base, semi, quote) {
|
|
1262
|
+
const lines = `import ${PLUGIN} from ${quote}next-intl/plugin${quote}${semi}
|
|
1263
|
+
|
|
1264
|
+
const withNextIntl = ${PLUGIN}()${semi}`;
|
|
1265
|
+
const last = (program.body ?? []).filter((s) => s.type === "ImportDeclaration").at(-1);
|
|
1266
|
+
const head = last ? { start: base + last.end, end: base + last.end, text: `
|
|
1267
|
+
${lines}` } : { start: base, end: base, text: `${lines}
|
|
1268
|
+
|
|
1269
|
+
` };
|
|
1270
|
+
return [head, { start: base + value.start, end: base + value.start, text: "withNextIntl(" }, { start: base + value.end, end: base + value.end, text: ")" }];
|
|
1271
|
+
}
|
|
1272
|
+
function cjsEdits(program, value, base, semi, quote) {
|
|
1273
|
+
const first = (program.body ?? [])[0];
|
|
1274
|
+
const start2 = base + Math.min(first.start, ...(first.leadingComments ?? []).map((c) => c.start));
|
|
1275
|
+
const lines = `const ${PLUGIN} = require(${quote}next-intl/plugin${quote})${semi}
|
|
1276
|
+
const withNextIntl = ${PLUGIN}()${semi}
|
|
1277
|
+
|
|
1278
|
+
`;
|
|
1279
|
+
return [{ start: start2, end: start2, text: lines }, { start: base + value.start, end: base + value.start, text: "withNextIntl(" }, { start: base + value.end, end: base + value.end, text: ")" }];
|
|
1280
|
+
}
|
|
1281
|
+
function configEdits(path, text) {
|
|
1282
|
+
if (text.includes(PLUGIN)) return [];
|
|
1283
|
+
const outcome = (0, import_lint3.parseSourceOutcome)(path, text);
|
|
1284
|
+
const block = "file" in outcome ? outcome.file.js[0] : void 0;
|
|
1285
|
+
if (!block) throw refuse(path);
|
|
1286
|
+
const program = block.ast.program;
|
|
1287
|
+
const { semi, quote } = styleOf(program, text, block.offset);
|
|
1288
|
+
const body = program.body ?? [];
|
|
1289
|
+
const esm = body.find((s) => s.type === "ExportDefaultDeclaration")?.declaration;
|
|
1290
|
+
if (esm) {
|
|
1291
|
+
if (FUNCTIONS2.has(esm.type) || esm.type === "ClassDeclaration") throw refuse(path);
|
|
1292
|
+
return esmEdits(program, esm, block.offset, semi, quote);
|
|
1293
|
+
}
|
|
1294
|
+
const assign = body.map((s) => s.type === "ExpressionStatement" ? s.expression : null).find((e) => e?.type === "AssignmentExpression" && isModuleExports(e.left));
|
|
1295
|
+
const value = assign?.right;
|
|
1296
|
+
if (!value || FUNCTIONS2.has(value.type)) throw refuse(path);
|
|
1297
|
+
return cjsEdits(program, value, block.offset, semi, quote);
|
|
1298
|
+
}
|
|
1299
|
+
function nextConfigPlan(files) {
|
|
1300
|
+
const path = CANDIDATES.find((c) => files(c) !== null);
|
|
1301
|
+
if (!path) return { writes: { "next.config.mjs": NEW_CONFIG }, edits: {} };
|
|
1302
|
+
const edits2 = configEdits(path, files(path));
|
|
1303
|
+
return { writes: {}, edits: edits2.length ? { [path]: edits2 } : {} };
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// src/generate/next-layout.ts
|
|
1307
|
+
var import_lint4 = require("@dualkit/lint");
|
|
1308
|
+
var FUNCTIONS3 = /* @__PURE__ */ new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
|
|
1309
|
+
var SKIP2 = /* @__PURE__ */ new Set(["loc", "leadingComments", "trailingComments", "innerComments", "extra"]);
|
|
1310
|
+
var LOOSE_TYPES = /* @__PURE__ */ new Set(["TSUnionType", "TSFunctionType", "TSConditionalType", "TSConstructorType"]);
|
|
1311
|
+
var PROVIDER = "NextIntlClientProvider";
|
|
1312
|
+
var at = (ctx, offset, text) => ({ start: ctx.base + offset, end: ctx.base + offset, text });
|
|
1313
|
+
function findElement(node, name) {
|
|
1314
|
+
if (Array.isArray(node)) return node.map((n2) => findElement(n2, name)).find(Boolean) ?? null;
|
|
1315
|
+
if (!node || typeof node !== "object") return null;
|
|
1316
|
+
const n = node;
|
|
1317
|
+
if (n.type === "JSXElement" && n.openingElement.name.name === name) return n;
|
|
1318
|
+
for (const [key, child] of Object.entries(n)) {
|
|
1319
|
+
if (SKIP2.has(key)) continue;
|
|
1320
|
+
const found = findElement(child, name);
|
|
1321
|
+
if (found) return found;
|
|
1322
|
+
}
|
|
1323
|
+
return null;
|
|
1324
|
+
}
|
|
1325
|
+
function htmlEdits(html, mode, ctx) {
|
|
1326
|
+
const opening = html.openingElement;
|
|
1327
|
+
const attrs = mode === "arabic" ? ' lang="ar" dir="rtl"' : " lang={locale} dir={locale === 'ar' ? 'rtl' : 'ltr'}";
|
|
1328
|
+
const edits2 = [at(ctx, opening.name.end, attrs)];
|
|
1329
|
+
for (const attr of opening.attributes ?? []) {
|
|
1330
|
+
const name = attr.type === "JSXAttribute" ? attr.name.name : void 0;
|
|
1331
|
+
if (name !== "lang" && name !== "dir") continue;
|
|
1332
|
+
let start2 = ctx.base + attr.start;
|
|
1333
|
+
while (/\s/.test(ctx.text[start2 - 1] ?? "")) start2--;
|
|
1334
|
+
edits2.push({ start: start2, end: ctx.base + attr.end, text: "" });
|
|
1335
|
+
}
|
|
1336
|
+
return edits2;
|
|
1337
|
+
}
|
|
1338
|
+
function providerEdits(html, ctx) {
|
|
1339
|
+
const body = findElement(html, "body");
|
|
1340
|
+
const holder = body?.closingElement ? body : html;
|
|
1341
|
+
const closing = holder.closingElement;
|
|
1342
|
+
if (!closing) return [];
|
|
1343
|
+
return [at(ctx, holder.openingElement.end, `<${PROVIDER}>`), at(ctx, closing.start, `</${PROVIDER}>`)];
|
|
1344
|
+
}
|
|
1345
|
+
function layoutFunction(program) {
|
|
1346
|
+
const body = program.body ?? [];
|
|
1347
|
+
const exported = body.find((s) => s.type === "ExportDefaultDeclaration")?.declaration;
|
|
1348
|
+
if (!exported) return null;
|
|
1349
|
+
if (FUNCTIONS3.has(exported.type)) return exported;
|
|
1350
|
+
if (exported.type !== "Identifier") return null;
|
|
1351
|
+
for (const s of body) {
|
|
1352
|
+
const d = s.type === "ExportNamedDeclaration" ? s.declaration : s;
|
|
1353
|
+
if (d?.type === "FunctionDeclaration" && d.id.name === exported.name) return d;
|
|
1354
|
+
for (const v of d?.type === "VariableDeclaration" ? d.declarations ?? [] : []) {
|
|
1355
|
+
const init = v.init;
|
|
1356
|
+
if (v.id.name === exported.name && init && FUNCTIONS3.has(init.type)) return init;
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
return null;
|
|
1360
|
+
}
|
|
1361
|
+
var paramsType = (options) => options.promiseParams ? "{ params: Promise<{ locale: string }> }" : "{ params: { locale: string } }";
|
|
1362
|
+
function annotate(param, options, ctx) {
|
|
1363
|
+
const annotation = param.typeAnnotation?.typeAnnotation;
|
|
1364
|
+
if (!annotation) return [];
|
|
1365
|
+
const extra = ` & ${paramsType(options)}`;
|
|
1366
|
+
if (!LOOSE_TYPES.has(annotation.type)) return [at(ctx, annotation.end, extra)];
|
|
1367
|
+
return [at(ctx, annotation.start, "("), at(ctx, annotation.end, `)${extra}`)];
|
|
1368
|
+
}
|
|
1369
|
+
function paramEdits(fn, options, ctx) {
|
|
1370
|
+
const [param] = fn.params ?? [];
|
|
1371
|
+
if (!param) {
|
|
1372
|
+
const open = ctx.text.indexOf("(", ctx.base + (fn.id?.end ?? fn.start));
|
|
1373
|
+
const type = ctx.typed ? `: ${paramsType(options)}` : "";
|
|
1374
|
+
return { edits: [{ start: open + 1, end: open + 1, text: `{ params }${type}` }], source: "params" };
|
|
1375
|
+
}
|
|
1376
|
+
if (param.type === "Identifier") return { edits: annotate(param, options, ctx), source: `${param.name}.params` };
|
|
1377
|
+
if (param.type !== "ObjectPattern") throw new UnsupportedError("The root layout takes its props in a form convert cannot add params to. Destructure them as { children }, then run convert again.");
|
|
1378
|
+
const first = (param.properties ?? [])[0];
|
|
1379
|
+
const insert = first ? at(ctx, first.start, "params, ") : at(ctx, param.start + 1, " params ");
|
|
1380
|
+
return { edits: [insert, ...annotate(param, options, ctx)], source: "params" };
|
|
1381
|
+
}
|
|
1382
|
+
var indentAt2 = (text, offset) => /^[ \t]*/.exec(text.slice(text.lastIndexOf("\n", offset - 1) + 1, offset))[0];
|
|
1383
|
+
function prepend(fn, lines, ctx) {
|
|
1384
|
+
const body = fn.body;
|
|
1385
|
+
if (body.type === "BlockStatement") {
|
|
1386
|
+
const first = (body.body ?? [])[0];
|
|
1387
|
+
if (first) {
|
|
1388
|
+
const indent3 = indentAt2(ctx.text, ctx.base + first.start);
|
|
1389
|
+
return [at(ctx, first.start, lines.map((l) => `${l}
|
|
1390
|
+
${indent3}`).join(""))];
|
|
1391
|
+
}
|
|
1392
|
+
const indent2 = `${indentAt2(ctx.text, ctx.base + fn.start)} `;
|
|
1393
|
+
return [at(ctx, body.start + 1, lines.map((l) => `
|
|
1394
|
+
${indent2}${l}`).join(""))];
|
|
1395
|
+
}
|
|
1396
|
+
const extra = body.extra;
|
|
1397
|
+
const open = extra?.parenthesized ? extra.parenStart : body.start;
|
|
1398
|
+
const indent = indentAt2(ctx.text, ctx.base + fn.start);
|
|
1399
|
+
return [
|
|
1400
|
+
at(ctx, open, `{
|
|
1401
|
+
${lines.map((l) => `${indent} ${l}
|
|
1402
|
+
`).join("")}${indent} return `),
|
|
1403
|
+
at(ctx, fn.end, `
|
|
1404
|
+
${indent}}`)
|
|
1405
|
+
];
|
|
1406
|
+
}
|
|
1407
|
+
function hookEdits2(fn, ctx) {
|
|
1408
|
+
const body = fn.body;
|
|
1409
|
+
const statements = body.type === "BlockStatement" ? body.body ?? [] : [];
|
|
1410
|
+
const hook = statements.find(isOwnHook);
|
|
1411
|
+
const init = hook?.declarations?.[0]?.init;
|
|
1412
|
+
if (!init || init.type === "AwaitExpression") return { edits: [], imports: [] };
|
|
1413
|
+
return {
|
|
1414
|
+
edits: [{ start: ctx.base + init.start, end: ctx.base + init.end, text: "await getTranslations()" }],
|
|
1415
|
+
imports: [{ name: "getTranslations", source: "next-intl/server" }]
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
var exportsName = (program, name) => (program.body ?? []).some((s) => {
|
|
1419
|
+
const d = s.type === "ExportNamedDeclaration" ? s.declaration : null;
|
|
1420
|
+
if (d?.type === "FunctionDeclaration") return d.id.name === name;
|
|
1421
|
+
if (d?.type === "VariableDeclaration") return (d.declarations ?? []).some((v) => v.id.name === name);
|
|
1422
|
+
return s.type === "ExportNamedDeclaration" && (s.specifiers ?? []).some((spec) => spec.exported.name === name);
|
|
1423
|
+
});
|
|
1424
|
+
function localeEdits(program, options, ctx) {
|
|
1425
|
+
const fn = layoutFunction(program);
|
|
1426
|
+
if (!fn) throw new UnsupportedError("Convert could not find the default export of the root layout, so it cannot read the locale there.");
|
|
1427
|
+
const { edits: edits2, source } = paramEdits(fn, options, ctx);
|
|
1428
|
+
if (!fn.async) edits2.push(at(ctx, fn.start, "async "));
|
|
1429
|
+
const semi = ctx.style.semi;
|
|
1430
|
+
edits2.push(...prepend(fn, [
|
|
1431
|
+
`const { locale } = await ${source}${semi}`,
|
|
1432
|
+
`if (!hasLocale(routing.locales, locale)) notFound()${semi}`,
|
|
1433
|
+
`setRequestLocale(locale)${semi}`
|
|
1434
|
+
], ctx));
|
|
1435
|
+
const hook = hookEdits2(fn, ctx);
|
|
1436
|
+
edits2.push(...hook.edits);
|
|
1437
|
+
if (!exportsName(program, "generateStaticParams")) {
|
|
1438
|
+
const end = program.end;
|
|
1439
|
+
const lead = ctx.text.slice(0, ctx.base + end).endsWith("\n") ? "\n" : "\n\n";
|
|
1440
|
+
edits2.push(at(ctx, end, `${lead}export function generateStaticParams() {
|
|
1441
|
+
return routing.locales.map((locale) => ({ locale }))${semi}
|
|
1442
|
+
}
|
|
1443
|
+
`));
|
|
1444
|
+
}
|
|
1445
|
+
return {
|
|
1446
|
+
edits: edits2,
|
|
1447
|
+
imports: [
|
|
1448
|
+
{ name: "hasLocale", source: "next-intl" },
|
|
1449
|
+
{ name: "setRequestLocale", source: "next-intl/server" },
|
|
1450
|
+
...hook.imports,
|
|
1451
|
+
{ name: "notFound", source: "next/navigation" },
|
|
1452
|
+
{ name: "routing", source: options.routing }
|
|
1453
|
+
]
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
function layoutEdits(path, text, mode, options) {
|
|
1457
|
+
if (text.includes(PROVIDER)) return [];
|
|
1458
|
+
const outcome = (0, import_lint4.parseSourceOutcome)(path, text);
|
|
1459
|
+
const block = "file" in outcome ? outcome.file.js[0] : void 0;
|
|
1460
|
+
if (!block) throw new UnsupportedError(`Convert could not parse the root layout in ${path}.`);
|
|
1461
|
+
const program = block.ast.program;
|
|
1462
|
+
if ((program.directives ?? []).some((d) => d.value.value === "use client")) {
|
|
1463
|
+
throw new UnsupportedError(`The root layout in ${path} is a client component. Convert needs it to be a server component to read the locale.`);
|
|
1464
|
+
}
|
|
1465
|
+
const html = findElement(program, "html");
|
|
1466
|
+
if (!html) throw new UnsupportedError(`The root layout in ${path} has no <html> element, so convert cannot set the language and direction.`);
|
|
1467
|
+
const ctx = { text, base: block.offset, style: styleOf(program, text, block.offset), typed: /\.tsx?$/.test(path) };
|
|
1468
|
+
const edits2 = [...htmlEdits(html, mode, ctx), ...providerEdits(html, ctx)];
|
|
1469
|
+
const imports = [{ name: PROVIDER, source: "next-intl" }];
|
|
1470
|
+
if (mode === "bilingual") {
|
|
1471
|
+
const locale = localeEdits(program, options, ctx);
|
|
1472
|
+
edits2.push(...locale.edits);
|
|
1473
|
+
imports.push(...locale.imports);
|
|
1474
|
+
}
|
|
1475
|
+
return [...edits2, ...addImports(program, imports, ctx.base, ctx.style)];
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// src/generate/next-routes.ts
|
|
1479
|
+
var import_lint5 = require("@dualkit/lint");
|
|
1480
|
+
var import_node_path12 = require("path");
|
|
1481
|
+
var SKIP3 = /* @__PURE__ */ new Set(["loc", "leadingComments", "trailingComments", "innerComments", "extra"]);
|
|
1482
|
+
var CODE = /\.(tsx?|jsx?|mjs|cjs|mts|cts)$/;
|
|
1483
|
+
var MOVED = /\.(tsx?|jsx?|mjs|cjs|mts|cts|mdx?)$/;
|
|
1484
|
+
var PAGE2 = /^page\.(tsx?|jsx?|mdx?)$/;
|
|
1485
|
+
var ROOT_KEPT = /^(favicon\.ico|global-error\.[jt]sx?|(icon|apple-icon|opengraph-image|twitter-image)\d*(\.alt)?\.\w+|robots\.\w+|sitemap\.\w+|manifest\.\w+)$/;
|
|
1486
|
+
var SEGMENT_METADATA = /^(icon|apple-icon|opengraph-image|twitter-image)\d*(\.alt)?\.\w+$/;
|
|
1487
|
+
var UNMATCHED = /^(api|_next|_vercel)$/;
|
|
1488
|
+
var localePath = (appDir, path) => `${appDir}/[locale]/${path.slice(appDir.length + 1)}`;
|
|
1489
|
+
var urlSegments = (dirs) => dirs.filter((d) => !/^\(.*\)$/.test(d) && !d.startsWith("@"));
|
|
1490
|
+
function fateOf(appDir, path) {
|
|
1491
|
+
if (!path.startsWith(`${appDir}/`)) return "stay";
|
|
1492
|
+
const parts = path.slice(appDir.length + 1).split("/");
|
|
1493
|
+
const name = parts.pop();
|
|
1494
|
+
if (parts[0] === "[locale]") return "stay";
|
|
1495
|
+
const url = urlSegments(parts);
|
|
1496
|
+
if (url[0] && UNMATCHED.test(url[0]) || url.some((d) => d.includes(".") && !d.startsWith("["))) return "kept-route";
|
|
1497
|
+
if (!parts.length && ROOT_KEPT.test(name)) return "stay";
|
|
1498
|
+
return MOVED.test(name) || parts.length > 0 && SEGMENT_METADATA.test(name) ? "move" : "stay";
|
|
1499
|
+
}
|
|
1500
|
+
function routeMoves(appDir, paths) {
|
|
1501
|
+
const moves = [];
|
|
1502
|
+
const keptPages = [];
|
|
1503
|
+
for (const p of paths) {
|
|
1504
|
+
const fate = fateOf(appDir, p);
|
|
1505
|
+
if (fate === "move") moves.push([p, localePath(appDir, p)]);
|
|
1506
|
+
if (fate === "kept-route" && PAGE2.test(import_node_path12.posix.basename(p))) keptPages.push(p);
|
|
1507
|
+
}
|
|
1508
|
+
return { moves, keptPages };
|
|
1509
|
+
}
|
|
1510
|
+
function eachNode(node, visit) {
|
|
1511
|
+
if (Array.isArray(node)) return node.forEach((child) => eachNode(child, visit));
|
|
1512
|
+
if (!node || typeof node !== "object") return;
|
|
1513
|
+
const n = node;
|
|
1514
|
+
if (typeof n.type === "string") visit(n);
|
|
1515
|
+
for (const [key, child] of Object.entries(n)) if (!SKIP3.has(key)) eachNode(child, visit);
|
|
1516
|
+
}
|
|
1517
|
+
var RELATIVE = /^\.\.?\//;
|
|
1518
|
+
var stringLiteral = (node) => node?.type === "StringLiteral" ? node : null;
|
|
1519
|
+
var localFontNames = (program) => new Set((program.body ?? []).filter((s) => s.type === "ImportDeclaration" && s.source.value === "next/font/local").flatMap((s) => (s.specifiers ?? []).filter((spec) => spec.type === "ImportDefaultSpecifier").map((spec) => spec.local.name)));
|
|
1520
|
+
function specifierLiterals(program) {
|
|
1521
|
+
const fonts = localFontNames(program);
|
|
1522
|
+
const found = [];
|
|
1523
|
+
eachNode(program, (n) => {
|
|
1524
|
+
if (/^(Import|ExportNamed|ExportAll)Declaration$/.test(n.type)) {
|
|
1525
|
+
const hit = stringLiteral(n.source);
|
|
1526
|
+
if (hit) found.push(hit);
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
if (n.type !== "CallExpression") return;
|
|
1530
|
+
const callee = n.callee;
|
|
1531
|
+
const args = n.arguments ?? [];
|
|
1532
|
+
if (callee.type === "Import" || callee.type === "Identifier" && callee.name === "require") {
|
|
1533
|
+
const hit = stringLiteral(args[0]);
|
|
1534
|
+
if (hit) found.push(hit);
|
|
1535
|
+
} else if (callee.type === "Identifier" && fonts.has(callee.name)) {
|
|
1536
|
+
eachNode(args[0], (inner) => {
|
|
1537
|
+
const hit = stringLiteral(inner);
|
|
1538
|
+
if (hit && RELATIVE.test(String(hit.value))) found.push(hit);
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
1541
|
+
});
|
|
1542
|
+
return found;
|
|
1543
|
+
}
|
|
1544
|
+
var newTarget = (scope, target) => resolveFile(target, (p) => scope.moved.has(p)) ? localePath(scope.appDir, target) : target;
|
|
1545
|
+
function relativeSpec(scope, from, to, spec) {
|
|
1546
|
+
const target = import_node_path12.posix.join(import_node_path12.posix.dirname(from), spec);
|
|
1547
|
+
let next = import_node_path12.posix.relative(import_node_path12.posix.dirname(to), newTarget(scope, target));
|
|
1548
|
+
if (!next.startsWith(".")) next = `./${next}`;
|
|
1549
|
+
if (spec.endsWith("/") && !next.endsWith("/")) next += "/";
|
|
1550
|
+
return import_node_path12.posix.normalize(next) === import_node_path12.posix.normalize(spec) ? null : next;
|
|
1551
|
+
}
|
|
1552
|
+
function aliasSpec(scope, spec) {
|
|
1553
|
+
const resolved = resolveAlias(spec, scope.aliases, scope.has);
|
|
1554
|
+
if (!resolved || !scope.moved.has(resolved.file)) return null;
|
|
1555
|
+
return resolved.respell(localePath(scope.appDir, resolved.path)) ?? "stuck";
|
|
1556
|
+
}
|
|
1557
|
+
var stuckFlag = (file, line, spec) => ({
|
|
1558
|
+
file,
|
|
1559
|
+
line,
|
|
1560
|
+
kind: "unsupported",
|
|
1561
|
+
message: `The import "${spec}" uses a path alias with no * that points at a file convert moved under [locale], so it still points at the old path.`,
|
|
1562
|
+
suggestion: "Point that alias in tsconfig.json at the new path, or import the file by a relative path."
|
|
1563
|
+
});
|
|
1564
|
+
var ROUTE_TYPES = /* @__PURE__ */ new Set(["PageProps", "LayoutProps", "RouteContext"]);
|
|
1565
|
+
function routeTypeLiterals(program) {
|
|
1566
|
+
const found = [];
|
|
1567
|
+
eachNode(program, (n) => {
|
|
1568
|
+
if (n.type !== "TSTypeReference") return;
|
|
1569
|
+
const name = n.typeName;
|
|
1570
|
+
const args = (n.typeParameters ?? n.typeArguments)?.params;
|
|
1571
|
+
const literal = args?.[0]?.type === "TSLiteralType" ? args[0].literal : void 0;
|
|
1572
|
+
if (name.type === "Identifier" && ROUTE_TYPES.has(name.name) && literal?.type === "StringLiteral" && String(literal.value).startsWith("/")) found.push(literal);
|
|
1573
|
+
});
|
|
1574
|
+
return found;
|
|
1575
|
+
}
|
|
1576
|
+
function routeTypeEdits(program, offset) {
|
|
1577
|
+
return routeTypeLiterals(program).map((node) => {
|
|
1578
|
+
const route = String(node.value);
|
|
1579
|
+
return { start: offset + node.start + 1, end: offset + node.end - 1, text: route === "/" ? "/[locale]" : `/[locale]${route}` };
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1582
|
+
function fileRewrites(scope, from, to, text) {
|
|
1583
|
+
const outcome = (0, import_lint5.parseSourceOutcome)(from, text);
|
|
1584
|
+
const block = "file" in outcome ? outcome.file.js[0] : void 0;
|
|
1585
|
+
if (!block) return { edits: [], flags: [] };
|
|
1586
|
+
const edits2 = from === to ? [] : routeTypeEdits(block.ast.program, block.offset);
|
|
1587
|
+
const flags = [];
|
|
1588
|
+
for (const node of specifierLiterals(block.ast.program)) {
|
|
1589
|
+
const spec = String(node.value);
|
|
1590
|
+
const next = RELATIVE.test(spec) ? relativeSpec(scope, from, to, spec) : aliasSpec(scope, spec);
|
|
1591
|
+
if (next === "stuck") flags.push(stuckFlag(to, lineOf(text, block.offset + node.start), spec));
|
|
1592
|
+
else if (next !== null && next !== spec) edits2.push({ start: block.offset + node.start + 1, end: block.offset + node.end - 1, text: next });
|
|
1593
|
+
}
|
|
1594
|
+
return { edits: edits2, flags };
|
|
1595
|
+
}
|
|
1596
|
+
function importRewrites(appDir, moveList, paths, files, aliases) {
|
|
1597
|
+
const known = new Set(paths);
|
|
1598
|
+
const scope = { appDir, moved: new Map(moveList), has: (p) => known.has(p), aliases };
|
|
1599
|
+
const edits2 = {};
|
|
1600
|
+
const flags = [];
|
|
1601
|
+
for (const path of paths) {
|
|
1602
|
+
if (!CODE.test(path)) continue;
|
|
1603
|
+
const text = files(path);
|
|
1604
|
+
if (text === null) continue;
|
|
1605
|
+
const to = scope.moved.get(path) ?? path;
|
|
1606
|
+
const result = fileRewrites(scope, path, to, text);
|
|
1607
|
+
if (result.edits.length) edits2[to] = result.edits;
|
|
1608
|
+
flags.push(...result.flags);
|
|
1609
|
+
}
|
|
1610
|
+
return { edits: edits2, flags };
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
// src/generate/next-intl.ts
|
|
1614
|
+
var DEPS = { "next-intl": "4.14.9" };
|
|
1615
|
+
var LATEST_NEXT_MAJOR = 16;
|
|
1616
|
+
var LATEST_NEXT_MINOR = 4;
|
|
1617
|
+
function versionIn(text, read) {
|
|
1618
|
+
try {
|
|
1619
|
+
const value = read(JSON.parse(text ?? "{}"));
|
|
1620
|
+
return typeof value === "string" ? value : "";
|
|
1621
|
+
} catch {
|
|
1622
|
+
return "";
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
function nextVersion(files) {
|
|
1626
|
+
const deps = (json2, field) => json2[field]?.next;
|
|
1627
|
+
const version = versionIn(files("node_modules/next/package.json"), (json2) => json2.version) || versionIn(files("package.json"), (json2) => deps(json2, "dependencies") ?? deps(json2, "devDependencies"));
|
|
1628
|
+
const match = /(\d+)(?:\.(\d+))?/.exec(version);
|
|
1629
|
+
return match ? { major: Number(match[1]), minor: Number(match[2] ?? 0) } : { major: LATEST_NEXT_MAJOR, minor: LATEST_NEXT_MINOR };
|
|
1630
|
+
}
|
|
1631
|
+
var nextMajor = (files) => nextVersion(files).major;
|
|
1632
|
+
function hasRootParams(files) {
|
|
1633
|
+
const { major, minor } = nextVersion(files);
|
|
1634
|
+
return major > 16 || major === 16 && minor >= 3;
|
|
1635
|
+
}
|
|
1636
|
+
var ROUTING = `import { defineRouting } from 'next-intl/routing'
|
|
1637
|
+
|
|
1638
|
+
export const routing = defineRouting({
|
|
1639
|
+
locales: ['en', 'ar'],
|
|
1640
|
+
defaultLocale: 'en',
|
|
1641
|
+
localePrefix: 'as-needed',
|
|
1642
|
+
})
|
|
1643
|
+
`;
|
|
1644
|
+
var NAVIGATION = `import { createNavigation } from 'next-intl/navigation'
|
|
1645
|
+
import { routing } from './routing'
|
|
1646
|
+
|
|
1647
|
+
export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing)
|
|
1648
|
+
`;
|
|
1649
|
+
function requestFile(mode, messages, rootParams) {
|
|
1650
|
+
if (mode === "arabic") {
|
|
1651
|
+
return `import { getRequestConfig } from 'next-intl/server'
|
|
1652
|
+
|
|
1653
|
+
export default getRequestConfig(async () => ({
|
|
1654
|
+
locale: 'ar',
|
|
1655
|
+
messages: (await import('${messages}/ar.json')).default,
|
|
1656
|
+
}))
|
|
1657
|
+
`;
|
|
1658
|
+
}
|
|
1659
|
+
if (rootParams) {
|
|
1660
|
+
return `import * as rootParams from 'next/root-params'
|
|
1661
|
+
import { hasLocale } from 'next-intl'
|
|
1662
|
+
import { getRequestConfig } from 'next-intl/server'
|
|
1663
|
+
import { routing } from './routing'
|
|
1664
|
+
|
|
1665
|
+
export default getRequestConfig(async ({ locale: given }) => {
|
|
1666
|
+
const requested = given ?? await rootParams.locale()
|
|
1667
|
+
const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale
|
|
1668
|
+
return {
|
|
1669
|
+
locale,
|
|
1670
|
+
messages: (await import(\`${messages}/\${locale}.json\`)).default,
|
|
1671
|
+
}
|
|
1672
|
+
})
|
|
1673
|
+
`;
|
|
1674
|
+
}
|
|
1675
|
+
return `import { hasLocale } from 'next-intl'
|
|
1676
|
+
import { getRequestConfig } from 'next-intl/server'
|
|
1677
|
+
import { routing } from './routing'
|
|
1678
|
+
|
|
1679
|
+
export default getRequestConfig(async ({ requestLocale }) => {
|
|
1680
|
+
const requested = await requestLocale
|
|
1681
|
+
const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale
|
|
1682
|
+
return {
|
|
1683
|
+
locale,
|
|
1684
|
+
messages: (await import(\`${messages}/\${locale}.json\`)).default,
|
|
1685
|
+
}
|
|
1686
|
+
})
|
|
1687
|
+
`;
|
|
1688
|
+
}
|
|
1689
|
+
var middlewareFile = (routing) => `import createMiddleware from 'next-intl/middleware'
|
|
1690
|
+
import { routing } from '${routing}'
|
|
1691
|
+
|
|
1692
|
+
export default createMiddleware(routing)
|
|
1693
|
+
|
|
1694
|
+
export const config = {
|
|
1695
|
+
matcher: '/((?!(?:api|_next|_vercel)(?:/|$)|.*\\\\..*).*)',
|
|
1696
|
+
}
|
|
1697
|
+
`;
|
|
1698
|
+
var composedMiddlewareFile = (routing, matcher, own) => `import { NextRequest, NextResponse, type NextFetchEvent } from 'next/server'
|
|
1699
|
+
import createMiddleware from 'next-intl/middleware'
|
|
1700
|
+
import { routing } from '${routing}'
|
|
1701
|
+
import { matchesApp, type Matcher } from '${matcher}'
|
|
1702
|
+
import * as own from '${own}'
|
|
1703
|
+
|
|
1704
|
+
const intl = createMiddleware(routing)
|
|
1705
|
+
type OwnHandler = (request: NextRequest, event: NextFetchEvent) => Response | null | undefined | Promise<Response | null | undefined>
|
|
1706
|
+
// A spread, so the bundler does not warn about the export names this app does not use.
|
|
1707
|
+
const exported: Record<string, unknown> = { ...own }
|
|
1708
|
+
const candidate = exported.default ?? exported.middleware ?? exported.proxy
|
|
1709
|
+
if (typeof candidate !== 'function') throw new Error('Dualkit could not find the middleware function exported by ${own}.')
|
|
1710
|
+
const ownHandler = candidate as OwnHandler
|
|
1711
|
+
const ownMatcher = (exported as { config?: { matcher?: Matcher } }).config?.matcher
|
|
1712
|
+
const OWN_ONLY = /^\\/(?:api|trpc)(?:\\/|$)/
|
|
1713
|
+
|
|
1714
|
+
export default async function middleware(request: NextRequest, event: NextFetchEvent) {
|
|
1715
|
+
const url = request.nextUrl.clone()
|
|
1716
|
+
const [, first = '', ...rest] = url.pathname.split('/')
|
|
1717
|
+
if ((routing.locales as readonly string[]).includes(first)) url.pathname = \`/\${rest.join('/')}\`
|
|
1718
|
+
const result = matchesApp(url.pathname, ownMatcher) ? await ownHandler(new NextRequest(url, request), event) : null
|
|
1719
|
+
// Anything but a plain "continue" is the app's answer: a redirect, a rewrite, a 401 or a page. Keep it as it is.
|
|
1720
|
+
if (result && result.headers.get('x-middleware-next') !== '1') return result
|
|
1721
|
+
if (OWN_ONLY.test(url.pathname)) return result ?? NextResponse.next()
|
|
1722
|
+
const response = intl(request)
|
|
1723
|
+
if (result) {
|
|
1724
|
+
for (const cookie of result.headers.getSetCookie()) response.headers.append('set-cookie', cookie)
|
|
1725
|
+
// Headers the app set for the browser, such as a content security policy.
|
|
1726
|
+
result.headers.forEach((value, key) => { if (key !== 'set-cookie' && !key.startsWith('x-middleware-')) response.headers.set(key, value) })
|
|
1727
|
+
const overridden = result.headers.get('x-middleware-override-headers')
|
|
1728
|
+
if (overridden) {
|
|
1729
|
+
const names = new Set([...(response.headers.get('x-middleware-override-headers') ?? '').split(','), ...overridden.split(',')].filter(Boolean))
|
|
1730
|
+
response.headers.set('x-middleware-override-headers', [...names].join(','))
|
|
1731
|
+
result.headers.forEach((value, key) => { if (key.startsWith('x-middleware-request-')) response.headers.set(key, value) })
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
return response
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
export const config = {
|
|
1738
|
+
matcher: '/((?!(?:_next|_vercel)(?:/|$)|.*\\\\..*).*)',
|
|
1739
|
+
}
|
|
1740
|
+
`;
|
|
1741
|
+
var APP_MATCHER = String.raw`/** Whether a path is covered by an app's own middleware matcher. No matcher means every path. */
|
|
1742
|
+
export type Matcher = string | { source: string } | (string | { source: string })[]
|
|
1743
|
+
|
|
1744
|
+
/** An entry with has or missing conditions is treated as matching, so the app's middleware runs rather than being skipped. */
|
|
1745
|
+
export function matchesApp(pathname: string, matcher: Matcher | undefined): boolean {
|
|
1746
|
+
if (matcher === undefined) return true
|
|
1747
|
+
return (Array.isArray(matcher) ? matcher : [matcher]).some((entry) => toRegExp(typeof entry === 'string' ? entry : entry.source).test(pathname))
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
function toRegExp(pattern: string): RegExp {
|
|
1751
|
+
let out = ''
|
|
1752
|
+
let i = 0
|
|
1753
|
+
while (i < pattern.length) {
|
|
1754
|
+
const char = pattern[i]!
|
|
1755
|
+
const param = char === '/' ? /^\/:[A-Za-z0-9_]+([*+?]?)/.exec(pattern.slice(i)) : null
|
|
1756
|
+
if (param && pattern[i + param[0].length] === '(') {
|
|
1757
|
+
out += '/'
|
|
1758
|
+
i += param[0].length
|
|
1759
|
+
} else if (param) {
|
|
1760
|
+
out += { '*': '(?:/.*)?', '+': '/.+', '?': '(?:/[^/]+)?', '': '/[^/]+' }[param[1]!]
|
|
1761
|
+
i += param[0].length
|
|
1762
|
+
} else if (char === '(') {
|
|
1763
|
+
let depth = 0
|
|
1764
|
+
let j = i
|
|
1765
|
+
for (; j < pattern.length; j++) {
|
|
1766
|
+
if (pattern[j] === '\\') j++
|
|
1767
|
+
else if (pattern[j] === '(') depth++
|
|
1768
|
+
else if (pattern[j] === ')' && --depth === 0) break
|
|
1769
|
+
}
|
|
1770
|
+
out += pattern.slice(i, j + 1)
|
|
1771
|
+
i = j + 1
|
|
1772
|
+
} else {
|
|
1773
|
+
out += char.replace(/[.*+?^$|{}[\]\\]/g, '\\$&')
|
|
1774
|
+
i++
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
return new RegExp('^' + out + (out.endsWith('/') ? '' : '/?') + '$')
|
|
1778
|
+
}
|
|
1779
|
+
`;
|
|
1780
|
+
var switchFile = (navigation) => `'use client'
|
|
1781
|
+
|
|
1782
|
+
import { useLocale } from 'next-intl'
|
|
1783
|
+
import { Link, usePathname } from '${navigation}'
|
|
1784
|
+
|
|
1785
|
+
const CHOICES = ${SWITCH_CHOICES}
|
|
1786
|
+
|
|
1787
|
+
/** Links to the current page in English and Arabic. Not placed by convert: add it where the app keeps its navigation. */
|
|
1788
|
+
export function DualkitLanguageSwitch() {
|
|
1789
|
+
const pathname = usePathname()
|
|
1790
|
+
const locale = useLocale()
|
|
1791
|
+
return (
|
|
1792
|
+
<nav aria-label="Language">
|
|
1793
|
+
{CHOICES.map(({ code, label }) => (
|
|
1794
|
+
<Link key={code} href={pathname} locale={code} lang={code} aria-current={locale === code ? 'true' : undefined}>
|
|
1795
|
+
{label}
|
|
1796
|
+
</Link>
|
|
1797
|
+
))}
|
|
1798
|
+
</nav>
|
|
1799
|
+
)
|
|
1800
|
+
}
|
|
1801
|
+
`;
|
|
1802
|
+
var CATCH_ALL = `import { notFound } from 'next/navigation'
|
|
1803
|
+
|
|
1804
|
+
/** Every path no other route matches shows the not-found page, inside the [locale] layout. */
|
|
1805
|
+
export default function CatchAll() {
|
|
1806
|
+
notFound()
|
|
1807
|
+
}
|
|
1808
|
+
`;
|
|
1809
|
+
var PAGE3 = /^page\.(tsx?|jsx?|mdx?)$/;
|
|
1810
|
+
var ROUTE = /^route\.(tsx?|jsx?)$/;
|
|
1811
|
+
var LAYOUT = /^layout\.(tsx?|jsx?)$/;
|
|
1812
|
+
var flagAt = (file, kind, message, suggestion) => ({ file, line: 1, kind, message, suggestion });
|
|
1813
|
+
function topCatchAll(appDir, moves, paths) {
|
|
1814
|
+
const catches = (path) => {
|
|
1815
|
+
const parts = path.slice(appDir.length + 1).split("/");
|
|
1816
|
+
const name = parts.pop();
|
|
1817
|
+
const url = urlSegments(parts);
|
|
1818
|
+
return (PAGE3.test(name) || ROUTE.test(name)) && url.length === 1 && /^\[\[?\.\.\./.test(url[0]);
|
|
1819
|
+
};
|
|
1820
|
+
const moved = moves.find(([from]) => catches(from));
|
|
1821
|
+
if (moved) return moved[1];
|
|
1822
|
+
return paths.find((p) => p.startsWith(`${appDir}/`) && catches(p)) ?? null;
|
|
1823
|
+
}
|
|
1824
|
+
function alreadyWired(project, mode, appDir, files, paths) {
|
|
1825
|
+
if (mode === "bilingual") {
|
|
1826
|
+
const prefix = `${appDir}/[locale]/`;
|
|
1827
|
+
return paths.find((p) => p.startsWith(prefix) && LAYOUT.test(p.slice(prefix.length))) ?? null;
|
|
1828
|
+
}
|
|
1829
|
+
const wired = (files(project.entry) ?? "").includes("NextIntlClientProvider") && files(`${sourceBase(project)}i18n/request.ts`) !== null;
|
|
1830
|
+
return wired ? project.entry : null;
|
|
1831
|
+
}
|
|
1832
|
+
function findMiddleware(base, files) {
|
|
1833
|
+
const found = [];
|
|
1834
|
+
for (const name of ["middleware", "proxy"]) {
|
|
1835
|
+
for (const ext of ["ts", "js", "mjs", "tsx", "jsx"]) {
|
|
1836
|
+
const path = `${base}${name}.${ext}`;
|
|
1837
|
+
const text = files(path);
|
|
1838
|
+
if (text !== null) found.push({ path, text });
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
if (found.length > 1) throw new UnsupportedError(`This app has both ${found[0].path} and ${found[1].path}. Convert cannot tell which one to compose with next-intl, so it stopped before changing anything.`);
|
|
1842
|
+
const only = found[0] ?? null;
|
|
1843
|
+
if (only && !EXPORTS_HANDLER.test(only.text)) {
|
|
1844
|
+
throw new UnsupportedError(`Convert could not find the middleware function exported from ${only.path}, so it stopped before changing anything.`);
|
|
1845
|
+
}
|
|
1846
|
+
return only;
|
|
1847
|
+
}
|
|
1848
|
+
var EXPORTS_HANDLER = /export\s+default\b|export\s+(?:async\s+)?function\s+(?:middleware|proxy)\b|export\s+(?:const|let|var)\s+(?:middleware|proxy)\b|export\s*\{[^}]*\b(?:middleware|proxy|default)\b[^}]*\}/;
|
|
1849
|
+
var movedMiddlewarePath = (base, existing) => `${base}app-middleware.${existing.split(".").pop()}`;
|
|
1850
|
+
var merge = (into, from) => {
|
|
1851
|
+
const out = { ...into };
|
|
1852
|
+
for (const [path, edits2] of Object.entries(from)) out[path] = [...out[path] ?? [], ...edits2];
|
|
1853
|
+
return out;
|
|
1854
|
+
};
|
|
1855
|
+
function bilingualParts(project, appDir, files, paths) {
|
|
1856
|
+
const base = sourceBase(project);
|
|
1857
|
+
const { moves, keptPages } = routeMoves(appDir, paths);
|
|
1858
|
+
const middlewarePath = `${base}${nextMajor(files) >= 16 ? "proxy" : "middleware"}.ts`;
|
|
1859
|
+
const own = findMiddleware(base, files);
|
|
1860
|
+
const routingSpec = specifierTo(middlewarePath, `${base}i18n/routing.ts`);
|
|
1861
|
+
const ownMoved = own ? movedMiddlewarePath(base, own.path) : null;
|
|
1862
|
+
if (ownMoved && files(ownMoved) !== null) {
|
|
1863
|
+
throw new UnsupportedError(`This app already has ${ownMoved}. Convert needs that name for the app's own middleware, so move or remove that file and run convert again.`);
|
|
1864
|
+
}
|
|
1865
|
+
const middleware = ownMoved ? composedMiddlewareFile(routingSpec, specifierTo(middlewarePath, `${base}i18n/app-matcher.ts`), specifierTo(middlewarePath, ownMoved)) : middlewareFile(routingSpec);
|
|
1866
|
+
const catchAll = topCatchAll(appDir, moves, paths);
|
|
1867
|
+
const flags = keptPages.map((page) => flagAt(
|
|
1868
|
+
page,
|
|
1869
|
+
"unsupported",
|
|
1870
|
+
`${page} stays outside [locale], because the middleware skips its path, so it has no Arabic version.`,
|
|
1871
|
+
"Move the page to a path the middleware matches, or translate it by hand."
|
|
1872
|
+
));
|
|
1873
|
+
if (own && ownMoved) {
|
|
1874
|
+
flags.push(flagAt(
|
|
1875
|
+
ownMoved,
|
|
1876
|
+
"info",
|
|
1877
|
+
`${own.path} moved to ${ownMoved}, and the new ${middlewarePath} runs it first on the path without its locale prefix, then next-intl.`,
|
|
1878
|
+
"Check the app middleware still behaves as before and that its auth checks still hold under /ar. Any answer other than a plain continue (a redirect, rewrite or 401) is returned as it is. Paths under /api and /trpc skip next-intl. Config options other than matcher, such as runtime, are not carried over, and has or missing conditions on a matcher entry are ignored, so the middleware runs on those paths."
|
|
1879
|
+
));
|
|
1880
|
+
}
|
|
1881
|
+
if (catchAll) {
|
|
1882
|
+
flags.push(flagAt(
|
|
1883
|
+
catchAll,
|
|
1884
|
+
"unsupported",
|
|
1885
|
+
`${catchAll} already catches every path under [locale], so convert did not add its own catch-all.`,
|
|
1886
|
+
"Call notFound() from next/navigation in that page for paths it does not know, so unknown URLs keep showing the not-found page."
|
|
1887
|
+
));
|
|
1888
|
+
}
|
|
1889
|
+
const writes = {
|
|
1890
|
+
[`${base}i18n/routing.ts`]: ROUTING,
|
|
1891
|
+
[`${base}i18n/navigation.ts`]: NAVIGATION,
|
|
1892
|
+
[middlewarePath]: middleware,
|
|
1893
|
+
...own ? { [`${base}i18n/app-matcher.ts`]: APP_MATCHER } : {},
|
|
1894
|
+
[switchPath(project)]: switchFile(specifierTo(switchPath(project), `${base}i18n/navigation.ts`)),
|
|
1895
|
+
...catchAll ? {} : { [`${appDir}/[locale]/[...rest]/page.tsx`]: CATCH_ALL }
|
|
1896
|
+
};
|
|
1897
|
+
const rewrites = importRewrites(appDir, moves, paths, files, readAliases(files));
|
|
1898
|
+
const ownMove = own && ownMoved ? [[own.path, ownMoved]] : [];
|
|
1899
|
+
return { moves: [...moves, ...ownMove], writes, flags: [...flags, ...rewrites.flags], edits: rewrites.edits };
|
|
1900
|
+
}
|
|
1901
|
+
function wireNextIntl(project, mode, files, paths) {
|
|
1902
|
+
const appDir = project.appDir ?? "app";
|
|
1903
|
+
const wired = alreadyWired(project, mode, appDir, files, paths);
|
|
1904
|
+
if (wired) {
|
|
1905
|
+
const flag3 = flagAt(wired, "info", "This app is already wired for next-intl, so convert left its locale setup as it is.", "Nothing to do.");
|
|
1906
|
+
return { writes: {}, edits: {}, moves: [], deps: {}, flags: [flag3] };
|
|
1907
|
+
}
|
|
1908
|
+
const base = sourceBase(project);
|
|
1909
|
+
const i18nDir = `${base}i18n`;
|
|
1910
|
+
const messages = import_node_path13.posix.relative(i18nDir, "messages");
|
|
1911
|
+
const config = nextConfigPlan(files);
|
|
1912
|
+
const parts = mode === "bilingual" ? bilingualParts(project, appDir, files, paths) : { moves: [], writes: {}, edits: {}, flags: [] };
|
|
1913
|
+
const writes = {
|
|
1914
|
+
...parts.writes,
|
|
1915
|
+
[`${i18nDir}/request.ts`]: requestFile(mode, messages, hasRootParams(files)),
|
|
1916
|
+
[localeHelperPath(project)]: LOCALE_HELPER,
|
|
1917
|
+
...config.writes
|
|
1918
|
+
};
|
|
1919
|
+
const moved = new Set(parts.moves.map(([from]) => from));
|
|
1920
|
+
guardWrites(writes, files, moved);
|
|
1921
|
+
const layoutPath = moved.has(project.entry) ? localePath(appDir, project.entry) : project.entry;
|
|
1922
|
+
const layoutText = files(project.entry);
|
|
1923
|
+
if (layoutText === null) throw new UnsupportedError(`Convert could not read the root layout at ${project.entry}.`);
|
|
1924
|
+
const layout = layoutEdits(layoutPath, layoutText, mode, {
|
|
1925
|
+
routing: specifierTo(layoutPath, `${i18nDir}/routing.ts`),
|
|
1926
|
+
promiseParams: nextMajor(files) >= 15
|
|
1927
|
+
});
|
|
1928
|
+
const edits2 = merge(merge(parts.edits, config.edits), layout.length ? { [layoutPath]: layout } : {});
|
|
1929
|
+
return { writes, edits: edits2, moves: parts.moves, deps: { ...DEPS }, flags: parts.flags };
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
// src/generate/react-i18next.ts
|
|
1933
|
+
var import_lint6 = require("@dualkit/lint");
|
|
1934
|
+
var import_node_path14 = require("path");
|
|
1935
|
+
var DEPS2 = { "react-i18next": "17.1.6", i18next: "26.5.8" };
|
|
1936
|
+
var INIT_PATH = "src/i18n.ts";
|
|
1937
|
+
var HTML_PATH = "index.html";
|
|
1938
|
+
var COOKIE_PATTERN = `/(?:^|; )${LOCALE_COOKIE}=([^;]*)/`;
|
|
1939
|
+
var messagesImport = (locale) => `import ${locale} from '${import_node_path14.posix.relative(import_node_path14.posix.dirname(INIT_PATH), `messages/${locale}.json`)}'`;
|
|
1940
|
+
function initFile(mode) {
|
|
1941
|
+
const head = "import i18n from 'i18next'\nimport { initReactI18next } from 'react-i18next'\n";
|
|
1942
|
+
const options = (resources, lng, fallback) => `void i18n.use(initReactI18next).init({
|
|
1943
|
+
resources: ${resources},
|
|
1944
|
+
lng: ${lng},
|
|
1945
|
+
fallbackLng: '${fallback}',
|
|
1946
|
+
initAsync: false,
|
|
1947
|
+
interpolation: { prefix: '{', suffix: '}', escapeValue: false },
|
|
1948
|
+
})
|
|
1949
|
+
|
|
1950
|
+
export default i18n
|
|
1951
|
+
`;
|
|
1952
|
+
if (mode === "arabic") return `${head}${messagesImport("ar")}
|
|
1953
|
+
|
|
1954
|
+
${options("{ ar: { translation: ar } }", "'ar'", "ar")}`;
|
|
1955
|
+
return `${head}${messagesImport("en")}
|
|
1956
|
+
${messagesImport("ar")}
|
|
1957
|
+
|
|
1958
|
+
/** The locale saved in the ${LOCALE_COOKIE} cookie, else English. */
|
|
1959
|
+
function savedLocale(): 'en' | 'ar' {
|
|
1960
|
+
const match = ${COOKIE_PATTERN}.exec(document.cookie)
|
|
1961
|
+
return match?.[1] === 'ar' ? 'ar' : 'en'
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
` + options("{ en: { translation: en }, ar: { translation: ar } }", "savedLocale()", "en");
|
|
1965
|
+
}
|
|
1966
|
+
var switchFile2 = () => `import { useTranslation } from 'react-i18next'
|
|
1967
|
+
|
|
1968
|
+
const CHOICES = ${SWITCH_CHOICES}
|
|
1969
|
+
|
|
1970
|
+
/** Buttons for English and Arabic. Not placed by convert: add it where the app keeps its navigation. */
|
|
1971
|
+
export function DualkitLanguageSwitch() {
|
|
1972
|
+
const { i18n } = useTranslation()
|
|
1973
|
+
const choose = (code: string): void => {
|
|
1974
|
+
document.cookie = \`${LOCALE_COOKIE}=\${code}; path=/; max-age=31536000; samesite=lax\`
|
|
1975
|
+
window.location.reload()
|
|
1976
|
+
}
|
|
1977
|
+
return (
|
|
1978
|
+
<nav aria-label="Language">
|
|
1979
|
+
{CHOICES.map(({ code, label }) => (
|
|
1980
|
+
<button key={code} type="button" lang={code} aria-current={i18n.language === code ? 'true' : undefined} onClick={() => choose(code)}>
|
|
1981
|
+
{label}
|
|
1982
|
+
</button>
|
|
1983
|
+
))}
|
|
1984
|
+
</nav>
|
|
1985
|
+
)
|
|
1986
|
+
}
|
|
1987
|
+
`;
|
|
1988
|
+
var INLINE_SCRIPT = `<script>(function(){var m=document.cookie.match(${COOKIE_PATTERN});var l=m&&m[1]==='ar'?'ar':'en';var h=document.documentElement;h.lang=l;h.dir=l==='ar'?'rtl':'ltr'})()</script>`;
|
|
1989
|
+
function htmlEdits2(html, mode) {
|
|
1990
|
+
if (mode === "bilingual") {
|
|
1991
|
+
if (html.includes(LOCALE_COOKIE)) return [];
|
|
1992
|
+
const head = /<head\b[^>]*>/i.exec(html);
|
|
1993
|
+
if (!head) return [];
|
|
1994
|
+
const at2 = head.index + head[0].length;
|
|
1995
|
+
return [{ start: at2, end: at2, text: `
|
|
1996
|
+
${INLINE_SCRIPT}` }];
|
|
1997
|
+
}
|
|
1998
|
+
const open = /<html\b([^>]*)>/i.exec(html);
|
|
1999
|
+
if (!open) return [];
|
|
2000
|
+
const rest = open[1].replace(/\s(lang|dir)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
2001
|
+
const text = `<html lang="ar" dir="rtl"${rest}>`;
|
|
2002
|
+
return text === open[0] ? [] : [{ start: open.index, end: open.index + open[0].length, text }];
|
|
2003
|
+
}
|
|
2004
|
+
function titleFlags(html) {
|
|
2005
|
+
const title = /<title\b[^>]*>([^<]*)<\/title>/i.exec(html);
|
|
2006
|
+
const text = title?.[1].trim() ?? "";
|
|
2007
|
+
if (!title || !/[A-Za-z]/.test(text) || /\{\{|%\w+%/.test(text)) return [];
|
|
2008
|
+
return [{
|
|
2009
|
+
file: HTML_PATH,
|
|
2010
|
+
line: lineOf(html, title.index),
|
|
2011
|
+
kind: "outside-component",
|
|
2012
|
+
message: `The page title "${text}" in index.html stays in English.`,
|
|
2013
|
+
suggestion: "Translate it in index.html for an Arabic-only app, or set document.title from a translated string in a component."
|
|
2014
|
+
}];
|
|
2015
|
+
}
|
|
2016
|
+
function entryEdits(entry, text) {
|
|
2017
|
+
const outcome = (0, import_lint6.parseSourceOutcome)(entry, text);
|
|
2018
|
+
const block = "file" in outcome ? outcome.file.js[0] : void 0;
|
|
2019
|
+
if (!block) return [];
|
|
2020
|
+
const program = block.ast.program;
|
|
2021
|
+
const spec = specifierTo(entry, INIT_PATH);
|
|
2022
|
+
const has = (program.body ?? []).some((s) => s.type === "ImportDeclaration" && [spec, `${spec}.ts`].includes(String(s.source.value)));
|
|
2023
|
+
if (has) return [];
|
|
2024
|
+
return [sideEffectImport(program, spec, block.offset, styleOf(program, text, block.offset))];
|
|
2025
|
+
}
|
|
2026
|
+
function wireReactI18next(project, mode, files) {
|
|
2027
|
+
const writes = {
|
|
2028
|
+
[INIT_PATH]: initFile(mode),
|
|
2029
|
+
[localeHelperPath(project)]: LOCALE_HELPER,
|
|
2030
|
+
...mode === "bilingual" ? { [switchPath(project)]: switchFile2() } : {}
|
|
2031
|
+
};
|
|
2032
|
+
guardWrites(writes, files, /* @__PURE__ */ new Set());
|
|
2033
|
+
const edits2 = {};
|
|
2034
|
+
const html = files(HTML_PATH);
|
|
2035
|
+
const htmlChange = html === null ? [] : htmlEdits2(html, mode);
|
|
2036
|
+
if (htmlChange.length) edits2[HTML_PATH] = htmlChange;
|
|
2037
|
+
const entry = files(project.entry);
|
|
2038
|
+
const entryChange = entry === null ? [] : entryEdits(project.entry, entry);
|
|
2039
|
+
if (entryChange.length) edits2[project.entry] = entryChange;
|
|
2040
|
+
return { writes, edits: edits2, moves: [], deps: { ...DEPS2 }, flags: html === null ? [] : titleFlags(html) };
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
// src/lint-context.ts
|
|
2044
|
+
var import_lint7 = require("@dualkit/lint");
|
|
2045
|
+
var import_node_path15 = require("path");
|
|
2046
|
+
var POSTCSS_LANGS = /* @__PURE__ */ new Set(["css", "scss", "vue", "svelte"]);
|
|
2047
|
+
function physicalContext(root) {
|
|
2048
|
+
const cwd = (0, import_node_path15.resolve)(root);
|
|
2049
|
+
const top = (0, import_lint7.repoTop)(cwd) ?? cwd;
|
|
2050
|
+
const postcssOf = (0, import_lint7.createFlipperLookup)(top, import_lint7.POSTCSS_FLIPPERS);
|
|
2051
|
+
const stylisOf = (0, import_lint7.createFlipperLookup)(top, import_lint7.STYLIS_FLIPPERS);
|
|
2052
|
+
const blockFlipper = (file, block) => block.fromTemplate ? stylisOf(file.abs) : POSTCSS_LANGS.has(file.lang) ? postcssOf(file.abs) : null;
|
|
2053
|
+
const reasons = /* @__PURE__ */ new Map();
|
|
2054
|
+
const physicalQuiet = (file) => {
|
|
2055
|
+
const known = reasons.get(file.path);
|
|
2056
|
+
if (known !== void 0) return known;
|
|
2057
|
+
const flipped = file.css.some((block) => blockFlipper(file, block) !== null);
|
|
2058
|
+
const reason = (0, import_lint7.quietReason)(file.path, file.text) ?? (flipped ? "flipped" : null);
|
|
2059
|
+
reasons.set(file.path, reason);
|
|
2060
|
+
return reason;
|
|
2061
|
+
};
|
|
2062
|
+
return { blockFlipper, physicalQuiet };
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
// src/passes.ts
|
|
2066
|
+
var import_lint11 = require("@dualkit/lint");
|
|
2067
|
+
|
|
2068
|
+
// src/transforms/css.ts
|
|
2069
|
+
var import_lint9 = require("@dualkit/lint");
|
|
2070
|
+
|
|
2071
|
+
// src/transforms/css-values.ts
|
|
2072
|
+
function splitSpaces(value) {
|
|
2073
|
+
const out = [];
|
|
2074
|
+
let depth = 0;
|
|
2075
|
+
let quote = "";
|
|
2076
|
+
let from = -1;
|
|
2077
|
+
for (let i = 0; i <= value.length; i++) {
|
|
2078
|
+
const ch = value[i] ?? " ";
|
|
2079
|
+
if (quote) {
|
|
2080
|
+
if (ch === quote) quote = "";
|
|
2081
|
+
continue;
|
|
2082
|
+
}
|
|
2083
|
+
if (ch === '"' || ch === "'") quote = ch;
|
|
2084
|
+
else if (ch === "(") depth++;
|
|
2085
|
+
else if (ch === ")" && depth > 0) depth--;
|
|
2086
|
+
const space = depth === 0 && /\s/.test(ch);
|
|
2087
|
+
if (!space && from === -1) from = i;
|
|
2088
|
+
if (space && from !== -1) {
|
|
2089
|
+
out.push(value.slice(from, i));
|
|
2090
|
+
from = -1;
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
return out;
|
|
2094
|
+
}
|
|
2095
|
+
function splitCommas(value) {
|
|
2096
|
+
const out = [];
|
|
2097
|
+
let depth = 0;
|
|
2098
|
+
let from = 0;
|
|
2099
|
+
for (let i = 0; i < value.length; i++) {
|
|
2100
|
+
const ch = value[i];
|
|
2101
|
+
if (ch === "(") depth++;
|
|
2102
|
+
else if (ch === ")" && depth > 0) depth--;
|
|
2103
|
+
else if (ch === "," && depth === 0) {
|
|
2104
|
+
out.push(value.slice(from, i));
|
|
2105
|
+
from = i + 1;
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
return [...out, value.slice(from)];
|
|
2109
|
+
}
|
|
2110
|
+
var BOX = /* @__PURE__ */ new Set(["margin", "padding"]);
|
|
2111
|
+
function splitShorthand(prop, value, normalised) {
|
|
2112
|
+
if (!BOX.has(prop) || /[$@]|#\{/.test(value)) return null;
|
|
2113
|
+
const raw = splitSpaces(value);
|
|
2114
|
+
const norm = splitSpaces(normalised);
|
|
2115
|
+
if (raw.length !== 4 || norm.length !== 4 || norm[1] === norm[3]) return null;
|
|
2116
|
+
return { property: prop, block: `${raw[0]} ${raw[2]}`, inline: `${raw[3]} ${raw[1]}` };
|
|
2117
|
+
}
|
|
2118
|
+
var ZERO = /^[+-]?0*\.?0+[a-z%]*$/;
|
|
2119
|
+
var LENGTH = /^[+-]?(?:\d|\.\d)/;
|
|
2120
|
+
var CENTRE = /^[+-]?50%$/;
|
|
2121
|
+
var CENTRE_SIBLINGS = /* @__PURE__ */ new Set(["left", "right", "inset-inline-start", "inset-inline-end"]);
|
|
2122
|
+
var hasCentreOffset = (live) => (live.decl.parent?.nodes ?? []).some((n) => n.type === "decl" && CENTRE_SIBLINGS.has(n.prop.toLowerCase()) && n.value.trim() === "50%");
|
|
2123
|
+
function translateMoves(live) {
|
|
2124
|
+
for (const m of live.value.matchAll(/translate(?:x|3d)?\(\s*([^,)]*)/g)) {
|
|
2125
|
+
const x = m[1].trim();
|
|
2126
|
+
if (ZERO.test(x)) continue;
|
|
2127
|
+
if (CENTRE.test(x) && hasCentreOffset(live)) continue;
|
|
2128
|
+
return true;
|
|
2129
|
+
}
|
|
2130
|
+
return false;
|
|
2131
|
+
}
|
|
2132
|
+
function shadowMoves(value) {
|
|
2133
|
+
return splitCommas(value).some((shadow) => {
|
|
2134
|
+
const x = splitSpaces(shadow).filter((t) => t !== "inset")[0];
|
|
2135
|
+
return x !== void 0 && LENGTH.test(x) && !ZERO.test(x);
|
|
2136
|
+
});
|
|
2137
|
+
}
|
|
2138
|
+
var SIDE_WORD = /(?:^|\s)(?:left|right)(?=$|\s)/;
|
|
2139
|
+
function corners(value) {
|
|
2140
|
+
if (value.includes("/")) return null;
|
|
2141
|
+
const t = splitSpaces(value);
|
|
2142
|
+
const [a, b, c, d] = t;
|
|
2143
|
+
if (t.length === 1) return [a, a, a, a];
|
|
2144
|
+
if (t.length === 2) return [a, b, a, b];
|
|
2145
|
+
if (t.length === 3) return [a, b, c, b];
|
|
2146
|
+
return t.length === 4 ? [a, b, c, d] : null;
|
|
2147
|
+
}
|
|
2148
|
+
function sidesDiffer(prop, value) {
|
|
2149
|
+
if (prop === "border-width") {
|
|
2150
|
+
const t = splitSpaces(value);
|
|
2151
|
+
return t.length === 4 && t[1] !== t[3];
|
|
2152
|
+
}
|
|
2153
|
+
const c = corners(value);
|
|
2154
|
+
return c !== null && (c[0] !== c[1] || c[3] !== c[2]);
|
|
2155
|
+
}
|
|
2156
|
+
var MOVES = {
|
|
2157
|
+
transform: "A horizontal translate moves the same way in Arabic. Use a logical offset, or flip the sign for Arabic with `:dir(rtl)`.",
|
|
2158
|
+
"background-position": "A background placed left or right stays there in Arabic. Use `:dir(rtl)` with the opposite side.",
|
|
2159
|
+
"box-shadow": "A shadow with a horizontal offset falls on the same side in Arabic. Use `:dir(rtl)` with the offset negated.",
|
|
2160
|
+
"border-width": "A border width cannot be set per logical side in one shorthand. Use border-inline-start-width and border-inline-end-width.",
|
|
2161
|
+
"border-radius": "Corner radii cannot be set per logical side in one shorthand. Use border-start-start-radius and its three siblings."
|
|
2162
|
+
};
|
|
2163
|
+
function noLogicalForm(live) {
|
|
2164
|
+
const { prop, value } = live;
|
|
2165
|
+
const hit = prop === "transform" ? translateMoves(live) : prop === "background-position" || prop === "background-position-x" ? SIDE_WORD.test(value) : prop === "box-shadow" ? shadowMoves(value) : prop === "border-width" || prop === "border-radius" ? sidesDiffer(prop, value) : false;
|
|
2166
|
+
return hit ? MOVES[prop === "background-position-x" ? "background-position" : prop] ?? null : null;
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
// src/transforms/css-jsx.ts
|
|
2170
|
+
var import_lint8 = require("@dualkit/lint");
|
|
2171
|
+
var SKIP4 = /* @__PURE__ */ new Set(["loc", "leadingComments", "trailingComments", "innerComments", "extra"]);
|
|
2172
|
+
function* styleObjects(node) {
|
|
2173
|
+
if (Array.isArray(node)) for (const child of node) yield* styleObjects(child);
|
|
2174
|
+
if (!node || typeof node !== "object" || Array.isArray(node)) return;
|
|
2175
|
+
const n = node;
|
|
2176
|
+
if (n.type === "JSXAttribute" && n.name?.name === "style") {
|
|
2177
|
+
const expression = n.value?.type === "JSXExpressionContainer" ? n.value.expression : void 0;
|
|
2178
|
+
if (expression?.type === "ObjectExpression") yield expression;
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
for (const [key, child] of Object.entries(n)) if (!SKIP4.has(key)) yield* styleObjects(child);
|
|
2182
|
+
}
|
|
2183
|
+
var kebab2 = (name) => name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
|
2184
|
+
var camel = (name) => name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
2185
|
+
var span = (n, base) => ({ start: base + n.start, end: base + n.end });
|
|
2186
|
+
function keyOf(property, base) {
|
|
2187
|
+
const key = property.key;
|
|
2188
|
+
if (property.computed || key.type !== "Identifier" && key.type !== "StringLiteral") return null;
|
|
2189
|
+
const name = key.type === "Identifier" ? key.name : String(key.value);
|
|
2190
|
+
if (!/^[a-z][A-Za-z]*$/.test(name)) return null;
|
|
2191
|
+
return { name, ...span(key, base), quote: key.type === "StringLiteral" ? "x" : "" };
|
|
2192
|
+
}
|
|
2193
|
+
function entryOf(property, base, text, dynamic) {
|
|
2194
|
+
if (property.type !== "ObjectProperty") return null;
|
|
2195
|
+
const key = keyOf(property, base);
|
|
2196
|
+
if (!key) return null;
|
|
2197
|
+
const value = property.value;
|
|
2198
|
+
const at2 = span(value, base);
|
|
2199
|
+
const quote = text[at2.start];
|
|
2200
|
+
const literal = value.type === "StringLiteral" ? { ...at2, quote } : null;
|
|
2201
|
+
const written = value.type === "StringLiteral" ? String(value.value) : value.type === "NumericLiteral" ? `${String(value.value)}px` : dynamic(text.slice(at2.start, at2.end));
|
|
2202
|
+
return {
|
|
2203
|
+
prop: kebab2(key.name),
|
|
2204
|
+
value: written.replace(/\s+/g, " "),
|
|
2205
|
+
key: { start: key.start, end: key.end, quote: key.quote ? text[key.start] : "" },
|
|
2206
|
+
literal,
|
|
2207
|
+
whole: span(property, base)
|
|
2208
|
+
};
|
|
2209
|
+
}
|
|
2210
|
+
var requote = (quote, inner) => `${quote}${inner}${quote}`;
|
|
2211
|
+
function edits(entry, live, physical) {
|
|
2212
|
+
const split = splitShorthand(live.prop, entry.value, live.value);
|
|
2213
|
+
if (split && entry.literal) {
|
|
2214
|
+
const q = entry.literal.quote;
|
|
2215
|
+
const text = `${camel(split.property)}Block: ${requote(q, split.block)}, ${camel(split.property)}Inline: ${requote(q, split.inline)}`;
|
|
2216
|
+
return [{ ...entry.whole, text }];
|
|
2217
|
+
}
|
|
2218
|
+
if (physical?.property) return [{ start: entry.key.start, end: entry.key.end, text: requote(entry.key.quote, camel(physical.property)) }];
|
|
2219
|
+
if (physical?.word && entry.literal) return [{ start: entry.literal.start, end: entry.literal.end, text: requote(entry.literal.quote, physical.word) }];
|
|
2220
|
+
return [];
|
|
2221
|
+
}
|
|
2222
|
+
function jsxStyleResult(file) {
|
|
2223
|
+
const editsOut = [];
|
|
2224
|
+
const flags = [];
|
|
2225
|
+
for (const block of file.js) {
|
|
2226
|
+
for (const object of styleObjects(block.ast.program)) {
|
|
2227
|
+
const ids = /* @__PURE__ */ new Map();
|
|
2228
|
+
const dynamic = (source) => {
|
|
2229
|
+
if (!ids.has(source)) ids.set(source, `$${"x".repeat(ids.size + 1)}`);
|
|
2230
|
+
return ids.get(source);
|
|
2231
|
+
};
|
|
2232
|
+
const entries = (object.properties ?? []).map((p) => entryOf(p, block.offset, file.text, dynamic)).filter((e) => e !== null);
|
|
2233
|
+
const starts = [];
|
|
2234
|
+
let css = "";
|
|
2235
|
+
for (const e of entries) {
|
|
2236
|
+
starts.push(css.length);
|
|
2237
|
+
css += `${e.prop}: ${e.value};
|
|
2238
|
+
`;
|
|
2239
|
+
}
|
|
2240
|
+
const synthetic = (0, import_lint8.parseSource)("style-object.css", css);
|
|
2241
|
+
if (!synthetic) continue;
|
|
2242
|
+
const entryAt = (offset) => entries[starts.indexOf(offset)];
|
|
2243
|
+
const physical = new Map((0, import_lint8.physicalDeclarations)(synthetic).map((d) => [d.offset, d]));
|
|
2244
|
+
for (const live of (0, import_lint8.liveDeclarations)(synthetic)) {
|
|
2245
|
+
const entry = entryAt(live.offset);
|
|
2246
|
+
if (!entry) continue;
|
|
2247
|
+
editsOut.push(...edits(entry, live, physical.get(live.offset) ?? null));
|
|
2248
|
+
const suggestion = noLogicalForm(live);
|
|
2249
|
+
if (suggestion) {
|
|
2250
|
+
const message = `\`${entry.prop}: ${live.shown}\` has no logical form in CSS and stays physical in Arabic.`;
|
|
2251
|
+
flags.push({ file: file.path, line: lineOf(file.text, entry.whole.start), kind: "no-logical-form", message, suggestion });
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
return { edits: editsOut, flags };
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
// src/transforms/css.ts
|
|
2260
|
+
function spans(file, d) {
|
|
2261
|
+
const end = d.block.offset + (d.decl.source?.end?.offset ?? -1) + 1;
|
|
2262
|
+
const name = d.offset + d.decl.prop.length;
|
|
2263
|
+
const value = name + (d.decl.raws.between?.length ?? 0);
|
|
2264
|
+
if (end <= value || file.text.slice(d.offset, name).toLowerCase() !== d.prop) return null;
|
|
2265
|
+
return { start: d.offset, name, value, end };
|
|
2266
|
+
}
|
|
2267
|
+
function renameEdits(file, d) {
|
|
2268
|
+
const at2 = spans(file, d);
|
|
2269
|
+
if (!at2) return [];
|
|
2270
|
+
if (d.property) return [{ start: at2.start, end: at2.name, text: d.property }];
|
|
2271
|
+
if (!d.word || file.text.slice(at2.value, at2.value + d.decl.value.length) !== d.decl.value) return [];
|
|
2272
|
+
return [{ start: at2.value, end: at2.value + d.decl.value.length, text: d.word }];
|
|
2273
|
+
}
|
|
2274
|
+
function shorthandEdits(file, d) {
|
|
2275
|
+
const split = splitShorthand(d.prop, d.decl.value, d.value);
|
|
2276
|
+
const at2 = spans(file, d);
|
|
2277
|
+
if (!split || !at2 || d.decl.raws.value) return [];
|
|
2278
|
+
const important = d.decl.important ? d.decl.raws.important ?? " !important" : "";
|
|
2279
|
+
const text = `${split.property}-block: ${split.block}${important}; ${split.property}-inline: ${split.inline}${important}`;
|
|
2280
|
+
const raw = file.text.slice(at2.start, at2.end);
|
|
2281
|
+
return [{ start: at2.start, end: at2.start + raw.replace(/\s*;?\s*$/, "").length, text }];
|
|
2282
|
+
}
|
|
2283
|
+
function noFormFlag(file, d) {
|
|
2284
|
+
const suggestion = noLogicalForm(d);
|
|
2285
|
+
if (!suggestion) return [];
|
|
2286
|
+
const message = `\`${d.prop}: ${d.shown}\` has no logical form in CSS and stays physical in Arabic.`;
|
|
2287
|
+
return [{ file: file.path, line: lineOf(file.text, d.offset), kind: "no-logical-form", message, suggestion }];
|
|
2288
|
+
}
|
|
2289
|
+
function cssEdits(file, ctx = {}) {
|
|
2290
|
+
const live = (0, import_lint9.liveDeclarations)(file, ctx);
|
|
2291
|
+
const edits2 = [
|
|
2292
|
+
...(0, import_lint9.physicalDeclarations)(file, ctx).flatMap((d) => renameEdits(file, d)),
|
|
2293
|
+
...live.flatMap((d) => shorthandEdits(file, d))
|
|
2294
|
+
];
|
|
2295
|
+
const flags = live.flatMap((d) => noFormFlag(file, d));
|
|
2296
|
+
const quiet = ctx.physicalQuiet?.(file);
|
|
2297
|
+
const jsx = quiet && quiet !== "flipped" ? { edits: [], flags: [] } : jsxStyleResult(file);
|
|
2298
|
+
return { edits: [...edits2, ...jsx.edits], flags: [...flags, ...jsx.flags].sort((a, b) => a.line - b.line) };
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
// src/transforms/formats.ts
|
|
2302
|
+
var SKIP5 = /* @__PURE__ */ new Set(["loc", "leadingComments", "trailingComments", "innerComments", "extra"]);
|
|
2303
|
+
var INTL = /* @__PURE__ */ new Set(["NumberFormat", "DateTimeFormat"]);
|
|
2304
|
+
var TO_LOCALE = /* @__PURE__ */ new Set(["toLocaleString", "toLocaleDateString", "toLocaleTimeString"]);
|
|
2305
|
+
var ENGLISH = /^en(-[A-Za-z0-9]+)*$/i;
|
|
2306
|
+
var DATE_NAME = /Date|Calendar|Picker/;
|
|
2307
|
+
var PICKERS = [
|
|
2308
|
+
[/^react-day-picker$/, null],
|
|
2309
|
+
[/^react-datepicker$/, null],
|
|
2310
|
+
[/^react-calendar$/, null],
|
|
2311
|
+
[/^react-date-range$/, null],
|
|
2312
|
+
[/^react-big-calendar$/, null],
|
|
2313
|
+
[/^react-flatpickr$/, null],
|
|
2314
|
+
[/^@fullcalendar\/react$/, null],
|
|
2315
|
+
[/^primereact\/calendar$/, null],
|
|
2316
|
+
[/^@mui\/x-date-pickers(-pro)?(\/.*)?$/, DATE_NAME],
|
|
2317
|
+
[/^@mantine\/dates$/, DATE_NAME],
|
|
2318
|
+
[/^@ark-ui\/react(\/.*)?$/, /DatePicker/],
|
|
2319
|
+
[/^(@heroui|@nextui-org)\/(react|date-picker|calendar)$/, DATE_NAME],
|
|
2320
|
+
[/^antd$/, /^(DatePicker|Calendar)$/]
|
|
2321
|
+
];
|
|
2322
|
+
var idName2 = (node) => node?.type === "Identifier" ? node.name : void 0;
|
|
2323
|
+
var statementsOf = (fn) => fn.body.type === "BlockStatement" ? fn.body.body ?? [] : [];
|
|
2324
|
+
var callOf = (d) => {
|
|
2325
|
+
const init = d?.init;
|
|
2326
|
+
return init?.type === "AwaitExpression" ? init.argument : init ?? void 0;
|
|
2327
|
+
};
|
|
2328
|
+
function isOwnLocale(statement, lib) {
|
|
2329
|
+
const [d, extra] = statement.declarations ?? [];
|
|
2330
|
+
const call = callOf(d);
|
|
2331
|
+
if (statement.type !== "VariableDeclaration" || extra || call?.type !== "CallExpression" || (call.arguments ?? []).length) return false;
|
|
2332
|
+
const callee = idName2(call.callee);
|
|
2333
|
+
const id = d.id;
|
|
2334
|
+
if (lib === "react-i18next") {
|
|
2335
|
+
const props = id.type === "ObjectPattern" ? id.properties ?? [] : [];
|
|
2336
|
+
return callee === "useTranslation" && props.length === 1 && idName2(props[0].value) === "i18n";
|
|
2337
|
+
}
|
|
2338
|
+
return idName2(id) === "locale" && (callee === "useLocale" || callee === "getLocale");
|
|
2339
|
+
}
|
|
2340
|
+
var bindingName = (lib) => lib === "react-i18next" ? "i18n" : "locale";
|
|
2341
|
+
var localeExpression = (lib) => lib === "react-i18next" ? "i18n.language" : "locale";
|
|
2342
|
+
function isFormatCall(call) {
|
|
2343
|
+
const callee = call.callee;
|
|
2344
|
+
if (callee.type !== "MemberExpression" && callee.type !== "OptionalMemberExpression") return false;
|
|
2345
|
+
if (callee.computed) return false;
|
|
2346
|
+
const property = idName2(callee.property);
|
|
2347
|
+
const isIntl = idName2(callee.object) === "Intl" && INTL.has(property ?? "");
|
|
2348
|
+
const isToLocale = call.type !== "NewExpression" && TO_LOCALE.has(property ?? "");
|
|
2349
|
+
return isIntl || isToLocale;
|
|
2350
|
+
}
|
|
2351
|
+
function localeArgument(call) {
|
|
2352
|
+
if (!isFormatCall(call)) return null;
|
|
2353
|
+
const arg = (call.arguments ?? [])[0];
|
|
2354
|
+
return arg?.type === "StringLiteral" && ENGLISH.test(String(arg.value)) ? arg : null;
|
|
2355
|
+
}
|
|
2356
|
+
function usesBrowserLocale(call) {
|
|
2357
|
+
if (!isFormatCall(call)) return false;
|
|
2358
|
+
const arg = (call.arguments ?? [])[0];
|
|
2359
|
+
return !arg || idName2(arg) === "undefined";
|
|
2360
|
+
}
|
|
2361
|
+
function flag2(ctx, offset, message, suggestion) {
|
|
2362
|
+
ctx.flags.push({ file: ctx.file.path, line: lineOf(ctx.file.text, offset), kind: "format", message, suggestion });
|
|
2363
|
+
}
|
|
2364
|
+
var helperName = (helper) => helper.replace(/\.(tsx?|jsx?)$/, "");
|
|
2365
|
+
function clashes(ctx, ancestors, fn) {
|
|
2366
|
+
const name = bindingName(ctx.lib);
|
|
2367
|
+
const from = ancestors.indexOf(fn);
|
|
2368
|
+
return ancestors.slice(from).some((node) => {
|
|
2369
|
+
if (node === fn.body && statementsOf(fn).some((s) => isOwnLocale(s, ctx.lib))) return false;
|
|
2370
|
+
return scopeNames(node).includes(name);
|
|
2371
|
+
});
|
|
2372
|
+
}
|
|
2373
|
+
function visitLocale(arg, ctx, ancestors) {
|
|
2374
|
+
const start2 = ctx.base + arg.start;
|
|
2375
|
+
const value = String(arg.value);
|
|
2376
|
+
const fn = componentOf(ancestors);
|
|
2377
|
+
const hook = fn ? statementsOf(fn).find(isOwnHook) : void 0;
|
|
2378
|
+
if (!fn || !hook) {
|
|
2379
|
+
flag2(
|
|
2380
|
+
ctx,
|
|
2381
|
+
start2,
|
|
2382
|
+
`The locale '${value}' is fixed here, so Arabic pages show English number and date formats.`,
|
|
2383
|
+
`Pass the active locale through dkIntlLocale from ${helperName(ctx.helper)}, or move this into a component that has the translation hook.`
|
|
2384
|
+
);
|
|
2385
|
+
return;
|
|
2386
|
+
}
|
|
2387
|
+
if (clashes(ctx, ancestors, fn)) {
|
|
2388
|
+
flag2(
|
|
2389
|
+
ctx,
|
|
2390
|
+
start2,
|
|
2391
|
+
`The locale '${value}' was left as it is: a variable named ${bindingName(ctx.lib)} is already in scope.`,
|
|
2392
|
+
`Rename that variable, then run convert again, or pass dkIntlLocale(${localeExpression(ctx.lib)}) by hand.`
|
|
2393
|
+
);
|
|
2394
|
+
return;
|
|
2395
|
+
}
|
|
2396
|
+
ctx.edits.push({ start: start2, end: ctx.base + arg.end, text: `dkIntlLocale(${localeExpression(ctx.lib)})` });
|
|
2397
|
+
if (!ctx.hooked.has(fn)) ctx.hooked.set(fn, { fn, hook, own: statementsOf(fn).find((s) => isOwnLocale(s, ctx.lib)) ?? null });
|
|
2398
|
+
}
|
|
2399
|
+
function visitCurrency(property, ctx) {
|
|
2400
|
+
const key = property.key;
|
|
2401
|
+
const name = key.type === "Identifier" && !property.computed ? key.name : key.type === "StringLiteral" ? key.value : void 0;
|
|
2402
|
+
const value = property.value;
|
|
2403
|
+
if (name !== "currency" || value.type !== "StringLiteral" || value.value !== "USD") return;
|
|
2404
|
+
flag2(
|
|
2405
|
+
ctx,
|
|
2406
|
+
ctx.base + value.start,
|
|
2407
|
+
"Prices are formatted in US dollars.",
|
|
2408
|
+
"Offer AED for Gulf customers, with VAT shown on prices."
|
|
2409
|
+
);
|
|
2410
|
+
}
|
|
2411
|
+
function visitImport(declaration, ctx) {
|
|
2412
|
+
if (declaration.importKind === "type") return;
|
|
2413
|
+
const source = String(declaration.source.value);
|
|
2414
|
+
const match = PICKERS.find(([pattern]) => pattern.test(source));
|
|
2415
|
+
if (!match) return;
|
|
2416
|
+
const names = (declaration.specifiers ?? []).filter((s) => s.importKind !== "type").map((s) => s.local.name);
|
|
2417
|
+
const picked = match[1] ? names.filter((n) => match[1].test(n)) : names;
|
|
2418
|
+
if (!picked.length) return;
|
|
2419
|
+
flag2(
|
|
2420
|
+
ctx,
|
|
2421
|
+
ctx.base + declaration.start,
|
|
2422
|
+
`${picked.join(", ")} from ${source} is a date picker or calendar with its own week start and calendar system.`,
|
|
2423
|
+
"Start weeks on Saturday for Gulf users and offer Hijri dates beside Gregorian ones where dates matter."
|
|
2424
|
+
);
|
|
2425
|
+
}
|
|
2426
|
+
function walk2(node, ctx, ancestors) {
|
|
2427
|
+
if (Array.isArray(node)) return node.forEach((child) => walk2(child, ctx, ancestors));
|
|
2428
|
+
if (!node || typeof node !== "object") return;
|
|
2429
|
+
const n = node;
|
|
2430
|
+
if (n.type === "CallExpression" || n.type === "OptionalCallExpression" || n.type === "NewExpression") {
|
|
2431
|
+
const arg = localeArgument(n);
|
|
2432
|
+
if (arg) visitLocale(arg, ctx, ancestors);
|
|
2433
|
+
else if (usesBrowserLocale(n)) {
|
|
2434
|
+
flag2(
|
|
2435
|
+
ctx,
|
|
2436
|
+
ctx.base + n.start,
|
|
2437
|
+
"This formats in the browser's own locale, so an Arabic page can show an English date or number, such as 11/1/2026 for 1 November.",
|
|
2438
|
+
`Pass dkIntlLocale(${localeExpression(ctx.lib)}) from ${helperName(ctx.helper)} as the locale, so the format follows the page language.`
|
|
2439
|
+
);
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
if (n.type === "ObjectProperty") visitCurrency(n, ctx);
|
|
2443
|
+
if (n.type === "ImportDeclaration") visitImport(n, ctx);
|
|
2444
|
+
const next = typeof n.type === "string" ? [...ancestors, n] : ancestors;
|
|
2445
|
+
for (const [key, child] of Object.entries(n)) if (!SKIP5.has(key)) walk2(child, ctx, next);
|
|
2446
|
+
}
|
|
2447
|
+
var indentAt3 = (text, offset) => /^[ \t]*/.exec(text.slice(text.lastIndexOf("\n", offset - 1) + 1, offset))[0];
|
|
2448
|
+
function localeStatement(lib, async) {
|
|
2449
|
+
if (lib === "react-i18next") return { statement: "const { i18n } = useTranslation()", import: { name: "useTranslation", source: "react-i18next" } };
|
|
2450
|
+
return async ? { statement: "const locale = await getLocale()", import: { name: "getLocale", source: "next-intl/server" } } : { statement: "const locale = useLocale()", import: { name: "useLocale", source: "next-intl" } };
|
|
2451
|
+
}
|
|
2452
|
+
function bindingEdits(ctx, style) {
|
|
2453
|
+
const imports = [];
|
|
2454
|
+
for (const { fn, hook, own } of ctx.hooked.values()) {
|
|
2455
|
+
if (own) continue;
|
|
2456
|
+
const binding = localeStatement(ctx.lib, Boolean(fn.async));
|
|
2457
|
+
const end = ctx.base + hook.end;
|
|
2458
|
+
const indent = indentAt3(ctx.file.text, ctx.base + hook.start);
|
|
2459
|
+
ctx.edits.push({ start: end, end, text: `
|
|
2460
|
+
${indent}${binding.statement}${style.semi}` });
|
|
2461
|
+
imports.push(binding.import);
|
|
2462
|
+
}
|
|
2463
|
+
return imports;
|
|
2464
|
+
}
|
|
2465
|
+
function formatEdits(file, lib, helper) {
|
|
2466
|
+
const edits2 = [];
|
|
2467
|
+
const flags = [];
|
|
2468
|
+
for (const block of file.js) {
|
|
2469
|
+
const program = block.ast.program;
|
|
2470
|
+
const ctx = { file, base: block.offset, lib, helper, edits: edits2, flags, hooked: /* @__PURE__ */ new Map() };
|
|
2471
|
+
const before = edits2.length;
|
|
2472
|
+
walk2(program, ctx, []);
|
|
2473
|
+
if (edits2.length === before) continue;
|
|
2474
|
+
const style = styleOf(program, file.text, ctx.base);
|
|
2475
|
+
const imports = bindingEdits(ctx, style);
|
|
2476
|
+
const helperImport = { name: "dkIntlLocale", source: specifierTo(file.path, helper) };
|
|
2477
|
+
edits2.push(...addImports(program, [...imports, helperImport], ctx.base, style));
|
|
2478
|
+
}
|
|
2479
|
+
return { edits: edits2, flags };
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
// src/transforms/markup.ts
|
|
2483
|
+
var SKIP6 = /* @__PURE__ */ new Set(["loc", "leadingComments", "trailingComments", "innerComments", "extra"]);
|
|
2484
|
+
var ICON_SOURCES = ["lucide-react", "@radix-ui/react-icons", "@tabler/icons-react", "@phosphor-icons/react"];
|
|
2485
|
+
var ICON_PREFIXES = ["@heroicons/react/", "react-icons/"];
|
|
2486
|
+
var MIRROR_ICON = /(Arrow|Chevron|Chevrons|Caret|Angle|Triangle)(Left|Right)|ArrowBack|ArrowForward|Undo|Redo|Reply|Forward|Send|LogIn|LogOut|SignIn|SignOut|Indent|Outdent/;
|
|
2487
|
+
var NEVER_MIRROR = /Play|Pause|FastForward|Rewind|Skip|Clock|Timer|Check|Music|Logo|Brand|Github|Twitter|X$/;
|
|
2488
|
+
var NUMBER_NAME = /(price|amount|total|subtotal|cost|fee|balance|phone|mobile|tel|qty|quantity|count)$/i;
|
|
2489
|
+
var NUMBER_CALL = /^(toLocaleString|toFixed|format.*)$/;
|
|
2490
|
+
var DIRECTION_LABEL = /\b(back|prev|previous|next|forward)\b/i;
|
|
2491
|
+
var LTR_TYPES = /* @__PURE__ */ new Set(["tel", "email", "url"]);
|
|
2492
|
+
var isIconSource = (source) => ICON_SOURCES.includes(source) || ICON_PREFIXES.some((p) => source.startsWith(p));
|
|
2493
|
+
function iconImports(program) {
|
|
2494
|
+
const found = /* @__PURE__ */ new Map();
|
|
2495
|
+
for (const statement of program.body ?? []) {
|
|
2496
|
+
if (statement.type !== "ImportDeclaration" || !isIconSource(String(statement.source.value))) continue;
|
|
2497
|
+
for (const spec of statement.specifiers ?? []) {
|
|
2498
|
+
const local = spec.local.name;
|
|
2499
|
+
const imported = spec.type === "ImportSpecifier" ? spec.imported.name ?? spec.imported.value : local;
|
|
2500
|
+
found.set(local, String(imported));
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
return found;
|
|
2504
|
+
}
|
|
2505
|
+
var isMirrored = (imported) => MIRROR_ICON.test(imported) && !NEVER_MIRROR.test(imported);
|
|
2506
|
+
var tagName = (opening) => {
|
|
2507
|
+
const name = opening.name;
|
|
2508
|
+
return name.type === "JSXIdentifier" ? name.name : null;
|
|
2509
|
+
};
|
|
2510
|
+
var attributes = (opening) => (opening.attributes ?? []).filter((a) => a.type === "JSXAttribute");
|
|
2511
|
+
var attributeNamed = (opening, name) => attributes(opening).find((a) => a.name.name === name);
|
|
2512
|
+
function literalValue(attribute) {
|
|
2513
|
+
const value = attribute?.value;
|
|
2514
|
+
if (value?.type === "StringLiteral") return String(value.value);
|
|
2515
|
+
const inner = value?.type === "JSXExpressionContainer" ? value.expression : void 0;
|
|
2516
|
+
return inner?.type === "StringLiteral" ? String(inner.value) : null;
|
|
2517
|
+
}
|
|
2518
|
+
function lastName(expression) {
|
|
2519
|
+
if (expression.type === "Identifier") return expression.name;
|
|
2520
|
+
if (expression.type === "MemberExpression" || expression.type === "OptionalMemberExpression") {
|
|
2521
|
+
const property = expression.property;
|
|
2522
|
+
return !expression.computed && property.type === "Identifier" ? property.name : null;
|
|
2523
|
+
}
|
|
2524
|
+
return null;
|
|
2525
|
+
}
|
|
2526
|
+
function isNumberExpression(expression) {
|
|
2527
|
+
if (expression.type === "CallExpression" || expression.type === "OptionalCallExpression") {
|
|
2528
|
+
const name2 = lastName(expression.callee);
|
|
2529
|
+
return name2 !== null && NUMBER_CALL.test(name2);
|
|
2530
|
+
}
|
|
2531
|
+
const name = lastName(expression);
|
|
2532
|
+
return name !== null && NUMBER_NAME.test(name);
|
|
2533
|
+
}
|
|
2534
|
+
function labelOf(element) {
|
|
2535
|
+
const opening = element.openingElement;
|
|
2536
|
+
const own = [literalValue(attributeNamed(opening, "aria-label")), literalValue(attributeNamed(opening, "title"))];
|
|
2537
|
+
const text = (element.children ?? []).filter((c) => c.type === "JSXText").map((c) => String(c.value));
|
|
2538
|
+
return [...own, ...text].filter(Boolean).join(" ");
|
|
2539
|
+
}
|
|
2540
|
+
function alreadyMirrored(opening, ctx) {
|
|
2541
|
+
if (attributeNamed(opening, "data-dk-mirror")) return true;
|
|
2542
|
+
return ["className", "class"].some((name) => {
|
|
2543
|
+
const attribute = attributeNamed(opening, name);
|
|
2544
|
+
return attribute !== void 0 && /(?:^|[\s"'`{])rtl:/.test(ctx.file.text.slice(ctx.base + attribute.start, ctx.base + attribute.end));
|
|
2545
|
+
});
|
|
2546
|
+
}
|
|
2547
|
+
function visitOpening(opening, ctx, ancestors) {
|
|
2548
|
+
const name = tagName(opening);
|
|
2549
|
+
if (!name) return;
|
|
2550
|
+
const after = ctx.base + opening.name.end;
|
|
2551
|
+
if (ctx.icons.has(name) && isMirrored(ctx.icons.get(name)) && !alreadyMirrored(opening, ctx)) {
|
|
2552
|
+
ctx.edits.push({ start: after, end: after, text: ' data-dk-mirror=""' });
|
|
2553
|
+
}
|
|
2554
|
+
if (name === "input" && LTR_TYPES.has(literalValue(attributeNamed(opening, "type")) ?? "") && !attributeNamed(opening, "dir")) {
|
|
2555
|
+
ctx.edits.push({ start: after, end: after, text: ' dir="ltr"' });
|
|
2556
|
+
}
|
|
2557
|
+
if (name === "svg") flagSvg(opening, ctx, ancestors);
|
|
2558
|
+
}
|
|
2559
|
+
function flagSvg(opening, ctx, ancestors) {
|
|
2560
|
+
if (alreadyMirrored(opening, ctx)) return;
|
|
2561
|
+
const control = [...ancestors].reverse().find((a) => a.type === "JSXElement" && ["button", "a"].includes(tagName(a.openingElement) ?? ""));
|
|
2562
|
+
if (!control || !DIRECTION_LABEL.test(labelOf(control))) return;
|
|
2563
|
+
ctx.flags.push({
|
|
2564
|
+
file: ctx.file.path,
|
|
2565
|
+
line: lineOf(ctx.file.text, ctx.base + opening.start),
|
|
2566
|
+
kind: "inline-svg-icon",
|
|
2567
|
+
message: "An inline svg sits in a back or next control and may point the wrong way in Arabic.",
|
|
2568
|
+
suggestion: 'Add data-dk-mirror="" to the svg, or rtl:-scale-x-100 to its class.'
|
|
2569
|
+
});
|
|
2570
|
+
}
|
|
2571
|
+
var isolates = (element) => {
|
|
2572
|
+
const opening = element.openingElement;
|
|
2573
|
+
return tagName(opening) === "bdi" || attributeNamed(opening, "dir") !== void 0;
|
|
2574
|
+
};
|
|
2575
|
+
function visitExpression(container, ancestors, ctx) {
|
|
2576
|
+
const parent = ancestors[ancestors.length - 1];
|
|
2577
|
+
if (parent.type !== "JSXElement" && parent.type !== "JSXFragment") return;
|
|
2578
|
+
if (ancestors.some((a) => a.type === "JSXElement" && isolates(a))) return;
|
|
2579
|
+
const expression = container.expression;
|
|
2580
|
+
if (!isNumberExpression(expression)) return;
|
|
2581
|
+
ctx.edits.push({ start: ctx.base + container.start, end: ctx.base + container.start, text: "<bdi>" });
|
|
2582
|
+
ctx.edits.push({ start: ctx.base + container.end, end: ctx.base + container.end, text: "</bdi>" });
|
|
2583
|
+
}
|
|
2584
|
+
function walk3(node, ctx, ancestors) {
|
|
2585
|
+
if (Array.isArray(node)) return node.forEach((child) => walk3(child, ctx, ancestors));
|
|
2586
|
+
if (!node || typeof node !== "object") return;
|
|
2587
|
+
const n = node;
|
|
2588
|
+
if (n.type === "JSXOpeningElement") visitOpening(n, ctx, ancestors);
|
|
2589
|
+
if (n.type === "JSXExpressionContainer" && ancestors.length) visitExpression(n, ancestors, ctx);
|
|
2590
|
+
const next = typeof n.type === "string" ? [...ancestors, n] : ancestors;
|
|
2591
|
+
for (const [key, child] of Object.entries(n)) if (!SKIP6.has(key)) walk3(child, ctx, next);
|
|
2592
|
+
}
|
|
2593
|
+
function markupEdits(file) {
|
|
2594
|
+
const edits2 = [];
|
|
2595
|
+
const flags = [];
|
|
2596
|
+
for (const block of file.js) {
|
|
2597
|
+
const program = block.ast.program;
|
|
2598
|
+
walk3(program, { file, base: block.offset, icons: iconImports(program), edits: edits2, flags }, []);
|
|
2599
|
+
}
|
|
2600
|
+
return { edits: edits2, flags };
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
// src/transforms/tailwind.ts
|
|
2604
|
+
var import_lint10 = require("@dualkit/lint");
|
|
2605
|
+
var FLOOR = "3.3.0";
|
|
2606
|
+
function parseVersion(text) {
|
|
2607
|
+
const match = text ? /(\d+)\.(\d+)\.(\d+)/.exec(text) : null;
|
|
2608
|
+
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
2609
|
+
}
|
|
2610
|
+
var compareVersions = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
|
|
2611
|
+
var tokensOf = (fragments) => fragments.flatMap((fragment) => [...fragment.text.matchAll(/\S+/g)].map((m) => ({ text: m[0], at: fragment.offset + m.index, parsed: (0, import_lint10.parseClass)(m[0]), fragment })));
|
|
2612
|
+
var rebuild = (p, core) => `${p.variants.map((v) => `${v}:`).join("")}${p.important[0]}${p.negative}${core}${p.important[1]}`;
|
|
2613
|
+
function pickEntry(matches, version) {
|
|
2614
|
+
const usable = matches.map((entry) => ({ entry, since: parseVersion(entry.since) })).filter(({ since }) => compareVersions(since, version) <= 0).sort((a, b) => compareVersions(b.since, a.since));
|
|
2615
|
+
return usable[0]?.entry ?? null;
|
|
2616
|
+
}
|
|
2617
|
+
function physicalEdits(fragments, version) {
|
|
2618
|
+
return (0, import_lint10.physicalTokens)(fragments).flatMap(({ token, at: at2, parsed, matches }) => {
|
|
2619
|
+
const replaced = pickEntry(matches, version)?.replace(parsed.core);
|
|
2620
|
+
return replaced ? [{ start: at2, end: at2 + token.length, text: rebuild(parsed, replaced) }] : [];
|
|
2621
|
+
});
|
|
2622
|
+
}
|
|
2623
|
+
var KEPT = (t) => t.fragment.deliberate !== true && t.fragment.sided !== true && !(0, import_lint10.sideScoped)(t.parsed.variants);
|
|
2624
|
+
var REVERSIBLE = /^(space|divide)-x(?:-|$)/;
|
|
2625
|
+
var REVERSE = /^(space|divide)-x-reverse$/;
|
|
2626
|
+
function reverseEdits(fragments) {
|
|
2627
|
+
const tokens = tokensOf(fragments);
|
|
2628
|
+
const reversed = new Set(tokens.filter((t) => REVERSE.test(t.parsed.core)).map((t) => REVERSE.exec(t.parsed.core)[1]));
|
|
2629
|
+
const edits2 = [];
|
|
2630
|
+
for (const t of tokens) {
|
|
2631
|
+
const kind = REVERSIBLE.exec(t.parsed.core)?.[1];
|
|
2632
|
+
if (!kind || REVERSE.test(t.parsed.core) || !KEPT(t) || reversed.has(kind)) continue;
|
|
2633
|
+
reversed.add(kind);
|
|
2634
|
+
const end = t.at + t.text.length;
|
|
2635
|
+
edits2.push({ start: end, end, text: ` rtl:${kind}-x-reverse` });
|
|
2636
|
+
}
|
|
2637
|
+
return edits2;
|
|
2638
|
+
}
|
|
2639
|
+
var TRANSLATE_CENTRE = /^translate-x-(?:1\/2|\[-?50%\])$/;
|
|
2640
|
+
var CENTRE_OFFSET = /^(?:left|right|start|end)-(?:1\/2|\[50%\])$/;
|
|
2641
|
+
var CORNERS = "(?:left|right|(?:top|bottom)-(?:left|right)|(?:left|right)-(?:top|bottom))";
|
|
2642
|
+
var CORNER_SHORT = "(?:l|r|tl|tr|bl|br)";
|
|
2643
|
+
function adviceFor(core, negative, tokens) {
|
|
2644
|
+
if (/^translate-x-/.test(core)) {
|
|
2645
|
+
if (TRANSLATE_CENTRE.test(core) && tokens.some((t) => CENTRE_OFFSET.test(t.parsed.core))) return null;
|
|
2646
|
+
return `Tailwind has no logical translate. Keep it for centring, or flip it for Arabic with an rtl: variant such as rtl:${negative ? "" : "-"}${core}.`;
|
|
2647
|
+
}
|
|
2648
|
+
if (new RegExp(`^bg-(?:gradient|linear)-to-${CORNER_SHORT}$`).test(core)) return "A gradient direction stays physical. Add an rtl: variant with the opposite side, such as rtl:bg-gradient-to-l.";
|
|
2649
|
+
if (new RegExp(`^origin-${CORNERS}$`).test(core)) return "A transform origin stays physical. Add an rtl: variant with the opposite side.";
|
|
2650
|
+
if (new RegExp(`^(?:bg|object)-${CORNERS}$`).test(core)) return "A background or object position stays physical. Add an rtl: variant with the opposite side.";
|
|
2651
|
+
if (/^rotate-(?!x-|y-)/.test(core) && !/^rotate-(?:z-)?0$/.test(core)) return "A rotation turns the same way in Arabic. If it points an arrow or chevron, add an rtl: variant such as rtl:-rotate-45, or use rtl:scale-x-[-1] to mirror it.";
|
|
2652
|
+
if (/^skew-x-/.test(core)) return "A horizontal skew leans the same way in Arabic. Add an rtl: variant with the opposite sign if it should mirror.";
|
|
2653
|
+
return shadowAdvice(core);
|
|
2654
|
+
}
|
|
2655
|
+
var OFFSET = /^(-?\d*\.?\d+)(?:px|rem|em)?$/;
|
|
2656
|
+
function shadowAdvice(core) {
|
|
2657
|
+
const inner = /^shadow-\[(.+)\]$/.exec(core)?.[1];
|
|
2658
|
+
if (!inner) return null;
|
|
2659
|
+
const parts = inner.split("_").filter((p) => p !== "inset");
|
|
2660
|
+
const x = OFFSET.exec(parts[0] ?? "")?.[1];
|
|
2661
|
+
return x !== void 0 && Number(x) !== 0 ? "A shadow with a horizontal offset falls on the same side in Arabic. Add an rtl: variant with the offset negated." : null;
|
|
2662
|
+
}
|
|
2663
|
+
function formFlags(file, fragments) {
|
|
2664
|
+
const tokens = tokensOf(fragments);
|
|
2665
|
+
return tokens.filter(KEPT).flatMap((t) => {
|
|
2666
|
+
const suggestion = adviceFor(t.parsed.core, t.parsed.negative, tokens);
|
|
2667
|
+
if (!suggestion) return [];
|
|
2668
|
+
const message = `\`${t.text}\` has no logical form in Tailwind and stays physical in Arabic.`;
|
|
2669
|
+
return [{ file: file.path, line: lineOf(file.text, t.at), kind: "no-logical-form", message, suggestion }];
|
|
2670
|
+
});
|
|
2671
|
+
}
|
|
2672
|
+
var SKIP7 = /* @__PURE__ */ new Set(["loc", "leadingComments", "trailingComments", "innerComments", "extra"]);
|
|
2673
|
+
function* templatesIn(node) {
|
|
2674
|
+
if (Array.isArray(node)) for (const child of node) yield* templatesIn(child);
|
|
2675
|
+
if (!node || typeof node !== "object" || Array.isArray(node)) return;
|
|
2676
|
+
const n = node;
|
|
2677
|
+
if (n.type === "TemplateLiteral" && Array.isArray(n.expressions) && n.expressions.length > 0) yield n;
|
|
2678
|
+
for (const [key, child] of Object.entries(n)) if (!SKIP7.has(key)) yield* templatesIn(child);
|
|
2679
|
+
}
|
|
2680
|
+
function* classAttributes(node) {
|
|
2681
|
+
if (Array.isArray(node)) for (const child of node) yield* classAttributes(child);
|
|
2682
|
+
if (!node || typeof node !== "object" || Array.isArray(node)) return;
|
|
2683
|
+
const n = node;
|
|
2684
|
+
if (n.type === "JSXAttribute" && (n.name?.name === "className" || n.name?.name === "class")) yield n;
|
|
2685
|
+
else for (const [key, child] of Object.entries(n)) if (!SKIP7.has(key)) yield* classAttributes(child);
|
|
2686
|
+
}
|
|
2687
|
+
function fontNames(program) {
|
|
2688
|
+
const body = program.body ?? [];
|
|
2689
|
+
const loaders = new Set(body.filter((s) => s.type === "ImportDeclaration" && /^next\/font\//.test(String(s.source.value))).flatMap((s) => s.specifiers.map((spec) => String(spec.local.name))));
|
|
2690
|
+
const names = /* @__PURE__ */ new Set();
|
|
2691
|
+
for (const s of body) {
|
|
2692
|
+
const declaration = s.type === "ExportNamedDeclaration" ? s.declaration : s;
|
|
2693
|
+
if (declaration?.type !== "VariableDeclaration") continue;
|
|
2694
|
+
for (const d of declaration.declarations) {
|
|
2695
|
+
const init = d.init;
|
|
2696
|
+
const callee = init?.type === "CallExpression" ? init.callee : null;
|
|
2697
|
+
if (callee?.type === "Identifier" && loaders.has(String(callee.name)) && d.id.type === "Identifier") names.add(String(d.id.name));
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
return names;
|
|
2701
|
+
}
|
|
2702
|
+
var isFontClass = (fonts, e) => {
|
|
2703
|
+
const object = e.object;
|
|
2704
|
+
const property = e.property;
|
|
2705
|
+
return e.type === "MemberExpression" && !e.computed && object?.type === "Identifier" && fonts.has(String(object.name)) && (property?.name === "variable" || property?.name === "className");
|
|
2706
|
+
};
|
|
2707
|
+
function computedFlags(file) {
|
|
2708
|
+
const starts = /* @__PURE__ */ new Set();
|
|
2709
|
+
for (const block of file.js) {
|
|
2710
|
+
const fonts = fontNames(block.ast.program);
|
|
2711
|
+
for (const attr of classAttributes(block.ast.program)) {
|
|
2712
|
+
for (const template of templatesIn(attr.value)) {
|
|
2713
|
+
if (template.expressions.every((e) => isFontClass(fonts, e))) continue;
|
|
2714
|
+
if (template.start !== void 0) starts.add(block.offset + template.start);
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
}
|
|
2718
|
+
return [...starts].sort((a, b) => a - b).map((at2) => ({
|
|
2719
|
+
file: file.path,
|
|
2720
|
+
line: lineOf(file.text, at2),
|
|
2721
|
+
kind: "computed-class",
|
|
2722
|
+
message: "This className is built at run time, so convert cannot see every class in it.",
|
|
2723
|
+
suggestion: "Check it by hand for ml-, mr-, pl-, pr-, left-, right-, text-left and text-right, and use the logical classes."
|
|
2724
|
+
}));
|
|
2725
|
+
}
|
|
2726
|
+
function groups(fragments) {
|
|
2727
|
+
const byGroup = /* @__PURE__ */ new Map();
|
|
2728
|
+
for (const f of fragments) byGroup.set(f.group, [...byGroup.get(f.group) ?? [], f]);
|
|
2729
|
+
return [...byGroup.values()];
|
|
2730
|
+
}
|
|
2731
|
+
function tailwindEdits(file, version) {
|
|
2732
|
+
const parsed = parseVersion(version);
|
|
2733
|
+
const fragments = (0, import_lint10.classStrings)(file);
|
|
2734
|
+
if (!parsed) return { edits: [], flags: [] };
|
|
2735
|
+
if (compareVersions(parsed, parseVersion(FLOOR)) < 0) {
|
|
2736
|
+
const first = fragments[0];
|
|
2737
|
+
if (!first) return { edits: [], flags: [] };
|
|
2738
|
+
const message = `Tailwind ${version} has no logical classes such as ms-4 or text-start. Upgrade Tailwind to 3.3 or later.`;
|
|
2739
|
+
const suggestion = "Upgrade Tailwind to 3.3 or later, then run convert again.";
|
|
2740
|
+
return { edits: [], flags: [{ file: file.path, line: lineOf(file.text, first.offset), kind: "unsupported", message, suggestion }] };
|
|
2741
|
+
}
|
|
2742
|
+
const sources = groups(fragments);
|
|
2743
|
+
const edits2 = sources.flatMap((g) => [...physicalEdits(g, parsed), ...parsed[0] < 4 ? reverseEdits(g) : []]);
|
|
2744
|
+
const flags = [...sources.flatMap((g) => formFlags(file, g)), ...computedFlags(file)];
|
|
2745
|
+
return { edits: edits2, flags: flags.sort((a, b) => a.line - b.line) };
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
// src/passes.ts
|
|
2749
|
+
var PASSES = [
|
|
2750
|
+
{ name: "tailwind", run: (file, ctx) => tailwindEdits(file, ctx.tailwind) },
|
|
2751
|
+
{ name: "css", run: (file, ctx) => cssEdits(file, ctx.css) },
|
|
2752
|
+
{ name: "markup", run: (file) => markupEdits(file) },
|
|
2753
|
+
{ name: "strings", run: (file, ctx) => extractStrings(file, ctx.lib, ctx.taken) },
|
|
2754
|
+
{ name: "formats", run: (file, ctx) => formatEdits(file, ctx.lib, ctx.helper) }
|
|
2755
|
+
];
|
|
2756
|
+
var parse = (path, text, abs) => {
|
|
2757
|
+
const outcome = (0, import_lint11.parseSourceOutcome)(path, text, abs);
|
|
2758
|
+
return "file" in outcome ? outcome.file : outcome.error;
|
|
2759
|
+
};
|
|
2760
|
+
function runPasses(path, text, abs, ctx) {
|
|
2761
|
+
const first = parse(path, text, abs);
|
|
2762
|
+
if (typeof first === "string") return { kind: "skipped", reason: `parse error: ${first}` };
|
|
2763
|
+
let file = first;
|
|
2764
|
+
let edits2 = 0;
|
|
2765
|
+
const byKind = { tailwind: 0, css: 0, markup: 0, strings: 0, formats: 0 };
|
|
2766
|
+
const flags = [];
|
|
2767
|
+
const entries = [];
|
|
2768
|
+
const taken = new Set(ctx.taken);
|
|
2769
|
+
for (const pass of PASSES) {
|
|
2770
|
+
const result = pass.run(file, { ...ctx, taken });
|
|
2771
|
+
flags.push(...result.flags);
|
|
2772
|
+
for (const entry of result.entries ?? []) {
|
|
2773
|
+
entries.push(entry);
|
|
2774
|
+
taken.add(entry.key);
|
|
2775
|
+
}
|
|
2776
|
+
if (result.edits.length === 0) continue;
|
|
2777
|
+
let next;
|
|
2778
|
+
try {
|
|
2779
|
+
next = applyEdits(file.text, result.edits);
|
|
2780
|
+
} catch (error) {
|
|
2781
|
+
if (!(error instanceof OverlapError)) throw error;
|
|
2782
|
+
return { kind: "skipped", reason: `the ${pass.name} pass made overlapping edits, so convert left the file as it was` };
|
|
2783
|
+
}
|
|
2784
|
+
const reparsed = parse(path, next, abs);
|
|
2785
|
+
if (typeof reparsed === "string") return { kind: "skipped", reason: `the ${pass.name} pass made code that does not parse, so convert left the file as it was` };
|
|
2786
|
+
file = reparsed;
|
|
2787
|
+
edits2 += result.edits.length;
|
|
2788
|
+
byKind[pass.name] += result.edits.length;
|
|
2789
|
+
}
|
|
2790
|
+
return { kind: "done", text: file.text, edits: edits2, byKind, flags, entries };
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
// src/rollback.ts
|
|
2794
|
+
var import_node_fs9 = require("fs");
|
|
2795
|
+
var import_node_path16 = require("path");
|
|
2796
|
+
var ConvertFailedError = class extends Error {
|
|
2797
|
+
constructor(message, cause) {
|
|
2798
|
+
super(message);
|
|
2799
|
+
this.cause = cause;
|
|
2800
|
+
this.name = "ConvertFailedError";
|
|
2801
|
+
}
|
|
2802
|
+
cause;
|
|
2803
|
+
};
|
|
2804
|
+
var reasonOf = (error) => (error instanceof Error ? error.message : String(error)).replace(/\s*\n\s*/g, " ").trim().replace(/\.$/, "");
|
|
2805
|
+
function manualSteps(target) {
|
|
2806
|
+
const commands = ["git reset --hard", ...target.madeBranch ? [`git checkout ${target.base}`, `git branch -D ${target.branch}`] : []];
|
|
2807
|
+
const files = target.created.length ? `, then delete these new files: ${target.created.join(", ")}` : "";
|
|
2808
|
+
return `${commands.join(", then ")}${files}`;
|
|
2809
|
+
}
|
|
2810
|
+
async function rollBack(target, step, error) {
|
|
2811
|
+
const failed = `convert failed at ${step}: ${reasonOf(error)}.`;
|
|
2812
|
+
try {
|
|
2813
|
+
await resetHard(target.root);
|
|
2814
|
+
for (const path of [...target.created].reverse()) (0, import_node_fs9.rmSync)((0, import_node_path16.join)(target.root, path), { recursive: true, force: true });
|
|
2815
|
+
if (target.madeBranch) {
|
|
2816
|
+
await checkout(target.root, target.base);
|
|
2817
|
+
await deleteBranch(target.root, target.branch);
|
|
2818
|
+
}
|
|
2819
|
+
} catch (undo2) {
|
|
2820
|
+
throw new ConvertFailedError(`${failed} Putting the repo back also failed (${reasonOf(undo2)}), so run these yourself: ${manualSteps(target)}.`, error);
|
|
2821
|
+
}
|
|
2822
|
+
const where = target.madeBranch ? target.base : target.branch;
|
|
2823
|
+
throw new ConvertFailedError(`${failed} Your repo is back on ${where} as it was.`, error);
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
// src/write-all.ts
|
|
2827
|
+
var LOCKFILES2 = {
|
|
2828
|
+
npm: ["package-lock.json"],
|
|
2829
|
+
pnpm: ["pnpm-lock.yaml"],
|
|
2830
|
+
yarn: ["yarn.lock"],
|
|
2831
|
+
bun: ["bun.lock", "bun.lockb"]
|
|
2832
|
+
};
|
|
2833
|
+
async function install(project, io) {
|
|
2834
|
+
const cmd = `${project.packageManager} install`;
|
|
2835
|
+
io.log(`Running ${cmd}.`);
|
|
2836
|
+
const result = await io.run(cmd, project.root);
|
|
2837
|
+
if (result.code === 0) return [];
|
|
2838
|
+
const message = `${cmd} exited with code ${result.code}, so the new packages may not be installed.`;
|
|
2839
|
+
return [{ file: "package.json", line: 1, kind: "unsupported", message, suggestion: `Run ${cmd} yourself and fix what it reports.` }];
|
|
2840
|
+
}
|
|
2841
|
+
async function installed(disk, project, io) {
|
|
2842
|
+
const lockfiles = LOCKFILES2[project.packageManager];
|
|
2843
|
+
const before = new Set(lockfiles.filter((f) => readText(disk.root, f) !== null));
|
|
2844
|
+
const flags = await install(project, io);
|
|
2845
|
+
disk.created.push(...lockfiles.filter((f) => !before.has(f) && readText(disk.root, f) !== null));
|
|
2846
|
+
if (io.signal?.aborted) throw new Error("the run was interrupted");
|
|
2847
|
+
return flags;
|
|
2848
|
+
}
|
|
2849
|
+
async function writeAll(input) {
|
|
2850
|
+
const { disk, project, options, io, plan, step } = input;
|
|
2851
|
+
const moved = new Map(plan.moves);
|
|
2852
|
+
const touched = [];
|
|
2853
|
+
for (const [path, text] of input.texts) if (writeText(disk, path, text)) touched.push(moved.get(path) ?? path);
|
|
2854
|
+
step.name = "wiring locales";
|
|
2855
|
+
const wired = await applyPlan(disk, plan);
|
|
2856
|
+
step.name = "adding the stylesheet and messages";
|
|
2857
|
+
const own = addOwnFiles(disk, project, input.ledger, moved.get(project.entry) ?? project.entry);
|
|
2858
|
+
step.name = "writing the strings ledger";
|
|
2859
|
+
if (saveLedger(disk, input.ledger)) touched.push(LEDGER_FILE);
|
|
2860
|
+
touched.push(...wired.touched, ...own.touched);
|
|
2861
|
+
step.name = "saving your answers";
|
|
2862
|
+
const configFile = options.configFile ?? CONFIG_FILE;
|
|
2863
|
+
if (options.configAnswers && writeConfigAnswers(disk, options.configAnswers, configFile)) touched.push(configFile);
|
|
2864
|
+
step.name = "updating package.json";
|
|
2865
|
+
const added = addDependencies(disk, plan.deps);
|
|
2866
|
+
if (added) touched.push("package.json");
|
|
2867
|
+
step.name = "installing packages";
|
|
2868
|
+
const flags = options.install && added ? await installed(disk, project, io) : [];
|
|
2869
|
+
if (options.install && added) touched.push(...LOCKFILES2[project.packageManager].filter((f) => readText(disk.root, f) !== null));
|
|
2870
|
+
step.name = "updating .gitignore";
|
|
2871
|
+
if (ignoreStateDir(disk)) touched.push(".gitignore");
|
|
2872
|
+
step.name = "committing";
|
|
2873
|
+
await stage(disk.root, [...new Set(touched)]);
|
|
2874
|
+
const changed2 = await stagedFiles(disk.root);
|
|
2875
|
+
const edits2 = wired.edits + own.edits;
|
|
2876
|
+
if (changed2.length) {
|
|
2877
|
+
step.name = "saving the state";
|
|
2878
|
+
const { state, summary } = input.record({ changed: changed2, edits: edits2, writes: wired.writes, wired: wired.perFile, flags });
|
|
2879
|
+
writeText(disk, STATE_FILE, stateText(state));
|
|
2880
|
+
writeText(disk, SUMMARY_FILE, summaryText(summary));
|
|
2881
|
+
await stage(disk.root, [STATE_FILE, SUMMARY_FILE]);
|
|
2882
|
+
step.name = "committing";
|
|
2883
|
+
await commit(disk.root, `dualkit: convert to ${options.mode}`);
|
|
2884
|
+
}
|
|
2885
|
+
return { changed: changed2, edits: edits2, writes: wired.writes, wired: wired.perFile, flags };
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2888
|
+
// src/convert.ts
|
|
2889
|
+
var BOM = "\uFEFF";
|
|
2890
|
+
var branchFor = (mode) => `dualkit/${mode}`;
|
|
2891
|
+
var problems = (flags) => flags.filter((f) => f.kind !== "info");
|
|
2892
|
+
function transformAll(project, generated, taken) {
|
|
2893
|
+
const { root } = project;
|
|
2894
|
+
const walked = (0, import_lint12.walk)(["."], root);
|
|
2895
|
+
const used = new Set(taken);
|
|
2896
|
+
const ctx = {
|
|
2897
|
+
tailwind: project.tailwind,
|
|
2898
|
+
lib: project.framework === "next-app" ? "next-intl" : "react-i18next",
|
|
2899
|
+
helper: localeHelperPath(project),
|
|
2900
|
+
css: physicalContext(root)
|
|
2901
|
+
};
|
|
2902
|
+
const out = { texts: /* @__PURE__ */ new Map(), edits: 0, byKind: { tailwind: 0, css: 0, markup: 0, strings: 0, formats: 0 }, perFile: /* @__PURE__ */ new Map(), flags: [], entries: [], skipped: [...walked.skipped] };
|
|
2903
|
+
for (const path of walked.files) {
|
|
2904
|
+
if (generated.has(path)) continue;
|
|
2905
|
+
const raw = readText(root, path);
|
|
2906
|
+
if (raw === null) {
|
|
2907
|
+
out.skipped.push({ file: path, reason: "unreadable" });
|
|
2908
|
+
continue;
|
|
2909
|
+
}
|
|
2910
|
+
if ((0, import_lint12.isMinified)(raw)) {
|
|
2911
|
+
out.skipped.push({ file: path, reason: "minified" });
|
|
2912
|
+
continue;
|
|
2913
|
+
}
|
|
2914
|
+
const result = runPasses(path, raw, (0, import_node_path17.join)(root, path), { ...ctx, taken: used });
|
|
2915
|
+
if (result.kind === "skipped") {
|
|
2916
|
+
out.skipped.push({ file: path, reason: result.reason });
|
|
2917
|
+
continue;
|
|
2918
|
+
}
|
|
2919
|
+
out.flags.push(...result.flags);
|
|
2920
|
+
out.entries.push(...result.entries);
|
|
2921
|
+
for (const entry of result.entries) used.add(entry.key);
|
|
2922
|
+
if (result.edits === 0) continue;
|
|
2923
|
+
out.edits += result.edits;
|
|
2924
|
+
for (const kind of Object.keys(out.byKind)) out.byKind[kind] += result.byKind[kind];
|
|
2925
|
+
out.perFile.set(path, result.edits);
|
|
2926
|
+
out.texts.set(path, raw.startsWith(BOM) ? BOM + result.text : result.text);
|
|
2927
|
+
}
|
|
2928
|
+
return out;
|
|
2929
|
+
}
|
|
2930
|
+
function changedFiles(perFile, moved, wiring) {
|
|
2931
|
+
const total = /* @__PURE__ */ new Map();
|
|
2932
|
+
const add = (file, n) => {
|
|
2933
|
+
if (n > 0) total.set(file, (total.get(file) ?? 0) + n);
|
|
2934
|
+
};
|
|
2935
|
+
for (const [path, n] of perFile) add(moved.get(path) ?? path, n);
|
|
2936
|
+
for (const [path, n] of Object.entries(wiring)) add(path, n);
|
|
2937
|
+
return [...total].map(([file, edits2]) => ({ file, edits: edits2 })).sort((a, b) => a.file < b.file ? -1 : a.file > b.file ? 1 : 0);
|
|
2938
|
+
}
|
|
2939
|
+
async function planFor(project, mode, texts) {
|
|
2940
|
+
const files = (rel) => texts.get(rel) ?? readText(project.root, rel);
|
|
2941
|
+
if (project.framework === "vite-react") return wireReactI18next(project, mode, files);
|
|
2942
|
+
return wireNextIntl(project, mode, files, await trackedFiles(project.root));
|
|
2943
|
+
}
|
|
2944
|
+
function finalFlags(flags, moved) {
|
|
2945
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2946
|
+
return flags.map((f) => ({ ...f, file: moved.get(f.file) ?? f.file })).filter((f) => {
|
|
2947
|
+
const key = `${f.file}\0${f.line}\0${f.kind}\0${f.message}`;
|
|
2948
|
+
return !seen.has(key) && Boolean(seen.add(key));
|
|
2949
|
+
});
|
|
2950
|
+
}
|
|
2951
|
+
function summarise({ branch, done, plan, moved, strings }, written) {
|
|
2952
|
+
const byKind = { ...done.byKind, wiring: written.edits + written.writes };
|
|
2953
|
+
const flags = finalFlags([...done.flags, ...plan.flags, ...written.flags], moved);
|
|
2954
|
+
return { branch, filesChanged: written.changed.length, edits: done.edits + written.edits, byKind, changed: changedFiles(done.perFile, moved, written.wired), flags, strings, skipped: done.skipped };
|
|
2955
|
+
}
|
|
2956
|
+
async function start(options) {
|
|
2957
|
+
await assertCleanRepo(options.root);
|
|
2958
|
+
const branch = branchFor(options.mode);
|
|
2959
|
+
const base = await currentBranch(options.root);
|
|
2960
|
+
const state = base === branch ? readState(options.root) : null;
|
|
2961
|
+
const rerun = base === branch && (state?.phase === "converted" || state?.phase === "finished") && state.mode === options.mode;
|
|
2962
|
+
if (base === branch && !state) {
|
|
2963
|
+
throw new RefusedError(`You are on ${branch}, but it has no convert state in ${STATE_FILE}. Switch back to the branch you started from, delete ${branch}, then run convert again.`);
|
|
2964
|
+
}
|
|
2965
|
+
if (!rerun && await branchExists(options.root, branch)) {
|
|
2966
|
+
throw new RefusedError(`The branch ${branch} already exists. Delete it, or switch to it if convert made it, then run convert again.`);
|
|
2967
|
+
}
|
|
2968
|
+
return { state, rerun, base };
|
|
2969
|
+
}
|
|
2970
|
+
async function baseline(project, options, io) {
|
|
2971
|
+
if (!options.baseline || options.mode === "arabic") return;
|
|
2972
|
+
const { start: cmd } = io.config;
|
|
2973
|
+
if (!cmd || !io.shoot) {
|
|
2974
|
+
io.log("No start command is set, so no English baseline was taken.");
|
|
2975
|
+
return;
|
|
2976
|
+
}
|
|
2977
|
+
const { unreachable } = await shootBaseline(project, { ...io.config, start: cmd }, io.shoot, io.log);
|
|
2978
|
+
for (const page of unreachable) io.log(`No baseline for ${page.route}: ${page.reason}.`);
|
|
2979
|
+
}
|
|
2980
|
+
var committedLine = (files, branch) => `Committed ${files} changed ${files === 1 ? "file" : "files"} on ${branch}.`;
|
|
2981
|
+
var leftLine = (open, skipped) => `${open} ${open === 1 ? "thing is" : "things are"} left to finish. ${skipped} ${skipped === 1 ? "file was" : "files were"} skipped.`;
|
|
2982
|
+
async function convert(options, ioIn) {
|
|
2983
|
+
const { root, mode } = options;
|
|
2984
|
+
const { state, rerun, base } = await start(options);
|
|
2985
|
+
if (options.configAnswers) checkConfigAnswers(root, options.configFile);
|
|
2986
|
+
const io = options.configAnswers ? { ...ioIn, config: { ...ioIn.config, ...options.configAnswers } } : ioIn;
|
|
2987
|
+
const project = detectProject(root);
|
|
2988
|
+
const branch = branchFor(mode);
|
|
2989
|
+
const generated = new Set(state?.generated ?? []);
|
|
2990
|
+
const existing = readLedger(root);
|
|
2991
|
+
const done = transformAll(project, generated, new Set(existing.map((e) => e.key)));
|
|
2992
|
+
const plan = await planFor(project, mode, done.texts);
|
|
2993
|
+
const moved = new Map(plan.moves);
|
|
2994
|
+
const ledger = mergeLedger(existing, done.entries.map((e) => ({ ...e, file: moved.get(e.file) ?? e.file })));
|
|
2995
|
+
guardWrites(ownFiles(project, ledger), (rel) => readText(root, rel), generated);
|
|
2996
|
+
await excludeStateDir(root);
|
|
2997
|
+
if (!rerun) {
|
|
2998
|
+
await baseline(project, options, io);
|
|
2999
|
+
await createBranch(root, branch);
|
|
3000
|
+
io.log(`Created the branch ${branch}.`);
|
|
3001
|
+
}
|
|
3002
|
+
const ours = [...Object.keys(plan.writes), ...Object.keys(ownFiles(project, ledger)), LEDGER_FILE];
|
|
3003
|
+
const nextState = { mode, base: state?.base ?? base, branch, framework: project.framework, phase: "converted", generated: [.../* @__PURE__ */ new Set([...generated, ...ours])].sort() };
|
|
3004
|
+
const run2 = { branch, done, plan, moved, strings: ledger.length - existing.length };
|
|
3005
|
+
const disk = { root, created: [] };
|
|
3006
|
+
const step = { name: "writing the converted files" };
|
|
3007
|
+
const record = (applied) => ({ state: nextState, summary: mergeSummary(readSummary(root), summarise(run2, applied)) });
|
|
3008
|
+
const written = await writeAll({ disk, project, options, io, texts: done.texts, plan, ledger, step, record }).catch((error) => rollBack({ root, base: rerun ? branch : base, branch, madeBranch: !rerun, created: disk.created }, step.name, error));
|
|
3009
|
+
io.log(written.changed.length ? committedLine(written.changed.length, branch) : "Nothing changed, so there is nothing to commit.");
|
|
3010
|
+
const summary = summarise(run2, written);
|
|
3011
|
+
io.log(leftLine(problems(summary.flags).length, done.skipped.length));
|
|
3012
|
+
return summary;
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
// src/finish.ts
|
|
3016
|
+
var import_node_fs11 = require("fs");
|
|
3017
|
+
var import_node_path19 = require("path");
|
|
3018
|
+
|
|
3019
|
+
// src/match.ts
|
|
3020
|
+
var import_pngjs = require("pngjs");
|
|
3021
|
+
var MAX_DIFF_RATIO = 1e-3;
|
|
3022
|
+
var CHANNEL_TOLERANCE = 8;
|
|
3023
|
+
var RED = [255, 0, 0, 255];
|
|
3024
|
+
function compare(a, b) {
|
|
3025
|
+
const left = import_pngjs.PNG.sync.read(a);
|
|
3026
|
+
const right = import_pngjs.PNG.sync.read(b);
|
|
3027
|
+
if (left.width !== right.width || left.height !== right.height) {
|
|
3028
|
+
const out2 = new import_pngjs.PNG({ width: Math.max(left.width, right.width), height: Math.max(left.height, right.height) });
|
|
3029
|
+
for (let i = 0; i < out2.data.length; i += 4) out2.data.set(RED, i);
|
|
3030
|
+
return { ratio: 1, diff: import_pngjs.PNG.sync.write(out2) };
|
|
3031
|
+
}
|
|
3032
|
+
const out = new import_pngjs.PNG({ width: left.width, height: left.height });
|
|
3033
|
+
let differing = 0;
|
|
3034
|
+
for (let i = 0; i < left.data.length; i += 4) {
|
|
3035
|
+
const differs = [0, 1, 2, 3].some((c) => Math.abs((left.data[i + c] ?? 0) - (right.data[i + c] ?? 0)) > CHANNEL_TOLERANCE);
|
|
3036
|
+
if (differs) {
|
|
3037
|
+
differing++;
|
|
3038
|
+
out.data.set(RED, i);
|
|
3039
|
+
} else {
|
|
3040
|
+
out.data.set([left.data[i] ?? 0, left.data[i + 1] ?? 0, left.data[i + 2] ?? 0, 64], i);
|
|
3041
|
+
}
|
|
3042
|
+
}
|
|
3043
|
+
return { ratio: differing / (left.width * left.height), diff: import_pngjs.PNG.sync.write(out) };
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3046
|
+
// src/finish-undo.ts
|
|
3047
|
+
var import_node_fs10 = require("fs");
|
|
3048
|
+
var import_node_path18 = require("path");
|
|
3049
|
+
async function snapshot(root, paths) {
|
|
3050
|
+
const clean = new Set(await cleanAtHead(root, paths));
|
|
3051
|
+
return paths.map((path) => ({ path, clean: clean.has(path), text: clean.has(path) ? null : readText(root, path) }));
|
|
3052
|
+
}
|
|
3053
|
+
async function undo(root, saved, staged) {
|
|
3054
|
+
await restoreFromHead(root, saved.filter((s) => s.clean).map((s) => s.path));
|
|
3055
|
+
const dirty = saved.filter((s) => !s.clean);
|
|
3056
|
+
for (const { path, text } of dirty) {
|
|
3057
|
+
if (text === null) (0, import_node_fs10.rmSync)((0, import_node_path18.join)(root, path), { force: true });
|
|
3058
|
+
else (0, import_node_fs10.writeFileSync)((0, import_node_path18.join)(root, path), text);
|
|
3059
|
+
}
|
|
3060
|
+
await unstage(root, [.../* @__PURE__ */ new Set([...dirty.map((s) => s.path), ...staged])]);
|
|
3061
|
+
}
|
|
3062
|
+
async function failFinish(root, saved, staged, step, error) {
|
|
3063
|
+
const failed = `finish failed at ${step}: ${reasonOf(error)}.`;
|
|
3064
|
+
try {
|
|
3065
|
+
await undo(root, saved, staged);
|
|
3066
|
+
} catch (cause) {
|
|
3067
|
+
throw new ConvertFailedError(`${failed} Putting its files back also failed (${reasonOf(cause)}), so check git status. Nothing was committed.`, error);
|
|
3068
|
+
}
|
|
3069
|
+
throw new ConvertFailedError(`${failed} Nothing was committed.`, error);
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
// src/report.ts
|
|
3073
|
+
var NATIVE_SPEAKER_LINE = "Drafted by a model. A native speaker should check these before release.";
|
|
3074
|
+
function esc(text) {
|
|
3075
|
+
return text.replace(/\s*\r?\n\s*/g, " ").replace(/([\\`*_[\]<>|#])/g, "\\$1").trim();
|
|
3076
|
+
}
|
|
3077
|
+
function link(file, line) {
|
|
3078
|
+
const path = file.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
3079
|
+
const target = encodeURI(path).replace(/[()#]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
3080
|
+
return line === void 0 ? `[${esc(path)}](${target})` : `[${esc(`${path}:${line}`)}](${target}#L${line})`;
|
|
3081
|
+
}
|
|
3082
|
+
var KIND_LABELS = {
|
|
3083
|
+
tailwind: "Tailwind classes",
|
|
3084
|
+
css: "CSS rules",
|
|
3085
|
+
markup: "Markup",
|
|
3086
|
+
strings: "Strings",
|
|
3087
|
+
formats: "Formats",
|
|
3088
|
+
wiring: "Wiring"
|
|
3089
|
+
};
|
|
3090
|
+
var MAX_LISTED_FILES = 30;
|
|
3091
|
+
var plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
3092
|
+
var section = (title, lines, empty) => `## ${title}
|
|
3093
|
+
|
|
3094
|
+
${(lines.length > 0 ? lines : [empty]).join("\n")}`;
|
|
3095
|
+
function flagLine(flag3) {
|
|
3096
|
+
const where = link(flag3.file, flag3.line);
|
|
3097
|
+
const message = esc(flag3.message);
|
|
3098
|
+
if (!flag3.suggestion.trim()) return `- ${where}: ${message}`;
|
|
3099
|
+
return `- ${where}: ${message.replace(/\.+$/, "")}. ${esc(flag3.suggestion)}`;
|
|
3100
|
+
}
|
|
3101
|
+
function kindLines(summary) {
|
|
3102
|
+
const kinds = Object.keys(KIND_LABELS);
|
|
3103
|
+
return kinds.filter((k) => summary.byKind[k] > 0).map((k) => `- ${KIND_LABELS[k]}: ${plural(summary.byKind[k], "edit", "edits")}`);
|
|
3104
|
+
}
|
|
3105
|
+
function fileLines(summary) {
|
|
3106
|
+
if (summary.changed.length === 0) return [];
|
|
3107
|
+
const shown = summary.changed.slice(0, MAX_LISTED_FILES);
|
|
3108
|
+
const more = summary.changed.length - shown.length;
|
|
3109
|
+
return [
|
|
3110
|
+
"",
|
|
3111
|
+
"Changed files:",
|
|
3112
|
+
"",
|
|
3113
|
+
...shown.map((c) => `- ${link(c.file)} (${plural(c.edits, "edit", "edits")})`),
|
|
3114
|
+
...more > 0 ? [`- and ${more} more`] : []
|
|
3115
|
+
];
|
|
3116
|
+
}
|
|
3117
|
+
function changed(data) {
|
|
3118
|
+
const { summary } = data;
|
|
3119
|
+
const notes = summary.flags.filter((f) => f.kind === "info");
|
|
3120
|
+
const lines = [
|
|
3121
|
+
...data.summaryMissing ? ["The phase 1 summary was not found, so the counts below are unknown.", ""] : [],
|
|
3122
|
+
`Mode: ${data.mode}. Branch: ${esc(data.branch)}, made from ${esc(data.base)}.`,
|
|
3123
|
+
"",
|
|
3124
|
+
`- ${plural(summary.filesChanged, "file", "files")} changed with ${plural(summary.edits, "edit", "edits")}.`,
|
|
3125
|
+
`- ${plural(summary.strings, "string", "strings")} moved into translation files.`,
|
|
3126
|
+
`- ${plural(data.remainingLint, "lint issue", "lint issues")} left after conversion.`,
|
|
3127
|
+
...kindLines(summary),
|
|
3128
|
+
...fileLines(summary),
|
|
3129
|
+
...notes.map(flagLine)
|
|
3130
|
+
];
|
|
3131
|
+
return section("What changed", lines, "");
|
|
3132
|
+
}
|
|
3133
|
+
function copy(data) {
|
|
3134
|
+
const todo = data.ledger.filter((e) => e.status === "pending" || e.status === "drafted");
|
|
3135
|
+
const lines = todo.length === 0 ? [] : [
|
|
3136
|
+
NATIVE_SPEAKER_LINE,
|
|
3137
|
+
"",
|
|
3138
|
+
"| Key | English | Arabic | Status | Where |",
|
|
3139
|
+
"| --- | --- | --- | --- | --- |",
|
|
3140
|
+
...todo.map((e) => `| ${esc(e.key)} | ${esc(e.en)} | ${esc(e.ar)} | ${e.status} | ${link(e.file, e.line)} |`)
|
|
3141
|
+
];
|
|
3142
|
+
return section("Arabic copy to check", lines, "No Arabic copy needs checking.");
|
|
3143
|
+
}
|
|
3144
|
+
function suggestions(data) {
|
|
3145
|
+
const lines = data.summary.flags.filter((f) => f.kind === "format").map(flagLine);
|
|
3146
|
+
return section("Regional suggestions", lines, "No regional suggestions.");
|
|
3147
|
+
}
|
|
3148
|
+
function english(data) {
|
|
3149
|
+
const lines = data.english.map((p) => {
|
|
3150
|
+
const pass = p.ratio < MAX_DIFF_RATIO;
|
|
3151
|
+
const share = `${(p.ratio * 100).toFixed(3)}% of pixels differ, limit ${(MAX_DIFF_RATIO * 100).toFixed(3)}%`;
|
|
3152
|
+
const diff = !pass && p.diffPath ? `. Diff at ${esc(p.diffPath)}` : "";
|
|
3153
|
+
return `- ${esc(p.route)} at ${p.width}px: ${pass ? "pass" : "fail"}: ${share}${diff}`;
|
|
3154
|
+
});
|
|
3155
|
+
const missing = (data.noBaseline ?? []).map((p) => `- ${esc(p.route)} at ${p.width}px: no English baseline was taken`);
|
|
3156
|
+
const unproven = lines.length === 0 && missing.length > 0 ? ["", "Warning: No English pages were compared, so the English match is not proven."] : [];
|
|
3157
|
+
return section("English pages", [...lines, ...missing, ...unproven], "No English pages were checked.");
|
|
3158
|
+
}
|
|
3159
|
+
function arabic(data) {
|
|
3160
|
+
const lines = data.arabic.map(
|
|
3161
|
+
(p) => !Number.isFinite(p.errors) ? `- ${esc(p.route)}: the check returned no result` : `- ${esc(p.route)}: score ${p.score}, ${plural(p.errors, "error", "errors")}, ${plural(p.warnings, "warning", "warnings")}`
|
|
3162
|
+
);
|
|
3163
|
+
return section("Arabic pages", lines, "No pages were checked.");
|
|
3164
|
+
}
|
|
3165
|
+
function notConverted(data) {
|
|
3166
|
+
const flags = data.summary.flags.filter((f) => f.kind !== "info" && f.kind !== "format").map(flagLine);
|
|
3167
|
+
const skipped = data.summary.skipped.map((s) => `- Skipped ${esc(s.file)}: ${esc(s.reason)}`);
|
|
3168
|
+
const pages = data.unreachable.map((u) => `- Could not reach ${esc(u.route)}: ${esc(u.reason)}`);
|
|
3169
|
+
return section("What Dualkit could not convert", [...flags, ...skipped, ...pages], "Nothing was left unconverted.");
|
|
3170
|
+
}
|
|
3171
|
+
function tests(data) {
|
|
3172
|
+
if (!data.tests) return section("The app's tests", [], `The app's tests were not run, because dualkit.config.json has no test command. Add one under "convert", such as "test": "npm test".`);
|
|
3173
|
+
const runs = Math.max(2, ...(data.tests.tail.match(/`+/g) ?? []).map((m) => m.length + 1));
|
|
3174
|
+
const fence = "`".repeat(Math.max(3, runs));
|
|
3175
|
+
const result = data.tests.code === 0 ? "The tests passed." : `The tests failed with exit code ${data.tests.code}.`;
|
|
3176
|
+
return section("The app's tests", [result, "", fence, data.tests.tail.trimEnd(), fence], "");
|
|
3177
|
+
}
|
|
3178
|
+
function renderConvertReport(data) {
|
|
3179
|
+
const parts = [
|
|
3180
|
+
"# Dualkit convert report",
|
|
3181
|
+
changed(data),
|
|
3182
|
+
copy(data),
|
|
3183
|
+
suggestions(data),
|
|
3184
|
+
...data.mode === "bilingual" ? [english(data)] : [],
|
|
3185
|
+
arabic(data),
|
|
3186
|
+
notConverted(data),
|
|
3187
|
+
tests(data)
|
|
3188
|
+
];
|
|
3189
|
+
return `${parts.join("\n\n")}
|
|
3190
|
+
`;
|
|
3191
|
+
}
|
|
3192
|
+
|
|
3193
|
+
// src/finish.ts
|
|
3194
|
+
var REPORT_FILE = "dualkit-convert.md";
|
|
3195
|
+
var DIFF_DIR = `${STATE_DIR}/diff`;
|
|
3196
|
+
var TEST_TAIL_LINES = 40;
|
|
3197
|
+
var NEEDS_BROWSER = "Finish needs a start command in your Dualkit config file and a browser to take screenshots with.";
|
|
3198
|
+
var LOCALE_COOKIE2 = "dk-locale";
|
|
3199
|
+
function arabicTarget(project, state, route) {
|
|
3200
|
+
if (state.mode === "arabic") return { path: route };
|
|
3201
|
+
if (project.framework === "next-app") return { path: route === "/" ? "/ar" : `/ar${route}` };
|
|
3202
|
+
return { path: route, options: { cookies: { [LOCALE_COOKIE2]: "ar" } } };
|
|
3203
|
+
}
|
|
3204
|
+
var ownFiles2 = (configFile) => [messagesPath("en"), messagesPath("ar"), REPORT_FILE, STATE_FILE, LEDGER_FILE, configFile];
|
|
3205
|
+
async function assertNothingElseStaged(root, own) {
|
|
3206
|
+
const others = (await stagedFiles(root)).filter((f) => !own.includes(f));
|
|
3207
|
+
if (others.length > 0) throw new RefusedError(`You have staged changes in ${others.join(", ")}. Commit or unstage them, then run finish again.`);
|
|
3208
|
+
}
|
|
3209
|
+
async function guard(root) {
|
|
3210
|
+
const state = readState(root);
|
|
3211
|
+
if (!state) throw new RefusedError("There is no saved convert state here. Run dualkit convert first, then fill in the Arabic and run this again.");
|
|
3212
|
+
if (state.phase !== "converted" && state.phase !== "finished") {
|
|
3213
|
+
throw new RefusedError("This conversion has not finished converting yet. Run dualkit convert until it completes, then run this again.");
|
|
3214
|
+
}
|
|
3215
|
+
const branch = await currentBranch(root);
|
|
3216
|
+
if (branch !== state.branch) {
|
|
3217
|
+
throw new RefusedError(`You are on ${branch}, but this conversion lives on ${state.branch}. Switch to that branch, then run dualkit convert --finish again.`);
|
|
3218
|
+
}
|
|
3219
|
+
return state;
|
|
3220
|
+
}
|
|
3221
|
+
function pendingCheck(ledger, force) {
|
|
3222
|
+
const pending = ledger.filter((e) => e.status === "pending").length;
|
|
3223
|
+
if (pending > 0 && !force) {
|
|
3224
|
+
throw new RefusedError(`${pending} ${pending === 1 ? "string is" : "strings are"} still pending in ${LEDGER_FILE}. Fill in the Arabic and set each status to drafted, or run with --force to use English for those strings.`);
|
|
3225
|
+
}
|
|
3226
|
+
}
|
|
3227
|
+
function writeMessages(root, ledger) {
|
|
3228
|
+
const disk = { root, created: [] };
|
|
3229
|
+
for (const [path, text] of Object.entries(messagesFiles(ledger))) writeText(disk, path, mergedMessages(root, path, text));
|
|
3230
|
+
}
|
|
3231
|
+
function mergedMessages(root, path, generated) {
|
|
3232
|
+
const full = (0, import_node_path19.join)(root, path);
|
|
3233
|
+
if (!(0, import_node_fs11.existsSync)(full)) return generated;
|
|
3234
|
+
try {
|
|
3235
|
+
const existing = JSON.parse((0, import_node_fs11.readFileSync)(full, "utf8"));
|
|
3236
|
+
if (typeof existing !== "object" || existing === null) return generated;
|
|
3237
|
+
return JSON.stringify(keepHandAdded(existing, JSON.parse(generated)), null, 2) + "\n";
|
|
3238
|
+
} catch {
|
|
3239
|
+
return generated;
|
|
3240
|
+
}
|
|
3241
|
+
}
|
|
3242
|
+
async function englishChecks(root, base, routes, shoot) {
|
|
3243
|
+
const english2 = [];
|
|
3244
|
+
const noBaseline = [];
|
|
3245
|
+
for (const route of routes) {
|
|
3246
|
+
for (const width of WIDTHS) {
|
|
3247
|
+
const file = (0, import_node_path19.join)(root, baselinePath(route, width));
|
|
3248
|
+
if (!(0, import_node_fs11.existsSync)(file)) {
|
|
3249
|
+
noBaseline.push({ route, width });
|
|
3250
|
+
continue;
|
|
3251
|
+
}
|
|
3252
|
+
const shot = await shoot(`${base}${route}`, width);
|
|
3253
|
+
const { ratio, diff } = compare((0, import_node_fs11.readFileSync)(file), shot);
|
|
3254
|
+
let diffPath;
|
|
3255
|
+
if (ratio >= MAX_DIFF_RATIO) {
|
|
3256
|
+
diffPath = `${DIFF_DIR}/${routeSlug(route)}-${width}.png`;
|
|
3257
|
+
(0, import_node_fs11.mkdirSync)((0, import_node_path19.join)(root, DIFF_DIR), { recursive: true });
|
|
3258
|
+
(0, import_node_fs11.writeFileSync)((0, import_node_path19.join)(root, diffPath), diff);
|
|
3259
|
+
}
|
|
3260
|
+
english2.push({ route, width, ratio, ...diffPath ? { diffPath } : {} });
|
|
3261
|
+
}
|
|
3262
|
+
}
|
|
3263
|
+
return { english: english2, noBaseline };
|
|
3264
|
+
}
|
|
3265
|
+
async function arabicChecks(project, state, base, routes, audit) {
|
|
3266
|
+
const arabic2 = [];
|
|
3267
|
+
for (const route of routes) {
|
|
3268
|
+
const { path, options } = arabicTarget(project, state, route);
|
|
3269
|
+
for (const width of WIDTHS) {
|
|
3270
|
+
const result = await audit(`${base}${path}`, width, options);
|
|
3271
|
+
const usable = result && Number.isFinite(result.errors);
|
|
3272
|
+
const failed = { score: Number.NaN, errors: Number.NaN, warnings: Number.NaN };
|
|
3273
|
+
arabic2.push({ route: `${path} at ${width}px`, ...usable ? result : failed });
|
|
3274
|
+
}
|
|
3275
|
+
}
|
|
3276
|
+
return arabic2;
|
|
3277
|
+
}
|
|
3278
|
+
async function runChecks(project, state, routes, { config, shoot, audit }, step) {
|
|
3279
|
+
const { start: cmd } = config;
|
|
3280
|
+
if (!cmd || !shoot) throw new RefusedError(NEEDS_BROWSER);
|
|
3281
|
+
const port = config.port ?? DEFAULT_PORTS[project.framework];
|
|
3282
|
+
step.name = "starting the app";
|
|
3283
|
+
const server = await startServer(cmd, port, project.root).catch((error) => {
|
|
3284
|
+
throw new Error(`Could not start the app with "${cmd}". ${error.message}`, { cause: error });
|
|
3285
|
+
});
|
|
3286
|
+
try {
|
|
3287
|
+
step.name = "checking the pages";
|
|
3288
|
+
const english2 = state.mode === "bilingual" ? await englishChecks(project.root, server.url, routes, shoot) : { english: [], noBaseline: [] };
|
|
3289
|
+
return { ...english2, arabic: await arabicChecks(project, state, server.url, routes, audit) };
|
|
3290
|
+
} finally {
|
|
3291
|
+
await server.stop();
|
|
3292
|
+
}
|
|
3293
|
+
}
|
|
3294
|
+
var emptySummary = (state, strings) => ({ branch: state.branch, filesChanged: 0, edits: 0, byKind: { tailwind: 0, css: 0, markup: 0, strings: 0, formats: 0, wiring: 0 }, changed: [], flags: [], strings, skipped: [] });
|
|
3295
|
+
var tail = (out) => out.trimEnd().split("\n").slice(-TEST_TAIL_LINES).join("\n");
|
|
3296
|
+
async function prepare(options, ioIn) {
|
|
3297
|
+
const { root } = options;
|
|
3298
|
+
const configFile = options.configFile ?? CONFIG_FILE;
|
|
3299
|
+
const io = options.configAnswers ? { ...ioIn, config: { ...ioIn.config, ...options.configAnswers } } : ioIn;
|
|
3300
|
+
if (options.configAnswers) checkConfigAnswers(root, configFile);
|
|
3301
|
+
const state = await guard(root);
|
|
3302
|
+
const ledger = readLedger(root);
|
|
3303
|
+
pendingCheck(ledger, options.force);
|
|
3304
|
+
if (!io.config.start || !io.shoot) throw new RefusedError(NEEDS_BROWSER);
|
|
3305
|
+
const own = ownFiles2(configFile);
|
|
3306
|
+
await assertNothingElseStaged(root, own);
|
|
3307
|
+
return { root, options, io, state, ledger, configFile, own };
|
|
3308
|
+
}
|
|
3309
|
+
async function run(ready, step) {
|
|
3310
|
+
const { root, options, io, state, ledger, configFile, own } = ready;
|
|
3311
|
+
const project = detectProject(root);
|
|
3312
|
+
const { routes, unreachable } = listRoutes(project, io.config);
|
|
3313
|
+
step.name = "writing the messages";
|
|
3314
|
+
writeMessages(root, ledger);
|
|
3315
|
+
const checks = await runChecks(project, state, routes, io, step);
|
|
3316
|
+
step.name = "running the tests";
|
|
3317
|
+
const tests2 = io.config.test ? await io.run(io.config.test, root) : void 0;
|
|
3318
|
+
step.name = "counting lint findings";
|
|
3319
|
+
const remainingLint = await io.lint(root);
|
|
3320
|
+
step.name = "writing the report";
|
|
3321
|
+
const earlier = readSummary(root);
|
|
3322
|
+
const data = {
|
|
3323
|
+
mode: state.mode,
|
|
3324
|
+
base: state.base,
|
|
3325
|
+
branch: state.branch,
|
|
3326
|
+
summary: earlier ?? emptySummary(state, ledger.length),
|
|
3327
|
+
ledger,
|
|
3328
|
+
...checks,
|
|
3329
|
+
...earlier ? {} : { summaryMissing: true },
|
|
3330
|
+
unreachable,
|
|
3331
|
+
remainingLint,
|
|
3332
|
+
...tests2 ? { tests: { code: tests2.code, tail: tail(tests2.out) } } : {}
|
|
3333
|
+
};
|
|
3334
|
+
(0, import_node_fs11.writeFileSync)((0, import_node_path19.join)(root, REPORT_FILE), renderConvertReport(data));
|
|
3335
|
+
step.name = "saving the state";
|
|
3336
|
+
saveState(root, { ...state, phase: "finished" });
|
|
3337
|
+
step.name = "saving your answers";
|
|
3338
|
+
if (options.configAnswers) writeConfigAnswers({ root, created: [] }, options.configAnswers, configFile);
|
|
3339
|
+
step.name = "committing";
|
|
3340
|
+
await stage(root, own.filter((p) => (p !== configFile || options.configAnswers) && (0, import_node_fs11.existsSync)((0, import_node_path19.join)(root, p))));
|
|
3341
|
+
if ((await stagedFiles(root)).length) await commit(root, `dualkit: finish ${state.mode} conversion`);
|
|
3342
|
+
const checked = checks.arabic.length > 0;
|
|
3343
|
+
const passed = checked && checks.english.every((p) => p.ratio < MAX_DIFF_RATIO) && checks.arabic.every((p) => p.errors === 0) && (tests2?.code ?? 0) === 0;
|
|
3344
|
+
return { reportPath: (0, import_node_path19.join)(root, REPORT_FILE), passed };
|
|
3345
|
+
}
|
|
3346
|
+
async function finish(options, ioIn) {
|
|
3347
|
+
const ready = await prepare(options, ioIn);
|
|
3348
|
+
const { root, configFile, own } = ready;
|
|
3349
|
+
const written = [messagesPath("en"), messagesPath("ar"), REPORT_FILE, STATE_FILE, ...options.configAnswers ? [configFile] : []];
|
|
3350
|
+
const saved = await snapshot(root, written);
|
|
3351
|
+
const step = { name: "writing the messages" };
|
|
3352
|
+
const result = await run(ready, step).catch((error) => failFinish(root, saved, own.filter((p) => !written.includes(p)), step.name, error));
|
|
3353
|
+
ready.io.log(result.passed ? "All checks passed." : "Some checks failed. The report lists them.");
|
|
3354
|
+
return result;
|
|
3355
|
+
}
|
|
3356
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
3357
|
+
0 && (module.exports = {
|
|
3358
|
+
ConvertFailedError,
|
|
3359
|
+
OverlapError,
|
|
3360
|
+
RefusedError,
|
|
3361
|
+
UnsupportedError,
|
|
3362
|
+
applyEdits,
|
|
3363
|
+
branchFor,
|
|
3364
|
+
convert,
|
|
3365
|
+
detectProject,
|
|
3366
|
+
finish,
|
|
3367
|
+
problems
|
|
3368
|
+
});
|