polycast-cli 0.1.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 +14 -2
- package/dist/commands.js +413 -50
- 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 +666 -113
- 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 +65 -6
- package/dist/xcstrings.js.map +1 -1
- package/package.json +3 -3
package/dist/commands.js
CHANGED
|
@@ -6,8 +6,32 @@ import { S, c, g, bold, dim, green, red, yellow, accent, header, section, kv, ba
|
|
|
6
6
|
import { DEFAULT_API, DEFAULT_EDGE, findConfig, requireConfig, writeConfig, ensureGitignored, stateDir, } from "./config.js";
|
|
7
7
|
import { admin, edge, project, requireAdminToken } from "./api.js";
|
|
8
8
|
import { storeToken, loadToken, clearToken } from "./keychain.js";
|
|
9
|
-
import { parseCatalog, mergeTranslations, writeCatalog, serializeCatalog, addMissingKeys } from "./xcstrings.js";
|
|
10
|
-
import
|
|
9
|
+
import { parseCatalog, mergeTranslations, writeCatalog, serializeCatalog, addMissingKeys, mergeScannerComments, catalogLocales, } from "./xcstrings.js";
|
|
10
|
+
import * as scanner from "./scan.js";
|
|
11
|
+
import { compileDenyKeys, isFileDenied, partitionPush, pruneCandidates, findPbxprojs, findFilesNamed, hasMemberImportVisibility, parseKnownRegions, userFacingInfoPlistKeys, } from "./hygiene.js";
|
|
12
|
+
/** Scan Swift sources, preferring the detailed scanner (feature-detected —
|
|
13
|
+
* strings + walk stats) and applying polycast.json denyFiles. denyFiles
|
|
14
|
+
* must apply per-occurrence BEFORE the scanner's cross-file dedupe (which
|
|
15
|
+
* attributes each key to the first file walked) — a post-hoc filter would
|
|
16
|
+
* drop keys that also legitimately occur in allowed files. */
|
|
17
|
+
function scanProject(dir, config) {
|
|
18
|
+
const denyFiles = config.denyFiles ?? [];
|
|
19
|
+
const fileFilter = denyFiles.length > 0
|
|
20
|
+
? (rel) => !isFileDenied(rel, denyFiles)
|
|
21
|
+
: undefined;
|
|
22
|
+
if (typeof scanner.scanSwiftSourcesDetailed === "function") {
|
|
23
|
+
return scanner.scanSwiftSourcesDetailed(dir, {
|
|
24
|
+
extraCalls: config.scanCalls ?? [],
|
|
25
|
+
...(fileFilter !== undefined ? { fileFilter } : {}),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
// Legacy scanner has no fileFilter hook — post-filter is best-effort.
|
|
29
|
+
const result = { strings: scanner.scanSwiftSources(dir, config.scanCalls ?? []) };
|
|
30
|
+
if (fileFilter !== undefined) {
|
|
31
|
+
result.strings = result.strings.filter((s) => fileFilter(s.file));
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
11
35
|
const apiOf = (config) => config?.endpoint ?? DEFAULT_API;
|
|
12
36
|
const edgeOf = (config) => config?.edgeEndpoint ?? DEFAULT_EDGE;
|
|
13
37
|
/** Resolve the project id for admin commands from polycast.json's key. */
|
|
@@ -117,6 +141,41 @@ function detectCatalog(cwd) {
|
|
|
117
141
|
}
|
|
118
142
|
return null;
|
|
119
143
|
}
|
|
144
|
+
/** Directory holding the most Swift sources — where the catalog belongs. */
|
|
145
|
+
function bestSourceDir(cwd) {
|
|
146
|
+
const counts = new Map();
|
|
147
|
+
const walk = (d, depth) => {
|
|
148
|
+
if (depth > 4)
|
|
149
|
+
return;
|
|
150
|
+
let entries;
|
|
151
|
+
try {
|
|
152
|
+
entries = readdirSync(d);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
for (const name of entries) {
|
|
158
|
+
if (name.startsWith(".") || ["node_modules", "Pods", "build", "DerivedData", ".build"].includes(name) || name.endsWith(".xcodeproj"))
|
|
159
|
+
continue;
|
|
160
|
+
const full = path.join(d, name);
|
|
161
|
+
try {
|
|
162
|
+
if (statSync(full).isDirectory())
|
|
163
|
+
walk(full, depth + 1);
|
|
164
|
+
else if (name.endsWith(".swift"))
|
|
165
|
+
counts.set(d, (counts.get(d) ?? 0) + 1);
|
|
166
|
+
}
|
|
167
|
+
catch { /* skip */ }
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
walk(cwd, 0);
|
|
171
|
+
let best = cwd, bestCount = 0;
|
|
172
|
+
for (const [dir, n] of counts)
|
|
173
|
+
if (n > bestCount) {
|
|
174
|
+
best = dir;
|
|
175
|
+
bestCount = n;
|
|
176
|
+
}
|
|
177
|
+
return best;
|
|
178
|
+
}
|
|
120
179
|
function detectProjectName(cwd) {
|
|
121
180
|
const xcodeproj = readdirSync(cwd).find((f) => f.endsWith(".xcodeproj"));
|
|
122
181
|
if (xcodeproj)
|
|
@@ -147,9 +206,16 @@ export async function init(flags) {
|
|
|
147
206
|
return;
|
|
148
207
|
}
|
|
149
208
|
const token = requireAdminToken();
|
|
150
|
-
|
|
209
|
+
let catalogPath = flags.catalog ?? detectCatalog(cwd);
|
|
151
210
|
if (!catalogPath) {
|
|
152
|
-
|
|
211
|
+
// No String Catalog yet (apps that never localized don't have one) —
|
|
212
|
+
// create it. The source scanner fills it from Text("…") literals on
|
|
213
|
+
// the first push, so empty is the correct starting state.
|
|
214
|
+
const appDir = bestSourceDir(cwd);
|
|
215
|
+
catalogPath = path.relative(cwd, path.join(appDir, "Localizable.xcstrings"));
|
|
216
|
+
writeFileSync(path.join(appDir, "Localizable.xcstrings"), JSON.stringify({ sourceLanguage: "en", strings: {}, version: "1.0" }, null, 2) + "\n");
|
|
217
|
+
console.log(` ${S.ok} created ${catalogPath} ${c.muted("(none existed — the source scanner fills it on push)")}`);
|
|
218
|
+
console.log(` ${S.warn} ${c.danger("add it to your app target in Xcode")} ${c.muted("(drag into the project navigator) so bundled fallbacks ship in the binary")}`);
|
|
153
219
|
}
|
|
154
220
|
parseCatalog(path.resolve(cwd, catalogPath)); // fail fast if unparseable
|
|
155
221
|
const name = flags.name ?? detectProjectName(cwd);
|
|
@@ -260,31 +326,52 @@ export async function langs(args, flags) {
|
|
|
260
326
|
export async function push(flags) {
|
|
261
327
|
const { config, dir } = requireConfig();
|
|
262
328
|
const catalogFile = path.resolve(dir, config.catalogPath);
|
|
329
|
+
const denyKeys = compileDenyKeys(config.denyKeys ?? []); // fail fast on bad config
|
|
330
|
+
const protectedTerms = new Set(config.protectedTerms ?? []);
|
|
263
331
|
// Zero-config capture: PText / Polycast.string literals in Swift
|
|
264
332
|
// sources are merged into the catalog automatically — write code,
|
|
265
|
-
// run push, done.
|
|
266
|
-
const scanned =
|
|
333
|
+
// run push, done. denyFiles filtering happens inside scanProject.
|
|
334
|
+
const scanned = scanProject(dir, config).strings;
|
|
267
335
|
{
|
|
268
336
|
const { raw } = parseCatalog(catalogFile);
|
|
269
337
|
const added = addMissingKeys(raw, scanned);
|
|
270
|
-
|
|
338
|
+
const comments = mergeScannerComments(raw, scanned);
|
|
339
|
+
if (added.length > 0 || comments.adopted.length > 0)
|
|
271
340
|
writeCatalog(catalogFile, raw);
|
|
341
|
+
if (added.length > 0) {
|
|
272
342
|
console.log(`\n ${S.ok} captured ${bold(String(added.length))} new string(s) from source ${c.muted(`→ ${config.catalogPath}`)}`);
|
|
273
343
|
for (const key of added.slice(0, 8))
|
|
274
344
|
console.log(` ${c.positive("+")} ${c.text(key)}`);
|
|
275
345
|
if (added.length > 8)
|
|
276
346
|
console.log(` ${c.muted(`… and ${added.length - 8} more`)}`);
|
|
277
347
|
}
|
|
348
|
+
if (comments.adopted.length > 0) {
|
|
349
|
+
console.log(` ${S.ok} adopted ${comments.adopted.length} scanner comment(s) into the catalog`);
|
|
350
|
+
}
|
|
351
|
+
for (const col of comments.collisions) {
|
|
352
|
+
console.log(` ${S.warn} ${yellow(`comment collision on "${col.key}"`)} ${c.muted(`catalog: "${col.catalog}" ${g.sep} scanned: "${col.scanned}" — catalog wins`)}`);
|
|
353
|
+
}
|
|
278
354
|
}
|
|
279
355
|
const { sourceLocale, strings } = parseCatalog(catalogFile);
|
|
356
|
+
const parts = partitionPush(strings, denyKeys, protectedTerms);
|
|
280
357
|
console.log(`\n ${S.diamond} ${c.text(`${strings.length} strings`)} ${c.muted(`(catalog + source scan) from ${config.catalogPath}`)}`);
|
|
358
|
+
if (parts.noTranslate > 0) {
|
|
359
|
+
console.log(` ${c.muted(`${g.sep} ${parts.noTranslate} excluded (shouldTranslate = false)`)}`);
|
|
360
|
+
}
|
|
361
|
+
if (parts.denied.length > 0) {
|
|
362
|
+
const shown = parts.denied.slice(0, 5).map((k) => JSON.stringify(k)).join(", ");
|
|
363
|
+
console.log(` ${c.muted(`${g.sep} ${parts.denied.length} excluded by denyKeys: ${shown}${parts.denied.length > 5 ? ` … and ${parts.denied.length - 5} more` : ""}`)}`);
|
|
364
|
+
}
|
|
365
|
+
if (parts.protectedHits.length > 0) {
|
|
366
|
+
console.log(` ${S.warn} ${yellow(`protected term(s) kept verbatim, never pushed: ${parts.protectedHits.map((k) => JSON.stringify(k)).join(", ")}`)}`);
|
|
367
|
+
}
|
|
281
368
|
const stateFile = path.join(stateDir(dir), "last-push.json");
|
|
282
369
|
const previous = existsSync(stateFile)
|
|
283
370
|
? (JSON.parse(readFileSync(stateFile, "utf8")).digests ?? {})
|
|
284
371
|
: {};
|
|
285
|
-
const changedOrNew =
|
|
372
|
+
const changedOrNew = parts.send.filter((s) => previous[s.key] !== s.digest);
|
|
286
373
|
const newCount = changedOrNew.filter((s) => !(s.key in previous)).length;
|
|
287
|
-
console.log(` ${green(`+${newCount} new`)} ${S.dot} ${yellow(`~${changedOrNew.length - newCount} changed`)} ${S.dot} ${dim(`${
|
|
374
|
+
console.log(` ${green(`+${newCount} new`)} ${S.dot} ${yellow(`~${changedOrNew.length - newCount} changed`)} ${S.dot} ${dim(`${parts.send.length - changedOrNew.length} unchanged`)}\n`);
|
|
288
375
|
if (changedOrNew.length === 0)
|
|
289
376
|
return;
|
|
290
377
|
const sp = spin("pushing source strings");
|
|
@@ -292,13 +379,19 @@ export async function push(flags) {
|
|
|
292
379
|
schemaVersion: 1,
|
|
293
380
|
projectKey: config.projectKey,
|
|
294
381
|
sourceLocale,
|
|
295
|
-
strings: changedOrNew.map(({ key, comment, pluralShape, digest }) => ({
|
|
296
|
-
key,
|
|
297
|
-
...(
|
|
382
|
+
strings: changedOrNew.map(({ key, value, comment, pluralShape, structural, digest }) => ({
|
|
383
|
+
key,
|
|
384
|
+
...(value !== undefined ? { value } : {}),
|
|
385
|
+
...(comment !== undefined ? { comment } : {}),
|
|
386
|
+
...(pluralShape ? { pluralShape } : {}),
|
|
387
|
+
...(structural ? { structural: true } : {}),
|
|
388
|
+
digest,
|
|
298
389
|
})),
|
|
299
390
|
});
|
|
300
391
|
const digests = { ...previous };
|
|
301
|
-
for
|
|
392
|
+
// Record only keys actually eligible for push — un-denying/un-protecting
|
|
393
|
+
// a key later must make it show up as new, not "unchanged".
|
|
394
|
+
for (const s of parts.send)
|
|
302
395
|
digests[s.key] = s.digest;
|
|
303
396
|
writeFileSync(stateFile, JSON.stringify({ digests }, null, 2));
|
|
304
397
|
sp.succeed(`pushed — ${accent(String(result.draftJobsEnqueued))} draft job(s) queued across enabled languages`);
|
|
@@ -313,10 +406,36 @@ export async function pull(flags) {
|
|
|
313
406
|
const wanted = flags.locales?.split(",").map((s) => s.trim()).filter(Boolean);
|
|
314
407
|
const locales = manifest.locales.map((l) => l.id)
|
|
315
408
|
.filter((id) => !wanted || wanted.includes(id));
|
|
316
|
-
|
|
409
|
+
const bundles = new Map();
|
|
317
410
|
for (const locale of locales) {
|
|
318
|
-
|
|
319
|
-
|
|
411
|
+
bundles.set(locale, (await edge.bundle(edgeOf(config), config.projectKey, locale)).strings);
|
|
412
|
+
}
|
|
413
|
+
const missing = new Set();
|
|
414
|
+
let changed = 0;
|
|
415
|
+
for (const [locale, strings] of bundles) {
|
|
416
|
+
changed += mergeTranslations(raw, locale, strings, { missing });
|
|
417
|
+
}
|
|
418
|
+
// Server keys with no local entry are never dropped silently: report
|
|
419
|
+
// them, and create them with --add (en value = key; plural skeleton
|
|
420
|
+
// when any bundle serves plural variants).
|
|
421
|
+
let addedFromServer = [];
|
|
422
|
+
if (missing.size > 0 && flags.add && !flags.check) {
|
|
423
|
+
addedFromServer = [...missing].sort();
|
|
424
|
+
addMissingKeys(raw, addedFromServer.map((key) => ({
|
|
425
|
+
key,
|
|
426
|
+
kind: [...bundles.values()].some((b) => b[key]?.plural) ? "plural" : "simple",
|
|
427
|
+
})));
|
|
428
|
+
for (const [locale, strings] of bundles)
|
|
429
|
+
changed += mergeTranslations(raw, locale, strings);
|
|
430
|
+
}
|
|
431
|
+
else if (missing.size > 0) {
|
|
432
|
+
const first = [...missing].sort().slice(0, 5);
|
|
433
|
+
console.log(` ${S.warn} ${yellow(`${missing.size} server key(s) missing from the local catalog — not merged`)}`);
|
|
434
|
+
for (const key of first)
|
|
435
|
+
console.log(` ${c.muted(g.sep)} ${c.text(JSON.stringify(key))}`);
|
|
436
|
+
if (missing.size > 5)
|
|
437
|
+
console.log(` ${c.muted(`… and ${missing.size - 5} more`)}`);
|
|
438
|
+
note("run `polycast pull --add` to add them to the catalog (en value = key)");
|
|
320
439
|
}
|
|
321
440
|
const after = serializeCatalog(raw);
|
|
322
441
|
if (flags.check) {
|
|
@@ -332,8 +451,97 @@ export async function pull(flags) {
|
|
|
332
451
|
return;
|
|
333
452
|
}
|
|
334
453
|
writeCatalog(catalogFile, raw);
|
|
454
|
+
if (addedFromServer.length > 0) {
|
|
455
|
+
console.log(` ${S.ok} added ${bold(String(addedFromServer.length))} server key(s) to the catalog`);
|
|
456
|
+
for (const key of addedFromServer.slice(0, 5))
|
|
457
|
+
console.log(` ${c.positive("+")} ${c.text(key)}`);
|
|
458
|
+
if (addedFromServer.length > 5)
|
|
459
|
+
console.log(` ${c.muted(`… and ${addedFromServer.length - 5} more`)}`);
|
|
460
|
+
}
|
|
335
461
|
success(`merged ${locales.map(flag).join(" ")} ${locales.join(", ")} — ${changed} value(s) updated`);
|
|
336
462
|
}
|
|
463
|
+
// ---- prune ----
|
|
464
|
+
/** List (dry-run) or delete (--apply) catalog junk: letterless non-
|
|
465
|
+
* structural artifacts and dangling concatenation fragments ("Save " when
|
|
466
|
+
* "Save 50%" exists). */
|
|
467
|
+
export async function prune(flags) {
|
|
468
|
+
const { config, dir } = requireConfig();
|
|
469
|
+
const catalogFile = path.resolve(dir, config.catalogPath);
|
|
470
|
+
const { raw } = parseCatalog(catalogFile);
|
|
471
|
+
const { artifacts, fragments } = pruneCandidates(Object.keys(raw.strings));
|
|
472
|
+
const all = [...artifacts, ...fragments];
|
|
473
|
+
if (!flags.json) {
|
|
474
|
+
if (all.length === 0) {
|
|
475
|
+
success("catalog is clean — nothing to prune");
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
header("prune", `${all.length} candidate(s) in ${config.catalogPath}`);
|
|
479
|
+
for (const key of artifacts) {
|
|
480
|
+
console.log(` ${c.negative("−")} ${c.text(JSON.stringify(key))} ${c.muted("empty/letterless artifact")}`);
|
|
481
|
+
}
|
|
482
|
+
for (const key of fragments) {
|
|
483
|
+
console.log(` ${c.negative("−")} ${c.text(JSON.stringify(key))} ${c.muted("dangling concatenation fragment (strict prefix of another key)")}`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
// Delete BEFORE reporting: `applied` in the machine output must reflect
|
|
488
|
+
// a write that actually happened, not one that was about to.
|
|
489
|
+
const applied = flags.apply && all.length > 0;
|
|
490
|
+
if (applied) {
|
|
491
|
+
for (const key of all)
|
|
492
|
+
delete raw.strings[key];
|
|
493
|
+
writeCatalog(catalogFile, raw);
|
|
494
|
+
}
|
|
495
|
+
if (flags.json) {
|
|
496
|
+
console.log(JSON.stringify({ artifacts, fragments, applied }, null, 2));
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
if (all.length === 0)
|
|
500
|
+
return;
|
|
501
|
+
if (!flags.apply) {
|
|
502
|
+
console.log();
|
|
503
|
+
note("dry run — re-run with --apply to delete these from the catalog");
|
|
504
|
+
console.log();
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
success(`pruned ${all.length} key(s) from ${config.catalogPath}`);
|
|
508
|
+
}
|
|
509
|
+
// ---- misses ----
|
|
510
|
+
/** Runtime miss telemetry: keys devices requested that the OTA bundle
|
|
511
|
+
* could not serve (admin endpoint; newer servers only). */
|
|
512
|
+
export async function misses(flags) {
|
|
513
|
+
const { config, token, projectId } = await resolveProject();
|
|
514
|
+
let limit = 100;
|
|
515
|
+
if (flags.limit !== undefined) {
|
|
516
|
+
limit = Number(flags.limit);
|
|
517
|
+
if (!Number.isInteger(limit) || limit < 1)
|
|
518
|
+
throw new CliError("--limit must be a positive integer", 2);
|
|
519
|
+
}
|
|
520
|
+
const { misses: rows } = await admin.misses(apiOf(config), token, projectId, limit);
|
|
521
|
+
if (flags.json) {
|
|
522
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
if (rows.length === 0) {
|
|
526
|
+
success("no runtime misses reported");
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
const sorted = [...rows].sort((a, b) => b.count - a.count);
|
|
530
|
+
header("runtime misses", `${rows.length} key/locale pair(s) the SDK could not serve OTA`);
|
|
531
|
+
console.log(table(["key", "locale", "count", "fallback", "last seen"], sorted.map((r) => [
|
|
532
|
+
c.text(r.key) + (r.inCatalog ? "" : ` ${c.danger("(uncaptured)")}`),
|
|
533
|
+
c.accent2(r.locale),
|
|
534
|
+
String(r.count),
|
|
535
|
+
c.muted(r.fallback),
|
|
536
|
+
c.muted(relativeTime(r.lastSeen)),
|
|
537
|
+
])));
|
|
538
|
+
const uncaptured = sorted.filter((r) => !r.inCatalog).length;
|
|
539
|
+
console.log();
|
|
540
|
+
if (uncaptured > 0) {
|
|
541
|
+
note(`${uncaptured} uncaptured key(s) — the scanner never saw them; check scanCalls in polycast.json or add them to the catalog`);
|
|
542
|
+
console.log();
|
|
543
|
+
}
|
|
544
|
+
}
|
|
337
545
|
// ---- publish ----
|
|
338
546
|
export async function publish(args, flags) {
|
|
339
547
|
const { config, token, projectId } = await resolveProject();
|
|
@@ -405,6 +613,22 @@ export async function status(flags) {
|
|
|
405
613
|
? `${c.text(`bundle v${latest.bundleVersion}`)} ${c.positive("live")} ${c.muted(`${g.sep} published ${relativeTime(latest.at)}`)}`
|
|
406
614
|
: c.muted("nothing published yet"));
|
|
407
615
|
kv("jobs", failed > 0 ? c.negative(`${failed} failed — inspect with status --json`) : c.positive("all healthy"));
|
|
616
|
+
// Runtime misses (newer servers only — a 404 means the endpoint does
|
|
617
|
+
// not exist yet; hide the line so old servers keep working).
|
|
618
|
+
try {
|
|
619
|
+
const { misses: missRows } = await admin.misses(apiOf(config), token, projectId, 500);
|
|
620
|
+
if (missRows.length > 0) {
|
|
621
|
+
const total = missRows.reduce((a, r) => a + r.count, 0);
|
|
622
|
+
const uncaptured = missRows.filter((r) => !r.inCatalog).length;
|
|
623
|
+
kv("runtime misses", c.danger(`${total} across ${missRows.length} key/locale pair(s)`)
|
|
624
|
+
+ (uncaptured > 0 ? c.muted(` ${g.sep} ${uncaptured} uncaptured`) : "")
|
|
625
|
+
+ c.muted(` ${g.sep} see \`polycast misses\``));
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
catch {
|
|
629
|
+
// Telemetry is informational — a transient 5xx/timeout must never
|
|
630
|
+
// take down `status`. Only `polycast misses` hard-fails.
|
|
631
|
+
}
|
|
408
632
|
section("languages");
|
|
409
633
|
console.log(table(["", "locale", "coverage", "", "drafts", "review", "bundle"], enabled.map((l) => [
|
|
410
634
|
flag(l.locale),
|
|
@@ -440,6 +664,7 @@ export async function doctor() {
|
|
|
440
664
|
await check("polycast.json present and valid", () => {
|
|
441
665
|
if (!found)
|
|
442
666
|
throw new Error("run `polycast init`");
|
|
667
|
+
compileDenyKeys(found.config.denyKeys ?? []); // invalid regex = invalid config
|
|
443
668
|
return found.config.projectKey.slice(0, 12) + "…";
|
|
444
669
|
});
|
|
445
670
|
if (!found) {
|
|
@@ -498,6 +723,102 @@ export async function doctor() {
|
|
|
498
723
|
if (cached.status !== 304)
|
|
499
724
|
throw new Error(`expected 304, got ${cached.status}`);
|
|
500
725
|
});
|
|
726
|
+
// ---- warn-only hygiene (informational; never fails doctor) ----
|
|
727
|
+
const warnOnly = async (label, fn) => {
|
|
728
|
+
const sp = spin(label);
|
|
729
|
+
try {
|
|
730
|
+
const r = await fn();
|
|
731
|
+
const warnings = r.warnings ?? [];
|
|
732
|
+
if (warnings.length === 0) {
|
|
733
|
+
sp.succeed(`${label}${r.detail ? dim(` ${r.detail}`) : ""}`);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
sp.warn(`${label}${r.detail ? dim(` ${r.detail}`) : ""}`);
|
|
737
|
+
for (const w of warnings)
|
|
738
|
+
console.log(` ${S.warn} ${yellow(w)}`);
|
|
739
|
+
}
|
|
740
|
+
catch (err) {
|
|
741
|
+
sp.warn(`${label} ${dim(`— skipped (${err.message})`)}`);
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
const rel = (f) => path.relative(dir, f) || ".";
|
|
745
|
+
const pbxprojs = findPbxprojs(dir);
|
|
746
|
+
await warnOnly("source scan", () => {
|
|
747
|
+
const r = scanProject(dir, config);
|
|
748
|
+
if (!r.stats)
|
|
749
|
+
return { detail: `${r.strings.length} string(s)` };
|
|
750
|
+
const skipped = r.stats.dirsSkipped.length > 0
|
|
751
|
+
? ` (${r.stats.dirsSkipped.slice(0, 4).map(rel).join(", ")}${r.stats.dirsSkipped.length > 4 ? ", …" : ""})`
|
|
752
|
+
: "";
|
|
753
|
+
return { detail: `${r.strings.length} string(s) ${g.sep} ${r.stats.filesScanned} file(s) scanned ${g.sep} ${r.stats.dirsSkipped.length} dir(s) skipped${skipped}` };
|
|
754
|
+
});
|
|
755
|
+
await warnOnly("member import visibility", () => {
|
|
756
|
+
if (pbxprojs.length === 0)
|
|
757
|
+
return { detail: "no .pbxproj — skipped" };
|
|
758
|
+
const bad = pbxprojs.filter((f) => hasMemberImportVisibility(readFileSync(f, "utf8")));
|
|
759
|
+
return {
|
|
760
|
+
warnings: bad.map((f) => `${rel(f)}: SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES breaks the Text shadow — set it to NO`),
|
|
761
|
+
};
|
|
762
|
+
});
|
|
763
|
+
await warnOnly("Text shadow coverage", () => {
|
|
764
|
+
const roots = detectModuleRoots(dir);
|
|
765
|
+
const missing = roots.filter((r) => !existsSync(path.join(r, "Polycast+Shadow.swift")));
|
|
766
|
+
return {
|
|
767
|
+
detail: `${roots.length - missing.length}/${roots.length} module(s) shadowed`,
|
|
768
|
+
warnings: missing.map((r) => `module ${rel(r)} has no Polycast+Shadow.swift — plain Text stays bundled-only there (run \`polycast shadow\`)`),
|
|
769
|
+
};
|
|
770
|
+
});
|
|
771
|
+
await warnOnly("Info.plist localization", () => {
|
|
772
|
+
const sources = [...findFilesNamed(dir, (n) => n === "Info.plist"), ...pbxprojs];
|
|
773
|
+
const keys = new Set();
|
|
774
|
+
for (const f of sources) {
|
|
775
|
+
for (const k of userFacingInfoPlistKeys(readFileSync(f, "utf8")))
|
|
776
|
+
keys.add(k);
|
|
777
|
+
}
|
|
778
|
+
if (keys.size === 0)
|
|
779
|
+
return { detail: "no user-facing Info.plist keys" };
|
|
780
|
+
if (findFilesNamed(dir, (n) => n === "InfoPlist.xcstrings").length > 0) {
|
|
781
|
+
return { detail: `${keys.size} user-facing key(s), InfoPlist.xcstrings present` };
|
|
782
|
+
}
|
|
783
|
+
return {
|
|
784
|
+
warnings: [`user-facing Info.plist key(s) without an InfoPlist.xcstrings — permission prompts stay unlocalized: ${[...keys].sort().join(", ")}`],
|
|
785
|
+
};
|
|
786
|
+
});
|
|
787
|
+
await warnOnly("Xcode knownRegions", () => {
|
|
788
|
+
if (pbxprojs.length === 0)
|
|
789
|
+
return { detail: "no .pbxproj — skipped" };
|
|
790
|
+
const { raw } = parseCatalog(path.resolve(dir, config.catalogPath));
|
|
791
|
+
const locales = catalogLocales(raw).filter((l) => l !== raw.sourceLanguage);
|
|
792
|
+
if (locales.length === 0)
|
|
793
|
+
return { detail: "no translated locales in the catalog yet" };
|
|
794
|
+
const warnings = [];
|
|
795
|
+
for (const f of pbxprojs) {
|
|
796
|
+
const regions = new Set(parseKnownRegions(readFileSync(f, "utf8")));
|
|
797
|
+
if (regions.size === 0)
|
|
798
|
+
continue;
|
|
799
|
+
const missing = locales.filter((l) => !regions.has(l));
|
|
800
|
+
if (missing.length > 0) {
|
|
801
|
+
warnings.push(`${rel(f)}: knownRegions missing ${missing.join(", ")} — add them in Xcode (Project → Info → Localizations)`);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
return { detail: warnings.length === 0 ? `${locales.length} catalog locale(s) covered` : undefined, warnings };
|
|
805
|
+
});
|
|
806
|
+
await warnOnly("pull currency", async () => {
|
|
807
|
+
const manifest = await edge.manifest(edgeOf(config), config.projectKey);
|
|
808
|
+
if (manifest.locales.length === 0)
|
|
809
|
+
return { detail: "no published bundles yet" };
|
|
810
|
+
const { raw } = parseCatalog(path.resolve(dir, config.catalogPath));
|
|
811
|
+
const stale = [];
|
|
812
|
+
for (const l of manifest.locales) {
|
|
813
|
+
const bundle = await edge.bundle(edgeOf(config), config.projectKey, l.id);
|
|
814
|
+
const clone = structuredClone(raw);
|
|
815
|
+
if (mergeTranslations(clone, l.id, bundle.strings) > 0)
|
|
816
|
+
stale.push(l.id);
|
|
817
|
+
}
|
|
818
|
+
if (stale.length === 0)
|
|
819
|
+
return { detail: `catalog current with ${manifest.locales.length} published locale(s)` };
|
|
820
|
+
return { warnings: [`catalog fallbacks stale for ${stale.join(", ")} — run \`polycast pull\``] };
|
|
821
|
+
});
|
|
501
822
|
if (failures > 0) {
|
|
502
823
|
console.log(`\n ${S.fail} ${red(`${failures} check(s) failed`)}\n`);
|
|
503
824
|
process.exit(1);
|
|
@@ -512,39 +833,18 @@ const SHADOW_CONTENT = `// Generated by \`polycast shadow\`: routes every plain
|
|
|
512
833
|
import PolycastSDK
|
|
513
834
|
typealias Text = PText
|
|
514
835
|
`;
|
|
515
|
-
/** Plant Polycast+Shadow.swift
|
|
516
|
-
*
|
|
836
|
+
/** Plant Polycast+Shadow.swift once per Swift MODULE (a typealias is
|
|
837
|
+
* module-scoped; duplicates in the same module are compile errors).
|
|
838
|
+
* SPM packages get one per Sources/<target>; an Xcode app (no package
|
|
839
|
+
* boundary) is a single module and gets exactly one at its source root. */
|
|
517
840
|
export async function shadow(args, _flags) {
|
|
518
841
|
const { dir } = requireConfig();
|
|
519
|
-
let targets
|
|
520
|
-
if (
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
return;
|
|
526
|
-
let entries;
|
|
527
|
-
try {
|
|
528
|
-
entries = readdirSync(d);
|
|
529
|
-
}
|
|
530
|
-
catch {
|
|
531
|
-
return;
|
|
532
|
-
}
|
|
533
|
-
for (const name of entries) {
|
|
534
|
-
if (name.startsWith(".") || ["node_modules", "Pods", ".build", "build", "DerivedData"].includes(name))
|
|
535
|
-
continue;
|
|
536
|
-
const full = path.join(d, name);
|
|
537
|
-
try {
|
|
538
|
-
if (statSync(full).isDirectory())
|
|
539
|
-
walk(full, depth + 1);
|
|
540
|
-
else if (name.endsWith(".swift") && !name.startsWith("Polycast+") && name !== "Package.swift")
|
|
541
|
-
roots.add(d);
|
|
542
|
-
}
|
|
543
|
-
catch { /* skip */ }
|
|
544
|
-
}
|
|
545
|
-
};
|
|
546
|
-
walk(dir, 0);
|
|
547
|
-
targets = [...roots];
|
|
842
|
+
let targets;
|
|
843
|
+
if (args.length > 0) {
|
|
844
|
+
targets = args.map((a) => path.resolve(dir, a));
|
|
845
|
+
}
|
|
846
|
+
else {
|
|
847
|
+
targets = detectModuleRoots(dir);
|
|
548
848
|
}
|
|
549
849
|
let planted = 0;
|
|
550
850
|
for (const target of targets) {
|
|
@@ -552,16 +852,79 @@ export async function shadow(args, _flags) {
|
|
|
552
852
|
if (existsSync(file))
|
|
553
853
|
continue;
|
|
554
854
|
writeFileSync(file, SHADOW_CONTENT);
|
|
555
|
-
console.log(` ${S.ok} ${path.relative(dir, file)}`);
|
|
855
|
+
console.log(` ${S.ok} ${path.relative(dir, file) || "Polycast+Shadow.swift"}`);
|
|
556
856
|
planted++;
|
|
557
857
|
}
|
|
558
858
|
if (planted === 0) {
|
|
559
|
-
note("all
|
|
859
|
+
note("all modules already shadowed");
|
|
560
860
|
return;
|
|
561
861
|
}
|
|
562
|
-
success(`planted ${planted} shadow file(s) — plain Text now renders OTA
|
|
862
|
+
success(`planted ${planted} shadow file(s) — plain Text now renders OTA`);
|
|
863
|
+
note("Xcode app targets: make sure the file is part of the target (drag into the navigator if needed)");
|
|
563
864
|
note("using XcodeGen? re-run `xcodegen generate` so the new files join the project");
|
|
564
865
|
}
|
|
866
|
+
/** One root per Swift module. */
|
|
867
|
+
function detectModuleRoots(root) {
|
|
868
|
+
const swiftDirs = [];
|
|
869
|
+
const packageDirs = [];
|
|
870
|
+
const walk = (d, depth) => {
|
|
871
|
+
if (depth > 6)
|
|
872
|
+
return;
|
|
873
|
+
let entries;
|
|
874
|
+
try {
|
|
875
|
+
entries = readdirSync(d);
|
|
876
|
+
}
|
|
877
|
+
catch {
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
if (entries.includes("Package.swift") && d !== root)
|
|
881
|
+
packageDirs.push(d);
|
|
882
|
+
for (const name of entries) {
|
|
883
|
+
if (name.startsWith(".") || ["node_modules", "Pods", ".build", "build", "DerivedData"].includes(name) || name.endsWith(".xcodeproj"))
|
|
884
|
+
continue;
|
|
885
|
+
const full = path.join(d, name);
|
|
886
|
+
try {
|
|
887
|
+
if (statSync(full).isDirectory())
|
|
888
|
+
walk(full, depth + 1);
|
|
889
|
+
else if (name.endsWith(".swift") && !name.startsWith("Polycast+") && name !== "Package.swift")
|
|
890
|
+
swiftDirs.push(d);
|
|
891
|
+
}
|
|
892
|
+
catch { /* skip */ }
|
|
893
|
+
}
|
|
894
|
+
};
|
|
895
|
+
walk(root, 0);
|
|
896
|
+
if (existsSync(path.join(root, "Package.swift")))
|
|
897
|
+
packageDirs.push(root);
|
|
898
|
+
const roots = new Set();
|
|
899
|
+
const inPackage = (d) => packageDirs.find((p) => d === p || d.startsWith(p + path.sep));
|
|
900
|
+
// SPM: one module per Sources/<target> directory.
|
|
901
|
+
for (const pkg of packageDirs) {
|
|
902
|
+
const sources = path.join(pkg, "Sources");
|
|
903
|
+
if (existsSync(sources)) {
|
|
904
|
+
for (const t of readdirSync(sources)) {
|
|
905
|
+
const td = path.join(sources, t);
|
|
906
|
+
try {
|
|
907
|
+
if (statSync(td).isDirectory())
|
|
908
|
+
roots.add(td);
|
|
909
|
+
}
|
|
910
|
+
catch { /* skip */ }
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
// Xcode app (everything outside package boundaries): ONE module — the
|
|
915
|
+
// common ancestor of its Swift dirs.
|
|
916
|
+
const appDirs = [...new Set(swiftDirs)].filter((d) => !inPackage(d));
|
|
917
|
+
if (appDirs.length > 0) {
|
|
918
|
+
let common = appDirs[0];
|
|
919
|
+
for (const d of appDirs) {
|
|
920
|
+
while (!(d === common || d.startsWith(common + path.sep))) {
|
|
921
|
+
common = path.dirname(common);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
roots.add(common);
|
|
925
|
+
}
|
|
926
|
+
return [...roots];
|
|
927
|
+
}
|
|
565
928
|
// ---- gen ----
|
|
566
929
|
function swiftIdent(part) {
|
|
567
930
|
const cleaned = part.replace(/[^A-Za-z0-9]+(.)?/g, (_, c) => c ? c.toUpperCase() : "");
|