@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 +5 -2
- package/dist/index.cjs +299 -59
- package/dist/index.js +299 -59
- package/package.json +1 -1
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
|
-
-
|
|
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
|
|
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([
|
|
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(/\{[^}]*\}
|
|
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,
|
|
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:
|
|
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",
|
|
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
|
|
1057
|
-
"Translate the whole sentence with
|
|
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
|
-
|
|
1069
|
-
const
|
|
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
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
run2
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
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
|
|
1154
|
+
return tags;
|
|
1083
1155
|
}
|
|
1084
|
-
|
|
1085
|
-
|
|
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
|
-
|
|
1091
|
-
|
|
1092
|
-
const
|
|
1093
|
-
|
|
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
|
|
1097
|
-
|
|
1098
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1746
|
-
if (
|
|
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
|
|
1761
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
|
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([
|
|
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(/\{[^}]*\}
|
|
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,
|
|
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:
|
|
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",
|
|
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
|
|
1022
|
-
"Translate the whole sentence with
|
|
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
|
-
|
|
1034
|
-
const
|
|
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
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
run2
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
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
|
|
1119
|
+
return tags;
|
|
1048
1120
|
}
|
|
1049
|
-
|
|
1050
|
-
|
|
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
|
-
|
|
1056
|
-
|
|
1057
|
-
const
|
|
1058
|
-
|
|
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
|
|
1062
|
-
|
|
1063
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1711
|
-
if (
|
|
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
|
|
1726
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
|
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 = [];
|