@dualkit/convert 0.3.0-beta.0 → 0.5.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/dist/index.cjs CHANGED
@@ -151,6 +151,11 @@ function detectPackageManager(root) {
151
151
  if (top || (0, import_node_path.dirname)(dir) === dir) return "npm";
152
152
  }
153
153
  }
154
+ var I18N_LIBRARIES = ["next-intl", "react-i18next", "i18next", "next-i18next", "react-intl", "@lingui/core", "next-international", "@inlang/paraglide-js"];
155
+ function existingI18n(root) {
156
+ const deps = readDeps(root);
157
+ return I18N_LIBRARIES.find((name) => name in deps) ?? null;
158
+ }
154
159
  function detectI18n(deps) {
155
160
  if ("next-intl" in deps) return "next-intl";
156
161
  if ("react-i18next" in deps) return "react-i18next";
@@ -172,8 +177,8 @@ function detectProject(root) {
172
177
  }
173
178
 
174
179
  // src/convert.ts
175
- var import_lint12 = require("@dualkit/lint");
176
- var import_node_path17 = require("path");
180
+ var import_lint13 = require("@dualkit/lint");
181
+ var import_node_path18 = require("path");
177
182
 
178
183
  // src/apply.ts
179
184
  var import_lint2 = require("@dualkit/lint");
@@ -269,11 +274,11 @@ var RefusedError = class extends Error {
269
274
  };
270
275
  var NO_GIT = "git is not installed or not on PATH";
271
276
  function git(cwd, args) {
272
- return new Promise((resolve3, reject) => {
277
+ return new Promise((resolve4, reject) => {
273
278
  (0, import_node_child_process.execFile)("git", ["--literal-pathspecs", ...args], { cwd, maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => {
274
279
  if (error && error.code === "ENOENT") return reject(new Error((0, import_node_fs3.existsSync)(cwd) ? NO_GIT : `The folder ${cwd} does not exist.`));
275
280
  const code = error ? typeof error.code === "number" ? error.code : 1 : 0;
276
- resolve3({ code, out: `${stdout}${stderr}`.trim(), stdout });
281
+ resolve4({ code, out: `${stdout}${stderr}`.trim(), stdout });
277
282
  });
278
283
  });
279
284
  }
@@ -327,7 +332,7 @@ async function stagedFiles(root) {
327
332
  return out.split("\0").filter(Boolean);
328
333
  }
329
334
  async function commit(root, message) {
330
- await must(root, ["commit", "-q", "-m", message]);
335
+ await must(root, ["commit", "-q", "--no-verify", "-m", message]);
331
336
  }
332
337
  async function trackedFiles(root) {
333
338
  const out = await must(root, ["ls-files", "-z"]);
@@ -562,6 +567,26 @@ function writeConfigAnswers(disk, answers2, file = CONFIG_FILE) {
562
567
  return writeText(disk, file, mergedConfigText(readText(disk.root, file), answers2, file));
563
568
  }
564
569
 
570
+ // src/closed-routes.ts
571
+ var CLOSED = /* @__PURE__ */ new Set([401, 403, 404]);
572
+ var STATUS_TIMEOUT_MS = 6e4;
573
+ var closedStatus = async (url) => {
574
+ try {
575
+ const res = await fetch(url, { signal: AbortSignal.timeout(STATUS_TIMEOUT_MS), redirect: "manual" });
576
+ return CLOSED.has(res.status) ? res.status : null;
577
+ } catch {
578
+ return null;
579
+ }
580
+ };
581
+ async function closedRoutes(base, routes) {
582
+ const found = [];
583
+ for (const route of routes) {
584
+ const status = await closedStatus(`${base}${route}`);
585
+ if (status) found.push({ route, reason: `the English page returns ${status}, so its Arabic page was not checked` });
586
+ }
587
+ return found;
588
+ }
589
+
565
590
  // src/baseline.ts
566
591
  var import_node_fs8 = require("fs");
567
592
  var import_node_path9 = require("path");
@@ -619,15 +644,15 @@ var ServerError = class extends Error {
619
644
  var OUTPUT_LINES = 20;
620
645
  var POLL_MS = 250;
621
646
  var KILL_GRACE_MS = 5e3;
622
- var sleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
647
+ var sleep = (ms) => new Promise((resolve4) => setTimeout(resolve4, ms));
623
648
  function portTaken(port) {
624
- return new Promise((resolve3) => {
649
+ return new Promise((resolve4) => {
625
650
  const socket = (0, import_node_net.connect)({ port, host: "localhost" });
626
651
  socket.once("connect", () => {
627
652
  socket.destroy();
628
- resolve3(true);
653
+ resolve4(true);
629
654
  });
630
- socket.once("error", () => resolve3(false));
655
+ socket.once("error", () => resolve4(false));
631
656
  });
632
657
  }
633
658
  async function answers(url) {
@@ -667,7 +692,7 @@ async function startServer(cmd, port, cwd, timeoutMs = 12e4) {
667
692
  });
668
693
  const pid = child.pid;
669
694
  if (pid === void 0) {
670
- await new Promise((resolve3) => setImmediate(resolve3));
695
+ await new Promise((resolve4) => setImmediate(resolve4));
671
696
  throw new ServerError(`Could not start "${cmd}": ${spawnError?.message ?? "no process id"}`);
672
697
  }
673
698
  const collect = (chunk) => {
@@ -706,6 +731,7 @@ ${tail2()}`);
706
731
 
707
732
  // src/baseline.ts
708
733
  var BASELINE_DIR = `${STATE_DIR}/baseline`;
734
+ var CLOSED_FILE = `${BASELINE_DIR}/closed.json`;
709
735
  var WIDTHS = [390, 1280];
710
736
  var DEFAULT_PORTS = { "next-app": 3e3, "vite-react": 5173 };
711
737
  var routeSlug = (route) => route.replace(/^\/+|\/+$/g, "").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "") || "home";
@@ -717,6 +743,7 @@ async function shootBaseline(project, config, shoot, log) {
717
743
  const server = await startServer(config.start, port, project.root);
718
744
  const shots = [];
719
745
  try {
746
+ (0, import_node_fs8.writeFileSync)((0, import_node_path9.join)(project.root, CLOSED_FILE), JSON.stringify((await closedRoutes(server.url, routes)).map((c) => c.route)));
720
747
  for (const route of routes) {
721
748
  for (const width of WIDTHS) {
722
749
  const path = baselinePath(route, width);
@@ -1093,6 +1120,7 @@ function pieceOf(child, ctx) {
1093
1120
  const source = ctx.file.text.slice(ctx.base + expression.start, ctx.base + expression.end);
1094
1121
  return { kind: "value", name, source: mayBeEmpty(expression) ? `${source} ?? ''` : source, start: start2, end };
1095
1122
  }
1123
+ var OWN_DIRECTION = /setAttribute\(\s*['"]dir['"]|documentElement\.dir\s*=[^=]/;
1096
1124
  var NODE_NAMES = /^(children|icon|avatar|logo|image|element|node)$/i;
1097
1125
  var mayBeEmpty = (expression) => /^Optional/.test(expression.type) || expression.type === "CallExpression" && !textMethodTarget(expression);
1098
1126
  var jsxText = (value) => value.replace(/^\s*\n\s*/, "").replace(/\s*\n\s*$/, "");
@@ -1342,6 +1370,15 @@ function extractStrings(file, lib, taken) {
1342
1370
  for (const block of file.js) {
1343
1371
  const program = block.ast.program;
1344
1372
  if (isServerFile(program)) continue;
1373
+ if (OWN_DIRECTION.test(file.text.slice(block.offset))) {
1374
+ flag(
1375
+ { file, flags },
1376
+ block.offset,
1377
+ "unsupported",
1378
+ "This file sets the page direction itself, which can clash with the Arabic layout.",
1379
+ "Check the Arabic pages, and drive this direction from the locale so components read it too."
1380
+ );
1381
+ }
1345
1382
  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, consumed: /* @__PURE__ */ new Set(), usedTrans: false };
1346
1383
  walk(program, ctx, []);
1347
1384
  if (ctx.sawJsx) flagModuleStrings(program, ctx);
@@ -1618,12 +1655,12 @@ function routeMoves(appDir, paths) {
1618
1655
  }
1619
1656
  return { moves, keptPages };
1620
1657
  }
1621
- function eachNode(node, visit) {
1622
- if (Array.isArray(node)) return node.forEach((child) => eachNode(child, visit));
1658
+ function eachNode(node, visit2) {
1659
+ if (Array.isArray(node)) return node.forEach((child) => eachNode(child, visit2));
1623
1660
  if (!node || typeof node !== "object") return;
1624
1661
  const n = node;
1625
- if (typeof n.type === "string") visit(n);
1626
- for (const [key, child] of Object.entries(n)) if (!SKIP3.has(key)) eachNode(child, visit);
1662
+ if (typeof n.type === "string") visit2(n);
1663
+ for (const [key, child] of Object.entries(n)) if (!SKIP3.has(key)) eachNode(child, visit2);
1627
1664
  }
1628
1665
  var RELATIVE = /^\.\.?\//;
1629
1666
  var stringLiteral = (node) => node?.type === "StringLiteral" ? node : null;
@@ -2096,7 +2133,7 @@ export function DualkitLanguageSwitch() {
2096
2133
  )
2097
2134
  }
2098
2135
  `;
2099
- 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>`;
2136
+ 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';if(l==='ar')new MutationObserver(function(){if(h.dir!=='rtl')h.dir='rtl'}).observe(h,{attributes:true,attributeFilter:['dir']})})()</script>`;
2100
2137
  function htmlEdits2(html, mode) {
2101
2138
  if (mode === "bilingual") {
2102
2139
  if (html.includes(LOCALE_COOKIE)) return [];
@@ -2692,12 +2729,27 @@ function visitExpression(container, ancestors, ctx) {
2692
2729
  ctx.edits.push({ start: ctx.base + container.start, end: ctx.base + container.start, text: "<bdi>" });
2693
2730
  ctx.edits.push({ start: ctx.base + container.end, end: ctx.base + container.end, text: "</bdi>" });
2694
2731
  }
2732
+ var SIGNED_NUMBER = /^(?:[+\-\u2212]\d[\d,.]*\s?%?|\d[\d,.]*\s?%)$/;
2733
+ var NO_BDI = /* @__PURE__ */ new Set(["svg", "text", "option", "title", "textarea", "style", "script"]);
2734
+ function visitText(text, ancestors, ctx) {
2735
+ const parent = ancestors[ancestors.length - 1];
2736
+ if (parent.type !== "JSXElement" && parent.type !== "JSXFragment") return;
2737
+ const value = String(text.value);
2738
+ const trimmed = value.trim();
2739
+ if (!SIGNED_NUMBER.test(trimmed)) return;
2740
+ const elements = ancestors.filter((a) => a.type === "JSXElement");
2741
+ if (elements.some((a) => isolates(a) || NO_BDI.has(tagName2(a.openingElement) ?? ""))) return;
2742
+ const start2 = ctx.base + text.start + value.indexOf(trimmed);
2743
+ ctx.edits.push({ start: start2, end: start2, text: "<bdi>" });
2744
+ ctx.edits.push({ start: start2 + trimmed.length, end: start2 + trimmed.length, text: "</bdi>" });
2745
+ }
2695
2746
  function walk3(node, ctx, ancestors) {
2696
2747
  if (Array.isArray(node)) return node.forEach((child) => walk3(child, ctx, ancestors));
2697
2748
  if (!node || typeof node !== "object") return;
2698
2749
  const n = node;
2699
2750
  if (n.type === "JSXOpeningElement") visitOpening(n, ctx, ancestors);
2700
2751
  if (n.type === "JSXExpressionContainer" && ancestors.length) visitExpression(n, ancestors, ctx);
2752
+ if (n.type === "JSXText" && ancestors.length) visitText(n, ancestors, ctx);
2701
2753
  const next = typeof n.type === "string" ? [...ancestors, n] : ancestors;
2702
2754
  for (const [key, child] of Object.entries(n)) if (!SKIP6.has(key)) walk3(child, ctx, next);
2703
2755
  }
@@ -2839,6 +2891,15 @@ function groups(fragments) {
2839
2891
  for (const f of fragments) byGroup.set(f.group, [...byGroup.get(f.group) ?? [], f]);
2840
2892
  return [...byGroup.values()];
2841
2893
  }
2894
+ var TRACKED = /^tracking-(?!normal$)/;
2895
+ function trackingEdits(fragments) {
2896
+ const tokens = tokensOf(fragments);
2897
+ if (tokens.some((t) => t.parsed.core.startsWith("tracking-") && t.parsed.variants.includes("rtl"))) return [];
2898
+ const first = tokens.find((t) => TRACKED.test(t.parsed.core) && KEPT(t));
2899
+ if (!first) return [];
2900
+ const end = first.at + first.text.length;
2901
+ return [{ start: end, end, text: " rtl:tracking-normal" }];
2902
+ }
2842
2903
  function tailwindEdits(file, version) {
2843
2904
  const parsed = parseVersion(version);
2844
2905
  const fragments = (0, import_lint10.classStrings)(file);
@@ -2851,7 +2912,7 @@ function tailwindEdits(file, version) {
2851
2912
  return { edits: [], flags: [{ file: file.path, line: lineOf(file.text, first.offset), kind: "unsupported", message, suggestion }] };
2852
2913
  }
2853
2914
  const sources = groups(fragments);
2854
- const edits2 = sources.flatMap((g) => [...physicalEdits(g, parsed), ...parsed[0] < 4 ? reverseEdits(g) : []]);
2915
+ const edits2 = sources.flatMap((g) => [...physicalEdits(g, parsed), ...parsed[0] < 4 ? reverseEdits(g) : [], ...trackingEdits(g)]);
2855
2916
  const flags = [...sources.flatMap((g) => formFlags(file, g)), ...computedFlags(file)];
2856
2917
  return { edits: edits2, flags: flags.sort((a, b) => a.line - b.line) };
2857
2918
  }
@@ -2901,9 +2962,208 @@ function runPasses(path, text, abs, ctx) {
2901
2962
  return { kind: "done", text: file.text, edits: edits2, byKind, flags, entries };
2902
2963
  }
2903
2964
 
2965
+ // src/transforms/data.ts
2966
+ var import_node_path16 = require("path");
2967
+ var import_lint12 = require("@dualkit/lint");
2968
+ var SKIP8 = /* @__PURE__ */ new Set(["loc", "leadingComments", "trailingComments", "innerComments", "extra"]);
2969
+ var TEXT_KEYS = /* @__PURE__ */ new Set(["title", "label", "description", "placeholder", "subtitle", "message", "text", "alt", "heading", "caption"]);
2970
+ var NAV_KEYS = /* @__PURE__ */ new Set(["href", "url", "path", "to", "icon"]);
2971
+ var RESERVED = /* @__PURE__ */ new Set(["metadata", "viewport", "config", "routing"]);
2972
+ var PROSE2 = /^\p{Lu}\p{Ll}/u;
2973
+ var NOT_PROSE2 = /[/\\_=<>{}@#|]/;
2974
+ var EXTENSIONS3 = ["", ".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.tsx", "/index.js", "/index.jsx"];
2975
+ var ALIASES = /^[@~]\//;
2976
+ var unwrap = (node) => node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression" ? unwrap(node.expression) : node;
2977
+ var children = (node) => Object.entries(node).filter(([key]) => !SKIP8.has(key));
2978
+ function visit(node, ancestors, fn) {
2979
+ if (Array.isArray(node)) return node.forEach((child) => visit(child, ancestors, fn));
2980
+ if (!node || typeof node !== "object") return;
2981
+ const n = node;
2982
+ if (typeof n.type === "string") fn(n, ancestors);
2983
+ const next = typeof n.type === "string" ? [...ancestors, n] : ancestors;
2984
+ for (const [, child] of children(n)) visit(child, next, fn);
2985
+ }
2986
+ var keyName = (property) => {
2987
+ const key = property.key;
2988
+ if (property.computed) return null;
2989
+ return key.type === "Identifier" ? String(key.name) : key.type === "StringLiteral" ? String(key.value) : null;
2990
+ };
2991
+ var isProse = (value) => PROSE2.test(value) && !NOT_PROSE2.test(value);
2992
+ function textProperty(property, parent) {
2993
+ const key = keyName(property);
2994
+ if (key === null) return false;
2995
+ if (TEXT_KEYS.has(key)) return true;
2996
+ if (key !== "name" || parent?.type !== "ObjectExpression") return false;
2997
+ return (parent.properties ?? []).some((p) => p.type === "ObjectProperty" && NAV_KEYS.has(keyName(p) ?? ""));
2998
+ }
2999
+ function textsIn(init, reserved) {
3000
+ const strings = [];
3001
+ let clash = false;
3002
+ visit(init, [], (node, above) => {
3003
+ if (FUNCTIONS.has(node.type) && scopeNames(node).includes("t")) clash = true;
3004
+ if (node.type !== "ObjectProperty" || above.some((a) => FUNCTIONS.has(a.type) || a.type?.startsWith("JSX"))) return;
3005
+ const value = node.value;
3006
+ if (value.type === "StringLiteral" && isProse(String(value.value)) && !reserved.has(String(value.value)) && textProperty(node, above[above.length - 1])) strings.push(value);
3007
+ });
3008
+ return clash ? null : strings;
3009
+ }
3010
+ function dataIn(module2, reserved) {
3011
+ const found = [];
3012
+ for (const statement of module2.program.body ?? []) {
3013
+ const declaration = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
3014
+ if (declaration?.type !== "VariableDeclaration" || declaration.kind !== "const") continue;
3015
+ for (const declarator of declaration.declarations ?? []) {
3016
+ const id = declarator.id;
3017
+ const init = declarator.init;
3018
+ if (id.type !== "Identifier" || !init || RESERVED.has(String(id.name))) continue;
3019
+ if (!["ArrayExpression", "ObjectExpression"].includes(unwrap(init).type)) continue;
3020
+ const strings = textsIn(init, reserved);
3021
+ if (strings?.length) found.push({ module: module2, name: String(id.name), declarator, init, strings });
3022
+ }
3023
+ }
3024
+ return found;
3025
+ }
3026
+ function literalTypes(modules) {
3027
+ const found = /* @__PURE__ */ new Set();
3028
+ for (const module2 of modules) {
3029
+ visit(module2.program, [], (node) => {
3030
+ const literal = node.type === "TSLiteralType" ? node.literal : null;
3031
+ if (literal?.type === "StringLiteral") found.add(String(literal.value));
3032
+ });
3033
+ }
3034
+ return found;
3035
+ }
3036
+ function resolve3(from, source, known) {
3037
+ const bases = source.startsWith(".") ? [(0, import_node_path16.join)((0, import_node_path16.dirname)(from), source)] : ALIASES.test(source) ? [source.slice(2), (0, import_node_path16.join)("src", source.slice(2))] : [];
3038
+ for (const base of bases) {
3039
+ for (const extension of EXTENSIONS3) {
3040
+ const path = (0, import_node_path16.normalize)(`${base}${extension}`);
3041
+ if (known.has(path)) return path;
3042
+ }
3043
+ }
3044
+ return null;
3045
+ }
3046
+ function opaque(module2, known) {
3047
+ const paths = [];
3048
+ for (const statement of module2.program.body ?? []) {
3049
+ const source = statement.source;
3050
+ if (!source) continue;
3051
+ const path = resolve3(module2.path, String(source.value), known);
3052
+ if (!path) continue;
3053
+ const star = (statement.specifiers ?? []).some((s) => s.type === "ImportNamespaceSpecifier");
3054
+ if (statement.type !== "ImportDeclaration" || star) paths.push(path);
3055
+ }
3056
+ return paths;
3057
+ }
3058
+ function usesOf(module2, local, skip, lib) {
3059
+ const uses = [];
3060
+ let blocked = false;
3061
+ visit(module2.program, [], (node, above) => {
3062
+ if (blocked || node.type !== "Identifier" || node.name !== local || node === skip) return;
3063
+ const parent = above[above.length - 1];
3064
+ if (parent.type?.startsWith("Import") || parent.type === "TSTypeReference" || parent.type === "TSQualifiedName") return;
3065
+ if (parent.type === "MemberExpression" && parent.property === node && !parent.computed) return;
3066
+ if (parent.type === "OptionalMemberExpression" && parent.property === node && !parent.computed) return;
3067
+ if (parent.type === "ObjectProperty" && parent.key === node && !parent.computed && !parent.shorthand) return;
3068
+ const inner = above.slice(1);
3069
+ const shadowed = inner.some((a) => scopeNames(a).includes(local));
3070
+ const component = componentOf(above);
3071
+ const stuck = parent.type === "TSTypeQuery" || parent.type?.startsWith("Export") || Boolean(parent.shorthand);
3072
+ const inParams = component !== null && node.start < component.body.start;
3073
+ if (shadowed || stuck || inParams || !component || tInScope(inner) || lib === "react-i18next" && component.async) blocked = true;
3074
+ else uses.push({ module: module2, node, local, component });
3075
+ });
3076
+ return blocked ? null : uses;
3077
+ }
3078
+ function allUses(data, modules, known, lib) {
3079
+ const own = usesOf(data.module, data.name, data.declarator.id, lib);
3080
+ if (!own) return null;
3081
+ const uses = [...own];
3082
+ for (const module2 of modules) {
3083
+ if (module2 === data.module) continue;
3084
+ for (const statement of module2.program.body ?? []) {
3085
+ if (statement.type !== "ImportDeclaration" || resolve3(module2.path, String(statement.source.value), known) !== data.module.path) continue;
3086
+ const specifier2 = (statement.specifiers ?? []).find((s) => s.type === "ImportSpecifier" && s.imported.name === data.name);
3087
+ if (!specifier2) continue;
3088
+ const found = usesOf(module2, String(specifier2.local.name), null, lib);
3089
+ if (!found) return null;
3090
+ uses.push(...found);
3091
+ }
3092
+ }
3093
+ return uses.length ? uses : null;
3094
+ }
3095
+ function parseModule({ path, text }) {
3096
+ if (text.includes("use server")) return null;
3097
+ const outcome = (0, import_lint12.parseSourceOutcome)(path, text, path);
3098
+ const block = "file" in outcome ? outcome.file.js[0] : void 0;
3099
+ return block ? { path, text, base: block.offset, program: block.ast.program } : null;
3100
+ }
3101
+ function keyFor2(keys, stem, en) {
3102
+ const known = keys.byText.get(`${stem}
3103
+ ${en}`);
3104
+ if (known) return { key: known, isNew: false };
3105
+ const base = `${stem}.${keyWords(en)}`;
3106
+ let key = base;
3107
+ for (let n = 2; keys.used.has(key); n++) key = `${base}-${n}`;
3108
+ keys.used.add(key);
3109
+ keys.byText.set(`${stem}
3110
+ ${en}`, key);
3111
+ return { key, isNew: true };
3112
+ }
3113
+ function dataEdits(data, keys, entries) {
3114
+ const { module: module2, init, declarator, strings } = data;
3115
+ const { base, text, path } = module2;
3116
+ const annotation = declarator.id.typeAnnotation;
3117
+ const returns = annotation ? text.slice(base + annotation.start, base + annotation.end) : "";
3118
+ const edits2 = [
3119
+ { start: base + init.start, end: base + init.start, text: `(t: (key: string) => string)${returns} => (` },
3120
+ { start: base + init.end, end: base + init.end, text: ")" },
3121
+ ...annotation ? [{ start: base + annotation.start, end: base + annotation.end, text: "" }] : []
3122
+ ];
3123
+ const stem = stemOf(path);
3124
+ for (const literal of strings) {
3125
+ const en = String(literal.value);
3126
+ const { key, isNew } = keyFor2(keys, stem, en);
3127
+ if (isNew) entries.push({ key, en, ar: "", status: "pending", file: path, line: lineOf(text, base + literal.start) });
3128
+ edits2.push({ start: base + literal.start, end: base + literal.end, text: `t('${key}')` });
3129
+ }
3130
+ return edits2;
3131
+ }
3132
+ function liftData(inputs, lib, taken) {
3133
+ const modules = inputs.map(parseModule).filter((m) => m !== null);
3134
+ const known = new Set(inputs.map((i) => (0, import_node_path16.normalize)(i.path)));
3135
+ const barred = new Set(modules.flatMap((m) => opaque(m, known)));
3136
+ const reserved = literalTypes(modules);
3137
+ const edits2 = /* @__PURE__ */ new Map();
3138
+ const components = /* @__PURE__ */ new Map();
3139
+ const entries = [];
3140
+ const keys = { used: new Set(taken), byText: /* @__PURE__ */ new Map() };
3141
+ const push = (module2, more) => {
3142
+ edits2.set(module2, [...edits2.get(module2) ?? [], ...more]);
3143
+ };
3144
+ let moved = 0;
3145
+ for (const data of modules.filter((m) => !barred.has((0, import_node_path16.normalize)(m.path))).flatMap((m) => dataIn(m, reserved))) {
3146
+ const uses = allUses(data, modules, known, lib);
3147
+ if (!uses) continue;
3148
+ push(data.module, dataEdits(data, keys, entries));
3149
+ moved += data.strings.length;
3150
+ for (const use of uses) {
3151
+ push(use.module, [{ start: use.module.base + use.node.end, end: use.module.base + use.node.end, text: "(t)" }]);
3152
+ components.set(use.module, (components.get(use.module) ?? /* @__PURE__ */ new Set()).add(use.component));
3153
+ }
3154
+ }
3155
+ for (const [module2, found] of components) {
3156
+ const style = styleOf(module2.program, module2.text, module2.base);
3157
+ const hooks = [...found].map((fn) => hookEdits(fn, lib, module2.text, module2.base, style));
3158
+ push(module2, [...hooks.flatMap((h) => h.edits), ...importEdits(module2.program, hooks.flatMap((h) => h.imports), module2.base, style)]);
3159
+ }
3160
+ const texts = new Map([...edits2].map(([module2, list]) => [module2.path, applyEdits(module2.text, list)]));
3161
+ return { texts, entries, moved };
3162
+ }
3163
+
2904
3164
  // src/rollback.ts
2905
3165
  var import_node_fs9 = require("fs");
2906
- var import_node_path16 = require("path");
3166
+ var import_node_path17 = require("path");
2907
3167
  var ConvertFailedError = class extends Error {
2908
3168
  constructor(message, cause) {
2909
3169
  super(message);
@@ -2922,7 +3182,7 @@ async function rollBack(target, step, error) {
2922
3182
  const failed = `convert failed at ${step}: ${reasonOf(error)}.`;
2923
3183
  try {
2924
3184
  await resetHard(target.root);
2925
- for (const path of [...target.created].reverse()) (0, import_node_fs9.rmSync)((0, import_node_path16.join)(target.root, path), { recursive: true, force: true });
3185
+ for (const path of [...target.created].reverse()) (0, import_node_fs9.rmSync)((0, import_node_path17.join)(target.root, path), { recursive: true, force: true });
2926
3186
  if (target.madeBranch) {
2927
3187
  await checkout(target.root, target.base);
2928
3188
  await deleteBranch(target.root, target.branch);
@@ -3002,7 +3262,7 @@ var branchFor = (mode) => `dualkit/${mode}`;
3002
3262
  var problems = (flags) => flags.filter((f) => f.kind !== "info");
3003
3263
  function transformAll(project, generated, taken) {
3004
3264
  const { root } = project;
3005
- const walked = (0, import_lint12.walk)(["."], root);
3265
+ const walked = (0, import_lint13.walk)(["."], root);
3006
3266
  const used = new Set(taken);
3007
3267
  const ctx = {
3008
3268
  tailwind: project.tailwind,
@@ -3011,18 +3271,21 @@ function transformAll(project, generated, taken) {
3011
3271
  css: physicalContext(root)
3012
3272
  };
3013
3273
  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] };
3274
+ const lifted = lift(project, walked.files.filter((path) => !generated.has(path)), ctx.lib, used);
3275
+ out.entries.push(...lifted.entries);
3276
+ for (const entry of lifted.entries) used.add(entry.key);
3014
3277
  for (const path of walked.files) {
3015
3278
  if (generated.has(path)) continue;
3016
- const raw = readText(root, path);
3279
+ const raw = lifted.texts.get(path) ?? readText(root, path);
3017
3280
  if (raw === null) {
3018
3281
  out.skipped.push({ file: path, reason: "unreadable" });
3019
3282
  continue;
3020
3283
  }
3021
- if ((0, import_lint12.isMinified)(raw)) {
3284
+ if ((0, import_lint13.isMinified)(raw)) {
3022
3285
  out.skipped.push({ file: path, reason: "minified" });
3023
3286
  continue;
3024
3287
  }
3025
- const result = runPasses(path, raw, (0, import_node_path17.join)(root, path), { ...ctx, taken: used });
3288
+ const result = runPasses(path, raw, (0, import_node_path18.join)(root, path), { ...ctx, taken: used });
3026
3289
  if (result.kind === "skipped") {
3027
3290
  out.skipped.push({ file: path, reason: result.reason });
3028
3291
  continue;
@@ -3030,7 +3293,7 @@ function transformAll(project, generated, taken) {
3030
3293
  out.flags.push(...result.flags);
3031
3294
  out.entries.push(...result.entries);
3032
3295
  for (const entry of result.entries) used.add(entry.key);
3033
- if (result.edits === 0) continue;
3296
+ if (result.edits === 0 && !lifted.texts.has(path)) continue;
3034
3297
  out.edits += result.edits;
3035
3298
  for (const kind of Object.keys(out.byKind)) out.byKind[kind] += result.byKind[kind];
3036
3299
  out.perFile.set(path, result.edits);
@@ -3038,6 +3301,13 @@ function transformAll(project, generated, taken) {
3038
3301
  }
3039
3302
  return out;
3040
3303
  }
3304
+ function lift(project, paths, lib, taken) {
3305
+ const inputs = paths.flatMap((path) => {
3306
+ const text = /\.[jt]sx?$/.test(path) ? readText(project.root, path) : null;
3307
+ return text !== null && !text.startsWith(BOM) && !(0, import_lint13.isMinified)(text) ? [{ path, text }] : [];
3308
+ });
3309
+ return liftData(inputs, lib, taken);
3310
+ }
3041
3311
  function changedFiles(perFile, moved, wiring) {
3042
3312
  const total = /* @__PURE__ */ new Map();
3043
3313
  const add = (file, n) => {
@@ -3096,6 +3366,8 @@ async function convert(options, ioIn) {
3096
3366
  if (options.configAnswers) checkConfigAnswers(root, options.configFile);
3097
3367
  const io = options.configAnswers ? { ...ioIn, config: { ...ioIn.config, ...options.configAnswers } } : ioIn;
3098
3368
  const project = detectProject(root);
3369
+ const library = rerun ? null : existingI18n(root);
3370
+ if (library) throw new UnsupportedError(`This app already uses ${library}. Convert adds its own translation setup and the two collide, so nothing was changed. Convert supports apps with no translation library yet.`);
3099
3371
  const branch = branchFor(mode);
3100
3372
  const generated = new Set(state?.generated ?? []);
3101
3373
  const existing = readLedger(root);
@@ -3125,7 +3397,7 @@ async function convert(options, ioIn) {
3125
3397
 
3126
3398
  // src/finish.ts
3127
3399
  var import_node_fs11 = require("fs");
3128
- var import_node_path19 = require("path");
3400
+ var import_node_path20 = require("path");
3129
3401
 
3130
3402
  // src/match.ts
3131
3403
  var import_pngjs = require("pngjs");
@@ -3156,7 +3428,7 @@ function compare(a, b) {
3156
3428
 
3157
3429
  // src/finish-undo.ts
3158
3430
  var import_node_fs10 = require("fs");
3159
- var import_node_path18 = require("path");
3431
+ var import_node_path19 = require("path");
3160
3432
  async function snapshot(root, paths) {
3161
3433
  const clean = new Set(await cleanAtHead(root, paths));
3162
3434
  return paths.map((path) => ({ path, clean: clean.has(path), text: clean.has(path) ? null : readText(root, path) }));
@@ -3165,8 +3437,8 @@ async function undo(root, saved, staged) {
3165
3437
  await restoreFromHead(root, saved.filter((s) => s.clean).map((s) => s.path));
3166
3438
  const dirty = saved.filter((s) => !s.clean);
3167
3439
  for (const { path, text } of dirty) {
3168
- if (text === null) (0, import_node_fs10.rmSync)((0, import_node_path18.join)(root, path), { force: true });
3169
- else (0, import_node_fs10.writeFileSync)((0, import_node_path18.join)(root, path), text);
3440
+ if (text === null) (0, import_node_fs10.rmSync)((0, import_node_path19.join)(root, path), { force: true });
3441
+ else (0, import_node_fs10.writeFileSync)((0, import_node_path19.join)(root, path), text);
3170
3442
  }
3171
3443
  await unstage(root, [.../* @__PURE__ */ new Set([...dirty.map((s) => s.path), ...staged])]);
3172
3444
  }
@@ -3269,7 +3541,8 @@ function english(data) {
3269
3541
  }
3270
3542
  function arabic(data) {
3271
3543
  const lines = data.arabic.map(
3272
- (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")}`
3544
+ (p) => !Number.isFinite(p.errors) ? `- ${esc(p.route)}: ${esc(p.problems?.[0] ?? "the check returned no result")}` : `- ${esc(p.route)}: score ${p.score}, ${plural(p.errors, "error", "errors")}, ${plural(p.warnings, "warning", "warnings")}${(p.problems ?? []).map((m) => `
3545
+ - ${esc(m)}`).join("")}`
3273
3546
  );
3274
3547
  return section("Arabic pages", lines, "No pages were checked.");
3275
3548
  }
@@ -3344,7 +3617,7 @@ function writeMessages(root, ledger) {
3344
3617
  for (const [path, text] of Object.entries(messagesFiles(ledger))) writeText(disk, path, mergedMessages(root, path, text));
3345
3618
  }
3346
3619
  function mergedMessages(root, path, generated) {
3347
- const full = (0, import_node_path19.join)(root, path);
3620
+ const full = (0, import_node_path20.join)(root, path);
3348
3621
  if (!(0, import_node_fs11.existsSync)(full)) return generated;
3349
3622
  try {
3350
3623
  const existing = JSON.parse((0, import_node_fs11.readFileSync)(full, "utf8"));
@@ -3359,7 +3632,7 @@ async function englishChecks(root, base, routes, shoot) {
3359
3632
  const noBaseline = [];
3360
3633
  for (const route of routes) {
3361
3634
  for (const width of WIDTHS) {
3362
- const file = (0, import_node_path19.join)(root, baselinePath(route, width));
3635
+ const file = (0, import_node_path20.join)(root, baselinePath(route, width));
3363
3636
  if (!(0, import_node_fs11.existsSync)(file)) {
3364
3637
  noBaseline.push({ route, width });
3365
3638
  continue;
@@ -3369,8 +3642,8 @@ async function englishChecks(root, base, routes, shoot) {
3369
3642
  let diffPath;
3370
3643
  if (ratio >= MAX_DIFF_RATIO) {
3371
3644
  diffPath = `${DIFF_DIR}/${routeSlug(route)}-${width}.png`;
3372
- (0, import_node_fs11.mkdirSync)((0, import_node_path19.join)(root, DIFF_DIR), { recursive: true });
3373
- (0, import_node_fs11.writeFileSync)((0, import_node_path19.join)(root, diffPath), diff);
3645
+ (0, import_node_fs11.mkdirSync)((0, import_node_path20.join)(root, DIFF_DIR), { recursive: true });
3646
+ (0, import_node_fs11.writeFileSync)((0, import_node_path20.join)(root, diffPath), diff);
3374
3647
  }
3375
3648
  english2.push({ route, width, ratio, ...diffPath ? { diffPath } : {} });
3376
3649
  }
@@ -3385,11 +3658,21 @@ async function arabicChecks(project, state, base, routes, audit) {
3385
3658
  const result = await audit(`${base}${path}`, width, options);
3386
3659
  const usable = result && Number.isFinite(result.errors);
3387
3660
  const failed = { score: Number.NaN, errors: Number.NaN, warnings: Number.NaN };
3388
- arabic2.push({ route: `${path} at ${width}px`, ...usable ? result : failed });
3661
+ arabic2.push({ route: `${path} at ${width}px`, ...usable ? result : { ...failed, ...result?.problems ? { problems: result.problems } : {} } });
3389
3662
  }
3390
3663
  }
3391
3664
  return arabic2;
3392
3665
  }
3666
+ function closedAtBaseline(root) {
3667
+ const path = (0, import_node_path20.join)(root, CLOSED_FILE);
3668
+ if (!(0, import_node_fs11.existsSync)(path)) return /* @__PURE__ */ new Set();
3669
+ try {
3670
+ const routes = JSON.parse((0, import_node_fs11.readFileSync)(path, "utf8"));
3671
+ return new Set(Array.isArray(routes) ? routes.filter((r) => typeof r === "string") : []);
3672
+ } catch {
3673
+ return /* @__PURE__ */ new Set();
3674
+ }
3675
+ }
3393
3676
  async function runChecks(project, state, routes, { config, shoot, audit }, step) {
3394
3677
  const { start: cmd } = config;
3395
3678
  if (!cmd || !shoot) throw new RefusedError(NEEDS_BROWSER);
@@ -3400,8 +3683,13 @@ async function runChecks(project, state, routes, { config, shoot, audit }, step)
3400
3683
  });
3401
3684
  try {
3402
3685
  step.name = "checking the pages";
3403
- const english2 = state.mode === "bilingual" ? await englishChecks(project.root, server.url, routes, shoot) : { english: [], noBaseline: [] };
3404
- return { ...english2, arabic: await arabicChecks(project, state, server.url, routes, audit) };
3686
+ const closed = await closedRoutes(server.url, routes);
3687
+ const shut = new Set(closed.map((c) => c.route));
3688
+ const open = routes.filter((route) => !shut.has(route));
3689
+ const before = closedAtBaseline(project.root);
3690
+ const compared = routes.filter((route) => !(shut.has(route) && before.has(route)));
3691
+ const english2 = state.mode === "bilingual" ? await englishChecks(project.root, server.url, compared, shoot) : { english: [], noBaseline: [] };
3692
+ return { ...english2, closed, arabic: await arabicChecks(project, state, server.url, open, audit) };
3405
3693
  } finally {
3406
3694
  await server.stop();
3407
3695
  }
@@ -3442,21 +3730,21 @@ async function run(ready, step) {
3442
3730
  ledger,
3443
3731
  ...checks,
3444
3732
  ...earlier ? {} : { summaryMissing: true },
3445
- unreachable,
3733
+ unreachable: [...unreachable, ...checks.closed],
3446
3734
  remainingLint,
3447
3735
  ...tests2 ? { tests: { code: tests2.code, tail: tail(tests2.out) } } : {}
3448
3736
  };
3449
- (0, import_node_fs11.writeFileSync)((0, import_node_path19.join)(root, REPORT_FILE), renderConvertReport(data));
3737
+ (0, import_node_fs11.writeFileSync)((0, import_node_path20.join)(root, REPORT_FILE), renderConvertReport(data));
3450
3738
  step.name = "saving the state";
3451
3739
  saveState(root, { ...state, phase: "finished" });
3452
3740
  step.name = "saving your answers";
3453
3741
  if (options.configAnswers) writeConfigAnswers({ root, created: [] }, options.configAnswers, configFile);
3454
3742
  step.name = "committing";
3455
- await stage(root, own.filter((p) => (p !== configFile || options.configAnswers) && (0, import_node_fs11.existsSync)((0, import_node_path19.join)(root, p))));
3743
+ await stage(root, own.filter((p) => (p !== configFile || options.configAnswers) && (0, import_node_fs11.existsSync)((0, import_node_path20.join)(root, p))));
3456
3744
  if ((await stagedFiles(root)).length) await commit(root, `dualkit: finish ${state.mode} conversion`);
3457
3745
  const checked = checks.arabic.length > 0;
3458
3746
  const passed = checked && checks.english.every((p) => p.ratio < MAX_DIFF_RATIO) && checks.arabic.every((p) => p.errors === 0) && (tests2?.code ?? 0) === 0;
3459
- return { reportPath: (0, import_node_path19.join)(root, REPORT_FILE), passed };
3747
+ return { reportPath: (0, import_node_path20.join)(root, REPORT_FILE), passed };
3460
3748
  }
3461
3749
  async function finish(options, ioIn) {
3462
3750
  const ready = await prepare(options, ioIn);
package/dist/index.d.cts CHANGED
@@ -157,6 +157,7 @@ type FinishIo = ConvertIo & {
157
157
  score: number;
158
158
  errors: number;
159
159
  warnings: number;
160
+ problems?: string[];
160
161
  }>;
161
162
  lint: (root: string) => Promise<number>;
162
163
  };
package/dist/index.d.ts CHANGED
@@ -157,6 +157,7 @@ type FinishIo = ConvertIo & {
157
157
  score: number;
158
158
  errors: number;
159
159
  warnings: number;
160
+ problems?: string[];
160
161
  }>;
161
162
  lint: (root: string) => Promise<number>;
162
163
  };
package/dist/index.js CHANGED
@@ -116,6 +116,11 @@ function detectPackageManager(root) {
116
116
  if (top || dirname(dir) === dir) return "npm";
117
117
  }
118
118
  }
119
+ var I18N_LIBRARIES = ["next-intl", "react-i18next", "i18next", "next-i18next", "react-intl", "@lingui/core", "next-international", "@inlang/paraglide-js"];
120
+ function existingI18n(root) {
121
+ const deps = readDeps(root);
122
+ return I18N_LIBRARIES.find((name) => name in deps) ?? null;
123
+ }
119
124
  function detectI18n(deps) {
120
125
  if ("next-intl" in deps) return "next-intl";
121
126
  if ("react-i18next" in deps) return "react-i18next";
@@ -138,7 +143,7 @@ function detectProject(root) {
138
143
 
139
144
  // src/convert.ts
140
145
  import { isMinified, walk as walk4 } from "@dualkit/lint";
141
- import { join as join9 } from "path";
146
+ import { join as join10 } from "path";
142
147
 
143
148
  // src/apply.ts
144
149
  import { parseSourceOutcome } from "@dualkit/lint";
@@ -234,11 +239,11 @@ var RefusedError = class extends Error {
234
239
  };
235
240
  var NO_GIT = "git is not installed or not on PATH";
236
241
  function git(cwd, args) {
237
- return new Promise((resolve3, reject) => {
242
+ return new Promise((resolve4, reject) => {
238
243
  execFile("git", ["--literal-pathspecs", ...args], { cwd, maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => {
239
244
  if (error && error.code === "ENOENT") return reject(new Error(existsSync3(cwd) ? NO_GIT : `The folder ${cwd} does not exist.`));
240
245
  const code = error ? typeof error.code === "number" ? error.code : 1 : 0;
241
- resolve3({ code, out: `${stdout}${stderr}`.trim(), stdout });
246
+ resolve4({ code, out: `${stdout}${stderr}`.trim(), stdout });
242
247
  });
243
248
  });
244
249
  }
@@ -292,7 +297,7 @@ async function stagedFiles(root) {
292
297
  return out.split("\0").filter(Boolean);
293
298
  }
294
299
  async function commit(root, message) {
295
- await must(root, ["commit", "-q", "-m", message]);
300
+ await must(root, ["commit", "-q", "--no-verify", "-m", message]);
296
301
  }
297
302
  async function trackedFiles(root) {
298
303
  const out = await must(root, ["ls-files", "-z"]);
@@ -527,6 +532,26 @@ function writeConfigAnswers(disk, answers2, file = CONFIG_FILE) {
527
532
  return writeText(disk, file, mergedConfigText(readText(disk.root, file), answers2, file));
528
533
  }
529
534
 
535
+ // src/closed-routes.ts
536
+ var CLOSED = /* @__PURE__ */ new Set([401, 403, 404]);
537
+ var STATUS_TIMEOUT_MS = 6e4;
538
+ var closedStatus = async (url) => {
539
+ try {
540
+ const res = await fetch(url, { signal: AbortSignal.timeout(STATUS_TIMEOUT_MS), redirect: "manual" });
541
+ return CLOSED.has(res.status) ? res.status : null;
542
+ } catch {
543
+ return null;
544
+ }
545
+ };
546
+ async function closedRoutes(base, routes) {
547
+ const found = [];
548
+ for (const route of routes) {
549
+ const status = await closedStatus(`${base}${route}`);
550
+ if (status) found.push({ route, reason: `the English page returns ${status}, so its Arabic page was not checked` });
551
+ }
552
+ return found;
553
+ }
554
+
530
555
  // src/baseline.ts
531
556
  import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync5 } from "fs";
532
557
  import { join as join7 } from "path";
@@ -584,15 +609,15 @@ var ServerError = class extends Error {
584
609
  var OUTPUT_LINES = 20;
585
610
  var POLL_MS = 250;
586
611
  var KILL_GRACE_MS = 5e3;
587
- var sleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
612
+ var sleep = (ms) => new Promise((resolve4) => setTimeout(resolve4, ms));
588
613
  function portTaken(port) {
589
- return new Promise((resolve3) => {
614
+ return new Promise((resolve4) => {
590
615
  const socket = connect({ port, host: "localhost" });
591
616
  socket.once("connect", () => {
592
617
  socket.destroy();
593
- resolve3(true);
618
+ resolve4(true);
594
619
  });
595
- socket.once("error", () => resolve3(false));
620
+ socket.once("error", () => resolve4(false));
596
621
  });
597
622
  }
598
623
  async function answers(url) {
@@ -632,7 +657,7 @@ async function startServer(cmd, port, cwd, timeoutMs = 12e4) {
632
657
  });
633
658
  const pid = child.pid;
634
659
  if (pid === void 0) {
635
- await new Promise((resolve3) => setImmediate(resolve3));
660
+ await new Promise((resolve4) => setImmediate(resolve4));
636
661
  throw new ServerError(`Could not start "${cmd}": ${spawnError?.message ?? "no process id"}`);
637
662
  }
638
663
  const collect = (chunk) => {
@@ -671,6 +696,7 @@ ${tail2()}`);
671
696
 
672
697
  // src/baseline.ts
673
698
  var BASELINE_DIR = `${STATE_DIR}/baseline`;
699
+ var CLOSED_FILE = `${BASELINE_DIR}/closed.json`;
674
700
  var WIDTHS = [390, 1280];
675
701
  var DEFAULT_PORTS = { "next-app": 3e3, "vite-react": 5173 };
676
702
  var routeSlug = (route) => route.replace(/^\/+|\/+$/g, "").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "") || "home";
@@ -682,6 +708,7 @@ async function shootBaseline(project, config, shoot, log) {
682
708
  const server = await startServer(config.start, port, project.root);
683
709
  const shots = [];
684
710
  try {
711
+ writeFileSync5(join7(project.root, CLOSED_FILE), JSON.stringify((await closedRoutes(server.url, routes)).map((c) => c.route)));
685
712
  for (const route of routes) {
686
713
  for (const width of WIDTHS) {
687
714
  const path = baselinePath(route, width);
@@ -1058,6 +1085,7 @@ function pieceOf(child, ctx) {
1058
1085
  const source = ctx.file.text.slice(ctx.base + expression.start, ctx.base + expression.end);
1059
1086
  return { kind: "value", name, source: mayBeEmpty(expression) ? `${source} ?? ''` : source, start: start2, end };
1060
1087
  }
1088
+ var OWN_DIRECTION = /setAttribute\(\s*['"]dir['"]|documentElement\.dir\s*=[^=]/;
1061
1089
  var NODE_NAMES = /^(children|icon|avatar|logo|image|element|node)$/i;
1062
1090
  var mayBeEmpty = (expression) => /^Optional/.test(expression.type) || expression.type === "CallExpression" && !textMethodTarget(expression);
1063
1091
  var jsxText = (value) => value.replace(/^\s*\n\s*/, "").replace(/\s*\n\s*$/, "");
@@ -1307,6 +1335,15 @@ function extractStrings(file, lib, taken) {
1307
1335
  for (const block of file.js) {
1308
1336
  const program = block.ast.program;
1309
1337
  if (isServerFile(program)) continue;
1338
+ if (OWN_DIRECTION.test(file.text.slice(block.offset))) {
1339
+ flag(
1340
+ { file, flags },
1341
+ block.offset,
1342
+ "unsupported",
1343
+ "This file sets the page direction itself, which can clash with the Arabic layout.",
1344
+ "Check the Arabic pages, and drive this direction from the locale so components read it too."
1345
+ );
1346
+ }
1310
1347
  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, consumed: /* @__PURE__ */ new Set(), usedTrans: false };
1311
1348
  walk(program, ctx, []);
1312
1349
  if (ctx.sawJsx) flagModuleStrings(program, ctx);
@@ -1583,12 +1620,12 @@ function routeMoves(appDir, paths) {
1583
1620
  }
1584
1621
  return { moves, keptPages };
1585
1622
  }
1586
- function eachNode(node, visit) {
1587
- if (Array.isArray(node)) return node.forEach((child) => eachNode(child, visit));
1623
+ function eachNode(node, visit2) {
1624
+ if (Array.isArray(node)) return node.forEach((child) => eachNode(child, visit2));
1588
1625
  if (!node || typeof node !== "object") return;
1589
1626
  const n = node;
1590
- if (typeof n.type === "string") visit(n);
1591
- for (const [key, child] of Object.entries(n)) if (!SKIP3.has(key)) eachNode(child, visit);
1627
+ if (typeof n.type === "string") visit2(n);
1628
+ for (const [key, child] of Object.entries(n)) if (!SKIP3.has(key)) eachNode(child, visit2);
1592
1629
  }
1593
1630
  var RELATIVE = /^\.\.?\//;
1594
1631
  var stringLiteral = (node) => node?.type === "StringLiteral" ? node : null;
@@ -2061,7 +2098,7 @@ export function DualkitLanguageSwitch() {
2061
2098
  )
2062
2099
  }
2063
2100
  `;
2064
- 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>`;
2101
+ 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';if(l==='ar')new MutationObserver(function(){if(h.dir!=='rtl')h.dir='rtl'}).observe(h,{attributes:true,attributeFilter:['dir']})})()</script>`;
2065
2102
  function htmlEdits2(html, mode) {
2066
2103
  if (mode === "bilingual") {
2067
2104
  if (html.includes(LOCALE_COOKIE)) return [];
@@ -2657,12 +2694,27 @@ function visitExpression(container, ancestors, ctx) {
2657
2694
  ctx.edits.push({ start: ctx.base + container.start, end: ctx.base + container.start, text: "<bdi>" });
2658
2695
  ctx.edits.push({ start: ctx.base + container.end, end: ctx.base + container.end, text: "</bdi>" });
2659
2696
  }
2697
+ var SIGNED_NUMBER = /^(?:[+\-\u2212]\d[\d,.]*\s?%?|\d[\d,.]*\s?%)$/;
2698
+ var NO_BDI = /* @__PURE__ */ new Set(["svg", "text", "option", "title", "textarea", "style", "script"]);
2699
+ function visitText(text, ancestors, ctx) {
2700
+ const parent = ancestors[ancestors.length - 1];
2701
+ if (parent.type !== "JSXElement" && parent.type !== "JSXFragment") return;
2702
+ const value = String(text.value);
2703
+ const trimmed = value.trim();
2704
+ if (!SIGNED_NUMBER.test(trimmed)) return;
2705
+ const elements = ancestors.filter((a) => a.type === "JSXElement");
2706
+ if (elements.some((a) => isolates(a) || NO_BDI.has(tagName2(a.openingElement) ?? ""))) return;
2707
+ const start2 = ctx.base + text.start + value.indexOf(trimmed);
2708
+ ctx.edits.push({ start: start2, end: start2, text: "<bdi>" });
2709
+ ctx.edits.push({ start: start2 + trimmed.length, end: start2 + trimmed.length, text: "</bdi>" });
2710
+ }
2660
2711
  function walk3(node, ctx, ancestors) {
2661
2712
  if (Array.isArray(node)) return node.forEach((child) => walk3(child, ctx, ancestors));
2662
2713
  if (!node || typeof node !== "object") return;
2663
2714
  const n = node;
2664
2715
  if (n.type === "JSXOpeningElement") visitOpening(n, ctx, ancestors);
2665
2716
  if (n.type === "JSXExpressionContainer" && ancestors.length) visitExpression(n, ancestors, ctx);
2717
+ if (n.type === "JSXText" && ancestors.length) visitText(n, ancestors, ctx);
2666
2718
  const next = typeof n.type === "string" ? [...ancestors, n] : ancestors;
2667
2719
  for (const [key, child] of Object.entries(n)) if (!SKIP6.has(key)) walk3(child, ctx, next);
2668
2720
  }
@@ -2804,6 +2856,15 @@ function groups(fragments) {
2804
2856
  for (const f of fragments) byGroup.set(f.group, [...byGroup.get(f.group) ?? [], f]);
2805
2857
  return [...byGroup.values()];
2806
2858
  }
2859
+ var TRACKED = /^tracking-(?!normal$)/;
2860
+ function trackingEdits(fragments) {
2861
+ const tokens = tokensOf(fragments);
2862
+ if (tokens.some((t) => t.parsed.core.startsWith("tracking-") && t.parsed.variants.includes("rtl"))) return [];
2863
+ const first = tokens.find((t) => TRACKED.test(t.parsed.core) && KEPT(t));
2864
+ if (!first) return [];
2865
+ const end = first.at + first.text.length;
2866
+ return [{ start: end, end, text: " rtl:tracking-normal" }];
2867
+ }
2807
2868
  function tailwindEdits(file, version) {
2808
2869
  const parsed = parseVersion(version);
2809
2870
  const fragments = classStrings(file);
@@ -2816,7 +2877,7 @@ function tailwindEdits(file, version) {
2816
2877
  return { edits: [], flags: [{ file: file.path, line: lineOf(file.text, first.offset), kind: "unsupported", message, suggestion }] };
2817
2878
  }
2818
2879
  const sources = groups(fragments);
2819
- const edits2 = sources.flatMap((g) => [...physicalEdits(g, parsed), ...parsed[0] < 4 ? reverseEdits(g) : []]);
2880
+ const edits2 = sources.flatMap((g) => [...physicalEdits(g, parsed), ...parsed[0] < 4 ? reverseEdits(g) : [], ...trackingEdits(g)]);
2820
2881
  const flags = [...sources.flatMap((g) => formFlags(file, g)), ...computedFlags(file)];
2821
2882
  return { edits: edits2, flags: flags.sort((a, b) => a.line - b.line) };
2822
2883
  }
@@ -2866,9 +2927,208 @@ function runPasses(path, text, abs, ctx) {
2866
2927
  return { kind: "done", text: file.text, edits: edits2, byKind, flags, entries };
2867
2928
  }
2868
2929
 
2930
+ // src/transforms/data.ts
2931
+ import { dirname as dirname4, join as join8, normalize } from "path";
2932
+ import { parseSourceOutcome as parseSourceOutcome7 } from "@dualkit/lint";
2933
+ var SKIP8 = /* @__PURE__ */ new Set(["loc", "leadingComments", "trailingComments", "innerComments", "extra"]);
2934
+ var TEXT_KEYS = /* @__PURE__ */ new Set(["title", "label", "description", "placeholder", "subtitle", "message", "text", "alt", "heading", "caption"]);
2935
+ var NAV_KEYS = /* @__PURE__ */ new Set(["href", "url", "path", "to", "icon"]);
2936
+ var RESERVED = /* @__PURE__ */ new Set(["metadata", "viewport", "config", "routing"]);
2937
+ var PROSE2 = /^\p{Lu}\p{Ll}/u;
2938
+ var NOT_PROSE2 = /[/\\_=<>{}@#|]/;
2939
+ var EXTENSIONS3 = ["", ".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.tsx", "/index.js", "/index.jsx"];
2940
+ var ALIASES = /^[@~]\//;
2941
+ var unwrap = (node) => node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression" ? unwrap(node.expression) : node;
2942
+ var children = (node) => Object.entries(node).filter(([key]) => !SKIP8.has(key));
2943
+ function visit(node, ancestors, fn) {
2944
+ if (Array.isArray(node)) return node.forEach((child) => visit(child, ancestors, fn));
2945
+ if (!node || typeof node !== "object") return;
2946
+ const n = node;
2947
+ if (typeof n.type === "string") fn(n, ancestors);
2948
+ const next = typeof n.type === "string" ? [...ancestors, n] : ancestors;
2949
+ for (const [, child] of children(n)) visit(child, next, fn);
2950
+ }
2951
+ var keyName = (property) => {
2952
+ const key = property.key;
2953
+ if (property.computed) return null;
2954
+ return key.type === "Identifier" ? String(key.name) : key.type === "StringLiteral" ? String(key.value) : null;
2955
+ };
2956
+ var isProse = (value) => PROSE2.test(value) && !NOT_PROSE2.test(value);
2957
+ function textProperty(property, parent) {
2958
+ const key = keyName(property);
2959
+ if (key === null) return false;
2960
+ if (TEXT_KEYS.has(key)) return true;
2961
+ if (key !== "name" || parent?.type !== "ObjectExpression") return false;
2962
+ return (parent.properties ?? []).some((p) => p.type === "ObjectProperty" && NAV_KEYS.has(keyName(p) ?? ""));
2963
+ }
2964
+ function textsIn(init, reserved) {
2965
+ const strings = [];
2966
+ let clash = false;
2967
+ visit(init, [], (node, above) => {
2968
+ if (FUNCTIONS.has(node.type) && scopeNames(node).includes("t")) clash = true;
2969
+ if (node.type !== "ObjectProperty" || above.some((a) => FUNCTIONS.has(a.type) || a.type?.startsWith("JSX"))) return;
2970
+ const value = node.value;
2971
+ if (value.type === "StringLiteral" && isProse(String(value.value)) && !reserved.has(String(value.value)) && textProperty(node, above[above.length - 1])) strings.push(value);
2972
+ });
2973
+ return clash ? null : strings;
2974
+ }
2975
+ function dataIn(module, reserved) {
2976
+ const found = [];
2977
+ for (const statement of module.program.body ?? []) {
2978
+ const declaration = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
2979
+ if (declaration?.type !== "VariableDeclaration" || declaration.kind !== "const") continue;
2980
+ for (const declarator of declaration.declarations ?? []) {
2981
+ const id = declarator.id;
2982
+ const init = declarator.init;
2983
+ if (id.type !== "Identifier" || !init || RESERVED.has(String(id.name))) continue;
2984
+ if (!["ArrayExpression", "ObjectExpression"].includes(unwrap(init).type)) continue;
2985
+ const strings = textsIn(init, reserved);
2986
+ if (strings?.length) found.push({ module, name: String(id.name), declarator, init, strings });
2987
+ }
2988
+ }
2989
+ return found;
2990
+ }
2991
+ function literalTypes(modules) {
2992
+ const found = /* @__PURE__ */ new Set();
2993
+ for (const module of modules) {
2994
+ visit(module.program, [], (node) => {
2995
+ const literal = node.type === "TSLiteralType" ? node.literal : null;
2996
+ if (literal?.type === "StringLiteral") found.add(String(literal.value));
2997
+ });
2998
+ }
2999
+ return found;
3000
+ }
3001
+ function resolve3(from, source, known) {
3002
+ const bases = source.startsWith(".") ? [join8(dirname4(from), source)] : ALIASES.test(source) ? [source.slice(2), join8("src", source.slice(2))] : [];
3003
+ for (const base of bases) {
3004
+ for (const extension of EXTENSIONS3) {
3005
+ const path = normalize(`${base}${extension}`);
3006
+ if (known.has(path)) return path;
3007
+ }
3008
+ }
3009
+ return null;
3010
+ }
3011
+ function opaque(module, known) {
3012
+ const paths = [];
3013
+ for (const statement of module.program.body ?? []) {
3014
+ const source = statement.source;
3015
+ if (!source) continue;
3016
+ const path = resolve3(module.path, String(source.value), known);
3017
+ if (!path) continue;
3018
+ const star = (statement.specifiers ?? []).some((s) => s.type === "ImportNamespaceSpecifier");
3019
+ if (statement.type !== "ImportDeclaration" || star) paths.push(path);
3020
+ }
3021
+ return paths;
3022
+ }
3023
+ function usesOf(module, local, skip, lib) {
3024
+ const uses = [];
3025
+ let blocked = false;
3026
+ visit(module.program, [], (node, above) => {
3027
+ if (blocked || node.type !== "Identifier" || node.name !== local || node === skip) return;
3028
+ const parent = above[above.length - 1];
3029
+ if (parent.type?.startsWith("Import") || parent.type === "TSTypeReference" || parent.type === "TSQualifiedName") return;
3030
+ if (parent.type === "MemberExpression" && parent.property === node && !parent.computed) return;
3031
+ if (parent.type === "OptionalMemberExpression" && parent.property === node && !parent.computed) return;
3032
+ if (parent.type === "ObjectProperty" && parent.key === node && !parent.computed && !parent.shorthand) return;
3033
+ const inner = above.slice(1);
3034
+ const shadowed = inner.some((a) => scopeNames(a).includes(local));
3035
+ const component = componentOf(above);
3036
+ const stuck = parent.type === "TSTypeQuery" || parent.type?.startsWith("Export") || Boolean(parent.shorthand);
3037
+ const inParams = component !== null && node.start < component.body.start;
3038
+ if (shadowed || stuck || inParams || !component || tInScope(inner) || lib === "react-i18next" && component.async) blocked = true;
3039
+ else uses.push({ module, node, local, component });
3040
+ });
3041
+ return blocked ? null : uses;
3042
+ }
3043
+ function allUses(data, modules, known, lib) {
3044
+ const own = usesOf(data.module, data.name, data.declarator.id, lib);
3045
+ if (!own) return null;
3046
+ const uses = [...own];
3047
+ for (const module of modules) {
3048
+ if (module === data.module) continue;
3049
+ for (const statement of module.program.body ?? []) {
3050
+ if (statement.type !== "ImportDeclaration" || resolve3(module.path, String(statement.source.value), known) !== data.module.path) continue;
3051
+ const specifier2 = (statement.specifiers ?? []).find((s) => s.type === "ImportSpecifier" && s.imported.name === data.name);
3052
+ if (!specifier2) continue;
3053
+ const found = usesOf(module, String(specifier2.local.name), null, lib);
3054
+ if (!found) return null;
3055
+ uses.push(...found);
3056
+ }
3057
+ }
3058
+ return uses.length ? uses : null;
3059
+ }
3060
+ function parseModule({ path, text }) {
3061
+ if (text.includes("use server")) return null;
3062
+ const outcome = parseSourceOutcome7(path, text, path);
3063
+ const block = "file" in outcome ? outcome.file.js[0] : void 0;
3064
+ return block ? { path, text, base: block.offset, program: block.ast.program } : null;
3065
+ }
3066
+ function keyFor2(keys, stem, en) {
3067
+ const known = keys.byText.get(`${stem}
3068
+ ${en}`);
3069
+ if (known) return { key: known, isNew: false };
3070
+ const base = `${stem}.${keyWords(en)}`;
3071
+ let key = base;
3072
+ for (let n = 2; keys.used.has(key); n++) key = `${base}-${n}`;
3073
+ keys.used.add(key);
3074
+ keys.byText.set(`${stem}
3075
+ ${en}`, key);
3076
+ return { key, isNew: true };
3077
+ }
3078
+ function dataEdits(data, keys, entries) {
3079
+ const { module, init, declarator, strings } = data;
3080
+ const { base, text, path } = module;
3081
+ const annotation = declarator.id.typeAnnotation;
3082
+ const returns = annotation ? text.slice(base + annotation.start, base + annotation.end) : "";
3083
+ const edits2 = [
3084
+ { start: base + init.start, end: base + init.start, text: `(t: (key: string) => string)${returns} => (` },
3085
+ { start: base + init.end, end: base + init.end, text: ")" },
3086
+ ...annotation ? [{ start: base + annotation.start, end: base + annotation.end, text: "" }] : []
3087
+ ];
3088
+ const stem = stemOf(path);
3089
+ for (const literal of strings) {
3090
+ const en = String(literal.value);
3091
+ const { key, isNew } = keyFor2(keys, stem, en);
3092
+ if (isNew) entries.push({ key, en, ar: "", status: "pending", file: path, line: lineOf(text, base + literal.start) });
3093
+ edits2.push({ start: base + literal.start, end: base + literal.end, text: `t('${key}')` });
3094
+ }
3095
+ return edits2;
3096
+ }
3097
+ function liftData(inputs, lib, taken) {
3098
+ const modules = inputs.map(parseModule).filter((m) => m !== null);
3099
+ const known = new Set(inputs.map((i) => normalize(i.path)));
3100
+ const barred = new Set(modules.flatMap((m) => opaque(m, known)));
3101
+ const reserved = literalTypes(modules);
3102
+ const edits2 = /* @__PURE__ */ new Map();
3103
+ const components = /* @__PURE__ */ new Map();
3104
+ const entries = [];
3105
+ const keys = { used: new Set(taken), byText: /* @__PURE__ */ new Map() };
3106
+ const push = (module, more) => {
3107
+ edits2.set(module, [...edits2.get(module) ?? [], ...more]);
3108
+ };
3109
+ let moved = 0;
3110
+ for (const data of modules.filter((m) => !barred.has(normalize(m.path))).flatMap((m) => dataIn(m, reserved))) {
3111
+ const uses = allUses(data, modules, known, lib);
3112
+ if (!uses) continue;
3113
+ push(data.module, dataEdits(data, keys, entries));
3114
+ moved += data.strings.length;
3115
+ for (const use of uses) {
3116
+ push(use.module, [{ start: use.module.base + use.node.end, end: use.module.base + use.node.end, text: "(t)" }]);
3117
+ components.set(use.module, (components.get(use.module) ?? /* @__PURE__ */ new Set()).add(use.component));
3118
+ }
3119
+ }
3120
+ for (const [module, found] of components) {
3121
+ const style = styleOf(module.program, module.text, module.base);
3122
+ const hooks = [...found].map((fn) => hookEdits(fn, lib, module.text, module.base, style));
3123
+ push(module, [...hooks.flatMap((h) => h.edits), ...importEdits(module.program, hooks.flatMap((h) => h.imports), module.base, style)]);
3124
+ }
3125
+ const texts = new Map([...edits2].map(([module, list]) => [module.path, applyEdits(module.text, list)]));
3126
+ return { texts, entries, moved };
3127
+ }
3128
+
2869
3129
  // src/rollback.ts
2870
3130
  import { rmSync } from "fs";
2871
- import { join as join8 } from "path";
3131
+ import { join as join9 } from "path";
2872
3132
  var ConvertFailedError = class extends Error {
2873
3133
  constructor(message, cause) {
2874
3134
  super(message);
@@ -2887,7 +3147,7 @@ async function rollBack(target, step, error) {
2887
3147
  const failed = `convert failed at ${step}: ${reasonOf(error)}.`;
2888
3148
  try {
2889
3149
  await resetHard(target.root);
2890
- for (const path of [...target.created].reverse()) rmSync(join8(target.root, path), { recursive: true, force: true });
3150
+ for (const path of [...target.created].reverse()) rmSync(join9(target.root, path), { recursive: true, force: true });
2891
3151
  if (target.madeBranch) {
2892
3152
  await checkout(target.root, target.base);
2893
3153
  await deleteBranch(target.root, target.branch);
@@ -2976,9 +3236,12 @@ function transformAll(project, generated, taken) {
2976
3236
  css: physicalContext(root)
2977
3237
  };
2978
3238
  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] };
3239
+ const lifted = lift(project, walked.files.filter((path) => !generated.has(path)), ctx.lib, used);
3240
+ out.entries.push(...lifted.entries);
3241
+ for (const entry of lifted.entries) used.add(entry.key);
2979
3242
  for (const path of walked.files) {
2980
3243
  if (generated.has(path)) continue;
2981
- const raw = readText(root, path);
3244
+ const raw = lifted.texts.get(path) ?? readText(root, path);
2982
3245
  if (raw === null) {
2983
3246
  out.skipped.push({ file: path, reason: "unreadable" });
2984
3247
  continue;
@@ -2987,7 +3250,7 @@ function transformAll(project, generated, taken) {
2987
3250
  out.skipped.push({ file: path, reason: "minified" });
2988
3251
  continue;
2989
3252
  }
2990
- const result = runPasses(path, raw, join9(root, path), { ...ctx, taken: used });
3253
+ const result = runPasses(path, raw, join10(root, path), { ...ctx, taken: used });
2991
3254
  if (result.kind === "skipped") {
2992
3255
  out.skipped.push({ file: path, reason: result.reason });
2993
3256
  continue;
@@ -2995,7 +3258,7 @@ function transformAll(project, generated, taken) {
2995
3258
  out.flags.push(...result.flags);
2996
3259
  out.entries.push(...result.entries);
2997
3260
  for (const entry of result.entries) used.add(entry.key);
2998
- if (result.edits === 0) continue;
3261
+ if (result.edits === 0 && !lifted.texts.has(path)) continue;
2999
3262
  out.edits += result.edits;
3000
3263
  for (const kind of Object.keys(out.byKind)) out.byKind[kind] += result.byKind[kind];
3001
3264
  out.perFile.set(path, result.edits);
@@ -3003,6 +3266,13 @@ function transformAll(project, generated, taken) {
3003
3266
  }
3004
3267
  return out;
3005
3268
  }
3269
+ function lift(project, paths, lib, taken) {
3270
+ const inputs = paths.flatMap((path) => {
3271
+ const text = /\.[jt]sx?$/.test(path) ? readText(project.root, path) : null;
3272
+ return text !== null && !text.startsWith(BOM) && !isMinified(text) ? [{ path, text }] : [];
3273
+ });
3274
+ return liftData(inputs, lib, taken);
3275
+ }
3006
3276
  function changedFiles(perFile, moved, wiring) {
3007
3277
  const total = /* @__PURE__ */ new Map();
3008
3278
  const add = (file, n) => {
@@ -3061,6 +3331,8 @@ async function convert(options, ioIn) {
3061
3331
  if (options.configAnswers) checkConfigAnswers(root, options.configFile);
3062
3332
  const io = options.configAnswers ? { ...ioIn, config: { ...ioIn.config, ...options.configAnswers } } : ioIn;
3063
3333
  const project = detectProject(root);
3334
+ const library = rerun ? null : existingI18n(root);
3335
+ if (library) throw new UnsupportedError(`This app already uses ${library}. Convert adds its own translation setup and the two collide, so nothing was changed. Convert supports apps with no translation library yet.`);
3064
3336
  const branch = branchFor(mode);
3065
3337
  const generated = new Set(state?.generated ?? []);
3066
3338
  const existing = readLedger(root);
@@ -3090,7 +3362,7 @@ async function convert(options, ioIn) {
3090
3362
 
3091
3363
  // src/finish.ts
3092
3364
  import { existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync7, writeFileSync as writeFileSync7 } from "fs";
3093
- import { join as join11 } from "path";
3365
+ import { join as join12 } from "path";
3094
3366
 
3095
3367
  // src/match.ts
3096
3368
  import { PNG } from "pngjs";
@@ -3121,7 +3393,7 @@ function compare(a, b) {
3121
3393
 
3122
3394
  // src/finish-undo.ts
3123
3395
  import { rmSync as rmSync2, writeFileSync as writeFileSync6 } from "fs";
3124
- import { join as join10 } from "path";
3396
+ import { join as join11 } from "path";
3125
3397
  async function snapshot(root, paths) {
3126
3398
  const clean = new Set(await cleanAtHead(root, paths));
3127
3399
  return paths.map((path) => ({ path, clean: clean.has(path), text: clean.has(path) ? null : readText(root, path) }));
@@ -3130,8 +3402,8 @@ async function undo(root, saved, staged) {
3130
3402
  await restoreFromHead(root, saved.filter((s) => s.clean).map((s) => s.path));
3131
3403
  const dirty = saved.filter((s) => !s.clean);
3132
3404
  for (const { path, text } of dirty) {
3133
- if (text === null) rmSync2(join10(root, path), { force: true });
3134
- else writeFileSync6(join10(root, path), text);
3405
+ if (text === null) rmSync2(join11(root, path), { force: true });
3406
+ else writeFileSync6(join11(root, path), text);
3135
3407
  }
3136
3408
  await unstage(root, [.../* @__PURE__ */ new Set([...dirty.map((s) => s.path), ...staged])]);
3137
3409
  }
@@ -3234,7 +3506,8 @@ function english(data) {
3234
3506
  }
3235
3507
  function arabic(data) {
3236
3508
  const lines = data.arabic.map(
3237
- (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")}`
3509
+ (p) => !Number.isFinite(p.errors) ? `- ${esc(p.route)}: ${esc(p.problems?.[0] ?? "the check returned no result")}` : `- ${esc(p.route)}: score ${p.score}, ${plural(p.errors, "error", "errors")}, ${plural(p.warnings, "warning", "warnings")}${(p.problems ?? []).map((m) => `
3510
+ - ${esc(m)}`).join("")}`
3238
3511
  );
3239
3512
  return section("Arabic pages", lines, "No pages were checked.");
3240
3513
  }
@@ -3309,7 +3582,7 @@ function writeMessages(root, ledger) {
3309
3582
  for (const [path, text] of Object.entries(messagesFiles(ledger))) writeText(disk, path, mergedMessages(root, path, text));
3310
3583
  }
3311
3584
  function mergedMessages(root, path, generated) {
3312
- const full = join11(root, path);
3585
+ const full = join12(root, path);
3313
3586
  if (!existsSync8(full)) return generated;
3314
3587
  try {
3315
3588
  const existing = JSON.parse(readFileSync7(full, "utf8"));
@@ -3324,7 +3597,7 @@ async function englishChecks(root, base, routes, shoot) {
3324
3597
  const noBaseline = [];
3325
3598
  for (const route of routes) {
3326
3599
  for (const width of WIDTHS) {
3327
- const file = join11(root, baselinePath(route, width));
3600
+ const file = join12(root, baselinePath(route, width));
3328
3601
  if (!existsSync8(file)) {
3329
3602
  noBaseline.push({ route, width });
3330
3603
  continue;
@@ -3334,8 +3607,8 @@ async function englishChecks(root, base, routes, shoot) {
3334
3607
  let diffPath;
3335
3608
  if (ratio >= MAX_DIFF_RATIO) {
3336
3609
  diffPath = `${DIFF_DIR}/${routeSlug(route)}-${width}.png`;
3337
- mkdirSync5(join11(root, DIFF_DIR), { recursive: true });
3338
- writeFileSync7(join11(root, diffPath), diff);
3610
+ mkdirSync5(join12(root, DIFF_DIR), { recursive: true });
3611
+ writeFileSync7(join12(root, diffPath), diff);
3339
3612
  }
3340
3613
  english2.push({ route, width, ratio, ...diffPath ? { diffPath } : {} });
3341
3614
  }
@@ -3350,11 +3623,21 @@ async function arabicChecks(project, state, base, routes, audit) {
3350
3623
  const result = await audit(`${base}${path}`, width, options);
3351
3624
  const usable = result && Number.isFinite(result.errors);
3352
3625
  const failed = { score: Number.NaN, errors: Number.NaN, warnings: Number.NaN };
3353
- arabic2.push({ route: `${path} at ${width}px`, ...usable ? result : failed });
3626
+ arabic2.push({ route: `${path} at ${width}px`, ...usable ? result : { ...failed, ...result?.problems ? { problems: result.problems } : {} } });
3354
3627
  }
3355
3628
  }
3356
3629
  return arabic2;
3357
3630
  }
3631
+ function closedAtBaseline(root) {
3632
+ const path = join12(root, CLOSED_FILE);
3633
+ if (!existsSync8(path)) return /* @__PURE__ */ new Set();
3634
+ try {
3635
+ const routes = JSON.parse(readFileSync7(path, "utf8"));
3636
+ return new Set(Array.isArray(routes) ? routes.filter((r) => typeof r === "string") : []);
3637
+ } catch {
3638
+ return /* @__PURE__ */ new Set();
3639
+ }
3640
+ }
3358
3641
  async function runChecks(project, state, routes, { config, shoot, audit }, step) {
3359
3642
  const { start: cmd } = config;
3360
3643
  if (!cmd || !shoot) throw new RefusedError(NEEDS_BROWSER);
@@ -3365,8 +3648,13 @@ async function runChecks(project, state, routes, { config, shoot, audit }, step)
3365
3648
  });
3366
3649
  try {
3367
3650
  step.name = "checking the pages";
3368
- const english2 = state.mode === "bilingual" ? await englishChecks(project.root, server.url, routes, shoot) : { english: [], noBaseline: [] };
3369
- return { ...english2, arabic: await arabicChecks(project, state, server.url, routes, audit) };
3651
+ const closed = await closedRoutes(server.url, routes);
3652
+ const shut = new Set(closed.map((c) => c.route));
3653
+ const open = routes.filter((route) => !shut.has(route));
3654
+ const before = closedAtBaseline(project.root);
3655
+ const compared = routes.filter((route) => !(shut.has(route) && before.has(route)));
3656
+ const english2 = state.mode === "bilingual" ? await englishChecks(project.root, server.url, compared, shoot) : { english: [], noBaseline: [] };
3657
+ return { ...english2, closed, arabic: await arabicChecks(project, state, server.url, open, audit) };
3370
3658
  } finally {
3371
3659
  await server.stop();
3372
3660
  }
@@ -3407,21 +3695,21 @@ async function run(ready, step) {
3407
3695
  ledger,
3408
3696
  ...checks,
3409
3697
  ...earlier ? {} : { summaryMissing: true },
3410
- unreachable,
3698
+ unreachable: [...unreachable, ...checks.closed],
3411
3699
  remainingLint,
3412
3700
  ...tests2 ? { tests: { code: tests2.code, tail: tail(tests2.out) } } : {}
3413
3701
  };
3414
- writeFileSync7(join11(root, REPORT_FILE), renderConvertReport(data));
3702
+ writeFileSync7(join12(root, REPORT_FILE), renderConvertReport(data));
3415
3703
  step.name = "saving the state";
3416
3704
  saveState(root, { ...state, phase: "finished" });
3417
3705
  step.name = "saving your answers";
3418
3706
  if (options.configAnswers) writeConfigAnswers({ root, created: [] }, options.configAnswers, configFile);
3419
3707
  step.name = "committing";
3420
- await stage(root, own.filter((p) => (p !== configFile || options.configAnswers) && existsSync8(join11(root, p))));
3708
+ await stage(root, own.filter((p) => (p !== configFile || options.configAnswers) && existsSync8(join12(root, p))));
3421
3709
  if ((await stagedFiles(root)).length) await commit(root, `dualkit: finish ${state.mode} conversion`);
3422
3710
  const checked = checks.arabic.length > 0;
3423
3711
  const passed = checked && checks.english.every((p) => p.ratio < MAX_DIFF_RATIO) && checks.arabic.every((p) => p.errors === 0) && (tests2?.code ?? 0) === 0;
3424
- return { reportPath: join11(root, REPORT_FILE), passed };
3712
+ return { reportPath: join12(root, REPORT_FILE), passed };
3425
3713
  }
3426
3714
  async function finish(options, ioIn) {
3427
3715
  const ready = await prepare(options, ioIn);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dualkit/convert",
3
- "version": "0.3.0-beta.0",
3
+ "version": "0.5.0-beta.0",
4
4
  "description": "Converts an English React or Next.js app to Arabic on a new git branch.",
5
5
  "license": "MIT",
6
6
  "type": "module",