@dualkit/convert 0.1.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.
@@ -94,11 +95,13 @@ Convert flags these in the report instead of guessing:
94
95
  It refuses, with a one-line reason, when:
95
96
 
96
97
  - The app is not Next.js App Router or Vite React. The Pages Router, Create React App, Remix, Vue, Svelte and Angular are refused.
97
- - The app already has its own middleware or proxy file, or files with the names convert would write.
98
+ - The app already has files with the names convert would write, such as `app-middleware`.
98
99
  - The root layout is a client component, has no `<html>` element, or takes its props in a form convert cannot add params to.
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.
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.
102
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.
103
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.
104
107
 
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) {
@@ -478,6 +480,15 @@ async function applyPlan(disk, plan) {
478
480
  return { touched, edits: edits2, writes, perFile };
479
481
  }
480
482
  var json = (value) => JSON.stringify(value, null, 2) + "\n";
483
+ var isMessages = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
484
+ function keepHandAdded(existing, generated) {
485
+ const kept = Object.entries(existing).map(([key, value]) => {
486
+ const fresh = generated[key];
487
+ if (fresh === void 0) return [key, value];
488
+ return [key, isMessages(value) && isMessages(fresh) ? keepHandAdded(value, fresh) : fresh];
489
+ });
490
+ return { ...generated, ...Object.fromEntries(kept) };
491
+ }
481
492
  var messagesFiles = (ledger) => ({
482
493
  [messagesPath("en")]: json(messagesFor(ledger, "en")),
483
494
  [messagesPath("ar")]: json(messagesFor(ledger, "ar"))
@@ -935,7 +946,25 @@ var tInScope = (ancestors) => ancestors.some((node) => scopeNames(node).includes
935
946
 
936
947
  // src/transforms/strings.ts
937
948
  var SKIP = /* @__PURE__ */ new Set(["loc", "leadingComments", "trailingComments", "innerComments", "extra"]);
938
- 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"]);
939
968
  var LETTER = /\p{L}/u;
940
969
  var PROSE = /^\p{Lu}\p{Ll}/u;
941
970
  var NOT_PROSE = /[/\\_=<>{}@#|]/;
@@ -953,7 +982,7 @@ function stemOf(path) {
953
982
  if (!ROUTE_STEMS.has(stem)) return kebab(stem) || "file";
954
983
  return kebab(routeDirs(parts).join("-")) || "home";
955
984
  }
956
- 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";
957
986
  function flag(ctx, offset, kind, message, suggestion) {
958
987
  ctx.flags.push({ file: ctx.file.path, line: lineOf(ctx.file.text, offset), kind, message, suggestion });
959
988
  }
@@ -967,7 +996,7 @@ function keyFor(ctx, en) {
967
996
  ctx.byText.set(en, key);
968
997
  return key;
969
998
  }
970
- function place(ctx, ancestors, start2, end, en, values) {
999
+ function place(ctx, ancestors, { start: start2, end, en, code, hookless }) {
971
1000
  const component = componentOf(ancestors);
972
1001
  if (!component) {
973
1002
  flag(
@@ -1002,8 +1031,8 @@ function place(ctx, ancestors, start2, end, en, values) {
1002
1031
  const isNew = !ctx.byText.has(en);
1003
1032
  const key = keyFor(ctx, en);
1004
1033
  if (isNew) ctx.entries.push({ key, en, ar: "", status: "pending", file: ctx.file.path, line: lineOf(ctx.file.text, start2) });
1005
- ctx.edits.push({ start: start2, end, text: `{t('${key}'${values ? `, { ${values} }` : ""})}` });
1006
- ctx.components.add(component);
1034
+ ctx.edits.push({ start: start2, end, text: code(key) });
1035
+ if (!hookless) ctx.components.add(component);
1007
1036
  }
1008
1037
  var TEXT_METHODS = /^(toLocale(Date|Time)?String|toFixed|toPrecision|toString|toUpperCase|toLowerCase|trim(Start|End)?|padStart|padEnd)$/;
1009
1038
  function textMethodTarget(expression) {
@@ -1023,6 +1052,21 @@ function placeholderName(expression) {
1023
1052
  }
1024
1053
  return null;
1025
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
+ }
1026
1070
  function hasText(element) {
1027
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));
1028
1072
  }
@@ -1031,30 +1075,33 @@ function pieceOf(child, ctx) {
1031
1075
  const end = ctx.base + child.end;
1032
1076
  if (child.type === "JSXText") {
1033
1077
  const raw = ctx.file.text.slice(start2, end);
1034
- 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 };
1035
1079
  const lead = raw.length - raw.trimStart().length;
1036
1080
  const trail = raw.length - raw.trimEnd().length;
1037
- 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 };
1038
1082
  }
1039
- if (child.type === "JSXSpreadChild") return { kind: "other", start: start2, end };
1040
- 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 };
1041
1085
  const expression = child.expression;
1042
1086
  if (expression.type === "JSXEmptyExpression") return { kind: "comment", start: start2, end };
1043
1087
  if (expression.type === "StringLiteral") {
1044
1088
  const value = String(expression.value);
1045
- 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 };
1046
1090
  }
1047
- const name = placeholderName(expression);
1048
- 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 };
1049
1093
  const source = ctx.file.text.slice(ctx.base + expression.start, ctx.base + expression.end);
1050
- return { kind: "value", name, source, start: start2, end };
1094
+ return { kind: "value", name, source: mayBeEmpty(expression) ? `${source} ?? ''` : source, start: start2, end };
1051
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*$/, "");
1052
1099
  var mixed = (ctx, offset) => flag(
1053
1100
  ctx,
1054
1101
  offset,
1055
1102
  "mixed-text",
1056
- "This text shares its element with other elements or expressions, so it was left as it is.",
1057
- "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."
1058
1105
  );
1059
1106
  function valuesOf(pieces) {
1060
1107
  const seen = /* @__PURE__ */ new Map();
@@ -1065,67 +1112,139 @@ function valuesOf(pieces) {
1065
1112
  }
1066
1113
  return [...seen].map(([name, source]) => name === source ? name : `${name}: ${source}`).join(", ");
1067
1114
  }
1068
- function unitsOf(pieces) {
1069
- 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 = [];
1070
1126
  let run2 = [];
1071
- let before;
1072
- for (const piece of [...pieces, void 0]) {
1073
- if (piece && piece.kind !== "element") {
1074
- run2.push(piece);
1075
- continue;
1076
- }
1077
- const texty = (p) => p?.kind === "element" && p.texty;
1078
- if (run2.length) units.push({ run: run2, touchesText: texty(before) || texty(piece) });
1079
- run2 = [];
1080
- 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
+ });
1081
1153
  }
1082
- return units;
1154
+ return tags;
1083
1155
  }
1084
- function messageOf(pieces) {
1085
- 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) {
1086
1168
  const edge = (p) => p.kind !== "space" && p.kind !== "comment";
1087
1169
  const first = pieces.findIndex(edge);
1088
1170
  const last = pieces.length - 1 - [...pieces].reverse().findIndex(edge);
1089
1171
  const run2 = pieces.slice(first, last + 1);
1090
- const values = valuesOf(run2);
1091
- if (values === null || run2.some((p) => p.kind === "comment")) return null;
1092
- const en = run2.map((p) => p.kind === "text" ? p.value : p.kind === "value" ? `{${p.name}}` : " ").join("").replace(/\s+/g, " ").trim();
1093
- 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 };
1094
1183
  }
1095
1184
  function visitChildren(element, ctx, ancestors) {
1096
- const pieces = (element.children ?? []).map((c) => pieceOf(c, ctx));
1097
- const messages = [];
1098
- 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)) {
1099
1192
  const lettered = run2.find((p) => p.kind === "text" && LETTER.test(p.value));
1100
1193
  if (!lettered) continue;
1101
- 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);
1102
1196
  if (!message) {
1103
1197
  mixed(ctx, lettered.start);
1104
- return false;
1198
+ continue;
1199
+ }
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;
1105
1205
  }
1106
- messages.push(message);
1206
+ place(ctx, ancestors, message);
1107
1207
  }
1108
- for (const m of messages) place(ctx, ancestors, m.start, m.end, m.en, m.values);
1109
- return true;
1110
1208
  }
1209
+ var bracedCall = (values) => (key) => `{t('${key}'${values ? `, { ${values} }` : ""})}`;
1111
1210
  function visitAttribute(attribute, ctx, ancestors) {
1112
1211
  const name = attribute.name;
1113
1212
  if (name.type !== "JSXIdentifier" || !TEXT_ATTRIBUTES.has(name.name)) return;
1114
1213
  const value = attribute.value;
1115
1214
  const literal = value?.type === "JSXExpressionContainer" ? value.expression : value;
1116
1215
  if (literal?.type !== "StringLiteral" || !LETTER.test(String(literal.value))) return;
1216
+ if (name.name === "placeholder" && /^(blur|empty)$/.test(String(literal.value))) return;
1117
1217
  const en = String(literal.value).replace(/\s+/g, " ").trim();
1118
- 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
+ }
1119
1236
  }
1120
1237
  function walk(node, ctx, ancestors) {
1121
1238
  if (Array.isArray(node)) return node.forEach((child) => walk(child, ctx, ancestors));
1122
1239
  if (!node || typeof node !== "object") return;
1123
1240
  const n = node;
1241
+ if (ctx.consumed.has(n)) return;
1124
1242
  if (n.type === "JSXElement" || n.type === "JSXFragment") {
1125
1243
  ctx.sawJsx = true;
1126
- if (!visitChildren(n, ctx, ancestors)) return;
1244
+ visitChildren(n, ctx, ancestors);
1127
1245
  }
1128
1246
  if (n.type === "JSXAttribute") visitAttribute(n, ctx, ancestors);
1247
+ if (n.type === "JSXExpressionContainer") visitBranches(n, ctx, ancestors);
1129
1248
  const next = typeof n.type === "string" ? [...ancestors, n] : ancestors;
1130
1249
  for (const [key, child] of Object.entries(n)) if (!SKIP.has(key)) walk(child, ctx, next);
1131
1250
  }
@@ -1223,13 +1342,14 @@ function extractStrings(file, lib, taken) {
1223
1342
  for (const block of file.js) {
1224
1343
  const program = block.ast.program;
1225
1344
  if (isServerFile(program)) continue;
1226
- 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 };
1227
1346
  walk(program, ctx, []);
1228
1347
  if (ctx.sawJsx) flagModuleStrings(program, ctx);
1229
1348
  const style = styleOf(program, file.text, ctx.base);
1230
1349
  const hooks = [...ctx.components].map((fn) => hookEdits(fn, lib, file.text, ctx.base, style));
1231
1350
  edits2.push(...hooks.flatMap((h) => h.edits));
1232
- 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));
1233
1353
  }
1234
1354
  return { edits: edits2, flags, entries };
1235
1355
  }
@@ -1686,6 +1806,88 @@ export const config = {
1686
1806
  matcher: '/((?!(?:api|_next|_vercel)(?:/|$)|.*\\\\..*).*)',
1687
1807
  }
1688
1808
  `;
1809
+ var composedMiddlewareFile = (routing, matcher, own) => `import { NextRequest, NextResponse, type NextFetchEvent } from 'next/server'
1810
+ import createMiddleware from 'next-intl/middleware'
1811
+ import { routing } from '${routing}'
1812
+ import { matchesApp, type Matcher } from '${matcher}'
1813
+ import * as own from '${own}'
1814
+
1815
+ const intl = createMiddleware(routing)
1816
+ type OwnHandler = (request: NextRequest, event: NextFetchEvent) => Response | null | undefined | Promise<Response | null | undefined>
1817
+ // A spread, so the bundler does not warn about the export names this app does not use.
1818
+ const exported: Record<string, unknown> = { ...own }
1819
+ const candidate = exported.default ?? exported.middleware ?? exported.proxy
1820
+ if (typeof candidate !== 'function') throw new Error('Dualkit could not find the middleware function exported by ${own}.')
1821
+ const ownHandler = candidate as OwnHandler
1822
+ const ownMatcher = (exported as { config?: { matcher?: Matcher } }).config?.matcher
1823
+ const OWN_ONLY = /^\\/(?:api|trpc)(?:\\/|$)/
1824
+
1825
+ export default async function middleware(request: NextRequest, event: NextFetchEvent) {
1826
+ const url = request.nextUrl.clone()
1827
+ const [, first = '', ...rest] = url.pathname.split('/')
1828
+ if ((routing.locales as readonly string[]).includes(first)) url.pathname = \`/\${rest.join('/')}\`
1829
+ const result = matchesApp(url.pathname, ownMatcher) ? await ownHandler(new NextRequest(url, request), event) : null
1830
+ // Anything but a plain "continue" is the app's answer: a redirect, a rewrite, a 401 or a page. Keep it as it is.
1831
+ if (result && result.headers.get('x-middleware-next') !== '1') return result
1832
+ if (OWN_ONLY.test(url.pathname)) return result ?? NextResponse.next()
1833
+ const response = intl(request)
1834
+ if (result) {
1835
+ for (const cookie of result.headers.getSetCookie()) response.headers.append('set-cookie', cookie)
1836
+ // Headers the app set for the browser, such as a content security policy.
1837
+ result.headers.forEach((value, key) => { if (key !== 'set-cookie' && !key.startsWith('x-middleware-')) response.headers.set(key, value) })
1838
+ const overridden = result.headers.get('x-middleware-override-headers')
1839
+ if (overridden) {
1840
+ const names = new Set([...(response.headers.get('x-middleware-override-headers') ?? '').split(','), ...overridden.split(',')].filter(Boolean))
1841
+ response.headers.set('x-middleware-override-headers', [...names].join(','))
1842
+ result.headers.forEach((value, key) => { if (key.startsWith('x-middleware-request-')) response.headers.set(key, value) })
1843
+ }
1844
+ }
1845
+ return response
1846
+ }
1847
+
1848
+ export const config = {
1849
+ matcher: '/((?!(?:_next|_vercel)(?:/|$)|.*\\\\..*).*)',
1850
+ }
1851
+ `;
1852
+ var APP_MATCHER = String.raw`/** Whether a path is covered by an app's own middleware matcher. No matcher means every path. */
1853
+ export type Matcher = string | { source: string } | (string | { source: string })[]
1854
+
1855
+ /** An entry with has or missing conditions is treated as matching, so the app's middleware runs rather than being skipped. */
1856
+ export function matchesApp(pathname: string, matcher: Matcher | undefined): boolean {
1857
+ if (matcher === undefined) return true
1858
+ return (Array.isArray(matcher) ? matcher : [matcher]).some((entry) => toRegExp(typeof entry === 'string' ? entry : entry.source).test(pathname))
1859
+ }
1860
+
1861
+ function toRegExp(pattern: string): RegExp {
1862
+ let out = ''
1863
+ let i = 0
1864
+ while (i < pattern.length) {
1865
+ const char = pattern[i]!
1866
+ const param = char === '/' ? /^\/:[A-Za-z0-9_]+([*+?]?)/.exec(pattern.slice(i)) : null
1867
+ if (param && pattern[i + param[0].length] === '(') {
1868
+ out += '/'
1869
+ i += param[0].length
1870
+ } else if (param) {
1871
+ out += { '*': '(?:/.*)?', '+': '/.+', '?': '(?:/[^/]+)?', '': '/[^/]+' }[param[1]!]
1872
+ i += param[0].length
1873
+ } else if (char === '(') {
1874
+ let depth = 0
1875
+ let j = i
1876
+ for (; j < pattern.length; j++) {
1877
+ if (pattern[j] === '\\') j++
1878
+ else if (pattern[j] === '(') depth++
1879
+ else if (pattern[j] === ')' && --depth === 0) break
1880
+ }
1881
+ out += pattern.slice(i, j + 1)
1882
+ i = j + 1
1883
+ } else {
1884
+ out += char.replace(/[.*+?^$|{}[\]\\]/g, '\\$&')
1885
+ i++
1886
+ }
1887
+ }
1888
+ return new RegExp('^' + out + (out.endsWith('/') ? '' : '/?') + '$')
1889
+ }
1890
+ `;
1689
1891
  var switchFile = (navigation) => `'use client'
1690
1892
 
1691
1893
  import { useLocale } from 'next-intl'
@@ -1738,16 +1940,24 @@ function alreadyWired(project, mode, appDir, files, paths) {
1738
1940
  const wired = (files(project.entry) ?? "").includes("NextIntlClientProvider") && files(`${sourceBase(project)}i18n/request.ts`) !== null;
1739
1941
  return wired ? project.entry : null;
1740
1942
  }
1741
- function guardMiddleware(base, ours, text, files) {
1943
+ function findMiddleware(base, files) {
1944
+ const found = [];
1742
1945
  for (const name of ["middleware", "proxy"]) {
1743
1946
  for (const ext of ["ts", "js", "mjs", "tsx", "jsx"]) {
1744
1947
  const path = `${base}${name}.${ext}`;
1745
- const existing = files(path);
1746
- if (existing === null || path === ours && existing === text) continue;
1747
- throw new UnsupportedError(`This app already has ${path}. Convert cannot merge the next-intl middleware into it, so it stopped before changing anything.`);
1948
+ const text = files(path);
1949
+ if (text !== null) found.push({ path, text });
1748
1950
  }
1749
1951
  }
1952
+ if (found.length > 1) throw new UnsupportedError(`This app has both ${found[0].path} and ${found[1].path}. Convert cannot tell which one to compose with next-intl, so it stopped before changing anything.`);
1953
+ const only = found[0] ?? null;
1954
+ if (only && !EXPORTS_HANDLER.test(only.text)) {
1955
+ throw new UnsupportedError(`Convert could not find the middleware function exported from ${only.path}, so it stopped before changing anything.`);
1956
+ }
1957
+ return only;
1750
1958
  }
1959
+ var EXPORTS_HANDLER = /export\s+default\b|export\s+(?:async\s+)?function\s+(?:middleware|proxy)\b|export\s+(?:const|let|var)\s+(?:middleware|proxy)\b|export\s*\{[^}]*\b(?:middleware|proxy|default)\b[^}]*\}/;
1960
+ var movedMiddlewarePath = (base, existing) => `${base}app-middleware.${existing.split(".").pop()}`;
1751
1961
  var merge = (into, from) => {
1752
1962
  const out = { ...into };
1753
1963
  for (const [path, edits2] of Object.entries(from)) out[path] = [...out[path] ?? [], ...edits2];
@@ -1757,8 +1967,13 @@ function bilingualParts(project, appDir, files, paths) {
1757
1967
  const base = sourceBase(project);
1758
1968
  const { moves, keptPages } = routeMoves(appDir, paths);
1759
1969
  const middlewarePath = `${base}${nextMajor(files) >= 16 ? "proxy" : "middleware"}.ts`;
1760
- const middleware = middlewareFile(specifierTo(middlewarePath, `${base}i18n/routing.ts`));
1761
- guardMiddleware(base, middlewarePath, middleware, files);
1970
+ const own = findMiddleware(base, files);
1971
+ const routingSpec = specifierTo(middlewarePath, `${base}i18n/routing.ts`);
1972
+ const ownMoved = own ? movedMiddlewarePath(base, own.path) : null;
1973
+ if (ownMoved && files(ownMoved) !== null) {
1974
+ throw new UnsupportedError(`This app already has ${ownMoved}. Convert needs that name for the app's own middleware, so move or remove that file and run convert again.`);
1975
+ }
1976
+ const middleware = ownMoved ? composedMiddlewareFile(routingSpec, specifierTo(middlewarePath, `${base}i18n/app-matcher.ts`), specifierTo(middlewarePath, ownMoved)) : middlewareFile(routingSpec);
1762
1977
  const catchAll = topCatchAll(appDir, moves, paths);
1763
1978
  const flags = keptPages.map((page) => flagAt(
1764
1979
  page,
@@ -1766,6 +1981,14 @@ function bilingualParts(project, appDir, files, paths) {
1766
1981
  `${page} stays outside [locale], because the middleware skips its path, so it has no Arabic version.`,
1767
1982
  "Move the page to a path the middleware matches, or translate it by hand."
1768
1983
  ));
1984
+ if (own && ownMoved) {
1985
+ flags.push(flagAt(
1986
+ ownMoved,
1987
+ "info",
1988
+ `${own.path} moved to ${ownMoved}, and the new ${middlewarePath} runs it first on the path without its locale prefix, then next-intl.`,
1989
+ "Check the app middleware still behaves as before and that its auth checks still hold under /ar. Any answer other than a plain continue (a redirect, rewrite or 401) is returned as it is. Paths under /api and /trpc skip next-intl. Config options other than matcher, such as runtime, are not carried over, and has or missing conditions on a matcher entry are ignored, so the middleware runs on those paths."
1990
+ ));
1991
+ }
1769
1992
  if (catchAll) {
1770
1993
  flags.push(flagAt(
1771
1994
  catchAll,
@@ -1778,11 +2001,13 @@ function bilingualParts(project, appDir, files, paths) {
1778
2001
  [`${base}i18n/routing.ts`]: ROUTING,
1779
2002
  [`${base}i18n/navigation.ts`]: NAVIGATION,
1780
2003
  [middlewarePath]: middleware,
2004
+ ...own ? { [`${base}i18n/app-matcher.ts`]: APP_MATCHER } : {},
1781
2005
  [switchPath(project)]: switchFile(specifierTo(switchPath(project), `${base}i18n/navigation.ts`)),
1782
2006
  ...catchAll ? {} : { [`${appDir}/[locale]/[...rest]/page.tsx`]: CATCH_ALL }
1783
2007
  };
1784
2008
  const rewrites = importRewrites(appDir, moves, paths, files, readAliases(files));
1785
- return { moves, writes, flags: [...flags, ...rewrites.flags], edits: rewrites.edits };
2009
+ const ownMove = own && ownMoved ? [[own.path, ownMoved]] : [];
2010
+ return { moves: [...moves, ...ownMove], writes, flags: [...flags, ...rewrites.flags], edits: rewrites.edits };
1786
2011
  }
1787
2012
  function wireNextIntl(project, mode, files, paths) {
1788
2013
  const appDir = project.appDir ?? "app";
@@ -1819,7 +2044,7 @@ function wireNextIntl(project, mode, files, paths) {
1819
2044
  var import_lint6 = require("@dualkit/lint");
1820
2045
  var import_node_path14 = require("path");
1821
2046
  var DEPS2 = { "react-i18next": "17.1.6", i18next: "26.5.8" };
1822
- var INIT_PATH = "src/i18n.ts";
2047
+ var INIT_PATH = "src/dualkit-i18n.ts";
1823
2048
  var HTML_PATH = "index.html";
1824
2049
  var COOKIE_PATTERN = `/(?:^|; )${LOCALE_COOKIE}=([^;]*)/`;
1825
2050
  var messagesImport = (locale) => `import ${locale} from '${import_node_path14.posix.relative(import_node_path14.posix.dirname(INIT_PATH), `messages/${locale}.json`)}'`;
@@ -2389,7 +2614,7 @@ function iconImports(program) {
2389
2614
  return found;
2390
2615
  }
2391
2616
  var isMirrored = (imported) => MIRROR_ICON.test(imported) && !NEVER_MIRROR.test(imported);
2392
- var tagName = (opening) => {
2617
+ var tagName2 = (opening) => {
2393
2618
  const name = opening.name;
2394
2619
  return name.type === "JSXIdentifier" ? name.name : null;
2395
2620
  };
@@ -2431,7 +2656,7 @@ function alreadyMirrored(opening, ctx) {
2431
2656
  });
2432
2657
  }
2433
2658
  function visitOpening(opening, ctx, ancestors) {
2434
- const name = tagName(opening);
2659
+ const name = tagName2(opening);
2435
2660
  if (!name) return;
2436
2661
  const after = ctx.base + opening.name.end;
2437
2662
  if (ctx.icons.has(name) && isMirrored(ctx.icons.get(name)) && !alreadyMirrored(opening, ctx)) {
@@ -2444,7 +2669,7 @@ function visitOpening(opening, ctx, ancestors) {
2444
2669
  }
2445
2670
  function flagSvg(opening, ctx, ancestors) {
2446
2671
  if (alreadyMirrored(opening, ctx)) return;
2447
- 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) ?? ""));
2448
2673
  if (!control || !DIRECTION_LABEL.test(labelOf(control))) return;
2449
2674
  ctx.flags.push({
2450
2675
  file: ctx.file.path,
@@ -2456,7 +2681,7 @@ function flagSvg(opening, ctx, ancestors) {
2456
2681
  }
2457
2682
  var isolates = (element) => {
2458
2683
  const opening = element.openingElement;
2459
- return tagName(opening) === "bdi" || attributeNamed(opening, "dir") !== void 0;
2684
+ return tagName2(opening) === "bdi" || attributeNamed(opening, "dir") !== void 0;
2460
2685
  };
2461
2686
  function visitExpression(container, ancestors, ctx) {
2462
2687
  const parent = ancestors[ancestors.length - 1];
@@ -3105,6 +3330,10 @@ async function guard(root) {
3105
3330
  return state;
3106
3331
  }
3107
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
+ }
3108
3337
  const pending = ledger.filter((e) => e.status === "pending").length;
3109
3338
  if (pending > 0 && !force) {
3110
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.`);
@@ -3112,7 +3341,18 @@ function pendingCheck(ledger, force) {
3112
3341
  }
3113
3342
  function writeMessages(root, ledger) {
3114
3343
  const disk = { root, created: [] };
3115
- for (const [path, text] of Object.entries(messagesFiles(ledger))) writeText(disk, path, text);
3344
+ for (const [path, text] of Object.entries(messagesFiles(ledger))) writeText(disk, path, mergedMessages(root, path, text));
3345
+ }
3346
+ function mergedMessages(root, path, generated) {
3347
+ const full = (0, import_node_path19.join)(root, path);
3348
+ if (!(0, import_node_fs11.existsSync)(full)) return generated;
3349
+ try {
3350
+ const existing = JSON.parse((0, import_node_fs11.readFileSync)(full, "utf8"));
3351
+ if (typeof existing !== "object" || existing === null) return generated;
3352
+ return JSON.stringify(keepHandAdded(existing, JSON.parse(generated)), null, 2) + "\n";
3353
+ } catch {
3354
+ return generated;
3355
+ }
3116
3356
  }
3117
3357
  async function englishChecks(root, base, routes, shoot) {
3118
3358
  const english2 = [];
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) {
@@ -443,6 +445,15 @@ async function applyPlan(disk, plan) {
443
445
  return { touched, edits: edits2, writes, perFile };
444
446
  }
445
447
  var json = (value) => JSON.stringify(value, null, 2) + "\n";
448
+ var isMessages = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
449
+ function keepHandAdded(existing, generated) {
450
+ const kept = Object.entries(existing).map(([key, value]) => {
451
+ const fresh = generated[key];
452
+ if (fresh === void 0) return [key, value];
453
+ return [key, isMessages(value) && isMessages(fresh) ? keepHandAdded(value, fresh) : fresh];
454
+ });
455
+ return { ...generated, ...Object.fromEntries(kept) };
456
+ }
446
457
  var messagesFiles = (ledger) => ({
447
458
  [messagesPath("en")]: json(messagesFor(ledger, "en")),
448
459
  [messagesPath("ar")]: json(messagesFor(ledger, "ar"))
@@ -900,7 +911,25 @@ var tInScope = (ancestors) => ancestors.some((node) => scopeNames(node).includes
900
911
 
901
912
  // src/transforms/strings.ts
902
913
  var SKIP = /* @__PURE__ */ new Set(["loc", "leadingComments", "trailingComments", "innerComments", "extra"]);
903
- 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"]);
904
933
  var LETTER = /\p{L}/u;
905
934
  var PROSE = /^\p{Lu}\p{Ll}/u;
906
935
  var NOT_PROSE = /[/\\_=<>{}@#|]/;
@@ -918,7 +947,7 @@ function stemOf(path) {
918
947
  if (!ROUTE_STEMS.has(stem)) return kebab(stem) || "file";
919
948
  return kebab(routeDirs(parts).join("-")) || "home";
920
949
  }
921
- 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";
922
951
  function flag(ctx, offset, kind, message, suggestion) {
923
952
  ctx.flags.push({ file: ctx.file.path, line: lineOf(ctx.file.text, offset), kind, message, suggestion });
924
953
  }
@@ -932,7 +961,7 @@ function keyFor(ctx, en) {
932
961
  ctx.byText.set(en, key);
933
962
  return key;
934
963
  }
935
- function place(ctx, ancestors, start2, end, en, values) {
964
+ function place(ctx, ancestors, { start: start2, end, en, code, hookless }) {
936
965
  const component = componentOf(ancestors);
937
966
  if (!component) {
938
967
  flag(
@@ -967,8 +996,8 @@ function place(ctx, ancestors, start2, end, en, values) {
967
996
  const isNew = !ctx.byText.has(en);
968
997
  const key = keyFor(ctx, en);
969
998
  if (isNew) ctx.entries.push({ key, en, ar: "", status: "pending", file: ctx.file.path, line: lineOf(ctx.file.text, start2) });
970
- ctx.edits.push({ start: start2, end, text: `{t('${key}'${values ? `, { ${values} }` : ""})}` });
971
- ctx.components.add(component);
999
+ ctx.edits.push({ start: start2, end, text: code(key) });
1000
+ if (!hookless) ctx.components.add(component);
972
1001
  }
973
1002
  var TEXT_METHODS = /^(toLocale(Date|Time)?String|toFixed|toPrecision|toString|toUpperCase|toLowerCase|trim(Start|End)?|padStart|padEnd)$/;
974
1003
  function textMethodTarget(expression) {
@@ -988,6 +1017,21 @@ function placeholderName(expression) {
988
1017
  }
989
1018
  return null;
990
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
+ }
991
1035
  function hasText(element) {
992
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));
993
1037
  }
@@ -996,30 +1040,33 @@ function pieceOf(child, ctx) {
996
1040
  const end = ctx.base + child.end;
997
1041
  if (child.type === "JSXText") {
998
1042
  const raw = ctx.file.text.slice(start2, end);
999
- 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 };
1000
1044
  const lead = raw.length - raw.trimStart().length;
1001
1045
  const trail = raw.length - raw.trimEnd().length;
1002
- 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 };
1003
1047
  }
1004
- if (child.type === "JSXSpreadChild") return { kind: "other", start: start2, end };
1005
- 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 };
1006
1050
  const expression = child.expression;
1007
1051
  if (expression.type === "JSXEmptyExpression") return { kind: "comment", start: start2, end };
1008
1052
  if (expression.type === "StringLiteral") {
1009
1053
  const value = String(expression.value);
1010
- 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 };
1011
1055
  }
1012
- const name = placeholderName(expression);
1013
- 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 };
1014
1058
  const source = ctx.file.text.slice(ctx.base + expression.start, ctx.base + expression.end);
1015
- return { kind: "value", name, source, start: start2, end };
1059
+ return { kind: "value", name, source: mayBeEmpty(expression) ? `${source} ?? ''` : source, start: start2, end };
1016
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*$/, "");
1017
1064
  var mixed = (ctx, offset) => flag(
1018
1065
  ctx,
1019
1066
  offset,
1020
1067
  "mixed-text",
1021
- "This text shares its element with other elements or expressions, so it was left as it is.",
1022
- "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."
1023
1070
  );
1024
1071
  function valuesOf(pieces) {
1025
1072
  const seen = /* @__PURE__ */ new Map();
@@ -1030,67 +1077,139 @@ function valuesOf(pieces) {
1030
1077
  }
1031
1078
  return [...seen].map(([name, source]) => name === source ? name : `${name}: ${source}`).join(", ");
1032
1079
  }
1033
- function unitsOf(pieces) {
1034
- 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 = [];
1035
1091
  let run2 = [];
1036
- let before;
1037
- for (const piece of [...pieces, void 0]) {
1038
- if (piece && piece.kind !== "element") {
1039
- run2.push(piece);
1040
- continue;
1041
- }
1042
- const texty = (p) => p?.kind === "element" && p.texty;
1043
- if (run2.length) units.push({ run: run2, touchesText: texty(before) || texty(piece) });
1044
- run2 = [];
1045
- 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
+ });
1046
1118
  }
1047
- return units;
1119
+ return tags;
1048
1120
  }
1049
- function messageOf(pieces) {
1050
- 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) {
1051
1133
  const edge = (p) => p.kind !== "space" && p.kind !== "comment";
1052
1134
  const first = pieces.findIndex(edge);
1053
1135
  const last = pieces.length - 1 - [...pieces].reverse().findIndex(edge);
1054
1136
  const run2 = pieces.slice(first, last + 1);
1055
- const values = valuesOf(run2);
1056
- if (values === null || run2.some((p) => p.kind === "comment")) return null;
1057
- const en = run2.map((p) => p.kind === "text" ? p.value : p.kind === "value" ? `{${p.name}}` : " ").join("").replace(/\s+/g, " ").trim();
1058
- 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 };
1059
1148
  }
1060
1149
  function visitChildren(element, ctx, ancestors) {
1061
- const pieces = (element.children ?? []).map((c) => pieceOf(c, ctx));
1062
- const messages = [];
1063
- 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)) {
1064
1157
  const lettered = run2.find((p) => p.kind === "text" && LETTER.test(p.value));
1065
1158
  if (!lettered) continue;
1066
- 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);
1067
1161
  if (!message) {
1068
1162
  mixed(ctx, lettered.start);
1069
- return false;
1163
+ continue;
1164
+ }
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;
1070
1170
  }
1071
- messages.push(message);
1171
+ place(ctx, ancestors, message);
1072
1172
  }
1073
- for (const m of messages) place(ctx, ancestors, m.start, m.end, m.en, m.values);
1074
- return true;
1075
1173
  }
1174
+ var bracedCall = (values) => (key) => `{t('${key}'${values ? `, { ${values} }` : ""})}`;
1076
1175
  function visitAttribute(attribute, ctx, ancestors) {
1077
1176
  const name = attribute.name;
1078
1177
  if (name.type !== "JSXIdentifier" || !TEXT_ATTRIBUTES.has(name.name)) return;
1079
1178
  const value = attribute.value;
1080
1179
  const literal = value?.type === "JSXExpressionContainer" ? value.expression : value;
1081
1180
  if (literal?.type !== "StringLiteral" || !LETTER.test(String(literal.value))) return;
1181
+ if (name.name === "placeholder" && /^(blur|empty)$/.test(String(literal.value))) return;
1082
1182
  const en = String(literal.value).replace(/\s+/g, " ").trim();
1083
- 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
+ }
1084
1201
  }
1085
1202
  function walk(node, ctx, ancestors) {
1086
1203
  if (Array.isArray(node)) return node.forEach((child) => walk(child, ctx, ancestors));
1087
1204
  if (!node || typeof node !== "object") return;
1088
1205
  const n = node;
1206
+ if (ctx.consumed.has(n)) return;
1089
1207
  if (n.type === "JSXElement" || n.type === "JSXFragment") {
1090
1208
  ctx.sawJsx = true;
1091
- if (!visitChildren(n, ctx, ancestors)) return;
1209
+ visitChildren(n, ctx, ancestors);
1092
1210
  }
1093
1211
  if (n.type === "JSXAttribute") visitAttribute(n, ctx, ancestors);
1212
+ if (n.type === "JSXExpressionContainer") visitBranches(n, ctx, ancestors);
1094
1213
  const next = typeof n.type === "string" ? [...ancestors, n] : ancestors;
1095
1214
  for (const [key, child] of Object.entries(n)) if (!SKIP.has(key)) walk(child, ctx, next);
1096
1215
  }
@@ -1188,13 +1307,14 @@ function extractStrings(file, lib, taken) {
1188
1307
  for (const block of file.js) {
1189
1308
  const program = block.ast.program;
1190
1309
  if (isServerFile(program)) continue;
1191
- 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 };
1192
1311
  walk(program, ctx, []);
1193
1312
  if (ctx.sawJsx) flagModuleStrings(program, ctx);
1194
1313
  const style = styleOf(program, file.text, ctx.base);
1195
1314
  const hooks = [...ctx.components].map((fn) => hookEdits(fn, lib, file.text, ctx.base, style));
1196
1315
  edits2.push(...hooks.flatMap((h) => h.edits));
1197
- 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));
1198
1318
  }
1199
1319
  return { edits: edits2, flags, entries };
1200
1320
  }
@@ -1651,6 +1771,88 @@ export const config = {
1651
1771
  matcher: '/((?!(?:api|_next|_vercel)(?:/|$)|.*\\\\..*).*)',
1652
1772
  }
1653
1773
  `;
1774
+ var composedMiddlewareFile = (routing, matcher, own) => `import { NextRequest, NextResponse, type NextFetchEvent } from 'next/server'
1775
+ import createMiddleware from 'next-intl/middleware'
1776
+ import { routing } from '${routing}'
1777
+ import { matchesApp, type Matcher } from '${matcher}'
1778
+ import * as own from '${own}'
1779
+
1780
+ const intl = createMiddleware(routing)
1781
+ type OwnHandler = (request: NextRequest, event: NextFetchEvent) => Response | null | undefined | Promise<Response | null | undefined>
1782
+ // A spread, so the bundler does not warn about the export names this app does not use.
1783
+ const exported: Record<string, unknown> = { ...own }
1784
+ const candidate = exported.default ?? exported.middleware ?? exported.proxy
1785
+ if (typeof candidate !== 'function') throw new Error('Dualkit could not find the middleware function exported by ${own}.')
1786
+ const ownHandler = candidate as OwnHandler
1787
+ const ownMatcher = (exported as { config?: { matcher?: Matcher } }).config?.matcher
1788
+ const OWN_ONLY = /^\\/(?:api|trpc)(?:\\/|$)/
1789
+
1790
+ export default async function middleware(request: NextRequest, event: NextFetchEvent) {
1791
+ const url = request.nextUrl.clone()
1792
+ const [, first = '', ...rest] = url.pathname.split('/')
1793
+ if ((routing.locales as readonly string[]).includes(first)) url.pathname = \`/\${rest.join('/')}\`
1794
+ const result = matchesApp(url.pathname, ownMatcher) ? await ownHandler(new NextRequest(url, request), event) : null
1795
+ // Anything but a plain "continue" is the app's answer: a redirect, a rewrite, a 401 or a page. Keep it as it is.
1796
+ if (result && result.headers.get('x-middleware-next') !== '1') return result
1797
+ if (OWN_ONLY.test(url.pathname)) return result ?? NextResponse.next()
1798
+ const response = intl(request)
1799
+ if (result) {
1800
+ for (const cookie of result.headers.getSetCookie()) response.headers.append('set-cookie', cookie)
1801
+ // Headers the app set for the browser, such as a content security policy.
1802
+ result.headers.forEach((value, key) => { if (key !== 'set-cookie' && !key.startsWith('x-middleware-')) response.headers.set(key, value) })
1803
+ const overridden = result.headers.get('x-middleware-override-headers')
1804
+ if (overridden) {
1805
+ const names = new Set([...(response.headers.get('x-middleware-override-headers') ?? '').split(','), ...overridden.split(',')].filter(Boolean))
1806
+ response.headers.set('x-middleware-override-headers', [...names].join(','))
1807
+ result.headers.forEach((value, key) => { if (key.startsWith('x-middleware-request-')) response.headers.set(key, value) })
1808
+ }
1809
+ }
1810
+ return response
1811
+ }
1812
+
1813
+ export const config = {
1814
+ matcher: '/((?!(?:_next|_vercel)(?:/|$)|.*\\\\..*).*)',
1815
+ }
1816
+ `;
1817
+ var APP_MATCHER = String.raw`/** Whether a path is covered by an app's own middleware matcher. No matcher means every path. */
1818
+ export type Matcher = string | { source: string } | (string | { source: string })[]
1819
+
1820
+ /** An entry with has or missing conditions is treated as matching, so the app's middleware runs rather than being skipped. */
1821
+ export function matchesApp(pathname: string, matcher: Matcher | undefined): boolean {
1822
+ if (matcher === undefined) return true
1823
+ return (Array.isArray(matcher) ? matcher : [matcher]).some((entry) => toRegExp(typeof entry === 'string' ? entry : entry.source).test(pathname))
1824
+ }
1825
+
1826
+ function toRegExp(pattern: string): RegExp {
1827
+ let out = ''
1828
+ let i = 0
1829
+ while (i < pattern.length) {
1830
+ const char = pattern[i]!
1831
+ const param = char === '/' ? /^\/:[A-Za-z0-9_]+([*+?]?)/.exec(pattern.slice(i)) : null
1832
+ if (param && pattern[i + param[0].length] === '(') {
1833
+ out += '/'
1834
+ i += param[0].length
1835
+ } else if (param) {
1836
+ out += { '*': '(?:/.*)?', '+': '/.+', '?': '(?:/[^/]+)?', '': '/[^/]+' }[param[1]!]
1837
+ i += param[0].length
1838
+ } else if (char === '(') {
1839
+ let depth = 0
1840
+ let j = i
1841
+ for (; j < pattern.length; j++) {
1842
+ if (pattern[j] === '\\') j++
1843
+ else if (pattern[j] === '(') depth++
1844
+ else if (pattern[j] === ')' && --depth === 0) break
1845
+ }
1846
+ out += pattern.slice(i, j + 1)
1847
+ i = j + 1
1848
+ } else {
1849
+ out += char.replace(/[.*+?^$|{}[\]\\]/g, '\\$&')
1850
+ i++
1851
+ }
1852
+ }
1853
+ return new RegExp('^' + out + (out.endsWith('/') ? '' : '/?') + '$')
1854
+ }
1855
+ `;
1654
1856
  var switchFile = (navigation) => `'use client'
1655
1857
 
1656
1858
  import { useLocale } from 'next-intl'
@@ -1703,16 +1905,24 @@ function alreadyWired(project, mode, appDir, files, paths) {
1703
1905
  const wired = (files(project.entry) ?? "").includes("NextIntlClientProvider") && files(`${sourceBase(project)}i18n/request.ts`) !== null;
1704
1906
  return wired ? project.entry : null;
1705
1907
  }
1706
- function guardMiddleware(base, ours, text, files) {
1908
+ function findMiddleware(base, files) {
1909
+ const found = [];
1707
1910
  for (const name of ["middleware", "proxy"]) {
1708
1911
  for (const ext of ["ts", "js", "mjs", "tsx", "jsx"]) {
1709
1912
  const path = `${base}${name}.${ext}`;
1710
- const existing = files(path);
1711
- if (existing === null || path === ours && existing === text) continue;
1712
- throw new UnsupportedError(`This app already has ${path}. Convert cannot merge the next-intl middleware into it, so it stopped before changing anything.`);
1913
+ const text = files(path);
1914
+ if (text !== null) found.push({ path, text });
1713
1915
  }
1714
1916
  }
1917
+ if (found.length > 1) throw new UnsupportedError(`This app has both ${found[0].path} and ${found[1].path}. Convert cannot tell which one to compose with next-intl, so it stopped before changing anything.`);
1918
+ const only = found[0] ?? null;
1919
+ if (only && !EXPORTS_HANDLER.test(only.text)) {
1920
+ throw new UnsupportedError(`Convert could not find the middleware function exported from ${only.path}, so it stopped before changing anything.`);
1921
+ }
1922
+ return only;
1715
1923
  }
1924
+ var EXPORTS_HANDLER = /export\s+default\b|export\s+(?:async\s+)?function\s+(?:middleware|proxy)\b|export\s+(?:const|let|var)\s+(?:middleware|proxy)\b|export\s*\{[^}]*\b(?:middleware|proxy|default)\b[^}]*\}/;
1925
+ var movedMiddlewarePath = (base, existing) => `${base}app-middleware.${existing.split(".").pop()}`;
1716
1926
  var merge = (into, from) => {
1717
1927
  const out = { ...into };
1718
1928
  for (const [path, edits2] of Object.entries(from)) out[path] = [...out[path] ?? [], ...edits2];
@@ -1722,8 +1932,13 @@ function bilingualParts(project, appDir, files, paths) {
1722
1932
  const base = sourceBase(project);
1723
1933
  const { moves, keptPages } = routeMoves(appDir, paths);
1724
1934
  const middlewarePath = `${base}${nextMajor(files) >= 16 ? "proxy" : "middleware"}.ts`;
1725
- const middleware = middlewareFile(specifierTo(middlewarePath, `${base}i18n/routing.ts`));
1726
- guardMiddleware(base, middlewarePath, middleware, files);
1935
+ const own = findMiddleware(base, files);
1936
+ const routingSpec = specifierTo(middlewarePath, `${base}i18n/routing.ts`);
1937
+ const ownMoved = own ? movedMiddlewarePath(base, own.path) : null;
1938
+ if (ownMoved && files(ownMoved) !== null) {
1939
+ throw new UnsupportedError(`This app already has ${ownMoved}. Convert needs that name for the app's own middleware, so move or remove that file and run convert again.`);
1940
+ }
1941
+ const middleware = ownMoved ? composedMiddlewareFile(routingSpec, specifierTo(middlewarePath, `${base}i18n/app-matcher.ts`), specifierTo(middlewarePath, ownMoved)) : middlewareFile(routingSpec);
1727
1942
  const catchAll = topCatchAll(appDir, moves, paths);
1728
1943
  const flags = keptPages.map((page) => flagAt(
1729
1944
  page,
@@ -1731,6 +1946,14 @@ function bilingualParts(project, appDir, files, paths) {
1731
1946
  `${page} stays outside [locale], because the middleware skips its path, so it has no Arabic version.`,
1732
1947
  "Move the page to a path the middleware matches, or translate it by hand."
1733
1948
  ));
1949
+ if (own && ownMoved) {
1950
+ flags.push(flagAt(
1951
+ ownMoved,
1952
+ "info",
1953
+ `${own.path} moved to ${ownMoved}, and the new ${middlewarePath} runs it first on the path without its locale prefix, then next-intl.`,
1954
+ "Check the app middleware still behaves as before and that its auth checks still hold under /ar. Any answer other than a plain continue (a redirect, rewrite or 401) is returned as it is. Paths under /api and /trpc skip next-intl. Config options other than matcher, such as runtime, are not carried over, and has or missing conditions on a matcher entry are ignored, so the middleware runs on those paths."
1955
+ ));
1956
+ }
1734
1957
  if (catchAll) {
1735
1958
  flags.push(flagAt(
1736
1959
  catchAll,
@@ -1743,11 +1966,13 @@ function bilingualParts(project, appDir, files, paths) {
1743
1966
  [`${base}i18n/routing.ts`]: ROUTING,
1744
1967
  [`${base}i18n/navigation.ts`]: NAVIGATION,
1745
1968
  [middlewarePath]: middleware,
1969
+ ...own ? { [`${base}i18n/app-matcher.ts`]: APP_MATCHER } : {},
1746
1970
  [switchPath(project)]: switchFile(specifierTo(switchPath(project), `${base}i18n/navigation.ts`)),
1747
1971
  ...catchAll ? {} : { [`${appDir}/[locale]/[...rest]/page.tsx`]: CATCH_ALL }
1748
1972
  };
1749
1973
  const rewrites = importRewrites(appDir, moves, paths, files, readAliases(files));
1750
- return { moves, writes, flags: [...flags, ...rewrites.flags], edits: rewrites.edits };
1974
+ const ownMove = own && ownMoved ? [[own.path, ownMoved]] : [];
1975
+ return { moves: [...moves, ...ownMove], writes, flags: [...flags, ...rewrites.flags], edits: rewrites.edits };
1751
1976
  }
1752
1977
  function wireNextIntl(project, mode, files, paths) {
1753
1978
  const appDir = project.appDir ?? "app";
@@ -1784,7 +2009,7 @@ function wireNextIntl(project, mode, files, paths) {
1784
2009
  import { parseSourceOutcome as parseSourceOutcome5 } from "@dualkit/lint";
1785
2010
  import { posix as posix6 } from "path";
1786
2011
  var DEPS2 = { "react-i18next": "17.1.6", i18next: "26.5.8" };
1787
- var INIT_PATH = "src/i18n.ts";
2012
+ var INIT_PATH = "src/dualkit-i18n.ts";
1788
2013
  var HTML_PATH = "index.html";
1789
2014
  var COOKIE_PATTERN = `/(?:^|; )${LOCALE_COOKIE}=([^;]*)/`;
1790
2015
  var messagesImport = (locale) => `import ${locale} from '${posix6.relative(posix6.dirname(INIT_PATH), `messages/${locale}.json`)}'`;
@@ -2354,7 +2579,7 @@ function iconImports(program) {
2354
2579
  return found;
2355
2580
  }
2356
2581
  var isMirrored = (imported) => MIRROR_ICON.test(imported) && !NEVER_MIRROR.test(imported);
2357
- var tagName = (opening) => {
2582
+ var tagName2 = (opening) => {
2358
2583
  const name = opening.name;
2359
2584
  return name.type === "JSXIdentifier" ? name.name : null;
2360
2585
  };
@@ -2396,7 +2621,7 @@ function alreadyMirrored(opening, ctx) {
2396
2621
  });
2397
2622
  }
2398
2623
  function visitOpening(opening, ctx, ancestors) {
2399
- const name = tagName(opening);
2624
+ const name = tagName2(opening);
2400
2625
  if (!name) return;
2401
2626
  const after = ctx.base + opening.name.end;
2402
2627
  if (ctx.icons.has(name) && isMirrored(ctx.icons.get(name)) && !alreadyMirrored(opening, ctx)) {
@@ -2409,7 +2634,7 @@ function visitOpening(opening, ctx, ancestors) {
2409
2634
  }
2410
2635
  function flagSvg(opening, ctx, ancestors) {
2411
2636
  if (alreadyMirrored(opening, ctx)) return;
2412
- 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) ?? ""));
2413
2638
  if (!control || !DIRECTION_LABEL.test(labelOf(control))) return;
2414
2639
  ctx.flags.push({
2415
2640
  file: ctx.file.path,
@@ -2421,7 +2646,7 @@ function flagSvg(opening, ctx, ancestors) {
2421
2646
  }
2422
2647
  var isolates = (element) => {
2423
2648
  const opening = element.openingElement;
2424
- return tagName(opening) === "bdi" || attributeNamed(opening, "dir") !== void 0;
2649
+ return tagName2(opening) === "bdi" || attributeNamed(opening, "dir") !== void 0;
2425
2650
  };
2426
2651
  function visitExpression(container, ancestors, ctx) {
2427
2652
  const parent = ancestors[ancestors.length - 1];
@@ -3070,6 +3295,10 @@ async function guard(root) {
3070
3295
  return state;
3071
3296
  }
3072
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
+ }
3073
3302
  const pending = ledger.filter((e) => e.status === "pending").length;
3074
3303
  if (pending > 0 && !force) {
3075
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.`);
@@ -3077,7 +3306,18 @@ function pendingCheck(ledger, force) {
3077
3306
  }
3078
3307
  function writeMessages(root, ledger) {
3079
3308
  const disk = { root, created: [] };
3080
- for (const [path, text] of Object.entries(messagesFiles(ledger))) writeText(disk, path, text);
3309
+ for (const [path, text] of Object.entries(messagesFiles(ledger))) writeText(disk, path, mergedMessages(root, path, text));
3310
+ }
3311
+ function mergedMessages(root, path, generated) {
3312
+ const full = join11(root, path);
3313
+ if (!existsSync8(full)) return generated;
3314
+ try {
3315
+ const existing = JSON.parse(readFileSync7(full, "utf8"));
3316
+ if (typeof existing !== "object" || existing === null) return generated;
3317
+ return JSON.stringify(keepHandAdded(existing, JSON.parse(generated)), null, 2) + "\n";
3318
+ } catch {
3319
+ return generated;
3320
+ }
3081
3321
  }
3082
3322
  async function englishChecks(root, base, routes, shoot) {
3083
3323
  const english2 = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dualkit/convert",
3
- "version": "0.1.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",