@dualkit/convert 0.2.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/README.md CHANGED
@@ -84,7 +84,8 @@ Convert reads a `convert` block from `dualkit.config.json`, or from the file `--
84
84
 
85
85
  Convert flags these in the report instead of guessing:
86
86
 
87
- - Text that shares an element with other elements, such as `Hello <b>{name}</b>`, or a plural built in code. These need rich text or a plural message, by hand. Rich text is not converted in this version.
87
+ - A word built by a ternary, such as `item{n === 1 ? '' : 's'}`. This needs a plural message, by hand.
88
+ - A sentence with a block element or a custom component inside it. Convert splits the text at that element, so each part is its own message.
88
89
  - Text outside any component, such as a module-level constant or a Next.js `metadata` title, and the `<title>` in a Vite app's `index.html`.
89
90
  - A `className` built at run time, which it cannot read in full.
90
91
  - Classes with no logical form, inline SVG icons and files it cannot parse.
@@ -99,6 +100,7 @@ It refuses, with a one-line reason, when:
99
100
 
100
101
  ## Known gaps
101
102
 
103
+ - next-intl needs TypeScript 5. An app on an older version will fail its type check after converting.
102
104
  - If the app has its own middleware or proxy file, convert moves it to `app-middleware` and runs it first, on the path without its locale. We tested this with a hand-written login check on Next.js 15. Clerk and other auth libraries are untested, so check your login after converting.
103
105
  - Convert does not place the language switch, because that would change the English look. It writes `components/dualkit-language-switch.tsx` and tells you to add it.
104
106
  - Next.js sends a visitor whose browser asks for Arabic from an English URL to the Arabic page. That is next-intl's locale detection, and convert leaves it on.
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"]);
@@ -392,6 +397,8 @@ function mergeLedger(existing, added) {
392
397
  }
393
398
  return [...existing, ...fresh].sort(byKey);
394
399
  }
400
+ var marks = (text) => [...text.matchAll(/\{[^}]*\}|<\/?[a-z0-9]+>/gi)].map((m) => m[0]).sort().join(" ");
401
+ var brokenMarks = (entries) => entries.filter((e) => e.status !== "pending" && e.ar !== "" && marks(e.ar) !== marks(e.en));
395
402
  var textFor = (entry, locale) => locale === "en" || entry.status === "pending" || entry.ar === "" ? entry.en : entry.ar;
396
403
  var group = () => /* @__PURE__ */ Object.create(null);
397
404
  function messagesFor(entries, locale) {
@@ -560,6 +567,26 @@ function writeConfigAnswers(disk, answers2, file = CONFIG_FILE) {
560
567
  return writeText(disk, file, mergedConfigText(readText(disk.root, file), answers2, file));
561
568
  }
562
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
+
563
590
  // src/baseline.ts
564
591
  var import_node_fs8 = require("fs");
565
592
  var import_node_path9 = require("path");
@@ -704,6 +731,7 @@ ${tail2()}`);
704
731
 
705
732
  // src/baseline.ts
706
733
  var BASELINE_DIR = `${STATE_DIR}/baseline`;
734
+ var CLOSED_FILE = `${BASELINE_DIR}/closed.json`;
707
735
  var WIDTHS = [390, 1280];
708
736
  var DEFAULT_PORTS = { "next-app": 3e3, "vite-react": 5173 };
709
737
  var routeSlug = (route) => route.replace(/^\/+|\/+$/g, "").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "") || "home";
@@ -715,6 +743,7 @@ async function shootBaseline(project, config, shoot, log) {
715
743
  const server = await startServer(config.start, port, project.root);
716
744
  const shots = [];
717
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)));
718
747
  for (const route of routes) {
719
748
  for (const width of WIDTHS) {
720
749
  const path = baselinePath(route, width);
@@ -944,7 +973,25 @@ var tInScope = (ancestors) => ancestors.some((node) => scopeNames(node).includes
944
973
 
945
974
  // src/transforms/strings.ts
946
975
  var SKIP = /* @__PURE__ */ new Set(["loc", "leadingComments", "trailingComments", "innerComments", "extra"]);
947
- var TEXT_ATTRIBUTES = /* @__PURE__ */ new Set(["alt", "title", "placeholder", "aria-label", "label"]);
976
+ var TEXT_ATTRIBUTES = /* @__PURE__ */ new Set([
977
+ "alt",
978
+ "title",
979
+ "placeholder",
980
+ "aria-label",
981
+ "label",
982
+ "aria-description",
983
+ "aria-placeholder",
984
+ "aria-roledescription",
985
+ "description",
986
+ "subtitle",
987
+ "heading",
988
+ "caption",
989
+ "hint",
990
+ "tooltip",
991
+ "helperText",
992
+ "pageTitle"
993
+ ]);
994
+ var INLINE = /* @__PURE__ */ new Set(["a", "abbr", "b", "bdi", "cite", "code", "del", "em", "i", "ins", "kbd", "Link", "mark", "q", "s", "small", "span", "strong", "sub", "sup", "u"]);
948
995
  var LETTER = /\p{L}/u;
949
996
  var PROSE = /^\p{Lu}\p{Ll}/u;
950
997
  var NOT_PROSE = /[/\\_=<>{}@#|]/;
@@ -962,7 +1009,7 @@ function stemOf(path) {
962
1009
  if (!ROUTE_STEMS.has(stem)) return kebab(stem) || "file";
963
1010
  return kebab(routeDirs(parts).join("-")) || "home";
964
1011
  }
965
- var keyWords = (en) => ascii(en.replace(/\{[^}]*\}/g, " ")).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).slice(0, 4).join("-") || "text";
1012
+ var keyWords = (en) => ascii(en.replace(/\{[^}]*\}|<[^>]*>/g, " ")).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).slice(0, 4).join("-") || "text";
966
1013
  function flag(ctx, offset, kind, message, suggestion) {
967
1014
  ctx.flags.push({ file: ctx.file.path, line: lineOf(ctx.file.text, offset), kind, message, suggestion });
968
1015
  }
@@ -976,7 +1023,7 @@ function keyFor(ctx, en) {
976
1023
  ctx.byText.set(en, key);
977
1024
  return key;
978
1025
  }
979
- function place(ctx, ancestors, start2, end, en, values) {
1026
+ function place(ctx, ancestors, { start: start2, end, en, code, hookless }) {
980
1027
  const component = componentOf(ancestors);
981
1028
  if (!component) {
982
1029
  flag(
@@ -1011,8 +1058,8 @@ function place(ctx, ancestors, start2, end, en, values) {
1011
1058
  const isNew = !ctx.byText.has(en);
1012
1059
  const key = keyFor(ctx, en);
1013
1060
  if (isNew) ctx.entries.push({ key, en, ar: "", status: "pending", file: ctx.file.path, line: lineOf(ctx.file.text, start2) });
1014
- ctx.edits.push({ start: start2, end, text: `{t('${key}'${values ? `, { ${values} }` : ""})}` });
1015
- ctx.components.add(component);
1061
+ ctx.edits.push({ start: start2, end, text: code(key) });
1062
+ if (!hookless) ctx.components.add(component);
1016
1063
  }
1017
1064
  var TEXT_METHODS = /^(toLocale(Date|Time)?String|toFixed|toPrecision|toString|toUpperCase|toLowerCase|trim(Start|End)?|padStart|padEnd)$/;
1018
1065
  function textMethodTarget(expression) {
@@ -1032,6 +1079,21 @@ function placeholderName(expression) {
1032
1079
  }
1033
1080
  return null;
1034
1081
  }
1082
+ function holdsJsx(node) {
1083
+ if (Array.isArray(node)) return node.some(holdsJsx);
1084
+ if (!node || typeof node !== "object") return false;
1085
+ const n = node;
1086
+ if (n.type === "JSXElement" || n.type === "JSXFragment") return true;
1087
+ return Object.entries(n).some(([key, child]) => !SKIP.has(key) && holdsJsx(child));
1088
+ }
1089
+ function callName(expression) {
1090
+ if (expression.type !== "CallExpression" && expression.type !== "OptionalCallExpression") return null;
1091
+ if (holdsJsx(expression)) return null;
1092
+ const callee = expression.callee;
1093
+ if (callee.type === "Identifier") return callee.name === "t" ? null : callee.name;
1094
+ if ((callee.type === "MemberExpression" || callee.type === "OptionalMemberExpression") && !callee.computed) return placeholderName(callee);
1095
+ return null;
1096
+ }
1035
1097
  function hasText(element) {
1036
1098
  return (element.children ?? []).some((c) => c.type === "JSXText" ? /\S/.test(String(c.value)) : c.type === "JSXExpressionContainer" ? c.expression.type !== "JSXEmptyExpression" : c.type === "JSXSpreadChild" || hasText(c));
1037
1099
  }
@@ -1040,30 +1102,34 @@ function pieceOf(child, ctx) {
1040
1102
  const end = ctx.base + child.end;
1041
1103
  if (child.type === "JSXText") {
1042
1104
  const raw = ctx.file.text.slice(start2, end);
1043
- if (!/\S/.test(raw)) return { kind: "space", start: start2, end };
1105
+ if (!/\S/.test(raw)) return { kind: "space", value: raw.includes("\n") ? "" : " ", start: start2, end };
1044
1106
  const lead = raw.length - raw.trimStart().length;
1045
1107
  const trail = raw.length - raw.trimEnd().length;
1046
- return { kind: "text", value: String(child.value), start: start2 + lead, end: end - trail };
1108
+ return { kind: "text", value: jsxText(String(child.value)), start: start2 + lead, end: end - trail };
1047
1109
  }
1048
- if (child.type === "JSXSpreadChild") return { kind: "other", start: start2, end };
1049
- if (child.type !== "JSXExpressionContainer") return { kind: "element", texty: hasText(child), start: start2, end };
1110
+ if (child.type === "JSXSpreadChild") return { kind: "other", node: child, start: start2, end };
1111
+ if (child.type !== "JSXExpressionContainer") return { kind: "element", node: child, start: start2, end };
1050
1112
  const expression = child.expression;
1051
1113
  if (expression.type === "JSXEmptyExpression") return { kind: "comment", start: start2, end };
1052
1114
  if (expression.type === "StringLiteral") {
1053
1115
  const value = String(expression.value);
1054
- return /\S/.test(value) ? { kind: "text", value, start: start2, end } : { kind: "space", start: start2, end };
1116
+ return /\S/.test(value) ? { kind: "text", value, start: start2, end } : { kind: "space", value, start: start2, end };
1055
1117
  }
1056
- const name = placeholderName(expression);
1057
- if (!name) return { kind: "other", start: start2, end };
1118
+ const name = placeholderName(expression) ?? callName(expression);
1119
+ if (!name || NODE_NAMES.test(name)) return { kind: "other", node: expression, start: start2, end };
1058
1120
  const source = ctx.file.text.slice(ctx.base + expression.start, ctx.base + expression.end);
1059
- return { kind: "value", name, source, start: start2, end };
1121
+ return { kind: "value", name, source: mayBeEmpty(expression) ? `${source} ?? ''` : source, start: start2, end };
1060
1122
  }
1123
+ var OWN_DIRECTION = /setAttribute\(\s*['"]dir['"]|documentElement\.dir\s*=[^=]/;
1124
+ var NODE_NAMES = /^(children|icon|avatar|logo|image|element|node)$/i;
1125
+ var mayBeEmpty = (expression) => /^Optional/.test(expression.type) || expression.type === "CallExpression" && !textMethodTarget(expression);
1126
+ var jsxText = (value) => value.replace(/^\s*\n\s*/, "").replace(/\s*\n\s*$/, "");
1061
1127
  var mixed = (ctx, offset) => flag(
1062
1128
  ctx,
1063
1129
  offset,
1064
1130
  "mixed-text",
1065
- "This text shares its element with other elements or expressions, so it was left as it is.",
1066
- "Translate the whole sentence with rich text or a plural message, by hand."
1131
+ "This text shares its element with an expression convert cannot read as one sentence, so it was left as it is.",
1132
+ "Translate the whole sentence with a plural message, by hand."
1067
1133
  );
1068
1134
  function valuesOf(pieces) {
1069
1135
  const seen = /* @__PURE__ */ new Map();
@@ -1074,67 +1140,139 @@ function valuesOf(pieces) {
1074
1140
  }
1075
1141
  return [...seen].map(([name, source]) => name === source ? name : `${name}: ${source}`).join(", ");
1076
1142
  }
1077
- function unitsOf(pieces) {
1078
- const units = [];
1143
+ var tagName = (element) => {
1144
+ const name = element.openingElement?.name;
1145
+ return name?.type === "JSXIdentifier" ? name.name : null;
1146
+ };
1147
+ var childPieces = (element, ctx) => (element.children ?? []).map((c) => pieceOf(c, ctx));
1148
+ function isInline(element, ctx) {
1149
+ if (!INLINE.has(tagName(element) ?? "") || !hasText(element)) return false;
1150
+ return childPieces(element, ctx).every((p) => p.kind === "text" || p.kind === "value" || p.kind === "space");
1151
+ }
1152
+ function runsOf(pieces, ctx) {
1153
+ const runs = [];
1079
1154
  let run2 = [];
1080
- let before;
1081
- for (const piece of [...pieces, void 0]) {
1082
- if (piece && piece.kind !== "element") {
1083
- run2.push(piece);
1084
- continue;
1085
- }
1086
- const texty = (p) => p?.kind === "element" && p.texty;
1087
- if (run2.length) units.push({ run: run2, touchesText: texty(before) || texty(piece) });
1088
- run2 = [];
1089
- before = piece;
1155
+ for (const piece of pieces) {
1156
+ if (piece.kind === "other" || piece.kind === "element" && !isInline(piece.node, ctx)) {
1157
+ if (run2.length) runs.push(run2);
1158
+ run2 = [];
1159
+ } else run2 = [...run2, piece];
1160
+ }
1161
+ return run2.length ? [...runs, run2] : runs;
1162
+ }
1163
+ var gluedTo = (piece, next, previous) => piece.kind === "other" && piece.node.type === "ConditionalExpression" && branchStrings(piece.node).some((n) => String(n.value).length < 3) && (previous?.kind === "text" && /\S$/.test(previous.value) || next?.kind === "text" && /^\S/.test(next.value));
1164
+ function tagsOf(run2, ctx) {
1165
+ const counts = /* @__PURE__ */ new Map();
1166
+ const tags = /* @__PURE__ */ new Map();
1167
+ for (const piece of run2) {
1168
+ if (piece.kind !== "element") continue;
1169
+ const name = tagName(piece.node);
1170
+ const base = name === "Link" ? "navlink" : name.toLowerCase();
1171
+ const count = (counts.get(base) ?? 0) + 1;
1172
+ counts.set(base, count);
1173
+ const open = piece.node.openingElement;
1174
+ const close = piece.node.closingElement;
1175
+ tags.set(piece, {
1176
+ tag: count === 1 ? base : `${base}${count}`,
1177
+ open: ctx.file.text.slice(ctx.base + open.start, ctx.base + open.end),
1178
+ close: ctx.file.text.slice(ctx.base + close.start, ctx.base + close.end),
1179
+ inner: childPieces(piece.node, ctx)
1180
+ });
1090
1181
  }
1091
- return units;
1182
+ return tags;
1092
1183
  }
1093
- function messageOf(pieces) {
1094
- if (pieces.some((p) => p.kind === "other")) return null;
1184
+ var plainText = (pieces) => pieces.map((p) => p.kind === "text" || p.kind === "space" ? p.value : p.kind === "value" ? `{${p.name}}` : "").join("");
1185
+ var nextIntlCode = (key, values, tags) => {
1186
+ if (!tags.length) return `{t('${key}'${values ? `, { ${values} }` : ""})}`;
1187
+ const parts = [...values ? [values] : [], ...tags.map((t) => `${t.tag}: (chunks) => ${t.open}{chunks}${t.close}`)];
1188
+ return `{t.rich('${key}', { ${parts.join(", ")} })}`;
1189
+ };
1190
+ var reactI18nextCode = (key, values, tags) => {
1191
+ if (!tags.length) return nextIntlCode(key, values, tags);
1192
+ const components = tags.map((t) => `${t.tag}: ${t.open.replace(/>$/, " />")}`).join(", ");
1193
+ return `<Trans i18nKey="${key}"${values ? ` values={{ ${values} }}` : ""} components={{ ${components} }} />`;
1194
+ };
1195
+ function messageOf(pieces, ctx) {
1095
1196
  const edge = (p) => p.kind !== "space" && p.kind !== "comment";
1096
1197
  const first = pieces.findIndex(edge);
1097
1198
  const last = pieces.length - 1 - [...pieces].reverse().findIndex(edge);
1098
1199
  const run2 = pieces.slice(first, last + 1);
1099
- const values = valuesOf(run2);
1100
- if (values === null || run2.some((p) => p.kind === "comment")) return null;
1101
- const en = run2.map((p) => p.kind === "text" ? p.value : p.kind === "value" ? `{${p.name}}` : " ").join("").replace(/\s+/g, " ").trim();
1102
- return { start: run2[0].start, end: run2[run2.length - 1].end, en, values };
1200
+ if (run2.some((p) => p.kind === "comment")) return null;
1201
+ const tags = tagsOf(run2, ctx);
1202
+ const values = valuesOf([...run2, ...[...tags.values()].flatMap((t) => t.inner)]);
1203
+ if (values === null) return null;
1204
+ const en = run2.map((p) => {
1205
+ const tag = tags.get(p);
1206
+ return tag ? `<${tag.tag}>${plainText(tag.inner)}</${tag.tag}>` : plainText([p]);
1207
+ }).join("").replace(/\s+/g, " ").trim();
1208
+ const list = [...tags.values()];
1209
+ const code = (key) => (ctx.lib === "react-i18next" ? reactI18nextCode : nextIntlCode)(key, values, list);
1210
+ return { start: run2[0].start, end: run2[run2.length - 1].end, en, code, tags: list, hookless: ctx.lib === "react-i18next" && list.length > 0 };
1103
1211
  }
1104
1212
  function visitChildren(element, ctx, ancestors) {
1105
- const pieces = (element.children ?? []).map((c) => pieceOf(c, ctx));
1106
- const messages = [];
1107
- for (const { run: run2, touchesText } of unitsOf(pieces)) {
1213
+ const pieces = childPieces(element, ctx);
1214
+ pieces.forEach((piece, i) => {
1215
+ if (!gluedTo(piece, pieces[i + 1], pieces[i - 1])) return;
1216
+ ctx.consumed.add(piece.node);
1217
+ });
1218
+ const glued = pieces.filter((p, i) => p.kind === "other" && gluedTo(p, pieces[i + 1], pieces[i - 1]));
1219
+ for (const run2 of runsOf(pieces, ctx)) {
1108
1220
  const lettered = run2.find((p) => p.kind === "text" && LETTER.test(p.value));
1109
1221
  if (!lettered) continue;
1110
- const message = touchesText ? null : messageOf(run2);
1222
+ const touchesGlue = glued.some((g) => run2.some((p) => p.end === g.start || p.start === g.end || ctx.file.text.slice(p.end, g.start).trim() === "" || ctx.file.text.slice(g.end, p.start).trim() === ""));
1223
+ const message = touchesGlue ? null : messageOf(run2, ctx);
1111
1224
  if (!message) {
1112
1225
  mixed(ctx, lettered.start);
1113
- return false;
1226
+ continue;
1114
1227
  }
1115
- messages.push(message);
1228
+ if (message.tags.length) {
1229
+ run2.forEach((p) => {
1230
+ if (p.kind === "element") ctx.consumed.add(p.node);
1231
+ });
1232
+ if (ctx.lib === "react-i18next") ctx.usedTrans = true;
1233
+ }
1234
+ place(ctx, ancestors, message);
1116
1235
  }
1117
- for (const m of messages) place(ctx, ancestors, m.start, m.end, m.en, m.values);
1118
- return true;
1119
1236
  }
1237
+ var bracedCall = (values) => (key) => `{t('${key}'${values ? `, { ${values} }` : ""})}`;
1120
1238
  function visitAttribute(attribute, ctx, ancestors) {
1121
1239
  const name = attribute.name;
1122
1240
  if (name.type !== "JSXIdentifier" || !TEXT_ATTRIBUTES.has(name.name)) return;
1123
1241
  const value = attribute.value;
1124
1242
  const literal = value?.type === "JSXExpressionContainer" ? value.expression : value;
1125
1243
  if (literal?.type !== "StringLiteral" || !LETTER.test(String(literal.value))) return;
1244
+ if (name.name === "placeholder" && /^(blur|empty)$/.test(String(literal.value))) return;
1126
1245
  const en = String(literal.value).replace(/\s+/g, " ").trim();
1127
- place(ctx, ancestors, ctx.base + value.start, ctx.base + value.end, en, "");
1246
+ place(ctx, ancestors, { start: ctx.base + value.start, end: ctx.base + value.end, en, code: bracedCall("") });
1247
+ }
1248
+ function branchStrings(node) {
1249
+ if (node.type === "StringLiteral") return [node];
1250
+ if (node.type === "ConditionalExpression") return [...branchStrings(node.consequent), ...branchStrings(node.alternate)];
1251
+ if (node.type === "LogicalExpression") return branchStrings(node.right);
1252
+ return [];
1253
+ }
1254
+ function visitBranches(container, ctx, ancestors) {
1255
+ const parent = ancestors.at(-1);
1256
+ const expression = container.expression;
1257
+ const shown = parent?.type === "JSXElement" || parent?.type === "JSXFragment" || parent?.type === "JSXAttribute" && TEXT_ATTRIBUTES.has(parent.name.name);
1258
+ if (!shown || expression.type === "StringLiteral" || ctx.consumed.has(expression)) return;
1259
+ for (const literal of branchStrings(expression)) {
1260
+ const en = String(literal.value).replace(/\s+/g, " ").trim();
1261
+ if (!LETTER.test(en) || NOT_PROSE.test(en)) continue;
1262
+ place(ctx, ancestors, { start: ctx.base + literal.start, end: ctx.base + literal.end, en, code: (key) => `t('${key}')` });
1263
+ }
1128
1264
  }
1129
1265
  function walk(node, ctx, ancestors) {
1130
1266
  if (Array.isArray(node)) return node.forEach((child) => walk(child, ctx, ancestors));
1131
1267
  if (!node || typeof node !== "object") return;
1132
1268
  const n = node;
1269
+ if (ctx.consumed.has(n)) return;
1133
1270
  if (n.type === "JSXElement" || n.type === "JSXFragment") {
1134
1271
  ctx.sawJsx = true;
1135
- if (!visitChildren(n, ctx, ancestors)) return;
1272
+ visitChildren(n, ctx, ancestors);
1136
1273
  }
1137
1274
  if (n.type === "JSXAttribute") visitAttribute(n, ctx, ancestors);
1275
+ if (n.type === "JSXExpressionContainer") visitBranches(n, ctx, ancestors);
1138
1276
  const next = typeof n.type === "string" ? [...ancestors, n] : ancestors;
1139
1277
  for (const [key, child] of Object.entries(n)) if (!SKIP.has(key)) walk(child, ctx, next);
1140
1278
  }
@@ -1232,13 +1370,23 @@ function extractStrings(file, lib, taken) {
1232
1370
  for (const block of file.js) {
1233
1371
  const program = block.ast.program;
1234
1372
  if (isServerFile(program)) continue;
1235
- const ctx = { file, base: block.offset, lib, stem: stemOf(file.path), used, byText: /* @__PURE__ */ new Map(), entries, edits: edits2, flags, components: /* @__PURE__ */ new Set(), sawJsx: false };
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
+ }
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 };
1236
1383
  walk(program, ctx, []);
1237
1384
  if (ctx.sawJsx) flagModuleStrings(program, ctx);
1238
1385
  const style = styleOf(program, file.text, ctx.base);
1239
1386
  const hooks = [...ctx.components].map((fn) => hookEdits(fn, lib, file.text, ctx.base, style));
1240
1387
  edits2.push(...hooks.flatMap((h) => h.edits));
1241
- edits2.push(...importEdits(program, hooks.flatMap((h) => h.imports), ctx.base, style));
1388
+ const trans = ctx.usedTrans ? [{ name: "Trans", source: "react-i18next" }] : [];
1389
+ edits2.push(...importEdits(program, [...hooks.flatMap((h) => h.imports), ...trans], ctx.base, style));
1242
1390
  }
1243
1391
  return { edits: edits2, flags, entries };
1244
1392
  }
@@ -1933,7 +2081,7 @@ function wireNextIntl(project, mode, files, paths) {
1933
2081
  var import_lint6 = require("@dualkit/lint");
1934
2082
  var import_node_path14 = require("path");
1935
2083
  var DEPS2 = { "react-i18next": "17.1.6", i18next: "26.5.8" };
1936
- var INIT_PATH = "src/i18n.ts";
2084
+ var INIT_PATH = "src/dualkit-i18n.ts";
1937
2085
  var HTML_PATH = "index.html";
1938
2086
  var COOKIE_PATTERN = `/(?:^|; )${LOCALE_COOKIE}=([^;]*)/`;
1939
2087
  var messagesImport = (locale) => `import ${locale} from '${import_node_path14.posix.relative(import_node_path14.posix.dirname(INIT_PATH), `messages/${locale}.json`)}'`;
@@ -1985,7 +2133,7 @@ export function DualkitLanguageSwitch() {
1985
2133
  )
1986
2134
  }
1987
2135
  `;
1988
- var INLINE_SCRIPT = `<script>(function(){var m=document.cookie.match(${COOKIE_PATTERN});var l=m&&m[1]==='ar'?'ar':'en';var h=document.documentElement;h.lang=l;h.dir=l==='ar'?'rtl':'ltr'})()</script>`;
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>`;
1989
2137
  function htmlEdits2(html, mode) {
1990
2138
  if (mode === "bilingual") {
1991
2139
  if (html.includes(LOCALE_COOKIE)) return [];
@@ -2503,7 +2651,7 @@ function iconImports(program) {
2503
2651
  return found;
2504
2652
  }
2505
2653
  var isMirrored = (imported) => MIRROR_ICON.test(imported) && !NEVER_MIRROR.test(imported);
2506
- var tagName = (opening) => {
2654
+ var tagName2 = (opening) => {
2507
2655
  const name = opening.name;
2508
2656
  return name.type === "JSXIdentifier" ? name.name : null;
2509
2657
  };
@@ -2545,7 +2693,7 @@ function alreadyMirrored(opening, ctx) {
2545
2693
  });
2546
2694
  }
2547
2695
  function visitOpening(opening, ctx, ancestors) {
2548
- const name = tagName(opening);
2696
+ const name = tagName2(opening);
2549
2697
  if (!name) return;
2550
2698
  const after = ctx.base + opening.name.end;
2551
2699
  if (ctx.icons.has(name) && isMirrored(ctx.icons.get(name)) && !alreadyMirrored(opening, ctx)) {
@@ -2558,7 +2706,7 @@ function visitOpening(opening, ctx, ancestors) {
2558
2706
  }
2559
2707
  function flagSvg(opening, ctx, ancestors) {
2560
2708
  if (alreadyMirrored(opening, ctx)) return;
2561
- const control = [...ancestors].reverse().find((a) => a.type === "JSXElement" && ["button", "a"].includes(tagName(a.openingElement) ?? ""));
2709
+ const control = [...ancestors].reverse().find((a) => a.type === "JSXElement" && ["button", "a"].includes(tagName2(a.openingElement) ?? ""));
2562
2710
  if (!control || !DIRECTION_LABEL.test(labelOf(control))) return;
2563
2711
  ctx.flags.push({
2564
2712
  file: ctx.file.path,
@@ -2570,7 +2718,7 @@ function flagSvg(opening, ctx, ancestors) {
2570
2718
  }
2571
2719
  var isolates = (element) => {
2572
2720
  const opening = element.openingElement;
2573
- return tagName(opening) === "bdi" || attributeNamed(opening, "dir") !== void 0;
2721
+ return tagName2(opening) === "bdi" || attributeNamed(opening, "dir") !== void 0;
2574
2722
  };
2575
2723
  function visitExpression(container, ancestors, ctx) {
2576
2724
  const parent = ancestors[ancestors.length - 1];
@@ -2581,12 +2729,27 @@ function visitExpression(container, ancestors, ctx) {
2581
2729
  ctx.edits.push({ start: ctx.base + container.start, end: ctx.base + container.start, text: "<bdi>" });
2582
2730
  ctx.edits.push({ start: ctx.base + container.end, end: ctx.base + container.end, text: "</bdi>" });
2583
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
+ }
2584
2746
  function walk3(node, ctx, ancestors) {
2585
2747
  if (Array.isArray(node)) return node.forEach((child) => walk3(child, ctx, ancestors));
2586
2748
  if (!node || typeof node !== "object") return;
2587
2749
  const n = node;
2588
2750
  if (n.type === "JSXOpeningElement") visitOpening(n, ctx, ancestors);
2589
2751
  if (n.type === "JSXExpressionContainer" && ancestors.length) visitExpression(n, ancestors, ctx);
2752
+ if (n.type === "JSXText" && ancestors.length) visitText(n, ancestors, ctx);
2590
2753
  const next = typeof n.type === "string" ? [...ancestors, n] : ancestors;
2591
2754
  for (const [key, child] of Object.entries(n)) if (!SKIP6.has(key)) walk3(child, ctx, next);
2592
2755
  }
@@ -2728,6 +2891,15 @@ function groups(fragments) {
2728
2891
  for (const f of fragments) byGroup.set(f.group, [...byGroup.get(f.group) ?? [], f]);
2729
2892
  return [...byGroup.values()];
2730
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
+ }
2731
2903
  function tailwindEdits(file, version) {
2732
2904
  const parsed = parseVersion(version);
2733
2905
  const fragments = (0, import_lint10.classStrings)(file);
@@ -2740,7 +2912,7 @@ function tailwindEdits(file, version) {
2740
2912
  return { edits: [], flags: [{ file: file.path, line: lineOf(file.text, first.offset), kind: "unsupported", message, suggestion }] };
2741
2913
  }
2742
2914
  const sources = groups(fragments);
2743
- 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)]);
2744
2916
  const flags = [...sources.flatMap((g) => formFlags(file, g)), ...computedFlags(file)];
2745
2917
  return { edits: edits2, flags: flags.sort((a, b) => a.line - b.line) };
2746
2918
  }
@@ -2985,6 +3157,8 @@ async function convert(options, ioIn) {
2985
3157
  if (options.configAnswers) checkConfigAnswers(root, options.configFile);
2986
3158
  const io = options.configAnswers ? { ...ioIn, config: { ...ioIn.config, ...options.configAnswers } } : ioIn;
2987
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.`);
2988
3162
  const branch = branchFor(mode);
2989
3163
  const generated = new Set(state?.generated ?? []);
2990
3164
  const existing = readLedger(root);
@@ -3158,7 +3332,8 @@ function english(data) {
3158
3332
  }
3159
3333
  function arabic(data) {
3160
3334
  const lines = data.arabic.map(
3161
- (p) => !Number.isFinite(p.errors) ? `- ${esc(p.route)}: the check returned no result` : `- ${esc(p.route)}: score ${p.score}, ${plural(p.errors, "error", "errors")}, ${plural(p.warnings, "warning", "warnings")}`
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("")}`
3162
3337
  );
3163
3338
  return section("Arabic pages", lines, "No pages were checked.");
3164
3339
  }
@@ -3219,6 +3394,10 @@ async function guard(root) {
3219
3394
  return state;
3220
3395
  }
3221
3396
  function pendingCheck(ledger, force) {
3397
+ const broken = brokenMarks(ledger);
3398
+ if (broken.length > 0) {
3399
+ throw new RefusedError(`${broken.length} Arabic ${broken.length === 1 ? "string has" : "strings have"} different placeholders or tags from the English, starting with ${broken[0].key}. Keep each one exactly as written.`);
3400
+ }
3222
3401
  const pending = ledger.filter((e) => e.status === "pending").length;
3223
3402
  if (pending > 0 && !force) {
3224
3403
  throw new RefusedError(`${pending} ${pending === 1 ? "string is" : "strings are"} still pending in ${LEDGER_FILE}. Fill in the Arabic and set each status to drafted, or run with --force to use English for those strings.`);
@@ -3270,11 +3449,21 @@ async function arabicChecks(project, state, base, routes, audit) {
3270
3449
  const result = await audit(`${base}${path}`, width, options);
3271
3450
  const usable = result && Number.isFinite(result.errors);
3272
3451
  const failed = { score: Number.NaN, errors: Number.NaN, warnings: Number.NaN };
3273
- 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 } : {} } });
3274
3453
  }
3275
3454
  }
3276
3455
  return arabic2;
3277
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
+ }
3278
3467
  async function runChecks(project, state, routes, { config, shoot, audit }, step) {
3279
3468
  const { start: cmd } = config;
3280
3469
  if (!cmd || !shoot) throw new RefusedError(NEEDS_BROWSER);
@@ -3285,8 +3474,13 @@ async function runChecks(project, state, routes, { config, shoot, audit }, step)
3285
3474
  });
3286
3475
  try {
3287
3476
  step.name = "checking the pages";
3288
- const english2 = state.mode === "bilingual" ? await englishChecks(project.root, server.url, routes, shoot) : { english: [], noBaseline: [] };
3289
- return { ...english2, arabic: await arabicChecks(project, state, server.url, routes, audit) };
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) };
3290
3484
  } finally {
3291
3485
  await server.stop();
3292
3486
  }
@@ -3327,7 +3521,7 @@ async function run(ready, step) {
3327
3521
  ledger,
3328
3522
  ...checks,
3329
3523
  ...earlier ? {} : { summaryMissing: true },
3330
- unreachable,
3524
+ unreachable: [...unreachable, ...checks.closed],
3331
3525
  remainingLint,
3332
3526
  ...tests2 ? { tests: { code: tests2.code, tail: tail(tests2.out) } } : {}
3333
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"]);
@@ -357,6 +362,8 @@ function mergeLedger(existing, added) {
357
362
  }
358
363
  return [...existing, ...fresh].sort(byKey);
359
364
  }
365
+ var marks = (text) => [...text.matchAll(/\{[^}]*\}|<\/?[a-z0-9]+>/gi)].map((m) => m[0]).sort().join(" ");
366
+ var brokenMarks = (entries) => entries.filter((e) => e.status !== "pending" && e.ar !== "" && marks(e.ar) !== marks(e.en));
360
367
  var textFor = (entry, locale) => locale === "en" || entry.status === "pending" || entry.ar === "" ? entry.en : entry.ar;
361
368
  var group = () => /* @__PURE__ */ Object.create(null);
362
369
  function messagesFor(entries, locale) {
@@ -525,6 +532,26 @@ function writeConfigAnswers(disk, answers2, file = CONFIG_FILE) {
525
532
  return writeText(disk, file, mergedConfigText(readText(disk.root, file), answers2, file));
526
533
  }
527
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
+
528
555
  // src/baseline.ts
529
556
  import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync5 } from "fs";
530
557
  import { join as join7 } from "path";
@@ -669,6 +696,7 @@ ${tail2()}`);
669
696
 
670
697
  // src/baseline.ts
671
698
  var BASELINE_DIR = `${STATE_DIR}/baseline`;
699
+ var CLOSED_FILE = `${BASELINE_DIR}/closed.json`;
672
700
  var WIDTHS = [390, 1280];
673
701
  var DEFAULT_PORTS = { "next-app": 3e3, "vite-react": 5173 };
674
702
  var routeSlug = (route) => route.replace(/^\/+|\/+$/g, "").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "") || "home";
@@ -680,6 +708,7 @@ async function shootBaseline(project, config, shoot, log) {
680
708
  const server = await startServer(config.start, port, project.root);
681
709
  const shots = [];
682
710
  try {
711
+ writeFileSync5(join7(project.root, CLOSED_FILE), JSON.stringify((await closedRoutes(server.url, routes)).map((c) => c.route)));
683
712
  for (const route of routes) {
684
713
  for (const width of WIDTHS) {
685
714
  const path = baselinePath(route, width);
@@ -909,7 +938,25 @@ var tInScope = (ancestors) => ancestors.some((node) => scopeNames(node).includes
909
938
 
910
939
  // src/transforms/strings.ts
911
940
  var SKIP = /* @__PURE__ */ new Set(["loc", "leadingComments", "trailingComments", "innerComments", "extra"]);
912
- var TEXT_ATTRIBUTES = /* @__PURE__ */ new Set(["alt", "title", "placeholder", "aria-label", "label"]);
941
+ var TEXT_ATTRIBUTES = /* @__PURE__ */ new Set([
942
+ "alt",
943
+ "title",
944
+ "placeholder",
945
+ "aria-label",
946
+ "label",
947
+ "aria-description",
948
+ "aria-placeholder",
949
+ "aria-roledescription",
950
+ "description",
951
+ "subtitle",
952
+ "heading",
953
+ "caption",
954
+ "hint",
955
+ "tooltip",
956
+ "helperText",
957
+ "pageTitle"
958
+ ]);
959
+ var INLINE = /* @__PURE__ */ new Set(["a", "abbr", "b", "bdi", "cite", "code", "del", "em", "i", "ins", "kbd", "Link", "mark", "q", "s", "small", "span", "strong", "sub", "sup", "u"]);
913
960
  var LETTER = /\p{L}/u;
914
961
  var PROSE = /^\p{Lu}\p{Ll}/u;
915
962
  var NOT_PROSE = /[/\\_=<>{}@#|]/;
@@ -927,7 +974,7 @@ function stemOf(path) {
927
974
  if (!ROUTE_STEMS.has(stem)) return kebab(stem) || "file";
928
975
  return kebab(routeDirs(parts).join("-")) || "home";
929
976
  }
930
- var keyWords = (en) => ascii(en.replace(/\{[^}]*\}/g, " ")).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).slice(0, 4).join("-") || "text";
977
+ var keyWords = (en) => ascii(en.replace(/\{[^}]*\}|<[^>]*>/g, " ")).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).slice(0, 4).join("-") || "text";
931
978
  function flag(ctx, offset, kind, message, suggestion) {
932
979
  ctx.flags.push({ file: ctx.file.path, line: lineOf(ctx.file.text, offset), kind, message, suggestion });
933
980
  }
@@ -941,7 +988,7 @@ function keyFor(ctx, en) {
941
988
  ctx.byText.set(en, key);
942
989
  return key;
943
990
  }
944
- function place(ctx, ancestors, start2, end, en, values) {
991
+ function place(ctx, ancestors, { start: start2, end, en, code, hookless }) {
945
992
  const component = componentOf(ancestors);
946
993
  if (!component) {
947
994
  flag(
@@ -976,8 +1023,8 @@ function place(ctx, ancestors, start2, end, en, values) {
976
1023
  const isNew = !ctx.byText.has(en);
977
1024
  const key = keyFor(ctx, en);
978
1025
  if (isNew) ctx.entries.push({ key, en, ar: "", status: "pending", file: ctx.file.path, line: lineOf(ctx.file.text, start2) });
979
- ctx.edits.push({ start: start2, end, text: `{t('${key}'${values ? `, { ${values} }` : ""})}` });
980
- ctx.components.add(component);
1026
+ ctx.edits.push({ start: start2, end, text: code(key) });
1027
+ if (!hookless) ctx.components.add(component);
981
1028
  }
982
1029
  var TEXT_METHODS = /^(toLocale(Date|Time)?String|toFixed|toPrecision|toString|toUpperCase|toLowerCase|trim(Start|End)?|padStart|padEnd)$/;
983
1030
  function textMethodTarget(expression) {
@@ -997,6 +1044,21 @@ function placeholderName(expression) {
997
1044
  }
998
1045
  return null;
999
1046
  }
1047
+ function holdsJsx(node) {
1048
+ if (Array.isArray(node)) return node.some(holdsJsx);
1049
+ if (!node || typeof node !== "object") return false;
1050
+ const n = node;
1051
+ if (n.type === "JSXElement" || n.type === "JSXFragment") return true;
1052
+ return Object.entries(n).some(([key, child]) => !SKIP.has(key) && holdsJsx(child));
1053
+ }
1054
+ function callName(expression) {
1055
+ if (expression.type !== "CallExpression" && expression.type !== "OptionalCallExpression") return null;
1056
+ if (holdsJsx(expression)) return null;
1057
+ const callee = expression.callee;
1058
+ if (callee.type === "Identifier") return callee.name === "t" ? null : callee.name;
1059
+ if ((callee.type === "MemberExpression" || callee.type === "OptionalMemberExpression") && !callee.computed) return placeholderName(callee);
1060
+ return null;
1061
+ }
1000
1062
  function hasText(element) {
1001
1063
  return (element.children ?? []).some((c) => c.type === "JSXText" ? /\S/.test(String(c.value)) : c.type === "JSXExpressionContainer" ? c.expression.type !== "JSXEmptyExpression" : c.type === "JSXSpreadChild" || hasText(c));
1002
1064
  }
@@ -1005,30 +1067,34 @@ function pieceOf(child, ctx) {
1005
1067
  const end = ctx.base + child.end;
1006
1068
  if (child.type === "JSXText") {
1007
1069
  const raw = ctx.file.text.slice(start2, end);
1008
- if (!/\S/.test(raw)) return { kind: "space", start: start2, end };
1070
+ if (!/\S/.test(raw)) return { kind: "space", value: raw.includes("\n") ? "" : " ", start: start2, end };
1009
1071
  const lead = raw.length - raw.trimStart().length;
1010
1072
  const trail = raw.length - raw.trimEnd().length;
1011
- return { kind: "text", value: String(child.value), start: start2 + lead, end: end - trail };
1073
+ return { kind: "text", value: jsxText(String(child.value)), start: start2 + lead, end: end - trail };
1012
1074
  }
1013
- if (child.type === "JSXSpreadChild") return { kind: "other", start: start2, end };
1014
- if (child.type !== "JSXExpressionContainer") return { kind: "element", texty: hasText(child), start: start2, end };
1075
+ if (child.type === "JSXSpreadChild") return { kind: "other", node: child, start: start2, end };
1076
+ if (child.type !== "JSXExpressionContainer") return { kind: "element", node: child, start: start2, end };
1015
1077
  const expression = child.expression;
1016
1078
  if (expression.type === "JSXEmptyExpression") return { kind: "comment", start: start2, end };
1017
1079
  if (expression.type === "StringLiteral") {
1018
1080
  const value = String(expression.value);
1019
- return /\S/.test(value) ? { kind: "text", value, start: start2, end } : { kind: "space", start: start2, end };
1081
+ return /\S/.test(value) ? { kind: "text", value, start: start2, end } : { kind: "space", value, start: start2, end };
1020
1082
  }
1021
- const name = placeholderName(expression);
1022
- if (!name) return { kind: "other", start: start2, end };
1083
+ const name = placeholderName(expression) ?? callName(expression);
1084
+ if (!name || NODE_NAMES.test(name)) return { kind: "other", node: expression, start: start2, end };
1023
1085
  const source = ctx.file.text.slice(ctx.base + expression.start, ctx.base + expression.end);
1024
- return { kind: "value", name, source, start: start2, end };
1086
+ return { kind: "value", name, source: mayBeEmpty(expression) ? `${source} ?? ''` : source, start: start2, end };
1025
1087
  }
1088
+ var OWN_DIRECTION = /setAttribute\(\s*['"]dir['"]|documentElement\.dir\s*=[^=]/;
1089
+ var NODE_NAMES = /^(children|icon|avatar|logo|image|element|node)$/i;
1090
+ var mayBeEmpty = (expression) => /^Optional/.test(expression.type) || expression.type === "CallExpression" && !textMethodTarget(expression);
1091
+ var jsxText = (value) => value.replace(/^\s*\n\s*/, "").replace(/\s*\n\s*$/, "");
1026
1092
  var mixed = (ctx, offset) => flag(
1027
1093
  ctx,
1028
1094
  offset,
1029
1095
  "mixed-text",
1030
- "This text shares its element with other elements or expressions, so it was left as it is.",
1031
- "Translate the whole sentence with rich text or a plural message, by hand."
1096
+ "This text shares its element with an expression convert cannot read as one sentence, so it was left as it is.",
1097
+ "Translate the whole sentence with a plural message, by hand."
1032
1098
  );
1033
1099
  function valuesOf(pieces) {
1034
1100
  const seen = /* @__PURE__ */ new Map();
@@ -1039,67 +1105,139 @@ function valuesOf(pieces) {
1039
1105
  }
1040
1106
  return [...seen].map(([name, source]) => name === source ? name : `${name}: ${source}`).join(", ");
1041
1107
  }
1042
- function unitsOf(pieces) {
1043
- const units = [];
1108
+ var tagName = (element) => {
1109
+ const name = element.openingElement?.name;
1110
+ return name?.type === "JSXIdentifier" ? name.name : null;
1111
+ };
1112
+ var childPieces = (element, ctx) => (element.children ?? []).map((c) => pieceOf(c, ctx));
1113
+ function isInline(element, ctx) {
1114
+ if (!INLINE.has(tagName(element) ?? "") || !hasText(element)) return false;
1115
+ return childPieces(element, ctx).every((p) => p.kind === "text" || p.kind === "value" || p.kind === "space");
1116
+ }
1117
+ function runsOf(pieces, ctx) {
1118
+ const runs = [];
1044
1119
  let run2 = [];
1045
- let before;
1046
- for (const piece of [...pieces, void 0]) {
1047
- if (piece && piece.kind !== "element") {
1048
- run2.push(piece);
1049
- continue;
1050
- }
1051
- const texty = (p) => p?.kind === "element" && p.texty;
1052
- if (run2.length) units.push({ run: run2, touchesText: texty(before) || texty(piece) });
1053
- run2 = [];
1054
- before = piece;
1120
+ for (const piece of pieces) {
1121
+ if (piece.kind === "other" || piece.kind === "element" && !isInline(piece.node, ctx)) {
1122
+ if (run2.length) runs.push(run2);
1123
+ run2 = [];
1124
+ } else run2 = [...run2, piece];
1125
+ }
1126
+ return run2.length ? [...runs, run2] : runs;
1127
+ }
1128
+ var gluedTo = (piece, next, previous) => piece.kind === "other" && piece.node.type === "ConditionalExpression" && branchStrings(piece.node).some((n) => String(n.value).length < 3) && (previous?.kind === "text" && /\S$/.test(previous.value) || next?.kind === "text" && /^\S/.test(next.value));
1129
+ function tagsOf(run2, ctx) {
1130
+ const counts = /* @__PURE__ */ new Map();
1131
+ const tags = /* @__PURE__ */ new Map();
1132
+ for (const piece of run2) {
1133
+ if (piece.kind !== "element") continue;
1134
+ const name = tagName(piece.node);
1135
+ const base = name === "Link" ? "navlink" : name.toLowerCase();
1136
+ const count = (counts.get(base) ?? 0) + 1;
1137
+ counts.set(base, count);
1138
+ const open = piece.node.openingElement;
1139
+ const close = piece.node.closingElement;
1140
+ tags.set(piece, {
1141
+ tag: count === 1 ? base : `${base}${count}`,
1142
+ open: ctx.file.text.slice(ctx.base + open.start, ctx.base + open.end),
1143
+ close: ctx.file.text.slice(ctx.base + close.start, ctx.base + close.end),
1144
+ inner: childPieces(piece.node, ctx)
1145
+ });
1055
1146
  }
1056
- return units;
1147
+ return tags;
1057
1148
  }
1058
- function messageOf(pieces) {
1059
- if (pieces.some((p) => p.kind === "other")) return null;
1149
+ var plainText = (pieces) => pieces.map((p) => p.kind === "text" || p.kind === "space" ? p.value : p.kind === "value" ? `{${p.name}}` : "").join("");
1150
+ var nextIntlCode = (key, values, tags) => {
1151
+ if (!tags.length) return `{t('${key}'${values ? `, { ${values} }` : ""})}`;
1152
+ const parts = [...values ? [values] : [], ...tags.map((t) => `${t.tag}: (chunks) => ${t.open}{chunks}${t.close}`)];
1153
+ return `{t.rich('${key}', { ${parts.join(", ")} })}`;
1154
+ };
1155
+ var reactI18nextCode = (key, values, tags) => {
1156
+ if (!tags.length) return nextIntlCode(key, values, tags);
1157
+ const components = tags.map((t) => `${t.tag}: ${t.open.replace(/>$/, " />")}`).join(", ");
1158
+ return `<Trans i18nKey="${key}"${values ? ` values={{ ${values} }}` : ""} components={{ ${components} }} />`;
1159
+ };
1160
+ function messageOf(pieces, ctx) {
1060
1161
  const edge = (p) => p.kind !== "space" && p.kind !== "comment";
1061
1162
  const first = pieces.findIndex(edge);
1062
1163
  const last = pieces.length - 1 - [...pieces].reverse().findIndex(edge);
1063
1164
  const run2 = pieces.slice(first, last + 1);
1064
- const values = valuesOf(run2);
1065
- if (values === null || run2.some((p) => p.kind === "comment")) return null;
1066
- const en = run2.map((p) => p.kind === "text" ? p.value : p.kind === "value" ? `{${p.name}}` : " ").join("").replace(/\s+/g, " ").trim();
1067
- return { start: run2[0].start, end: run2[run2.length - 1].end, en, values };
1165
+ if (run2.some((p) => p.kind === "comment")) return null;
1166
+ const tags = tagsOf(run2, ctx);
1167
+ const values = valuesOf([...run2, ...[...tags.values()].flatMap((t) => t.inner)]);
1168
+ if (values === null) return null;
1169
+ const en = run2.map((p) => {
1170
+ const tag = tags.get(p);
1171
+ return tag ? `<${tag.tag}>${plainText(tag.inner)}</${tag.tag}>` : plainText([p]);
1172
+ }).join("").replace(/\s+/g, " ").trim();
1173
+ const list = [...tags.values()];
1174
+ const code = (key) => (ctx.lib === "react-i18next" ? reactI18nextCode : nextIntlCode)(key, values, list);
1175
+ return { start: run2[0].start, end: run2[run2.length - 1].end, en, code, tags: list, hookless: ctx.lib === "react-i18next" && list.length > 0 };
1068
1176
  }
1069
1177
  function visitChildren(element, ctx, ancestors) {
1070
- const pieces = (element.children ?? []).map((c) => pieceOf(c, ctx));
1071
- const messages = [];
1072
- for (const { run: run2, touchesText } of unitsOf(pieces)) {
1178
+ const pieces = childPieces(element, ctx);
1179
+ pieces.forEach((piece, i) => {
1180
+ if (!gluedTo(piece, pieces[i + 1], pieces[i - 1])) return;
1181
+ ctx.consumed.add(piece.node);
1182
+ });
1183
+ const glued = pieces.filter((p, i) => p.kind === "other" && gluedTo(p, pieces[i + 1], pieces[i - 1]));
1184
+ for (const run2 of runsOf(pieces, ctx)) {
1073
1185
  const lettered = run2.find((p) => p.kind === "text" && LETTER.test(p.value));
1074
1186
  if (!lettered) continue;
1075
- const message = touchesText ? null : messageOf(run2);
1187
+ const touchesGlue = glued.some((g) => run2.some((p) => p.end === g.start || p.start === g.end || ctx.file.text.slice(p.end, g.start).trim() === "" || ctx.file.text.slice(g.end, p.start).trim() === ""));
1188
+ const message = touchesGlue ? null : messageOf(run2, ctx);
1076
1189
  if (!message) {
1077
1190
  mixed(ctx, lettered.start);
1078
- return false;
1191
+ continue;
1079
1192
  }
1080
- messages.push(message);
1193
+ if (message.tags.length) {
1194
+ run2.forEach((p) => {
1195
+ if (p.kind === "element") ctx.consumed.add(p.node);
1196
+ });
1197
+ if (ctx.lib === "react-i18next") ctx.usedTrans = true;
1198
+ }
1199
+ place(ctx, ancestors, message);
1081
1200
  }
1082
- for (const m of messages) place(ctx, ancestors, m.start, m.end, m.en, m.values);
1083
- return true;
1084
1201
  }
1202
+ var bracedCall = (values) => (key) => `{t('${key}'${values ? `, { ${values} }` : ""})}`;
1085
1203
  function visitAttribute(attribute, ctx, ancestors) {
1086
1204
  const name = attribute.name;
1087
1205
  if (name.type !== "JSXIdentifier" || !TEXT_ATTRIBUTES.has(name.name)) return;
1088
1206
  const value = attribute.value;
1089
1207
  const literal = value?.type === "JSXExpressionContainer" ? value.expression : value;
1090
1208
  if (literal?.type !== "StringLiteral" || !LETTER.test(String(literal.value))) return;
1209
+ if (name.name === "placeholder" && /^(blur|empty)$/.test(String(literal.value))) return;
1091
1210
  const en = String(literal.value).replace(/\s+/g, " ").trim();
1092
- place(ctx, ancestors, ctx.base + value.start, ctx.base + value.end, en, "");
1211
+ place(ctx, ancestors, { start: ctx.base + value.start, end: ctx.base + value.end, en, code: bracedCall("") });
1212
+ }
1213
+ function branchStrings(node) {
1214
+ if (node.type === "StringLiteral") return [node];
1215
+ if (node.type === "ConditionalExpression") return [...branchStrings(node.consequent), ...branchStrings(node.alternate)];
1216
+ if (node.type === "LogicalExpression") return branchStrings(node.right);
1217
+ return [];
1218
+ }
1219
+ function visitBranches(container, ctx, ancestors) {
1220
+ const parent = ancestors.at(-1);
1221
+ const expression = container.expression;
1222
+ const shown = parent?.type === "JSXElement" || parent?.type === "JSXFragment" || parent?.type === "JSXAttribute" && TEXT_ATTRIBUTES.has(parent.name.name);
1223
+ if (!shown || expression.type === "StringLiteral" || ctx.consumed.has(expression)) return;
1224
+ for (const literal of branchStrings(expression)) {
1225
+ const en = String(literal.value).replace(/\s+/g, " ").trim();
1226
+ if (!LETTER.test(en) || NOT_PROSE.test(en)) continue;
1227
+ place(ctx, ancestors, { start: ctx.base + literal.start, end: ctx.base + literal.end, en, code: (key) => `t('${key}')` });
1228
+ }
1093
1229
  }
1094
1230
  function walk(node, ctx, ancestors) {
1095
1231
  if (Array.isArray(node)) return node.forEach((child) => walk(child, ctx, ancestors));
1096
1232
  if (!node || typeof node !== "object") return;
1097
1233
  const n = node;
1234
+ if (ctx.consumed.has(n)) return;
1098
1235
  if (n.type === "JSXElement" || n.type === "JSXFragment") {
1099
1236
  ctx.sawJsx = true;
1100
- if (!visitChildren(n, ctx, ancestors)) return;
1237
+ visitChildren(n, ctx, ancestors);
1101
1238
  }
1102
1239
  if (n.type === "JSXAttribute") visitAttribute(n, ctx, ancestors);
1240
+ if (n.type === "JSXExpressionContainer") visitBranches(n, ctx, ancestors);
1103
1241
  const next = typeof n.type === "string" ? [...ancestors, n] : ancestors;
1104
1242
  for (const [key, child] of Object.entries(n)) if (!SKIP.has(key)) walk(child, ctx, next);
1105
1243
  }
@@ -1197,13 +1335,23 @@ function extractStrings(file, lib, taken) {
1197
1335
  for (const block of file.js) {
1198
1336
  const program = block.ast.program;
1199
1337
  if (isServerFile(program)) continue;
1200
- 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 };
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
+ }
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 };
1201
1348
  walk(program, ctx, []);
1202
1349
  if (ctx.sawJsx) flagModuleStrings(program, ctx);
1203
1350
  const style = styleOf(program, file.text, ctx.base);
1204
1351
  const hooks = [...ctx.components].map((fn) => hookEdits(fn, lib, file.text, ctx.base, style));
1205
1352
  edits2.push(...hooks.flatMap((h) => h.edits));
1206
- edits2.push(...importEdits(program, hooks.flatMap((h) => h.imports), ctx.base, style));
1353
+ const trans = ctx.usedTrans ? [{ name: "Trans", source: "react-i18next" }] : [];
1354
+ edits2.push(...importEdits(program, [...hooks.flatMap((h) => h.imports), ...trans], ctx.base, style));
1207
1355
  }
1208
1356
  return { edits: edits2, flags, entries };
1209
1357
  }
@@ -1898,7 +2046,7 @@ function wireNextIntl(project, mode, files, paths) {
1898
2046
  import { parseSourceOutcome as parseSourceOutcome5 } from "@dualkit/lint";
1899
2047
  import { posix as posix6 } from "path";
1900
2048
  var DEPS2 = { "react-i18next": "17.1.6", i18next: "26.5.8" };
1901
- var INIT_PATH = "src/i18n.ts";
2049
+ var INIT_PATH = "src/dualkit-i18n.ts";
1902
2050
  var HTML_PATH = "index.html";
1903
2051
  var COOKIE_PATTERN = `/(?:^|; )${LOCALE_COOKIE}=([^;]*)/`;
1904
2052
  var messagesImport = (locale) => `import ${locale} from '${posix6.relative(posix6.dirname(INIT_PATH), `messages/${locale}.json`)}'`;
@@ -1950,7 +2098,7 @@ export function DualkitLanguageSwitch() {
1950
2098
  )
1951
2099
  }
1952
2100
  `;
1953
- 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>`;
1954
2102
  function htmlEdits2(html, mode) {
1955
2103
  if (mode === "bilingual") {
1956
2104
  if (html.includes(LOCALE_COOKIE)) return [];
@@ -2468,7 +2616,7 @@ function iconImports(program) {
2468
2616
  return found;
2469
2617
  }
2470
2618
  var isMirrored = (imported) => MIRROR_ICON.test(imported) && !NEVER_MIRROR.test(imported);
2471
- var tagName = (opening) => {
2619
+ var tagName2 = (opening) => {
2472
2620
  const name = opening.name;
2473
2621
  return name.type === "JSXIdentifier" ? name.name : null;
2474
2622
  };
@@ -2510,7 +2658,7 @@ function alreadyMirrored(opening, ctx) {
2510
2658
  });
2511
2659
  }
2512
2660
  function visitOpening(opening, ctx, ancestors) {
2513
- const name = tagName(opening);
2661
+ const name = tagName2(opening);
2514
2662
  if (!name) return;
2515
2663
  const after = ctx.base + opening.name.end;
2516
2664
  if (ctx.icons.has(name) && isMirrored(ctx.icons.get(name)) && !alreadyMirrored(opening, ctx)) {
@@ -2523,7 +2671,7 @@ function visitOpening(opening, ctx, ancestors) {
2523
2671
  }
2524
2672
  function flagSvg(opening, ctx, ancestors) {
2525
2673
  if (alreadyMirrored(opening, ctx)) return;
2526
- const control = [...ancestors].reverse().find((a) => a.type === "JSXElement" && ["button", "a"].includes(tagName(a.openingElement) ?? ""));
2674
+ const control = [...ancestors].reverse().find((a) => a.type === "JSXElement" && ["button", "a"].includes(tagName2(a.openingElement) ?? ""));
2527
2675
  if (!control || !DIRECTION_LABEL.test(labelOf(control))) return;
2528
2676
  ctx.flags.push({
2529
2677
  file: ctx.file.path,
@@ -2535,7 +2683,7 @@ function flagSvg(opening, ctx, ancestors) {
2535
2683
  }
2536
2684
  var isolates = (element) => {
2537
2685
  const opening = element.openingElement;
2538
- return tagName(opening) === "bdi" || attributeNamed(opening, "dir") !== void 0;
2686
+ return tagName2(opening) === "bdi" || attributeNamed(opening, "dir") !== void 0;
2539
2687
  };
2540
2688
  function visitExpression(container, ancestors, ctx) {
2541
2689
  const parent = ancestors[ancestors.length - 1];
@@ -2546,12 +2694,27 @@ function visitExpression(container, ancestors, ctx) {
2546
2694
  ctx.edits.push({ start: ctx.base + container.start, end: ctx.base + container.start, text: "<bdi>" });
2547
2695
  ctx.edits.push({ start: ctx.base + container.end, end: ctx.base + container.end, text: "</bdi>" });
2548
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
+ }
2549
2711
  function walk3(node, ctx, ancestors) {
2550
2712
  if (Array.isArray(node)) return node.forEach((child) => walk3(child, ctx, ancestors));
2551
2713
  if (!node || typeof node !== "object") return;
2552
2714
  const n = node;
2553
2715
  if (n.type === "JSXOpeningElement") visitOpening(n, ctx, ancestors);
2554
2716
  if (n.type === "JSXExpressionContainer" && ancestors.length) visitExpression(n, ancestors, ctx);
2717
+ if (n.type === "JSXText" && ancestors.length) visitText(n, ancestors, ctx);
2555
2718
  const next = typeof n.type === "string" ? [...ancestors, n] : ancestors;
2556
2719
  for (const [key, child] of Object.entries(n)) if (!SKIP6.has(key)) walk3(child, ctx, next);
2557
2720
  }
@@ -2693,6 +2856,15 @@ function groups(fragments) {
2693
2856
  for (const f of fragments) byGroup.set(f.group, [...byGroup.get(f.group) ?? [], f]);
2694
2857
  return [...byGroup.values()];
2695
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
+ }
2696
2868
  function tailwindEdits(file, version) {
2697
2869
  const parsed = parseVersion(version);
2698
2870
  const fragments = classStrings(file);
@@ -2705,7 +2877,7 @@ function tailwindEdits(file, version) {
2705
2877
  return { edits: [], flags: [{ file: file.path, line: lineOf(file.text, first.offset), kind: "unsupported", message, suggestion }] };
2706
2878
  }
2707
2879
  const sources = groups(fragments);
2708
- 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)]);
2709
2881
  const flags = [...sources.flatMap((g) => formFlags(file, g)), ...computedFlags(file)];
2710
2882
  return { edits: edits2, flags: flags.sort((a, b) => a.line - b.line) };
2711
2883
  }
@@ -2950,6 +3122,8 @@ async function convert(options, ioIn) {
2950
3122
  if (options.configAnswers) checkConfigAnswers(root, options.configFile);
2951
3123
  const io = options.configAnswers ? { ...ioIn, config: { ...ioIn.config, ...options.configAnswers } } : ioIn;
2952
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.`);
2953
3127
  const branch = branchFor(mode);
2954
3128
  const generated = new Set(state?.generated ?? []);
2955
3129
  const existing = readLedger(root);
@@ -3123,7 +3297,8 @@ function english(data) {
3123
3297
  }
3124
3298
  function arabic(data) {
3125
3299
  const lines = data.arabic.map(
3126
- (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("")}`
3127
3302
  );
3128
3303
  return section("Arabic pages", lines, "No pages were checked.");
3129
3304
  }
@@ -3184,6 +3359,10 @@ async function guard(root) {
3184
3359
  return state;
3185
3360
  }
3186
3361
  function pendingCheck(ledger, force) {
3362
+ const broken = brokenMarks(ledger);
3363
+ if (broken.length > 0) {
3364
+ throw new RefusedError(`${broken.length} Arabic ${broken.length === 1 ? "string has" : "strings have"} different placeholders or tags from the English, starting with ${broken[0].key}. Keep each one exactly as written.`);
3365
+ }
3187
3366
  const pending = ledger.filter((e) => e.status === "pending").length;
3188
3367
  if (pending > 0 && !force) {
3189
3368
  throw new RefusedError(`${pending} ${pending === 1 ? "string is" : "strings are"} still pending in ${LEDGER_FILE}. Fill in the Arabic and set each status to drafted, or run with --force to use English for those strings.`);
@@ -3235,11 +3414,21 @@ async function arabicChecks(project, state, base, routes, audit) {
3235
3414
  const result = await audit(`${base}${path}`, width, options);
3236
3415
  const usable = result && Number.isFinite(result.errors);
3237
3416
  const failed = { score: Number.NaN, errors: Number.NaN, warnings: Number.NaN };
3238
- 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 } : {} } });
3239
3418
  }
3240
3419
  }
3241
3420
  return arabic2;
3242
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
+ }
3243
3432
  async function runChecks(project, state, routes, { config, shoot, audit }, step) {
3244
3433
  const { start: cmd } = config;
3245
3434
  if (!cmd || !shoot) throw new RefusedError(NEEDS_BROWSER);
@@ -3250,8 +3439,13 @@ async function runChecks(project, state, routes, { config, shoot, audit }, step)
3250
3439
  });
3251
3440
  try {
3252
3441
  step.name = "checking the pages";
3253
- const english2 = state.mode === "bilingual" ? await englishChecks(project.root, server.url, routes, shoot) : { english: [], noBaseline: [] };
3254
- 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) };
3255
3449
  } finally {
3256
3450
  await server.stop();
3257
3451
  }
@@ -3292,7 +3486,7 @@ async function run(ready, step) {
3292
3486
  ledger,
3293
3487
  ...checks,
3294
3488
  ...earlier ? {} : { summaryMissing: true },
3295
- unreachable,
3489
+ unreachable: [...unreachable, ...checks.closed],
3296
3490
  remainingLint,
3297
3491
  ...tests2 ? { tests: { code: tests2.code, tail: tail(tests2.out) } } : {}
3298
3492
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dualkit/convert",
3
- "version": "0.2.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",