polycast-cli 0.2.0 → 0.3.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/dist/api.d.ts +15 -0
- package/dist/api.js +5 -2
- package/dist/api.js.map +1 -1
- package/dist/commands.d.ts +10 -0
- package/dist/commands.js +293 -14
- package/dist/commands.js.map +1 -1
- package/dist/config.d.ts +9 -0
- package/dist/config.js.map +1 -1
- package/dist/hygiene.d.ts +48 -0
- package/dist/hygiene.js +213 -0
- package/dist/hygiene.js.map +1 -0
- package/dist/index.js +15 -3
- package/dist/index.js.map +1 -1
- package/dist/scan.d.ts +72 -16
- package/dist/scan.js +662 -129
- package/dist/scan.js.map +1 -1
- package/dist/util.d.ts +7 -1
- package/dist/util.js +6 -1
- package/dist/util.js.map +1 -1
- package/dist/xcstrings.d.ts +44 -5
- package/dist/xcstrings.js +63 -10
- package/dist/xcstrings.js.map +1 -1
- package/package.json +1 -1
package/dist/scan.js
CHANGED
|
@@ -1,74 +1,647 @@
|
|
|
1
|
-
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
const
|
|
3
|
+
const DEFAULT_SKIP_DIRS = [
|
|
4
4
|
"node_modules", "Pods", ".build", "build", "DerivedData", ".git",
|
|
5
5
|
".swiftpm", "Carthage",
|
|
6
|
-
]
|
|
7
|
-
export const DEFAULT_SCAN_CALLS = [
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const parts = [];
|
|
18
|
-
if (ctors.length)
|
|
19
|
-
parts.push(`(?<![A-Za-z0-9_.])(?:${ctors.join("|")})`);
|
|
20
|
-
if (methods.length)
|
|
21
|
-
parts.push(`\\.(?:${methods.join("|")})`);
|
|
22
|
-
// Optional first-argument label: ContinueButton(title: "…") — the label
|
|
23
|
-
// is captured so scanFile can skip non-display ones (verbatim:, id:, …).
|
|
24
|
-
return new RegExp(`(?:${parts.join("|")})\\(\\s*(?:([A-Za-z_][A-Za-z0-9_]*)\\s*:\\s*)?"`, "g");
|
|
25
|
-
}
|
|
6
|
+
];
|
|
7
|
+
export const DEFAULT_SCAN_CALLS = [
|
|
8
|
+
"Text", "PText", "Polycast.string", "NSLocalizedString",
|
|
9
|
+
".navigationTitle", ".accessibilityLabel", ".accessibilityHint",
|
|
10
|
+
".accessibilityValue", "WindowGroup", "CommandMenu", "LocalizedStringKey",
|
|
11
|
+
];
|
|
12
|
+
/** `.prop = "literal"` assignment targets captured by default. */
|
|
13
|
+
export const DEFAULT_ASSIGN_PROPS = [
|
|
14
|
+
"title", "body", "text", "subtitle", "message", "alertMessage",
|
|
15
|
+
"localizedReason", "localizedFallbackTitle", "placeholder",
|
|
16
|
+
];
|
|
26
17
|
/** First-argument labels that mark a NON-display string. */
|
|
27
18
|
const SKIP_LABELS = new Set([
|
|
28
19
|
"verbatim", "systemImage", "id", "tag", "key", "icon", "imageName",
|
|
29
20
|
"named", "systemName", "url", "rawValue", "identifier",
|
|
30
21
|
]);
|
|
31
|
-
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
|
|
35
|
-
|
|
22
|
+
/** Only Polycast's own calls treat `, count:` as a CLDR plural marker —
|
|
23
|
+
* custom components may have unrelated count: params (badge counts etc.),
|
|
24
|
+
* and a plural entry without a numeric specifier breaks Xcode's catalog
|
|
25
|
+
* compile. */
|
|
26
|
+
const PLURAL_CAPABLE = new Set(["Text", "PText", "Polycast.string"]);
|
|
27
|
+
const OP_CHARS = new Set([..."-+*/%<>=!&|^~?"]);
|
|
28
|
+
const PUNCT_CHARS = new Set([..."()[]{},:;.@#$\\"]);
|
|
29
|
+
function isIdentStart(ch) {
|
|
30
|
+
return (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z") || ch === "_";
|
|
31
|
+
}
|
|
32
|
+
function isIdentChar(ch) {
|
|
33
|
+
return isIdentStart(ch) || (ch >= "0" && ch <= "9");
|
|
34
|
+
}
|
|
35
|
+
function isRawStringStart(src, i) {
|
|
36
|
+
let j = i;
|
|
37
|
+
while (src[j] === "#")
|
|
38
|
+
j++;
|
|
39
|
+
return src[j] === '"';
|
|
40
|
+
}
|
|
41
|
+
function skipBlockComment(src, i) {
|
|
42
|
+
// i points just past the opening "/*"; block comments nest in Swift.
|
|
43
|
+
let depth = 1;
|
|
44
|
+
const n = src.length;
|
|
45
|
+
while (i < n && depth > 0) {
|
|
46
|
+
if (src[i] === "/" && src[i + 1] === "*") {
|
|
47
|
+
depth++;
|
|
48
|
+
i += 2;
|
|
49
|
+
}
|
|
50
|
+
else if (src[i] === "*" && src[i + 1] === "/") {
|
|
51
|
+
depth--;
|
|
52
|
+
i += 2;
|
|
53
|
+
}
|
|
54
|
+
else
|
|
55
|
+
i++;
|
|
56
|
+
}
|
|
57
|
+
return i;
|
|
58
|
+
}
|
|
59
|
+
/** Decode one escape sequence; `e` points at the char after `\` (+ #s).
|
|
60
|
+
* Returns [decoded text, chars consumed starting at e]. */
|
|
61
|
+
function decodeEscape(src, e) {
|
|
62
|
+
const c = src[e];
|
|
63
|
+
switch (c) {
|
|
64
|
+
case "n": return ["\n", 1];
|
|
65
|
+
case "t": return ["\t", 1];
|
|
66
|
+
case "r": return ["\r", 1];
|
|
67
|
+
case "0": return ["\0", 1];
|
|
68
|
+
case "\\": return ["\\", 1];
|
|
69
|
+
case '"': return ['"', 1];
|
|
70
|
+
case "'": return ["'", 1];
|
|
71
|
+
case "u": {
|
|
72
|
+
if (src[e + 1] === "{") {
|
|
73
|
+
const close = src.indexOf("}", e + 2);
|
|
74
|
+
const hex = close > 0 ? src.slice(e + 2, close) : "";
|
|
75
|
+
if (hex.length >= 1 && hex.length <= 8 && /^[0-9a-fA-F]+$/.test(hex)) {
|
|
76
|
+
try {
|
|
77
|
+
return [String.fromCodePoint(parseInt(hex, 16)), close - e + 1];
|
|
78
|
+
}
|
|
79
|
+
catch { /* invalid scalar — fall through */ }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return ["u", 1];
|
|
83
|
+
}
|
|
84
|
+
default: return [c ?? "", 1];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Skip a balanced `\(…)` interpolation body; `i` points just past the
|
|
88
|
+
* opening paren. String-literal and comment aware, so a ")" inside a
|
|
89
|
+
* nested string (e.g. `\(x, specifier: "%.1f")`) can't end it early. */
|
|
90
|
+
function skipInterpolation(src, i) {
|
|
91
|
+
const n = src.length;
|
|
92
|
+
let depth = 1;
|
|
93
|
+
while (i < n && depth > 0) {
|
|
94
|
+
const ch = src[i];
|
|
95
|
+
if (ch === '"' || (ch === "#" && isRawStringStart(src, i))) {
|
|
96
|
+
i = parseString(src, i).end;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (ch === "/" && src[i + 1] === "/") {
|
|
100
|
+
while (i < n && src[i] !== "\n")
|
|
101
|
+
i++;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (ch === "/" && src[i + 1] === "*") {
|
|
105
|
+
i = skipBlockComment(src, i + 2);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (ch === "(")
|
|
109
|
+
depth++;
|
|
110
|
+
else if (ch === ")")
|
|
111
|
+
depth--;
|
|
112
|
+
i++;
|
|
113
|
+
}
|
|
114
|
+
return i;
|
|
115
|
+
}
|
|
116
|
+
/** Parse any Swift string literal starting at `start` (at the first `#`
|
|
117
|
+
* for raw strings, else at the opening quote). */
|
|
118
|
+
function parseString(src, start) {
|
|
36
119
|
let i = start;
|
|
37
|
-
|
|
120
|
+
let pounds = 0;
|
|
121
|
+
while (src[i] === "#") {
|
|
122
|
+
pounds++;
|
|
123
|
+
i++;
|
|
124
|
+
}
|
|
125
|
+
const hashes = "#".repeat(pounds);
|
|
126
|
+
const multiline = src.startsWith('"""', i);
|
|
127
|
+
i += multiline ? 3 : 1;
|
|
128
|
+
return multiline
|
|
129
|
+
? parseMultilineString(src, i, pounds, hashes)
|
|
130
|
+
: parseSingleLineString(src, i, pounds, hashes);
|
|
131
|
+
}
|
|
132
|
+
function parseSingleLineString(src, i, pounds, hashes) {
|
|
133
|
+
const n = src.length;
|
|
134
|
+
const parts = [];
|
|
135
|
+
let lit = "";
|
|
136
|
+
while (i < n) {
|
|
38
137
|
const ch = src[i];
|
|
39
|
-
if (ch === '"')
|
|
40
|
-
return { key, end: i + 1 };
|
|
41
138
|
if (ch === "\n")
|
|
139
|
+
break; // unterminated — tolerate, end at the newline
|
|
140
|
+
if (ch === '"' && src.startsWith(hashes, i + 1)) {
|
|
141
|
+
i += 1 + pounds;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
// In a raw string, `\` only escapes when followed by the matching #s.
|
|
145
|
+
if (ch === "\\" && src.startsWith(hashes, i + 1)) {
|
|
146
|
+
const e = i + 1 + pounds;
|
|
147
|
+
if (src[e] === "(") {
|
|
148
|
+
parts.push(lit, null);
|
|
149
|
+
lit = "";
|
|
150
|
+
i = skipInterpolation(src, e + 1);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const [decoded, len] = decodeEscape(src, e);
|
|
154
|
+
lit += decoded;
|
|
155
|
+
i = e + len;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
lit += ch;
|
|
159
|
+
i++;
|
|
160
|
+
}
|
|
161
|
+
parts.push(lit);
|
|
162
|
+
return { parts, end: i };
|
|
163
|
+
}
|
|
164
|
+
function parseMultilineString(src, i, pounds, hashes) {
|
|
165
|
+
const n = src.length;
|
|
166
|
+
// Pass 1: locate the closing delimiter (escape/interpolation aware).
|
|
167
|
+
let close = n;
|
|
168
|
+
{
|
|
169
|
+
let j = i;
|
|
170
|
+
while (j < n) {
|
|
171
|
+
const ch = src[j];
|
|
172
|
+
if (ch === "\\" && src.startsWith(hashes, j + 1)) {
|
|
173
|
+
const e = j + 1 + pounds;
|
|
174
|
+
j = src[e] === "(" ? skipInterpolation(src, e + 1) : e + 1;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (ch === '"' && src.startsWith('""' + hashes, j + 1)) {
|
|
178
|
+
close = j;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
j++;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const end = Math.min(close + 3 + pounds, n);
|
|
185
|
+
// Swift strips the closing delimiter's indentation from every line and
|
|
186
|
+
// drops the newlines adjacent to the delimiters.
|
|
187
|
+
const lastNL = src.lastIndexOf("\n", close - 1);
|
|
188
|
+
let indent = "";
|
|
189
|
+
let contentEnd = close;
|
|
190
|
+
if (lastNL >= i) {
|
|
191
|
+
const maybe = src.slice(lastNL + 1, close);
|
|
192
|
+
if (/^[ \t]*$/.test(maybe)) {
|
|
193
|
+
indent = maybe;
|
|
194
|
+
contentEnd = lastNL;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
let contentStart = i;
|
|
198
|
+
const firstNL = src.indexOf("\n", i);
|
|
199
|
+
if (firstNL !== -1 && firstNL < contentEnd)
|
|
200
|
+
contentStart = firstNL + 1;
|
|
201
|
+
// Pass 2: decode content.
|
|
202
|
+
const parts = [];
|
|
203
|
+
let lit = "";
|
|
204
|
+
let j = contentStart;
|
|
205
|
+
let atLineStart = true;
|
|
206
|
+
while (j < contentEnd) {
|
|
207
|
+
if (atLineStart) {
|
|
208
|
+
atLineStart = false;
|
|
209
|
+
if (indent.length > 0 && src.startsWith(indent, j))
|
|
210
|
+
j += indent.length;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const ch = src[j];
|
|
214
|
+
if (ch === "\\" && src.startsWith(hashes, j + 1)) {
|
|
215
|
+
const e = j + 1 + pounds;
|
|
216
|
+
const esc = src[e];
|
|
217
|
+
if (esc === "(") {
|
|
218
|
+
parts.push(lit, null);
|
|
219
|
+
lit = "";
|
|
220
|
+
j = skipInterpolation(src, e + 1);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (esc === "\n" || (esc === "\r" && src[e + 1] === "\n")) {
|
|
224
|
+
// line continuation: `\` at end of line eats the newline
|
|
225
|
+
j = esc === "\n" ? e + 1 : e + 2;
|
|
226
|
+
atLineStart = true;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const [decoded, len] = decodeEscape(src, e);
|
|
230
|
+
lit += decoded;
|
|
231
|
+
j = e + len;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (ch === "\r") {
|
|
235
|
+
j++;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (ch === "\n") {
|
|
239
|
+
lit += "\n";
|
|
240
|
+
j++;
|
|
241
|
+
atLineStart = true;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
lit += ch;
|
|
245
|
+
j++;
|
|
246
|
+
}
|
|
247
|
+
parts.push(lit);
|
|
248
|
+
return { parts, end };
|
|
249
|
+
}
|
|
250
|
+
function lex(src) {
|
|
251
|
+
const toks = [];
|
|
252
|
+
const n = src.length;
|
|
253
|
+
let i = 0;
|
|
254
|
+
while (i < n) {
|
|
255
|
+
const ch = src[i];
|
|
256
|
+
if (ch === " " || ch === "\t" || ch === "\r" || ch === "\n") {
|
|
257
|
+
i++;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (ch === "/" && src[i + 1] === "/") {
|
|
261
|
+
while (i < n && src[i] !== "\n")
|
|
262
|
+
i++;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (ch === "/" && src[i + 1] === "*") {
|
|
266
|
+
i = skipBlockComment(src, i + 2);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (ch === '"' || (ch === "#" && isRawStringStart(src, i))) {
|
|
270
|
+
const s = parseString(src, i);
|
|
271
|
+
toks.push({ t: "str", parts: s.parts });
|
|
272
|
+
i = s.end;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (isIdentStart(ch)) {
|
|
276
|
+
let j = i + 1;
|
|
277
|
+
while (j < n && isIdentChar(src[j]))
|
|
278
|
+
j++;
|
|
279
|
+
toks.push({ t: "id", v: src.slice(i, j) });
|
|
280
|
+
i = j;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (ch === "`") { // escaped identifier: `default`
|
|
284
|
+
let j = i + 1;
|
|
285
|
+
while (j < n && src[j] !== "`" && src[j] !== "\n")
|
|
286
|
+
j++;
|
|
287
|
+
toks.push({ t: "id", v: src.slice(i + 1, j) });
|
|
288
|
+
i = j + 1;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (ch >= "0" && ch <= "9") {
|
|
292
|
+
let j = i + 1;
|
|
293
|
+
while (j < n && (isIdentChar(src[j]) || (src[j] === "." && (src[j + 1] ?? "") >= "0" && (src[j + 1] ?? "") <= "9")))
|
|
294
|
+
j++;
|
|
295
|
+
toks.push({ t: "num" });
|
|
296
|
+
i = j;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (OP_CHARS.has(ch)) {
|
|
300
|
+
let j = i;
|
|
301
|
+
while (j < n && OP_CHARS.has(src[j]) &&
|
|
302
|
+
!(src[j] === "/" && (src[j + 1] === "/" || src[j + 1] === "*")))
|
|
303
|
+
j++;
|
|
304
|
+
if (j > i) {
|
|
305
|
+
toks.push({ t: "op", v: src.slice(i, j) });
|
|
306
|
+
i = j;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (PUNCT_CHARS.has(ch)) {
|
|
311
|
+
toks.push({ t: "p", v: ch });
|
|
312
|
+
i++;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
i++; // anything else (unicode operators, emoji in code) is inert
|
|
316
|
+
}
|
|
317
|
+
return toks;
|
|
318
|
+
}
|
|
319
|
+
/** Fold adjacent `"a" + "b"` literal concatenations (possibly across
|
|
320
|
+
* newlines) into one string token, chains included — the runtime sees a
|
|
321
|
+
* single string, so the catalog must too. */
|
|
322
|
+
function foldConcat(toks) {
|
|
323
|
+
const out = [];
|
|
324
|
+
for (const t of toks) {
|
|
325
|
+
const plus = out[out.length - 1];
|
|
326
|
+
const left = out[out.length - 2];
|
|
327
|
+
if (t.t === "str" && plus?.t === "op" && plus.v === "+" && left?.t === "str") {
|
|
328
|
+
out.pop();
|
|
329
|
+
left.parts = [...left.parts, ...t.parts];
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
out.push(t);
|
|
333
|
+
}
|
|
334
|
+
return out;
|
|
335
|
+
}
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
// Key building + classification
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
/** Build the catalog key from decoded parts. Interpolations render as %@;
|
|
340
|
+
* literal "%" doubles to "%%" ONLY when at least one interpolation exists
|
|
341
|
+
* (parity with PolycastFormatString at runtime). */
|
|
342
|
+
function keyFromParts(parts) {
|
|
343
|
+
const hasInterp = parts.some((p) => p === null);
|
|
344
|
+
let out = "";
|
|
345
|
+
for (const p of parts)
|
|
346
|
+
out += p === null ? "%@" : hasInterp ? p.replace(/%/g, "%%") : p;
|
|
347
|
+
return out;
|
|
348
|
+
}
|
|
349
|
+
const SPECIFIER_RE = /%(?:\d+\$)?[-+#0']*\d*(?:\.\d+)?(?:hh|h|ll|l|q|z|t|L)?[@diouxXeEfgGaAcspSCF]/g;
|
|
350
|
+
const JOINER_RE = /[@·–—%°/:]/;
|
|
351
|
+
/** Keep letterful keys as normal strings. Letterless keys survive only as
|
|
352
|
+
* `structural` joiners: >=2 format specifiers, or >=1 specifier plus a
|
|
353
|
+
* joiner character. Empty / pure-emoji / single-specifier-only keys drop
|
|
354
|
+
* (not translatable; they also break Xcode's catalog symbol generation). */
|
|
355
|
+
function classifyKey(key) {
|
|
356
|
+
if (key.trim().length === 0)
|
|
357
|
+
return { keep: false, structural: false };
|
|
358
|
+
const guarded = key.replace(/%%/g, "\uE000\uE000"); // protect escaped percents
|
|
359
|
+
const specifiers = guarded.match(SPECIFIER_RE)?.length ?? 0;
|
|
360
|
+
const residual = guarded.replace(SPECIFIER_RE, "").replace(/\uE000/g, "%");
|
|
361
|
+
if (/\p{L}/u.test(residual))
|
|
362
|
+
return { keep: true, structural: false };
|
|
363
|
+
if (specifiers >= 2 || (specifiers >= 1 && JOINER_RE.test(residual))) {
|
|
364
|
+
return { keep: true, structural: true };
|
|
365
|
+
}
|
|
366
|
+
return { keep: false, structural: false };
|
|
367
|
+
}
|
|
368
|
+
function compileRegistry(extra) {
|
|
369
|
+
const names = [...new Set([...DEFAULT_SCAN_CALLS, ...extra])];
|
|
370
|
+
const ctors = [];
|
|
371
|
+
const methods = new Set();
|
|
372
|
+
const labels = new Set();
|
|
373
|
+
for (const n of names) {
|
|
374
|
+
if (n.startsWith("."))
|
|
375
|
+
methods.add(n.slice(1));
|
|
376
|
+
else if (n.endsWith(":"))
|
|
377
|
+
labels.add(n.slice(0, -1));
|
|
378
|
+
else
|
|
379
|
+
ctors.push({
|
|
380
|
+
name: n,
|
|
381
|
+
segs: n.split("."),
|
|
382
|
+
firstOnly: n === "NSLocalizedString",
|
|
383
|
+
requiredLabel: undefined,
|
|
384
|
+
pluralCapable: PLURAL_CAPABLE.has(n),
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
// Foundation localized-string ctors: only the `localized:` argument is a
|
|
388
|
+
// key — String(format:…) / String(describing:…) must never be captured.
|
|
389
|
+
for (const n of ["String", "AttributedString"]) {
|
|
390
|
+
if (!ctors.some((c) => c.name === n)) {
|
|
391
|
+
ctors.push({ name: n, segs: [n], firstOnly: false, requiredLabel: "localized", pluralCapable: false });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return { ctors, methods, labels };
|
|
395
|
+
}
|
|
396
|
+
function tokIs(t, kind, v) {
|
|
397
|
+
if (t === undefined)
|
|
398
|
+
return false;
|
|
399
|
+
return (t.t === "p" || t.t === "op") && t.t === kind && t.v === v;
|
|
400
|
+
}
|
|
401
|
+
function tokId(t) {
|
|
402
|
+
return t !== undefined && t.t === "id" ? t.v : undefined;
|
|
403
|
+
}
|
|
404
|
+
/** Match a registered call name at token j. Handles dotted names
|
|
405
|
+
* ("Polycast.string"), a `SwiftUI.` qualifier (marked native), and a
|
|
406
|
+
* trailing `.init`. Rejects other qualified accesses (Foo.Text) — and
|
|
407
|
+
* `RichText(` can never match `Text` because identifiers are whole
|
|
408
|
+
* tokens. */
|
|
409
|
+
function matchCtor(toks, j, spec) {
|
|
410
|
+
if (tokId(toks[j]) !== spec.segs[0])
|
|
411
|
+
return null;
|
|
412
|
+
let native = false;
|
|
413
|
+
const prev = toks[j - 1];
|
|
414
|
+
if (prev !== undefined && prev.t === "p" && prev.v === ".") {
|
|
415
|
+
if (tokId(toks[j - 2]) !== "SwiftUI" || tokIs(toks[j - 3], "p", "."))
|
|
42
416
|
return null;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
417
|
+
native = true;
|
|
418
|
+
}
|
|
419
|
+
let k = j + 1;
|
|
420
|
+
for (let s = 1; s < spec.segs.length; s++) {
|
|
421
|
+
if (!tokIs(toks[k], "p", ".") || tokId(toks[k + 1]) !== spec.segs[s])
|
|
422
|
+
return null;
|
|
423
|
+
k += 2;
|
|
424
|
+
}
|
|
425
|
+
if (tokIs(toks[k], "p", ".") && tokId(toks[k + 1]) === "init")
|
|
426
|
+
k += 2; // Text.init(
|
|
427
|
+
if (!tokIs(toks[k], "p", "("))
|
|
428
|
+
return null;
|
|
429
|
+
return { open: k, native };
|
|
430
|
+
}
|
|
431
|
+
const GENERIC_POLICY = { firstOnly: false, requiredLabel: undefined, pluralCapable: false };
|
|
432
|
+
/** The label immediately preceding a literal (`label: "…"`), if any.
|
|
433
|
+
* `ident :` counts as an argument label ONLY when the identifier itself
|
|
434
|
+
* follows `(` or `,` — in `cond ? ident : "literal"` the identifier is a
|
|
435
|
+
* ternary operand, not a label, and the literal must stay capturable
|
|
436
|
+
* (variable names like `key`/`id`/`url` collide with SKIP_LABELS). */
|
|
437
|
+
function literalLabel(toks, j) {
|
|
438
|
+
if (!tokIs(toks[j - 1], "p", ":"))
|
|
439
|
+
return undefined;
|
|
440
|
+
const label = tokId(toks[j - 2]);
|
|
441
|
+
if (label === undefined)
|
|
442
|
+
return undefined;
|
|
443
|
+
if (!tokIs(toks[j - 3], "p", "(") && !tokIs(toks[j - 3], "p", ","))
|
|
444
|
+
return undefined;
|
|
445
|
+
return label;
|
|
446
|
+
}
|
|
447
|
+
/** Capture every top-level string literal in the balanced argument list
|
|
448
|
+
* starting after the "(" at `open`. Literals inside nested closures
|
|
449
|
+
* `{ … }` or subscripts/collection literals `[ … ]` are not display
|
|
450
|
+
* strings for this call and are skipped; so are literals inside a NESTED
|
|
451
|
+
* call's parens (identifier right before `(`) — they are that call's
|
|
452
|
+
* arguments, which the runtime can never match against this key (a
|
|
453
|
+
* registered nested call gets its own region via runCapture; pure
|
|
454
|
+
* grouping parens don't count). Literals glued to `+` with a non-literal
|
|
455
|
+
* operand are unfoldable concatenation fragments and are skipped too
|
|
456
|
+
* (foldConcat already merged literal+literal). SKIP-labelled literals are
|
|
457
|
+
* skipped; a `comment:`-labelled literal becomes the translator note.
|
|
458
|
+
* Required-label policies (String(localized:…)) accept the labelled
|
|
459
|
+
* literal itself plus expression-position literals after the label
|
|
460
|
+
* (`String(localized: flag ? "Alpha" : "Beta")` captures both). */
|
|
461
|
+
function scanCallRegion(toks, open, policy, native, emit) {
|
|
462
|
+
let depth = 1;
|
|
463
|
+
let brace = 0;
|
|
464
|
+
let bracket = 0;
|
|
465
|
+
let nestedCall = 0;
|
|
466
|
+
const parenIsCall = [];
|
|
467
|
+
let requiredSeen = false;
|
|
468
|
+
let comment;
|
|
469
|
+
const hits = [];
|
|
470
|
+
for (let j = open + 1; j < toks.length && depth > 0; j++) {
|
|
471
|
+
const t = toks[j];
|
|
472
|
+
if (t.t === "p") {
|
|
473
|
+
if (t.v === "(") {
|
|
474
|
+
depth++;
|
|
475
|
+
const isCall = tokId(toks[j - 1]) !== undefined;
|
|
476
|
+
parenIsCall.push(isCall);
|
|
477
|
+
if (isCall)
|
|
478
|
+
nestedCall++;
|
|
479
|
+
}
|
|
480
|
+
else if (t.v === ")") {
|
|
481
|
+
depth--;
|
|
482
|
+
if (depth > 0 && parenIsCall.pop() === true)
|
|
483
|
+
nestedCall--;
|
|
484
|
+
}
|
|
485
|
+
else if (t.v === "{")
|
|
486
|
+
brace++;
|
|
487
|
+
else if (t.v === "}")
|
|
488
|
+
brace = brace > 0 ? brace - 1 : 0;
|
|
489
|
+
else if (t.v === "[")
|
|
490
|
+
bracket++;
|
|
491
|
+
else if (t.v === "]")
|
|
492
|
+
bracket = bracket > 0 ? bracket - 1 : 0;
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
if (t.t === "id") {
|
|
496
|
+
// `localized:` seen in argument-label position at this call's top
|
|
497
|
+
// level → expression-position literals after it are the key.
|
|
498
|
+
if (policy.requiredLabel !== undefined && t.v === policy.requiredLabel &&
|
|
499
|
+
depth === 1 && brace === 0 && bracket === 0 &&
|
|
500
|
+
tokIs(toks[j + 1], "p", ":") &&
|
|
501
|
+
(tokIs(toks[j - 1], "p", "(") || tokIs(toks[j - 1], "p", ","))) {
|
|
502
|
+
requiredSeen = true;
|
|
503
|
+
}
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
if (t.t !== "str" || brace > 0 || bracket > 0 || nestedCall > 0)
|
|
507
|
+
continue;
|
|
508
|
+
if (tokIs(toks[j - 1], "op", "+") || tokIs(toks[j + 1], "op", "+"))
|
|
509
|
+
continue;
|
|
510
|
+
const label = literalLabel(toks, j);
|
|
511
|
+
if (label === "comment") {
|
|
512
|
+
const c = keyFromParts(t.parts);
|
|
513
|
+
if (c.trim().length > 0 && comment === undefined)
|
|
514
|
+
comment = c;
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
if (label !== undefined && SKIP_LABELS.has(label))
|
|
518
|
+
continue;
|
|
519
|
+
if (policy.requiredLabel !== undefined && label !== policy.requiredLabel &&
|
|
520
|
+
!(label === undefined && requiredSeen))
|
|
521
|
+
continue;
|
|
522
|
+
if (policy.firstOnly && hits.length > 0)
|
|
523
|
+
continue;
|
|
524
|
+
// `, count:` right after the call's first literal marks a CLDR plural.
|
|
525
|
+
const plural = policy.pluralCapable && hits.length === 0 &&
|
|
526
|
+
tokIs(toks[j + 1], "p", ",") && tokId(toks[j + 2]) === "count" && tokIs(toks[j + 3], "p", ":");
|
|
527
|
+
hits.push({ parts: t.parts, kind: plural ? "plural" : "simple" });
|
|
528
|
+
}
|
|
529
|
+
for (const h of hits)
|
|
530
|
+
emit({ key: keyFromParts(h.parts), kind: h.kind, comment, native });
|
|
531
|
+
}
|
|
532
|
+
function runCapture(toks, reg, assignProps, emit) {
|
|
533
|
+
for (let j = 0; j < toks.length; j++) {
|
|
534
|
+
const t = toks[j];
|
|
535
|
+
if (t.t === "id") {
|
|
536
|
+
// Registered calls (incl. SwiftUI.-qualified and .init spellings).
|
|
537
|
+
for (const spec of reg.ctors) {
|
|
538
|
+
const m = matchCtor(toks, j, spec);
|
|
539
|
+
if (m !== null) {
|
|
540
|
+
scanCallRegion(toks, m.open, spec, m.native, emit);
|
|
541
|
+
break;
|
|
54
542
|
}
|
|
55
|
-
|
|
543
|
+
}
|
|
544
|
+
// "title:"-style registered labels, any argument position. Never
|
|
545
|
+
// after a dot (that's a member access, e.g. `case .title:`).
|
|
546
|
+
if (reg.labels.has(t.v) && tokIs(toks[j + 1], "p", ":") && !tokIs(toks[j - 1], "p", ".")) {
|
|
547
|
+
const s = toks[j + 2];
|
|
548
|
+
if (s?.t === "str")
|
|
549
|
+
emit({ key: keyFromParts(s.parts), kind: "simple", comment: undefined, native: false });
|
|
550
|
+
}
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
if (t.t === "p" && t.v === ".") {
|
|
554
|
+
const name = tokId(toks[j + 1]);
|
|
555
|
+
if (name === undefined)
|
|
56
556
|
continue;
|
|
557
|
+
// Method-style: view.navigationTitle("…")
|
|
558
|
+
if (reg.methods.has(name) && tokIs(toks[j + 2], "p", "(")) {
|
|
559
|
+
scanCallRegion(toks, j + 2, GENERIC_POLICY, false, emit);
|
|
560
|
+
}
|
|
561
|
+
// Assignment shape: label.text = "…" (never `==` — that's one op token)
|
|
562
|
+
if (assignProps.has(name) && tokIs(toks[j + 2], "op", "=")) {
|
|
563
|
+
const s = toks[j + 3];
|
|
564
|
+
if (s?.t === "str")
|
|
565
|
+
emit({ key: keyFromParts(s.parts), kind: "simple", comment: undefined, native: false });
|
|
57
566
|
}
|
|
58
|
-
key += next === "n" ? "\n" : next === "t" ? "\t" : next ?? "";
|
|
59
|
-
i += 2;
|
|
60
567
|
continue;
|
|
61
568
|
}
|
|
62
|
-
|
|
63
|
-
|
|
569
|
+
// Typed defaults: `: String = "…"` / `: String? = "…"` (vars, lets,
|
|
570
|
+
// and function default parameters alike).
|
|
571
|
+
if (t.t === "p" && t.v === ":" && tokId(toks[j + 1]) === "String") {
|
|
572
|
+
let m = j + 2;
|
|
573
|
+
if (tokIs(toks[m], "op", "?"))
|
|
574
|
+
m++;
|
|
575
|
+
if (tokIs(toks[m], "op", "=") || (m === j + 2 && tokIs(toks[m], "op", "?="))) {
|
|
576
|
+
const s = toks[m + 1];
|
|
577
|
+
if (s?.t === "str")
|
|
578
|
+
emit({ key: keyFromParts(s.parts), kind: "simple", comment: undefined, native: false });
|
|
579
|
+
}
|
|
580
|
+
}
|
|
64
581
|
}
|
|
65
|
-
return null;
|
|
66
582
|
}
|
|
67
|
-
|
|
68
|
-
|
|
583
|
+
// ---------------------------------------------------------------------------
|
|
584
|
+
// File-system walk
|
|
585
|
+
// ---------------------------------------------------------------------------
|
|
586
|
+
/** Best-effort discovery of local SPM path dependencies:
|
|
587
|
+
* `.package(path: "…")` (optionally with a leading `name:`) in the root's
|
|
588
|
+
* Package.swift. */
|
|
589
|
+
function packagePathDeps(root) {
|
|
590
|
+
const manifest = path.join(root, "Package.swift");
|
|
591
|
+
if (!existsSync(manifest))
|
|
592
|
+
return [];
|
|
593
|
+
let src;
|
|
594
|
+
try {
|
|
595
|
+
src = readFileSync(manifest, "utf8");
|
|
596
|
+
}
|
|
597
|
+
catch {
|
|
598
|
+
return [];
|
|
599
|
+
}
|
|
600
|
+
const deps = [];
|
|
601
|
+
for (const m of src.matchAll(/\.package\s*\(\s*(?:name\s*:\s*"[^"]*"\s*,\s*)?path\s*:\s*"([^"]+)"/g)) {
|
|
602
|
+
deps.push(path.resolve(root, m[1]));
|
|
603
|
+
}
|
|
604
|
+
return deps;
|
|
605
|
+
}
|
|
606
|
+
export function scanSwiftSourcesDetailed(root, opts = {}) {
|
|
607
|
+
const mainRoot = path.resolve(root);
|
|
608
|
+
const reg = compileRegistry(opts.extraCalls ?? []);
|
|
609
|
+
const assignProps = new Set(opts.assignProps ?? DEFAULT_ASSIGN_PROPS);
|
|
610
|
+
const maxDepth = opts.maxDepth ?? 12;
|
|
611
|
+
const skipDirs = new Set(opts.skipDirs ?? DEFAULT_SKIP_DIRS);
|
|
612
|
+
const candidates = [mainRoot];
|
|
613
|
+
for (const r of opts.extraRoots ?? [])
|
|
614
|
+
candidates.push(path.resolve(mainRoot, r));
|
|
615
|
+
candidates.push(...packagePathDeps(mainRoot));
|
|
69
616
|
const found = new Map();
|
|
617
|
+
const stats = { filesScanned: 0, dirsSkipped: [], rootsScanned: [] };
|
|
618
|
+
const seenFiles = new Set();
|
|
619
|
+
const emitFor = (file) => (c) => {
|
|
620
|
+
const cls = classifyKey(c.key);
|
|
621
|
+
if (!cls.keep)
|
|
622
|
+
return;
|
|
623
|
+
const prev = found.get(c.key);
|
|
624
|
+
if (prev === undefined) {
|
|
625
|
+
const s = { key: c.key, kind: c.kind, file: path.relative(mainRoot, file) };
|
|
626
|
+
if (c.comment !== undefined)
|
|
627
|
+
s.comment = c.comment;
|
|
628
|
+
if (cls.structural)
|
|
629
|
+
s.structural = true;
|
|
630
|
+
if (c.native)
|
|
631
|
+
s.native = true;
|
|
632
|
+
found.set(c.key, s);
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
if (prev.kind === "simple" && c.kind === "plural")
|
|
636
|
+
prev.kind = "plural";
|
|
637
|
+
if (prev.comment === undefined && c.comment !== undefined)
|
|
638
|
+
prev.comment = c.comment;
|
|
639
|
+
// `native` only survives when EVERY occurrence is SwiftUI-qualified.
|
|
640
|
+
if (prev.native === true && !c.native)
|
|
641
|
+
delete prev.native;
|
|
642
|
+
};
|
|
70
643
|
const walk = (dir, depth) => {
|
|
71
|
-
if (depth >
|
|
644
|
+
if (depth > maxDepth)
|
|
72
645
|
return;
|
|
73
646
|
let entries;
|
|
74
647
|
try {
|
|
@@ -78,9 +651,15 @@ export function scanSwiftSources(root, extraCalls = []) {
|
|
|
78
651
|
return;
|
|
79
652
|
}
|
|
80
653
|
for (const name of entries) {
|
|
81
|
-
if (name.startsWith(".") || SKIP_DIRS.has(name))
|
|
82
|
-
continue;
|
|
83
654
|
const full = path.join(dir, name);
|
|
655
|
+
if (name.startsWith(".") || skipDirs.has(name)) {
|
|
656
|
+
try {
|
|
657
|
+
if (statSync(full).isDirectory())
|
|
658
|
+
stats.dirsSkipped.push(full);
|
|
659
|
+
}
|
|
660
|
+
catch { /* ignore */ }
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
84
663
|
let st;
|
|
85
664
|
try {
|
|
86
665
|
st = statSync(full);
|
|
@@ -88,97 +667,51 @@ export function scanSwiftSources(root, extraCalls = []) {
|
|
|
88
667
|
catch {
|
|
89
668
|
continue;
|
|
90
669
|
}
|
|
91
|
-
if (st.isDirectory())
|
|
670
|
+
if (st.isDirectory()) {
|
|
92
671
|
walk(full, depth + 1);
|
|
93
|
-
else if (name.endsWith(".swift"))
|
|
94
|
-
scanFile(full, root, found, matcher);
|
|
95
|
-
}
|
|
96
|
-
};
|
|
97
|
-
walk(root, 0);
|
|
98
|
-
return [...found.values()].sort((a, b) => (a.key < b.key ? -1 : 1));
|
|
99
|
-
}
|
|
100
|
-
/** Blank out line and block comments (string-literal aware) so doc
|
|
101
|
-
* examples like `Text("…")` never get captured. Nested blocks handled. */
|
|
102
|
-
export function stripComments(src) {
|
|
103
|
-
const out = [...src];
|
|
104
|
-
let i = 0, inString = false, block = 0;
|
|
105
|
-
while (i < src.length) {
|
|
106
|
-
const ch = src[i], next = src[i + 1];
|
|
107
|
-
if (block > 0) {
|
|
108
|
-
if (ch === "/" && next === "*") {
|
|
109
|
-
block++;
|
|
110
|
-
out[i] = out[i + 1] = " ";
|
|
111
|
-
i += 2;
|
|
112
672
|
continue;
|
|
113
673
|
}
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
674
|
+
if (!name.endsWith(".swift") || seenFiles.has(full))
|
|
675
|
+
continue;
|
|
676
|
+
seenFiles.add(full);
|
|
677
|
+
if (opts.fileFilter !== undefined && !opts.fileFilter(path.relative(mainRoot, full)))
|
|
118
678
|
continue;
|
|
679
|
+
let src;
|
|
680
|
+
try {
|
|
681
|
+
src = readFileSync(full, "utf8");
|
|
119
682
|
}
|
|
120
|
-
|
|
121
|
-
out[i] = " ";
|
|
122
|
-
i++;
|
|
123
|
-
continue;
|
|
124
|
-
}
|
|
125
|
-
if (inString) {
|
|
126
|
-
if (ch === "\\") {
|
|
127
|
-
i += 2;
|
|
683
|
+
catch {
|
|
128
684
|
continue;
|
|
129
685
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
686
|
+
stats.filesScanned++;
|
|
687
|
+
try {
|
|
688
|
+
runCapture(foldConcat(lex(src)), reg, assignProps, emitFor(full));
|
|
689
|
+
}
|
|
690
|
+
catch { /* a scanner must never fail the push — skip pathological files */ }
|
|
134
691
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
692
|
+
};
|
|
693
|
+
const seenRoots = new Set();
|
|
694
|
+
for (const r of candidates) {
|
|
695
|
+
if (seenRoots.has(r))
|
|
138
696
|
continue;
|
|
697
|
+
seenRoots.add(r);
|
|
698
|
+
let st;
|
|
699
|
+
try {
|
|
700
|
+
st = statSync(r);
|
|
139
701
|
}
|
|
140
|
-
|
|
141
|
-
while (i < src.length && src[i] !== "\n") {
|
|
142
|
-
out[i] = " ";
|
|
143
|
-
i++;
|
|
144
|
-
}
|
|
702
|
+
catch {
|
|
145
703
|
continue;
|
|
146
704
|
}
|
|
147
|
-
if (
|
|
148
|
-
block = 1;
|
|
149
|
-
out[i] = out[i + 1] = " ";
|
|
150
|
-
i += 2;
|
|
705
|
+
if (!st.isDirectory())
|
|
151
706
|
continue;
|
|
152
|
-
|
|
153
|
-
|
|
707
|
+
stats.rootsScanned.push(r);
|
|
708
|
+
walk(r, 0);
|
|
154
709
|
}
|
|
155
|
-
|
|
710
|
+
const strings = [...found.values()].sort((a, b) => (a.key < b.key ? -1 : 1));
|
|
711
|
+
return { strings, stats };
|
|
156
712
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (m[1] && SKIP_LABELS.has(m[1]))
|
|
161
|
-
continue;
|
|
162
|
-
const literalStart = m.index + m[0].length;
|
|
163
|
-
const parsed = parseLiteral(src, literalStart);
|
|
164
|
-
if (!parsed || parsed.key.trim().length === 0)
|
|
165
|
-
continue;
|
|
166
|
-
// Not translatable content: emoji, separators, SF Symbol names,
|
|
167
|
-
// format-only strings — anything without a letter. (Also what makes
|
|
168
|
-
// Xcode's catalog symbol generation choke.)
|
|
169
|
-
if (!/\p{L}/u.test(parsed.key.replace(/%[@a-zA-Z]+/g, "")))
|
|
170
|
-
continue;
|
|
171
|
-
// `, count:` marks a CLDR plural — but only on Polycast's own calls.
|
|
172
|
-
// Custom components (scanCalls) may have unrelated count: params
|
|
173
|
-
// (badge counts etc.), and a plural entry without a numeric format
|
|
174
|
-
// specifier breaks Xcode's catalog compile.
|
|
175
|
-
const callee = m[0].replace(/\(.*$/s, "").replace(/^\./, "");
|
|
176
|
-
const pluralCapable = DEFAULT_SCAN_CALLS.includes(callee);
|
|
177
|
-
const kind = pluralCapable && /^\s*,\s*count\s*:/.test(src.slice(parsed.end)) ? "plural" : "simple";
|
|
178
|
-
const existing = found.get(parsed.key);
|
|
179
|
-
if (!existing || (existing.kind === "simple" && kind === "plural")) {
|
|
180
|
-
found.set(parsed.key, { key: parsed.key, kind, file: path.relative(root, file) });
|
|
181
|
-
}
|
|
182
|
-
}
|
|
713
|
+
/** Back-compat wrapper — same shape the CLI has always consumed. */
|
|
714
|
+
export function scanSwiftSources(root, extraCalls = []) {
|
|
715
|
+
return scanSwiftSourcesDetailed(root, { extraCalls }).strings;
|
|
183
716
|
}
|
|
184
717
|
//# sourceMappingURL=scan.js.map
|