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