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