@dualkit/convert 0.2.0-beta.0 → 0.3.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
@@ -392,6 +392,8 @@ function mergeLedger(existing, added) {
392
392
  }
393
393
  return [...existing, ...fresh].sort(byKey);
394
394
  }
395
+ var marks = (text) => [...text.matchAll(/\{[^}]*\}|<\/?[a-z0-9]+>/gi)].map((m) => m[0]).sort().join(" ");
396
+ var brokenMarks = (entries) => entries.filter((e) => e.status !== "pending" && e.ar !== "" && marks(e.ar) !== marks(e.en));
395
397
  var textFor = (entry, locale) => locale === "en" || entry.status === "pending" || entry.ar === "" ? entry.en : entry.ar;
396
398
  var group = () => /* @__PURE__ */ Object.create(null);
397
399
  function messagesFor(entries, locale) {
@@ -944,7 +946,25 @@ var tInScope = (ancestors) => ancestors.some((node) => scopeNames(node).includes
944
946
 
945
947
  // src/transforms/strings.ts
946
948
  var SKIP = /* @__PURE__ */ new Set(["loc", "leadingComments", "trailingComments", "innerComments", "extra"]);
947
- var TEXT_ATTRIBUTES = /* @__PURE__ */ new Set(["alt", "title", "placeholder", "aria-label", "label"]);
949
+ var TEXT_ATTRIBUTES = /* @__PURE__ */ new Set([
950
+ "alt",
951
+ "title",
952
+ "placeholder",
953
+ "aria-label",
954
+ "label",
955
+ "aria-description",
956
+ "aria-placeholder",
957
+ "aria-roledescription",
958
+ "description",
959
+ "subtitle",
960
+ "heading",
961
+ "caption",
962
+ "hint",
963
+ "tooltip",
964
+ "helperText",
965
+ "pageTitle"
966
+ ]);
967
+ 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
968
  var LETTER = /\p{L}/u;
949
969
  var PROSE = /^\p{Lu}\p{Ll}/u;
950
970
  var NOT_PROSE = /[/\\_=<>{}@#|]/;
@@ -962,7 +982,7 @@ function stemOf(path) {
962
982
  if (!ROUTE_STEMS.has(stem)) return kebab(stem) || "file";
963
983
  return kebab(routeDirs(parts).join("-")) || "home";
964
984
  }
965
- var keyWords = (en) => ascii(en.replace(/\{[^}]*\}/g, " ")).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).slice(0, 4).join("-") || "text";
985
+ var keyWords = (en) => ascii(en.replace(/\{[^}]*\}|<[^>]*>/g, " ")).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).slice(0, 4).join("-") || "text";
966
986
  function flag(ctx, offset, kind, message, suggestion) {
967
987
  ctx.flags.push({ file: ctx.file.path, line: lineOf(ctx.file.text, offset), kind, message, suggestion });
968
988
  }
@@ -976,7 +996,7 @@ function keyFor(ctx, en) {
976
996
  ctx.byText.set(en, key);
977
997
  return key;
978
998
  }
979
- function place(ctx, ancestors, start2, end, en, values) {
999
+ function place(ctx, ancestors, { start: start2, end, en, code, hookless }) {
980
1000
  const component = componentOf(ancestors);
981
1001
  if (!component) {
982
1002
  flag(
@@ -1011,8 +1031,8 @@ function place(ctx, ancestors, start2, end, en, values) {
1011
1031
  const isNew = !ctx.byText.has(en);
1012
1032
  const key = keyFor(ctx, en);
1013
1033
  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);
1034
+ ctx.edits.push({ start: start2, end, text: code(key) });
1035
+ if (!hookless) ctx.components.add(component);
1016
1036
  }
1017
1037
  var TEXT_METHODS = /^(toLocale(Date|Time)?String|toFixed|toPrecision|toString|toUpperCase|toLowerCase|trim(Start|End)?|padStart|padEnd)$/;
1018
1038
  function textMethodTarget(expression) {
@@ -1032,6 +1052,21 @@ function placeholderName(expression) {
1032
1052
  }
1033
1053
  return null;
1034
1054
  }
1055
+ function holdsJsx(node) {
1056
+ if (Array.isArray(node)) return node.some(holdsJsx);
1057
+ if (!node || typeof node !== "object") return false;
1058
+ const n = node;
1059
+ if (n.type === "JSXElement" || n.type === "JSXFragment") return true;
1060
+ return Object.entries(n).some(([key, child]) => !SKIP.has(key) && holdsJsx(child));
1061
+ }
1062
+ function callName(expression) {
1063
+ if (expression.type !== "CallExpression" && expression.type !== "OptionalCallExpression") return null;
1064
+ if (holdsJsx(expression)) return null;
1065
+ const callee = expression.callee;
1066
+ if (callee.type === "Identifier") return callee.name === "t" ? null : callee.name;
1067
+ if ((callee.type === "MemberExpression" || callee.type === "OptionalMemberExpression") && !callee.computed) return placeholderName(callee);
1068
+ return null;
1069
+ }
1035
1070
  function hasText(element) {
1036
1071
  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
1072
  }
@@ -1040,30 +1075,33 @@ function pieceOf(child, ctx) {
1040
1075
  const end = ctx.base + child.end;
1041
1076
  if (child.type === "JSXText") {
1042
1077
  const raw = ctx.file.text.slice(start2, end);
1043
- if (!/\S/.test(raw)) return { kind: "space", start: start2, end };
1078
+ if (!/\S/.test(raw)) return { kind: "space", value: raw.includes("\n") ? "" : " ", start: start2, end };
1044
1079
  const lead = raw.length - raw.trimStart().length;
1045
1080
  const trail = raw.length - raw.trimEnd().length;
1046
- return { kind: "text", value: String(child.value), start: start2 + lead, end: end - trail };
1081
+ return { kind: "text", value: jsxText(String(child.value)), start: start2 + lead, end: end - trail };
1047
1082
  }
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 };
1083
+ if (child.type === "JSXSpreadChild") return { kind: "other", node: child, start: start2, end };
1084
+ if (child.type !== "JSXExpressionContainer") return { kind: "element", node: child, start: start2, end };
1050
1085
  const expression = child.expression;
1051
1086
  if (expression.type === "JSXEmptyExpression") return { kind: "comment", start: start2, end };
1052
1087
  if (expression.type === "StringLiteral") {
1053
1088
  const value = String(expression.value);
1054
- return /\S/.test(value) ? { kind: "text", value, start: start2, end } : { kind: "space", start: start2, end };
1089
+ return /\S/.test(value) ? { kind: "text", value, start: start2, end } : { kind: "space", value, start: start2, end };
1055
1090
  }
1056
- const name = placeholderName(expression);
1057
- if (!name) return { kind: "other", start: start2, end };
1091
+ const name = placeholderName(expression) ?? callName(expression);
1092
+ if (!name || NODE_NAMES.test(name)) return { kind: "other", node: expression, start: start2, end };
1058
1093
  const source = ctx.file.text.slice(ctx.base + expression.start, ctx.base + expression.end);
1059
- return { kind: "value", name, source, start: start2, end };
1094
+ return { kind: "value", name, source: mayBeEmpty(expression) ? `${source} ?? ''` : source, start: start2, end };
1060
1095
  }
1096
+ var NODE_NAMES = /^(children|icon|avatar|logo|image|element|node)$/i;
1097
+ var mayBeEmpty = (expression) => /^Optional/.test(expression.type) || expression.type === "CallExpression" && !textMethodTarget(expression);
1098
+ var jsxText = (value) => value.replace(/^\s*\n\s*/, "").replace(/\s*\n\s*$/, "");
1061
1099
  var mixed = (ctx, offset) => flag(
1062
1100
  ctx,
1063
1101
  offset,
1064
1102
  "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."
1103
+ "This text shares its element with an expression convert cannot read as one sentence, so it was left as it is.",
1104
+ "Translate the whole sentence with a plural message, by hand."
1067
1105
  );
1068
1106
  function valuesOf(pieces) {
1069
1107
  const seen = /* @__PURE__ */ new Map();
@@ -1074,67 +1112,139 @@ function valuesOf(pieces) {
1074
1112
  }
1075
1113
  return [...seen].map(([name, source]) => name === source ? name : `${name}: ${source}`).join(", ");
1076
1114
  }
1077
- function unitsOf(pieces) {
1078
- const units = [];
1115
+ var tagName = (element) => {
1116
+ const name = element.openingElement?.name;
1117
+ return name?.type === "JSXIdentifier" ? name.name : null;
1118
+ };
1119
+ var childPieces = (element, ctx) => (element.children ?? []).map((c) => pieceOf(c, ctx));
1120
+ function isInline(element, ctx) {
1121
+ if (!INLINE.has(tagName(element) ?? "") || !hasText(element)) return false;
1122
+ return childPieces(element, ctx).every((p) => p.kind === "text" || p.kind === "value" || p.kind === "space");
1123
+ }
1124
+ function runsOf(pieces, ctx) {
1125
+ const runs = [];
1079
1126
  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;
1127
+ for (const piece of pieces) {
1128
+ if (piece.kind === "other" || piece.kind === "element" && !isInline(piece.node, ctx)) {
1129
+ if (run2.length) runs.push(run2);
1130
+ run2 = [];
1131
+ } else run2 = [...run2, piece];
1132
+ }
1133
+ return run2.length ? [...runs, run2] : runs;
1134
+ }
1135
+ 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));
1136
+ function tagsOf(run2, ctx) {
1137
+ const counts = /* @__PURE__ */ new Map();
1138
+ const tags = /* @__PURE__ */ new Map();
1139
+ for (const piece of run2) {
1140
+ if (piece.kind !== "element") continue;
1141
+ const name = tagName(piece.node);
1142
+ const base = name === "Link" ? "navlink" : name.toLowerCase();
1143
+ const count = (counts.get(base) ?? 0) + 1;
1144
+ counts.set(base, count);
1145
+ const open = piece.node.openingElement;
1146
+ const close = piece.node.closingElement;
1147
+ tags.set(piece, {
1148
+ tag: count === 1 ? base : `${base}${count}`,
1149
+ open: ctx.file.text.slice(ctx.base + open.start, ctx.base + open.end),
1150
+ close: ctx.file.text.slice(ctx.base + close.start, ctx.base + close.end),
1151
+ inner: childPieces(piece.node, ctx)
1152
+ });
1090
1153
  }
1091
- return units;
1154
+ return tags;
1092
1155
  }
1093
- function messageOf(pieces) {
1094
- if (pieces.some((p) => p.kind === "other")) return null;
1156
+ var plainText = (pieces) => pieces.map((p) => p.kind === "text" || p.kind === "space" ? p.value : p.kind === "value" ? `{${p.name}}` : "").join("");
1157
+ var nextIntlCode = (key, values, tags) => {
1158
+ if (!tags.length) return `{t('${key}'${values ? `, { ${values} }` : ""})}`;
1159
+ const parts = [...values ? [values] : [], ...tags.map((t) => `${t.tag}: (chunks) => ${t.open}{chunks}${t.close}`)];
1160
+ return `{t.rich('${key}', { ${parts.join(", ")} })}`;
1161
+ };
1162
+ var reactI18nextCode = (key, values, tags) => {
1163
+ if (!tags.length) return nextIntlCode(key, values, tags);
1164
+ const components = tags.map((t) => `${t.tag}: ${t.open.replace(/>$/, " />")}`).join(", ");
1165
+ return `<Trans i18nKey="${key}"${values ? ` values={{ ${values} }}` : ""} components={{ ${components} }} />`;
1166
+ };
1167
+ function messageOf(pieces, ctx) {
1095
1168
  const edge = (p) => p.kind !== "space" && p.kind !== "comment";
1096
1169
  const first = pieces.findIndex(edge);
1097
1170
  const last = pieces.length - 1 - [...pieces].reverse().findIndex(edge);
1098
1171
  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 };
1172
+ if (run2.some((p) => p.kind === "comment")) return null;
1173
+ const tags = tagsOf(run2, ctx);
1174
+ const values = valuesOf([...run2, ...[...tags.values()].flatMap((t) => t.inner)]);
1175
+ if (values === null) return null;
1176
+ const en = run2.map((p) => {
1177
+ const tag = tags.get(p);
1178
+ return tag ? `<${tag.tag}>${plainText(tag.inner)}</${tag.tag}>` : plainText([p]);
1179
+ }).join("").replace(/\s+/g, " ").trim();
1180
+ const list = [...tags.values()];
1181
+ const code = (key) => (ctx.lib === "react-i18next" ? reactI18nextCode : nextIntlCode)(key, values, list);
1182
+ return { start: run2[0].start, end: run2[run2.length - 1].end, en, code, tags: list, hookless: ctx.lib === "react-i18next" && list.length > 0 };
1103
1183
  }
1104
1184
  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)) {
1185
+ const pieces = childPieces(element, ctx);
1186
+ pieces.forEach((piece, i) => {
1187
+ if (!gluedTo(piece, pieces[i + 1], pieces[i - 1])) return;
1188
+ ctx.consumed.add(piece.node);
1189
+ });
1190
+ const glued = pieces.filter((p, i) => p.kind === "other" && gluedTo(p, pieces[i + 1], pieces[i - 1]));
1191
+ for (const run2 of runsOf(pieces, ctx)) {
1108
1192
  const lettered = run2.find((p) => p.kind === "text" && LETTER.test(p.value));
1109
1193
  if (!lettered) continue;
1110
- const message = touchesText ? null : messageOf(run2);
1194
+ 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() === ""));
1195
+ const message = touchesGlue ? null : messageOf(run2, ctx);
1111
1196
  if (!message) {
1112
1197
  mixed(ctx, lettered.start);
1113
- return false;
1198
+ continue;
1114
1199
  }
1115
- messages.push(message);
1200
+ if (message.tags.length) {
1201
+ run2.forEach((p) => {
1202
+ if (p.kind === "element") ctx.consumed.add(p.node);
1203
+ });
1204
+ if (ctx.lib === "react-i18next") ctx.usedTrans = true;
1205
+ }
1206
+ place(ctx, ancestors, message);
1116
1207
  }
1117
- for (const m of messages) place(ctx, ancestors, m.start, m.end, m.en, m.values);
1118
- return true;
1119
1208
  }
1209
+ var bracedCall = (values) => (key) => `{t('${key}'${values ? `, { ${values} }` : ""})}`;
1120
1210
  function visitAttribute(attribute, ctx, ancestors) {
1121
1211
  const name = attribute.name;
1122
1212
  if (name.type !== "JSXIdentifier" || !TEXT_ATTRIBUTES.has(name.name)) return;
1123
1213
  const value = attribute.value;
1124
1214
  const literal = value?.type === "JSXExpressionContainer" ? value.expression : value;
1125
1215
  if (literal?.type !== "StringLiteral" || !LETTER.test(String(literal.value))) return;
1216
+ if (name.name === "placeholder" && /^(blur|empty)$/.test(String(literal.value))) return;
1126
1217
  const en = String(literal.value).replace(/\s+/g, " ").trim();
1127
- place(ctx, ancestors, ctx.base + value.start, ctx.base + value.end, en, "");
1218
+ place(ctx, ancestors, { start: ctx.base + value.start, end: ctx.base + value.end, en, code: bracedCall("") });
1219
+ }
1220
+ function branchStrings(node) {
1221
+ if (node.type === "StringLiteral") return [node];
1222
+ if (node.type === "ConditionalExpression") return [...branchStrings(node.consequent), ...branchStrings(node.alternate)];
1223
+ if (node.type === "LogicalExpression") return branchStrings(node.right);
1224
+ return [];
1225
+ }
1226
+ function visitBranches(container, ctx, ancestors) {
1227
+ const parent = ancestors.at(-1);
1228
+ const expression = container.expression;
1229
+ const shown = parent?.type === "JSXElement" || parent?.type === "JSXFragment" || parent?.type === "JSXAttribute" && TEXT_ATTRIBUTES.has(parent.name.name);
1230
+ if (!shown || expression.type === "StringLiteral" || ctx.consumed.has(expression)) return;
1231
+ for (const literal of branchStrings(expression)) {
1232
+ const en = String(literal.value).replace(/\s+/g, " ").trim();
1233
+ if (!LETTER.test(en) || NOT_PROSE.test(en)) continue;
1234
+ place(ctx, ancestors, { start: ctx.base + literal.start, end: ctx.base + literal.end, en, code: (key) => `t('${key}')` });
1235
+ }
1128
1236
  }
1129
1237
  function walk(node, ctx, ancestors) {
1130
1238
  if (Array.isArray(node)) return node.forEach((child) => walk(child, ctx, ancestors));
1131
1239
  if (!node || typeof node !== "object") return;
1132
1240
  const n = node;
1241
+ if (ctx.consumed.has(n)) return;
1133
1242
  if (n.type === "JSXElement" || n.type === "JSXFragment") {
1134
1243
  ctx.sawJsx = true;
1135
- if (!visitChildren(n, ctx, ancestors)) return;
1244
+ visitChildren(n, ctx, ancestors);
1136
1245
  }
1137
1246
  if (n.type === "JSXAttribute") visitAttribute(n, ctx, ancestors);
1247
+ if (n.type === "JSXExpressionContainer") visitBranches(n, ctx, ancestors);
1138
1248
  const next = typeof n.type === "string" ? [...ancestors, n] : ancestors;
1139
1249
  for (const [key, child] of Object.entries(n)) if (!SKIP.has(key)) walk(child, ctx, next);
1140
1250
  }
@@ -1232,13 +1342,14 @@ function extractStrings(file, lib, taken) {
1232
1342
  for (const block of file.js) {
1233
1343
  const program = block.ast.program;
1234
1344
  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 };
1345
+ 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
1346
  walk(program, ctx, []);
1237
1347
  if (ctx.sawJsx) flagModuleStrings(program, ctx);
1238
1348
  const style = styleOf(program, file.text, ctx.base);
1239
1349
  const hooks = [...ctx.components].map((fn) => hookEdits(fn, lib, file.text, ctx.base, style));
1240
1350
  edits2.push(...hooks.flatMap((h) => h.edits));
1241
- edits2.push(...importEdits(program, hooks.flatMap((h) => h.imports), ctx.base, style));
1351
+ const trans = ctx.usedTrans ? [{ name: "Trans", source: "react-i18next" }] : [];
1352
+ edits2.push(...importEdits(program, [...hooks.flatMap((h) => h.imports), ...trans], ctx.base, style));
1242
1353
  }
1243
1354
  return { edits: edits2, flags, entries };
1244
1355
  }
@@ -1933,7 +2044,7 @@ function wireNextIntl(project, mode, files, paths) {
1933
2044
  var import_lint6 = require("@dualkit/lint");
1934
2045
  var import_node_path14 = require("path");
1935
2046
  var DEPS2 = { "react-i18next": "17.1.6", i18next: "26.5.8" };
1936
- var INIT_PATH = "src/i18n.ts";
2047
+ var INIT_PATH = "src/dualkit-i18n.ts";
1937
2048
  var HTML_PATH = "index.html";
1938
2049
  var COOKIE_PATTERN = `/(?:^|; )${LOCALE_COOKIE}=([^;]*)/`;
1939
2050
  var messagesImport = (locale) => `import ${locale} from '${import_node_path14.posix.relative(import_node_path14.posix.dirname(INIT_PATH), `messages/${locale}.json`)}'`;
@@ -2503,7 +2614,7 @@ function iconImports(program) {
2503
2614
  return found;
2504
2615
  }
2505
2616
  var isMirrored = (imported) => MIRROR_ICON.test(imported) && !NEVER_MIRROR.test(imported);
2506
- var tagName = (opening) => {
2617
+ var tagName2 = (opening) => {
2507
2618
  const name = opening.name;
2508
2619
  return name.type === "JSXIdentifier" ? name.name : null;
2509
2620
  };
@@ -2545,7 +2656,7 @@ function alreadyMirrored(opening, ctx) {
2545
2656
  });
2546
2657
  }
2547
2658
  function visitOpening(opening, ctx, ancestors) {
2548
- const name = tagName(opening);
2659
+ const name = tagName2(opening);
2549
2660
  if (!name) return;
2550
2661
  const after = ctx.base + opening.name.end;
2551
2662
  if (ctx.icons.has(name) && isMirrored(ctx.icons.get(name)) && !alreadyMirrored(opening, ctx)) {
@@ -2558,7 +2669,7 @@ function visitOpening(opening, ctx, ancestors) {
2558
2669
  }
2559
2670
  function flagSvg(opening, ctx, ancestors) {
2560
2671
  if (alreadyMirrored(opening, ctx)) return;
2561
- const control = [...ancestors].reverse().find((a) => a.type === "JSXElement" && ["button", "a"].includes(tagName(a.openingElement) ?? ""));
2672
+ const control = [...ancestors].reverse().find((a) => a.type === "JSXElement" && ["button", "a"].includes(tagName2(a.openingElement) ?? ""));
2562
2673
  if (!control || !DIRECTION_LABEL.test(labelOf(control))) return;
2563
2674
  ctx.flags.push({
2564
2675
  file: ctx.file.path,
@@ -2570,7 +2681,7 @@ function flagSvg(opening, ctx, ancestors) {
2570
2681
  }
2571
2682
  var isolates = (element) => {
2572
2683
  const opening = element.openingElement;
2573
- return tagName(opening) === "bdi" || attributeNamed(opening, "dir") !== void 0;
2684
+ return tagName2(opening) === "bdi" || attributeNamed(opening, "dir") !== void 0;
2574
2685
  };
2575
2686
  function visitExpression(container, ancestors, ctx) {
2576
2687
  const parent = ancestors[ancestors.length - 1];
@@ -3219,6 +3330,10 @@ async function guard(root) {
3219
3330
  return state;
3220
3331
  }
3221
3332
  function pendingCheck(ledger, force) {
3333
+ const broken = brokenMarks(ledger);
3334
+ if (broken.length > 0) {
3335
+ 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.`);
3336
+ }
3222
3337
  const pending = ledger.filter((e) => e.status === "pending").length;
3223
3338
  if (pending > 0 && !force) {
3224
3339
  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.`);
package/dist/index.js CHANGED
@@ -357,6 +357,8 @@ function mergeLedger(existing, added) {
357
357
  }
358
358
  return [...existing, ...fresh].sort(byKey);
359
359
  }
360
+ var marks = (text) => [...text.matchAll(/\{[^}]*\}|<\/?[a-z0-9]+>/gi)].map((m) => m[0]).sort().join(" ");
361
+ var brokenMarks = (entries) => entries.filter((e) => e.status !== "pending" && e.ar !== "" && marks(e.ar) !== marks(e.en));
360
362
  var textFor = (entry, locale) => locale === "en" || entry.status === "pending" || entry.ar === "" ? entry.en : entry.ar;
361
363
  var group = () => /* @__PURE__ */ Object.create(null);
362
364
  function messagesFor(entries, locale) {
@@ -909,7 +911,25 @@ var tInScope = (ancestors) => ancestors.some((node) => scopeNames(node).includes
909
911
 
910
912
  // src/transforms/strings.ts
911
913
  var SKIP = /* @__PURE__ */ new Set(["loc", "leadingComments", "trailingComments", "innerComments", "extra"]);
912
- var TEXT_ATTRIBUTES = /* @__PURE__ */ new Set(["alt", "title", "placeholder", "aria-label", "label"]);
914
+ var TEXT_ATTRIBUTES = /* @__PURE__ */ new Set([
915
+ "alt",
916
+ "title",
917
+ "placeholder",
918
+ "aria-label",
919
+ "label",
920
+ "aria-description",
921
+ "aria-placeholder",
922
+ "aria-roledescription",
923
+ "description",
924
+ "subtitle",
925
+ "heading",
926
+ "caption",
927
+ "hint",
928
+ "tooltip",
929
+ "helperText",
930
+ "pageTitle"
931
+ ]);
932
+ 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
933
  var LETTER = /\p{L}/u;
914
934
  var PROSE = /^\p{Lu}\p{Ll}/u;
915
935
  var NOT_PROSE = /[/\\_=<>{}@#|]/;
@@ -927,7 +947,7 @@ function stemOf(path) {
927
947
  if (!ROUTE_STEMS.has(stem)) return kebab(stem) || "file";
928
948
  return kebab(routeDirs(parts).join("-")) || "home";
929
949
  }
930
- var keyWords = (en) => ascii(en.replace(/\{[^}]*\}/g, " ")).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).slice(0, 4).join("-") || "text";
950
+ var keyWords = (en) => ascii(en.replace(/\{[^}]*\}|<[^>]*>/g, " ")).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).slice(0, 4).join("-") || "text";
931
951
  function flag(ctx, offset, kind, message, suggestion) {
932
952
  ctx.flags.push({ file: ctx.file.path, line: lineOf(ctx.file.text, offset), kind, message, suggestion });
933
953
  }
@@ -941,7 +961,7 @@ function keyFor(ctx, en) {
941
961
  ctx.byText.set(en, key);
942
962
  return key;
943
963
  }
944
- function place(ctx, ancestors, start2, end, en, values) {
964
+ function place(ctx, ancestors, { start: start2, end, en, code, hookless }) {
945
965
  const component = componentOf(ancestors);
946
966
  if (!component) {
947
967
  flag(
@@ -976,8 +996,8 @@ function place(ctx, ancestors, start2, end, en, values) {
976
996
  const isNew = !ctx.byText.has(en);
977
997
  const key = keyFor(ctx, en);
978
998
  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);
999
+ ctx.edits.push({ start: start2, end, text: code(key) });
1000
+ if (!hookless) ctx.components.add(component);
981
1001
  }
982
1002
  var TEXT_METHODS = /^(toLocale(Date|Time)?String|toFixed|toPrecision|toString|toUpperCase|toLowerCase|trim(Start|End)?|padStart|padEnd)$/;
983
1003
  function textMethodTarget(expression) {
@@ -997,6 +1017,21 @@ function placeholderName(expression) {
997
1017
  }
998
1018
  return null;
999
1019
  }
1020
+ function holdsJsx(node) {
1021
+ if (Array.isArray(node)) return node.some(holdsJsx);
1022
+ if (!node || typeof node !== "object") return false;
1023
+ const n = node;
1024
+ if (n.type === "JSXElement" || n.type === "JSXFragment") return true;
1025
+ return Object.entries(n).some(([key, child]) => !SKIP.has(key) && holdsJsx(child));
1026
+ }
1027
+ function callName(expression) {
1028
+ if (expression.type !== "CallExpression" && expression.type !== "OptionalCallExpression") return null;
1029
+ if (holdsJsx(expression)) return null;
1030
+ const callee = expression.callee;
1031
+ if (callee.type === "Identifier") return callee.name === "t" ? null : callee.name;
1032
+ if ((callee.type === "MemberExpression" || callee.type === "OptionalMemberExpression") && !callee.computed) return placeholderName(callee);
1033
+ return null;
1034
+ }
1000
1035
  function hasText(element) {
1001
1036
  return (element.children ?? []).some((c) => c.type === "JSXText" ? /\S/.test(String(c.value)) : c.type === "JSXExpressionContainer" ? c.expression.type !== "JSXEmptyExpression" : c.type === "JSXSpreadChild" || hasText(c));
1002
1037
  }
@@ -1005,30 +1040,33 @@ function pieceOf(child, ctx) {
1005
1040
  const end = ctx.base + child.end;
1006
1041
  if (child.type === "JSXText") {
1007
1042
  const raw = ctx.file.text.slice(start2, end);
1008
- if (!/\S/.test(raw)) return { kind: "space", start: start2, end };
1043
+ if (!/\S/.test(raw)) return { kind: "space", value: raw.includes("\n") ? "" : " ", start: start2, end };
1009
1044
  const lead = raw.length - raw.trimStart().length;
1010
1045
  const trail = raw.length - raw.trimEnd().length;
1011
- return { kind: "text", value: String(child.value), start: start2 + lead, end: end - trail };
1046
+ return { kind: "text", value: jsxText(String(child.value)), start: start2 + lead, end: end - trail };
1012
1047
  }
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 };
1048
+ if (child.type === "JSXSpreadChild") return { kind: "other", node: child, start: start2, end };
1049
+ if (child.type !== "JSXExpressionContainer") return { kind: "element", node: child, start: start2, end };
1015
1050
  const expression = child.expression;
1016
1051
  if (expression.type === "JSXEmptyExpression") return { kind: "comment", start: start2, end };
1017
1052
  if (expression.type === "StringLiteral") {
1018
1053
  const value = String(expression.value);
1019
- return /\S/.test(value) ? { kind: "text", value, start: start2, end } : { kind: "space", start: start2, end };
1054
+ return /\S/.test(value) ? { kind: "text", value, start: start2, end } : { kind: "space", value, start: start2, end };
1020
1055
  }
1021
- const name = placeholderName(expression);
1022
- if (!name) return { kind: "other", start: start2, end };
1056
+ const name = placeholderName(expression) ?? callName(expression);
1057
+ if (!name || NODE_NAMES.test(name)) return { kind: "other", node: expression, start: start2, end };
1023
1058
  const source = ctx.file.text.slice(ctx.base + expression.start, ctx.base + expression.end);
1024
- return { kind: "value", name, source, start: start2, end };
1059
+ return { kind: "value", name, source: mayBeEmpty(expression) ? `${source} ?? ''` : source, start: start2, end };
1025
1060
  }
1061
+ var NODE_NAMES = /^(children|icon|avatar|logo|image|element|node)$/i;
1062
+ var mayBeEmpty = (expression) => /^Optional/.test(expression.type) || expression.type === "CallExpression" && !textMethodTarget(expression);
1063
+ var jsxText = (value) => value.replace(/^\s*\n\s*/, "").replace(/\s*\n\s*$/, "");
1026
1064
  var mixed = (ctx, offset) => flag(
1027
1065
  ctx,
1028
1066
  offset,
1029
1067
  "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."
1068
+ "This text shares its element with an expression convert cannot read as one sentence, so it was left as it is.",
1069
+ "Translate the whole sentence with a plural message, by hand."
1032
1070
  );
1033
1071
  function valuesOf(pieces) {
1034
1072
  const seen = /* @__PURE__ */ new Map();
@@ -1039,67 +1077,139 @@ function valuesOf(pieces) {
1039
1077
  }
1040
1078
  return [...seen].map(([name, source]) => name === source ? name : `${name}: ${source}`).join(", ");
1041
1079
  }
1042
- function unitsOf(pieces) {
1043
- const units = [];
1080
+ var tagName = (element) => {
1081
+ const name = element.openingElement?.name;
1082
+ return name?.type === "JSXIdentifier" ? name.name : null;
1083
+ };
1084
+ var childPieces = (element, ctx) => (element.children ?? []).map((c) => pieceOf(c, ctx));
1085
+ function isInline(element, ctx) {
1086
+ if (!INLINE.has(tagName(element) ?? "") || !hasText(element)) return false;
1087
+ return childPieces(element, ctx).every((p) => p.kind === "text" || p.kind === "value" || p.kind === "space");
1088
+ }
1089
+ function runsOf(pieces, ctx) {
1090
+ const runs = [];
1044
1091
  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;
1092
+ for (const piece of pieces) {
1093
+ if (piece.kind === "other" || piece.kind === "element" && !isInline(piece.node, ctx)) {
1094
+ if (run2.length) runs.push(run2);
1095
+ run2 = [];
1096
+ } else run2 = [...run2, piece];
1097
+ }
1098
+ return run2.length ? [...runs, run2] : runs;
1099
+ }
1100
+ 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));
1101
+ function tagsOf(run2, ctx) {
1102
+ const counts = /* @__PURE__ */ new Map();
1103
+ const tags = /* @__PURE__ */ new Map();
1104
+ for (const piece of run2) {
1105
+ if (piece.kind !== "element") continue;
1106
+ const name = tagName(piece.node);
1107
+ const base = name === "Link" ? "navlink" : name.toLowerCase();
1108
+ const count = (counts.get(base) ?? 0) + 1;
1109
+ counts.set(base, count);
1110
+ const open = piece.node.openingElement;
1111
+ const close = piece.node.closingElement;
1112
+ tags.set(piece, {
1113
+ tag: count === 1 ? base : `${base}${count}`,
1114
+ open: ctx.file.text.slice(ctx.base + open.start, ctx.base + open.end),
1115
+ close: ctx.file.text.slice(ctx.base + close.start, ctx.base + close.end),
1116
+ inner: childPieces(piece.node, ctx)
1117
+ });
1055
1118
  }
1056
- return units;
1119
+ return tags;
1057
1120
  }
1058
- function messageOf(pieces) {
1059
- if (pieces.some((p) => p.kind === "other")) return null;
1121
+ var plainText = (pieces) => pieces.map((p) => p.kind === "text" || p.kind === "space" ? p.value : p.kind === "value" ? `{${p.name}}` : "").join("");
1122
+ var nextIntlCode = (key, values, tags) => {
1123
+ if (!tags.length) return `{t('${key}'${values ? `, { ${values} }` : ""})}`;
1124
+ const parts = [...values ? [values] : [], ...tags.map((t) => `${t.tag}: (chunks) => ${t.open}{chunks}${t.close}`)];
1125
+ return `{t.rich('${key}', { ${parts.join(", ")} })}`;
1126
+ };
1127
+ var reactI18nextCode = (key, values, tags) => {
1128
+ if (!tags.length) return nextIntlCode(key, values, tags);
1129
+ const components = tags.map((t) => `${t.tag}: ${t.open.replace(/>$/, " />")}`).join(", ");
1130
+ return `<Trans i18nKey="${key}"${values ? ` values={{ ${values} }}` : ""} components={{ ${components} }} />`;
1131
+ };
1132
+ function messageOf(pieces, ctx) {
1060
1133
  const edge = (p) => p.kind !== "space" && p.kind !== "comment";
1061
1134
  const first = pieces.findIndex(edge);
1062
1135
  const last = pieces.length - 1 - [...pieces].reverse().findIndex(edge);
1063
1136
  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 };
1137
+ if (run2.some((p) => p.kind === "comment")) return null;
1138
+ const tags = tagsOf(run2, ctx);
1139
+ const values = valuesOf([...run2, ...[...tags.values()].flatMap((t) => t.inner)]);
1140
+ if (values === null) return null;
1141
+ const en = run2.map((p) => {
1142
+ const tag = tags.get(p);
1143
+ return tag ? `<${tag.tag}>${plainText(tag.inner)}</${tag.tag}>` : plainText([p]);
1144
+ }).join("").replace(/\s+/g, " ").trim();
1145
+ const list = [...tags.values()];
1146
+ const code = (key) => (ctx.lib === "react-i18next" ? reactI18nextCode : nextIntlCode)(key, values, list);
1147
+ return { start: run2[0].start, end: run2[run2.length - 1].end, en, code, tags: list, hookless: ctx.lib === "react-i18next" && list.length > 0 };
1068
1148
  }
1069
1149
  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)) {
1150
+ const pieces = childPieces(element, ctx);
1151
+ pieces.forEach((piece, i) => {
1152
+ if (!gluedTo(piece, pieces[i + 1], pieces[i - 1])) return;
1153
+ ctx.consumed.add(piece.node);
1154
+ });
1155
+ const glued = pieces.filter((p, i) => p.kind === "other" && gluedTo(p, pieces[i + 1], pieces[i - 1]));
1156
+ for (const run2 of runsOf(pieces, ctx)) {
1073
1157
  const lettered = run2.find((p) => p.kind === "text" && LETTER.test(p.value));
1074
1158
  if (!lettered) continue;
1075
- const message = touchesText ? null : messageOf(run2);
1159
+ 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() === ""));
1160
+ const message = touchesGlue ? null : messageOf(run2, ctx);
1076
1161
  if (!message) {
1077
1162
  mixed(ctx, lettered.start);
1078
- return false;
1163
+ continue;
1079
1164
  }
1080
- messages.push(message);
1165
+ if (message.tags.length) {
1166
+ run2.forEach((p) => {
1167
+ if (p.kind === "element") ctx.consumed.add(p.node);
1168
+ });
1169
+ if (ctx.lib === "react-i18next") ctx.usedTrans = true;
1170
+ }
1171
+ place(ctx, ancestors, message);
1081
1172
  }
1082
- for (const m of messages) place(ctx, ancestors, m.start, m.end, m.en, m.values);
1083
- return true;
1084
1173
  }
1174
+ var bracedCall = (values) => (key) => `{t('${key}'${values ? `, { ${values} }` : ""})}`;
1085
1175
  function visitAttribute(attribute, ctx, ancestors) {
1086
1176
  const name = attribute.name;
1087
1177
  if (name.type !== "JSXIdentifier" || !TEXT_ATTRIBUTES.has(name.name)) return;
1088
1178
  const value = attribute.value;
1089
1179
  const literal = value?.type === "JSXExpressionContainer" ? value.expression : value;
1090
1180
  if (literal?.type !== "StringLiteral" || !LETTER.test(String(literal.value))) return;
1181
+ if (name.name === "placeholder" && /^(blur|empty)$/.test(String(literal.value))) return;
1091
1182
  const en = String(literal.value).replace(/\s+/g, " ").trim();
1092
- place(ctx, ancestors, ctx.base + value.start, ctx.base + value.end, en, "");
1183
+ place(ctx, ancestors, { start: ctx.base + value.start, end: ctx.base + value.end, en, code: bracedCall("") });
1184
+ }
1185
+ function branchStrings(node) {
1186
+ if (node.type === "StringLiteral") return [node];
1187
+ if (node.type === "ConditionalExpression") return [...branchStrings(node.consequent), ...branchStrings(node.alternate)];
1188
+ if (node.type === "LogicalExpression") return branchStrings(node.right);
1189
+ return [];
1190
+ }
1191
+ function visitBranches(container, ctx, ancestors) {
1192
+ const parent = ancestors.at(-1);
1193
+ const expression = container.expression;
1194
+ const shown = parent?.type === "JSXElement" || parent?.type === "JSXFragment" || parent?.type === "JSXAttribute" && TEXT_ATTRIBUTES.has(parent.name.name);
1195
+ if (!shown || expression.type === "StringLiteral" || ctx.consumed.has(expression)) return;
1196
+ for (const literal of branchStrings(expression)) {
1197
+ const en = String(literal.value).replace(/\s+/g, " ").trim();
1198
+ if (!LETTER.test(en) || NOT_PROSE.test(en)) continue;
1199
+ place(ctx, ancestors, { start: ctx.base + literal.start, end: ctx.base + literal.end, en, code: (key) => `t('${key}')` });
1200
+ }
1093
1201
  }
1094
1202
  function walk(node, ctx, ancestors) {
1095
1203
  if (Array.isArray(node)) return node.forEach((child) => walk(child, ctx, ancestors));
1096
1204
  if (!node || typeof node !== "object") return;
1097
1205
  const n = node;
1206
+ if (ctx.consumed.has(n)) return;
1098
1207
  if (n.type === "JSXElement" || n.type === "JSXFragment") {
1099
1208
  ctx.sawJsx = true;
1100
- if (!visitChildren(n, ctx, ancestors)) return;
1209
+ visitChildren(n, ctx, ancestors);
1101
1210
  }
1102
1211
  if (n.type === "JSXAttribute") visitAttribute(n, ctx, ancestors);
1212
+ if (n.type === "JSXExpressionContainer") visitBranches(n, ctx, ancestors);
1103
1213
  const next = typeof n.type === "string" ? [...ancestors, n] : ancestors;
1104
1214
  for (const [key, child] of Object.entries(n)) if (!SKIP.has(key)) walk(child, ctx, next);
1105
1215
  }
@@ -1197,13 +1307,14 @@ function extractStrings(file, lib, taken) {
1197
1307
  for (const block of file.js) {
1198
1308
  const program = block.ast.program;
1199
1309
  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 };
1310
+ 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
1311
  walk(program, ctx, []);
1202
1312
  if (ctx.sawJsx) flagModuleStrings(program, ctx);
1203
1313
  const style = styleOf(program, file.text, ctx.base);
1204
1314
  const hooks = [...ctx.components].map((fn) => hookEdits(fn, lib, file.text, ctx.base, style));
1205
1315
  edits2.push(...hooks.flatMap((h) => h.edits));
1206
- edits2.push(...importEdits(program, hooks.flatMap((h) => h.imports), ctx.base, style));
1316
+ const trans = ctx.usedTrans ? [{ name: "Trans", source: "react-i18next" }] : [];
1317
+ edits2.push(...importEdits(program, [...hooks.flatMap((h) => h.imports), ...trans], ctx.base, style));
1207
1318
  }
1208
1319
  return { edits: edits2, flags, entries };
1209
1320
  }
@@ -1898,7 +2009,7 @@ function wireNextIntl(project, mode, files, paths) {
1898
2009
  import { parseSourceOutcome as parseSourceOutcome5 } from "@dualkit/lint";
1899
2010
  import { posix as posix6 } from "path";
1900
2011
  var DEPS2 = { "react-i18next": "17.1.6", i18next: "26.5.8" };
1901
- var INIT_PATH = "src/i18n.ts";
2012
+ var INIT_PATH = "src/dualkit-i18n.ts";
1902
2013
  var HTML_PATH = "index.html";
1903
2014
  var COOKIE_PATTERN = `/(?:^|; )${LOCALE_COOKIE}=([^;]*)/`;
1904
2015
  var messagesImport = (locale) => `import ${locale} from '${posix6.relative(posix6.dirname(INIT_PATH), `messages/${locale}.json`)}'`;
@@ -2468,7 +2579,7 @@ function iconImports(program) {
2468
2579
  return found;
2469
2580
  }
2470
2581
  var isMirrored = (imported) => MIRROR_ICON.test(imported) && !NEVER_MIRROR.test(imported);
2471
- var tagName = (opening) => {
2582
+ var tagName2 = (opening) => {
2472
2583
  const name = opening.name;
2473
2584
  return name.type === "JSXIdentifier" ? name.name : null;
2474
2585
  };
@@ -2510,7 +2621,7 @@ function alreadyMirrored(opening, ctx) {
2510
2621
  });
2511
2622
  }
2512
2623
  function visitOpening(opening, ctx, ancestors) {
2513
- const name = tagName(opening);
2624
+ const name = tagName2(opening);
2514
2625
  if (!name) return;
2515
2626
  const after = ctx.base + opening.name.end;
2516
2627
  if (ctx.icons.has(name) && isMirrored(ctx.icons.get(name)) && !alreadyMirrored(opening, ctx)) {
@@ -2523,7 +2634,7 @@ function visitOpening(opening, ctx, ancestors) {
2523
2634
  }
2524
2635
  function flagSvg(opening, ctx, ancestors) {
2525
2636
  if (alreadyMirrored(opening, ctx)) return;
2526
- const control = [...ancestors].reverse().find((a) => a.type === "JSXElement" && ["button", "a"].includes(tagName(a.openingElement) ?? ""));
2637
+ const control = [...ancestors].reverse().find((a) => a.type === "JSXElement" && ["button", "a"].includes(tagName2(a.openingElement) ?? ""));
2527
2638
  if (!control || !DIRECTION_LABEL.test(labelOf(control))) return;
2528
2639
  ctx.flags.push({
2529
2640
  file: ctx.file.path,
@@ -2535,7 +2646,7 @@ function flagSvg(opening, ctx, ancestors) {
2535
2646
  }
2536
2647
  var isolates = (element) => {
2537
2648
  const opening = element.openingElement;
2538
- return tagName(opening) === "bdi" || attributeNamed(opening, "dir") !== void 0;
2649
+ return tagName2(opening) === "bdi" || attributeNamed(opening, "dir") !== void 0;
2539
2650
  };
2540
2651
  function visitExpression(container, ancestors, ctx) {
2541
2652
  const parent = ancestors[ancestors.length - 1];
@@ -3184,6 +3295,10 @@ async function guard(root) {
3184
3295
  return state;
3185
3296
  }
3186
3297
  function pendingCheck(ledger, force) {
3298
+ const broken = brokenMarks(ledger);
3299
+ if (broken.length > 0) {
3300
+ 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.`);
3301
+ }
3187
3302
  const pending = ledger.filter((e) => e.status === "pending").length;
3188
3303
  if (pending > 0 && !force) {
3189
3304
  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.`);
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.3.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",