@dualkit/convert 0.3.0-beta.0 → 0.4.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";
@@ -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");
@@ -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);
@@ -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
  }
@@ -3096,6 +3157,8 @@ async function convert(options, ioIn) {
3096
3157
  if (options.configAnswers) checkConfigAnswers(root, options.configFile);
3097
3158
  const io = options.configAnswers ? { ...ioIn, config: { ...ioIn.config, ...options.configAnswers } } : ioIn;
3098
3159
  const project = detectProject(root);
3160
+ const library = rerun ? null : existingI18n(root);
3161
+ 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
3162
  const branch = branchFor(mode);
3100
3163
  const generated = new Set(state?.generated ?? []);
3101
3164
  const existing = readLedger(root);
@@ -3269,7 +3332,8 @@ function english(data) {
3269
3332
  }
3270
3333
  function arabic(data) {
3271
3334
  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")}`
3335
+ (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) => `
3336
+ - ${esc(m)}`).join("")}`
3273
3337
  );
3274
3338
  return section("Arabic pages", lines, "No pages were checked.");
3275
3339
  }
@@ -3385,11 +3449,21 @@ async function arabicChecks(project, state, base, routes, audit) {
3385
3449
  const result = await audit(`${base}${path}`, width, options);
3386
3450
  const usable = result && Number.isFinite(result.errors);
3387
3451
  const failed = { score: Number.NaN, errors: Number.NaN, warnings: Number.NaN };
3388
- arabic2.push({ route: `${path} at ${width}px`, ...usable ? result : failed });
3452
+ arabic2.push({ route: `${path} at ${width}px`, ...usable ? result : { ...failed, ...result?.problems ? { problems: result.problems } : {} } });
3389
3453
  }
3390
3454
  }
3391
3455
  return arabic2;
3392
3456
  }
3457
+ function closedAtBaseline(root) {
3458
+ const path = (0, import_node_path19.join)(root, CLOSED_FILE);
3459
+ if (!(0, import_node_fs11.existsSync)(path)) return /* @__PURE__ */ new Set();
3460
+ try {
3461
+ const routes = JSON.parse((0, import_node_fs11.readFileSync)(path, "utf8"));
3462
+ return new Set(Array.isArray(routes) ? routes.filter((r) => typeof r === "string") : []);
3463
+ } catch {
3464
+ return /* @__PURE__ */ new Set();
3465
+ }
3466
+ }
3393
3467
  async function runChecks(project, state, routes, { config, shoot, audit }, step) {
3394
3468
  const { start: cmd } = config;
3395
3469
  if (!cmd || !shoot) throw new RefusedError(NEEDS_BROWSER);
@@ -3400,8 +3474,13 @@ async function runChecks(project, state, routes, { config, shoot, audit }, step)
3400
3474
  });
3401
3475
  try {
3402
3476
  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) };
3477
+ const closed = await closedRoutes(server.url, routes);
3478
+ const shut = new Set(closed.map((c) => c.route));
3479
+ const open = routes.filter((route) => !shut.has(route));
3480
+ const before = closedAtBaseline(project.root);
3481
+ const compared = routes.filter((route) => !(shut.has(route) && before.has(route)));
3482
+ const english2 = state.mode === "bilingual" ? await englishChecks(project.root, server.url, compared, shoot) : { english: [], noBaseline: [] };
3483
+ return { ...english2, closed, arabic: await arabicChecks(project, state, server.url, open, audit) };
3405
3484
  } finally {
3406
3485
  await server.stop();
3407
3486
  }
@@ -3442,7 +3521,7 @@ async function run(ready, step) {
3442
3521
  ledger,
3443
3522
  ...checks,
3444
3523
  ...earlier ? {} : { summaryMissing: true },
3445
- unreachable,
3524
+ unreachable: [...unreachable, ...checks.closed],
3446
3525
  remainingLint,
3447
3526
  ...tests2 ? { tests: { code: tests2.code, tail: tail(tests2.out) } } : {}
3448
3527
  };
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";
@@ -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";
@@ -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);
@@ -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
  }
@@ -3061,6 +3122,8 @@ async function convert(options, ioIn) {
3061
3122
  if (options.configAnswers) checkConfigAnswers(root, options.configFile);
3062
3123
  const io = options.configAnswers ? { ...ioIn, config: { ...ioIn.config, ...options.configAnswers } } : ioIn;
3063
3124
  const project = detectProject(root);
3125
+ const library = rerun ? null : existingI18n(root);
3126
+ 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
3127
  const branch = branchFor(mode);
3065
3128
  const generated = new Set(state?.generated ?? []);
3066
3129
  const existing = readLedger(root);
@@ -3234,7 +3297,8 @@ function english(data) {
3234
3297
  }
3235
3298
  function arabic(data) {
3236
3299
  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")}`
3300
+ (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) => `
3301
+ - ${esc(m)}`).join("")}`
3238
3302
  );
3239
3303
  return section("Arabic pages", lines, "No pages were checked.");
3240
3304
  }
@@ -3350,11 +3414,21 @@ async function arabicChecks(project, state, base, routes, audit) {
3350
3414
  const result = await audit(`${base}${path}`, width, options);
3351
3415
  const usable = result && Number.isFinite(result.errors);
3352
3416
  const failed = { score: Number.NaN, errors: Number.NaN, warnings: Number.NaN };
3353
- arabic2.push({ route: `${path} at ${width}px`, ...usable ? result : failed });
3417
+ arabic2.push({ route: `${path} at ${width}px`, ...usable ? result : { ...failed, ...result?.problems ? { problems: result.problems } : {} } });
3354
3418
  }
3355
3419
  }
3356
3420
  return arabic2;
3357
3421
  }
3422
+ function closedAtBaseline(root) {
3423
+ const path = join11(root, CLOSED_FILE);
3424
+ if (!existsSync8(path)) return /* @__PURE__ */ new Set();
3425
+ try {
3426
+ const routes = JSON.parse(readFileSync7(path, "utf8"));
3427
+ return new Set(Array.isArray(routes) ? routes.filter((r) => typeof r === "string") : []);
3428
+ } catch {
3429
+ return /* @__PURE__ */ new Set();
3430
+ }
3431
+ }
3358
3432
  async function runChecks(project, state, routes, { config, shoot, audit }, step) {
3359
3433
  const { start: cmd } = config;
3360
3434
  if (!cmd || !shoot) throw new RefusedError(NEEDS_BROWSER);
@@ -3365,8 +3439,13 @@ async function runChecks(project, state, routes, { config, shoot, audit }, step)
3365
3439
  });
3366
3440
  try {
3367
3441
  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) };
3442
+ const closed = await closedRoutes(server.url, routes);
3443
+ const shut = new Set(closed.map((c) => c.route));
3444
+ const open = routes.filter((route) => !shut.has(route));
3445
+ const before = closedAtBaseline(project.root);
3446
+ const compared = routes.filter((route) => !(shut.has(route) && before.has(route)));
3447
+ const english2 = state.mode === "bilingual" ? await englishChecks(project.root, server.url, compared, shoot) : { english: [], noBaseline: [] };
3448
+ return { ...english2, closed, arabic: await arabicChecks(project, state, server.url, open, audit) };
3370
3449
  } finally {
3371
3450
  await server.stop();
3372
3451
  }
@@ -3407,7 +3486,7 @@ async function run(ready, step) {
3407
3486
  ledger,
3408
3487
  ...checks,
3409
3488
  ...earlier ? {} : { summaryMissing: true },
3410
- unreachable,
3489
+ unreachable: [...unreachable, ...checks.closed],
3411
3490
  remainingLint,
3412
3491
  ...tests2 ? { tests: { code: tests2.code, tail: tail(tests2.out) } } : {}
3413
3492
  };
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.4.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",