glotfile 0.7.3 → 0.8.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.
@@ -4186,592 +4186,168 @@ var init_scanner = __esm({
4186
4186
  }
4187
4187
  });
4188
4188
 
4189
- // src/server/spell.ts
4190
- function spellTokens(value) {
4191
- return value.replace(ICU_BLOCK, " ").replace(MASK, " ").match(WORD) ?? [];
4192
- }
4193
- function ignoreWordsFor(glossary, customWords = []) {
4194
- const set = /* @__PURE__ */ new Set();
4195
- const add = (text) => {
4196
- for (const w of text.match(WORD) ?? []) set.add(w.toLowerCase());
4197
- };
4198
- for (const e of glossary) {
4199
- add(e.term);
4200
- for (const t of Object.values(e.translations ?? {})) add(t);
4189
+ // src/server/import/detect.ts
4190
+ import { existsSync as existsSync11, readdirSync as readdirSync4, readFileSync as readFileSync12, statSync as statSync3 } from "fs";
4191
+ import { join as join6 } from "path";
4192
+ function safeIsDir(p) {
4193
+ try {
4194
+ return statSync3(p).isDirectory();
4195
+ } catch {
4196
+ return false;
4201
4197
  }
4202
- for (const w of customWords) add(w);
4203
- return set;
4204
4198
  }
4205
- async function getSpeller(dictId) {
4206
- const key = norm(dictId);
4207
- const existing = instances.get(key);
4208
- if (existing) return existing;
4209
- if (unavailable.has(key)) return null;
4199
+ function listDirs(dir) {
4200
+ return readdirSync4(dir).filter((e) => safeIsDir(join6(dir, e)));
4201
+ }
4202
+ function fileCount(dir) {
4210
4203
  try {
4211
- const nspellMod = await import("nspell");
4212
- const nspell = nspellMod.default ?? nspellMod;
4213
- const dictMod = await import(`dictionary-${key}`);
4214
- const dictExport = dictMod.default ?? dictMod;
4215
- const dict = typeof dictExport === "function" ? await dictExport() : dictExport;
4216
- const speller = nspell(dict);
4217
- instances.set(key, speller);
4218
- return speller;
4204
+ return readdirSync4(dir).length;
4219
4205
  } catch {
4220
- unavailable.add(key);
4221
- return null;
4222
- } finally {
4223
- loading.delete(key);
4206
+ return 0;
4224
4207
  }
4225
4208
  }
4226
- function spellValue(dictId, value, ignore) {
4227
- const key = norm(dictId);
4228
- if (unavailable.has(key)) return [];
4229
- const spell = instances.get(key);
4230
- if (!spell) {
4231
- if (!loading.has(key)) {
4232
- loading.add(key);
4233
- void getSpeller(key);
4209
+ function pickSource(locales, sizeOf) {
4210
+ if (locales.includes("en")) return "en";
4211
+ return [...locales].sort((a, b) => sizeOf(b) - sizeOf(a) || a.localeCompare(b))[0] ?? "en";
4212
+ }
4213
+ function detectLaravel(root) {
4214
+ const localeRoot = [join6(root, "resources", "lang"), join6(root, "lang")].find(safeIsDir);
4215
+ if (!localeRoot) return null;
4216
+ const locales = listDirs(localeRoot).filter((d) => LOCALE_RE.test(d));
4217
+ if (locales.length === 0) return null;
4218
+ const sourceLocale = pickSource(locales, (loc) => fileCount(join6(localeRoot, loc)));
4219
+ return { format: "laravel-php", localeRoot, locales, sourceLocale };
4220
+ }
4221
+ function detectVue(root, forced = false) {
4222
+ for (const rel of VUE_DIR_CANDIDATES) {
4223
+ const localeRoot = join6(root, rel);
4224
+ if (!safeIsDir(localeRoot)) continue;
4225
+ const locales = readdirSync4(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
4226
+ const enough = locales.length >= 2 || locales.length === 1 && (forced || locales[0] === "en" || locales[0].startsWith("en-") || locales[0].startsWith("en_"));
4227
+ if (enough) {
4228
+ const sourceLocale = pickSource(locales, (loc) => {
4229
+ try {
4230
+ return statSync3(join6(localeRoot, `${loc}.json`)).size;
4231
+ } catch {
4232
+ return 0;
4233
+ }
4234
+ });
4235
+ return { format: "vue-i18n-json", localeRoot, locales, sourceLocale };
4234
4236
  }
4235
- return null;
4236
4237
  }
4237
- const cacheKey = key + " " + value;
4238
- let allBad = cache.get(cacheKey);
4239
- if (!allBad) {
4240
- allBad = spellTokens(value).filter((w) => !spell.correct(w));
4241
- cache.set(cacheKey, allBad);
4242
- }
4243
- return allBad.filter((w) => !ignore.has(w.toLowerCase()));
4238
+ return null;
4244
4239
  }
4245
- var instances, loading, unavailable, cache, norm, ICU_BLOCK, MASK, WORD;
4246
- var init_spell = __esm({
4247
- "src/server/spell.ts"() {
4248
- "use strict";
4249
- instances = /* @__PURE__ */ new Map();
4250
- loading = /* @__PURE__ */ new Set();
4251
- unavailable = /* @__PURE__ */ new Set();
4252
- cache = /* @__PURE__ */ new Map();
4253
- norm = (dictId) => dictId.toLowerCase();
4254
- ICU_BLOCK = /\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g;
4255
- MASK = /\{[^}]*\}|<[^>]*>|:\w+|%[sd]/g;
4256
- WORD = new RegExp("\\p{L}[\\p{L}''\u2019-]*", "gu");
4240
+ function detectArb(root) {
4241
+ for (const rel of ["lib/l10n", "l10n", "lib/src/l10n"]) {
4242
+ const localeRoot = join6(root, rel);
4243
+ if (!safeIsDir(localeRoot)) continue;
4244
+ const locales = readdirSync4(localeRoot).map((f) => f.match(/^(?:app_)?(.+)\.arb$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l));
4245
+ if (locales.length >= 1) {
4246
+ return { format: "flutter-arb", localeRoot, locales, sourceLocale: pickSource(locales, () => 0) };
4247
+ }
4257
4248
  }
4258
- });
4259
-
4260
- // src/server/lint/spelling.ts
4261
- var spellingRule, defaultLoader;
4262
- var init_spelling = __esm({
4263
- "src/server/lint/spelling.ts"() {
4264
- "use strict";
4265
- init_spell();
4266
- spellingRule = {
4267
- id: "spelling",
4268
- run(state, ctx) {
4269
- const out = [];
4270
- for (const key of Object.keys(state.keys)) {
4271
- const entry = state.keys[key];
4272
- for (const locale of ctx.targetLocales) {
4273
- const speller = ctx.spellers.get(locale);
4274
- if (!speller) continue;
4275
- const value = entry.values[locale]?.value;
4276
- if (!value) continue;
4277
- for (const word of spellTokens(value)) {
4278
- if (ctx.allowWords.has(word.toLowerCase())) continue;
4279
- if (!speller.correct(word)) {
4280
- out.push({ ruleId: "spelling", key, locale, message: `possible misspelling: "${word}"` });
4281
- }
4282
- }
4249
+ return null;
4250
+ }
4251
+ function lprojLocales(dir) {
4252
+ return listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync11(join6(dir, `${l}.lproj`, "Localizable.strings")));
4253
+ }
4254
+ function detectApple(root) {
4255
+ const candidates = [root, ...listDirs(root).map((d) => join6(root, d))];
4256
+ let best = null;
4257
+ for (const dir of candidates) {
4258
+ const locales = lprojLocales(dir);
4259
+ if (locales.length === 0) continue;
4260
+ if (!best || locales.length > best.locales.length) {
4261
+ best = {
4262
+ format: "apple-strings",
4263
+ localeRoot: dir,
4264
+ locales,
4265
+ sourceLocale: pickSource(locales, (loc) => {
4266
+ try {
4267
+ return statSync3(join6(dir, `${loc}.lproj`, "Localizable.strings")).size;
4268
+ } catch {
4269
+ return 0;
4283
4270
  }
4284
- }
4285
- return out;
4271
+ })
4272
+ };
4273
+ }
4274
+ }
4275
+ return best;
4276
+ }
4277
+ function detectAngularXliff(root) {
4278
+ for (const rel of ANGULAR_DIR_CANDIDATES) {
4279
+ const localeRoot = rel === "." ? root : join6(root, rel);
4280
+ if (!safeIsDir(localeRoot)) continue;
4281
+ const files = readdirSync4(localeRoot).filter((f) => /^messages(\..+)?\.xlf$/.test(f)).sort();
4282
+ if (files.length === 0) continue;
4283
+ const locales = files.map((f) => f.match(/^messages\.(.+)\.xlf$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l));
4284
+ const attrFile = files.includes("messages.xlf") ? "messages.xlf" : files[0];
4285
+ let sourceLocale;
4286
+ try {
4287
+ sourceLocale = readFileSync12(join6(localeRoot, attrFile), "utf8").match(/source-language="([^"]+)"/)?.[1];
4288
+ } catch {
4289
+ }
4290
+ if (!sourceLocale && locales.length === 0) continue;
4291
+ sourceLocale ??= pickSource(locales, () => 0);
4292
+ if (!locales.includes(sourceLocale)) locales.unshift(sourceLocale);
4293
+ return { format: "angular-xliff", localeRoot, locales, sourceLocale };
4294
+ }
4295
+ return null;
4296
+ }
4297
+ function detectRails(root) {
4298
+ const localeRoot = join6(root, "config", "locales");
4299
+ if (!safeIsDir(localeRoot)) return null;
4300
+ const locales = [];
4301
+ for (const file of readdirSync4(localeRoot).sort()) {
4302
+ if (!/\.ya?ml$/.test(file)) continue;
4303
+ let text;
4304
+ try {
4305
+ text = readFileSync12(join6(localeRoot, file), "utf8");
4306
+ } catch {
4307
+ continue;
4308
+ }
4309
+ for (const m of text.matchAll(/^(["']?)([A-Za-z][\w-]*)\1:\s*(?:#.*)?$/gm)) {
4310
+ const token = m[2];
4311
+ if (LOCALE_RE.test(token) && !locales.includes(token)) locales.push(token);
4312
+ }
4313
+ }
4314
+ if (locales.length === 0) return null;
4315
+ return { format: "rails-yaml", localeRoot, locales, sourceLocale: pickSource(locales, () => 0) };
4316
+ }
4317
+ function detectI18next(root) {
4318
+ for (const rel of I18NEXT_DIR_CANDIDATES) {
4319
+ const localeRoot = join6(root, rel);
4320
+ if (!safeIsDir(localeRoot)) continue;
4321
+ const locales = listDirs(localeRoot).filter(
4322
+ (d) => LOCALE_RE.test(d) && readdirSync4(join6(localeRoot, d)).some((f) => f.endsWith(".json"))
4323
+ );
4324
+ if (locales.length === 0) continue;
4325
+ const sourceLocale = pickSource(locales, (loc) => {
4326
+ try {
4327
+ return readdirSync4(join6(localeRoot, loc)).filter((f) => f.endsWith(".json")).reduce((sum, f) => sum + statSync3(join6(localeRoot, loc, f)).size, 0);
4328
+ } catch {
4329
+ return 0;
4286
4330
  }
4287
- };
4288
- defaultLoader = (dictId) => getSpeller(dictId);
4331
+ });
4332
+ return { format: "i18next-json", localeRoot, locales, sourceLocale };
4289
4333
  }
4290
- });
4291
-
4292
- // src/server/lint/rules.ts
4293
- var emptySourceRule, emptyTranslationRule, identicalToSourceRule, whitespaceRule, placeholderMismatchRule, icuMismatchRule, maxLengthRule, glossaryViolationRule, ALL_RULES;
4294
- var init_rules = __esm({
4295
- "src/server/lint/rules.ts"() {
4296
- "use strict";
4297
- init_scan();
4298
- init_placeholders();
4299
- init_glossary();
4300
- init_spelling();
4301
- emptySourceRule = {
4302
- id: "empty-source",
4303
- run(state, ctx) {
4304
- const out = [];
4305
- for (const key of Object.keys(state.keys)) {
4306
- const entry = state.keys[key];
4307
- const v = entry.plural ? entry.values[ctx.sourceLocale]?.forms?.other : entry.values[ctx.sourceLocale]?.value;
4308
- if (!v || !v.trim()) out.push({ ruleId: "empty-source", key, locale: "", message: "source value is empty" });
4309
- }
4310
- return out;
4311
- }
4312
- };
4313
- emptyTranslationRule = {
4314
- id: "empty-translation",
4315
- // findMissing is the shared "untranslated" walk (also behind the editor's
4316
- // untranslated check and /scan/missing); a whitespace-only value counts as
4317
- // missing there, so no separate whitespace pass is needed.
4318
- run(state) {
4319
- const out = [];
4320
- for (const m of findMissing(state)) {
4321
- out.push({ ruleId: "empty-translation", key: m.key, locale: m.locale, message: "translation is empty or missing" });
4322
- }
4323
- return out;
4324
- }
4325
- };
4326
- identicalToSourceRule = {
4327
- id: "identical-to-source",
4328
- run(state, ctx) {
4329
- const out = [];
4330
- for (const key of Object.keys(state.keys)) {
4331
- const entry = state.keys[key];
4332
- if (entry.skipTranslate) continue;
4333
- const src = entry.values[ctx.sourceLocale]?.value;
4334
- if (!src) continue;
4335
- for (const locale of ctx.targetLocales) {
4336
- const v = entry.values[locale]?.value;
4337
- if (v && v === src) out.push({ ruleId: "identical-to-source", key, locale, message: "translation is identical to the source" });
4338
- }
4339
- }
4340
- return out;
4341
- }
4342
- };
4343
- whitespaceRule = {
4344
- id: "whitespace",
4345
- run(state, ctx) {
4346
- const out = [];
4347
- for (const key of Object.keys(state.keys)) {
4348
- const entry = state.keys[key];
4349
- const src = entry.values[ctx.sourceLocale]?.value ?? "";
4350
- const srcEdge = src !== src.trim();
4351
- for (const locale of ctx.targetLocales) {
4352
- const v = entry.values[locale]?.value;
4353
- if (!v) continue;
4354
- if (v !== v.trim() !== srcEdge) {
4355
- out.push({ ruleId: "whitespace", key, locale, message: "leading/trailing whitespace differs from the source" });
4356
- }
4357
- }
4358
- }
4359
- return out;
4360
- }
4361
- };
4362
- placeholderMismatchRule = {
4363
- id: "placeholder-mismatch",
4364
- run(state, ctx) {
4365
- const out = [];
4366
- for (const key of Object.keys(state.keys)) {
4367
- const entry = state.keys[key];
4368
- if (entry.plural) {
4369
- const srcForm = entry.values[ctx.sourceLocale]?.forms?.other;
4370
- if (!srcForm) continue;
4371
- for (const locale of ctx.targetLocales) {
4372
- const forms = entry.values[locale]?.forms;
4373
- if (!forms) continue;
4374
- const bad = Object.entries(forms).some(
4375
- ([cat, form]) => form && !pluralFormPlaceholdersMatch(cat, srcForm, form)
4376
- );
4377
- if (bad) {
4378
- out.push({ ruleId: "placeholder-mismatch", key, locale, message: "placeholders differ from the source" });
4379
- }
4380
- }
4381
- continue;
4382
- }
4383
- const src = entry.values[ctx.sourceLocale]?.value;
4384
- if (!src) continue;
4385
- for (const locale of ctx.targetLocales) {
4386
- const v = entry.values[locale]?.value;
4387
- if (!v) continue;
4388
- if (!placeholdersMatch(src, v)) {
4389
- out.push({ ruleId: "placeholder-mismatch", key, locale, message: "placeholders differ from the source" });
4390
- }
4391
- }
4392
- }
4393
- return out;
4394
- }
4395
- };
4396
- icuMismatchRule = {
4397
- id: "icu-mismatch",
4398
- run(state, ctx) {
4399
- const out = [];
4400
- for (const key of Object.keys(state.keys)) {
4401
- const entry = state.keys[key];
4402
- const src = entry.values[ctx.sourceLocale]?.value;
4403
- if (!src) continue;
4404
- const srcIcu = isIcuPluralOrSelect(src);
4405
- for (const locale of ctx.targetLocales) {
4406
- const v = entry.values[locale]?.value;
4407
- if (!v) continue;
4408
- if (isIcuPluralOrSelect(v) !== srcIcu) {
4409
- out.push({
4410
- ruleId: "icu-mismatch",
4411
- key,
4412
- locale,
4413
- message: srcIcu ? "source is an ICU plural/select but the translation is not" : "translation is an ICU plural/select but the source is not"
4414
- });
4415
- }
4416
- }
4417
- }
4418
- return out;
4419
- }
4420
- };
4421
- maxLengthRule = {
4422
- id: "max-length",
4423
- run(state, ctx) {
4424
- const out = [];
4425
- for (const key of Object.keys(state.keys)) {
4426
- const entry = state.keys[key];
4427
- const max = entry.maxLength;
4428
- if (max == null) continue;
4429
- for (const locale of ctx.targetLocales) {
4430
- const v = entry.values[locale]?.value;
4431
- if (v && v.length > max) {
4432
- out.push({ ruleId: "max-length", key, locale, message: `length ${v.length} exceeds maxLength ${max}` });
4433
- }
4434
- }
4435
- }
4436
- return out;
4437
- }
4438
- };
4439
- glossaryViolationRule = {
4440
- id: "glossary-violation",
4441
- run(state, ctx) {
4442
- const out = [];
4443
- for (const key of Object.keys(state.keys)) {
4444
- const entry = state.keys[key];
4445
- const src = entry.values[ctx.sourceLocale]?.value;
4446
- if (!src) continue;
4447
- for (const locale of ctx.targetLocales) {
4448
- const v = entry.values[locale]?.value;
4449
- if (!v) continue;
4450
- for (const viol of glossaryViolations(src, v, locale, ctx.glossary)) {
4451
- out.push({
4452
- ruleId: "glossary-violation",
4453
- key,
4454
- locale,
4455
- message: viol.kind === "do-not-translate" ? `do-not-translate term "${viol.term}" is missing or altered` : `expected glossary translation "${viol.expected}" for "${viol.term}"`
4456
- });
4457
- }
4458
- }
4459
- }
4460
- return out;
4461
- }
4462
- };
4463
- ALL_RULES = [
4464
- emptySourceRule,
4465
- emptyTranslationRule,
4466
- placeholderMismatchRule,
4467
- icuMismatchRule,
4468
- glossaryViolationRule,
4469
- maxLengthRule,
4470
- identicalToSourceRule,
4471
- whitespaceRule,
4472
- spellingRule
4473
- ];
4474
- }
4475
- });
4476
-
4477
- // src/server/lint/run.ts
4478
- function resolveSeverity(id, config) {
4479
- return config.rules?.[id] ?? DEFAULT_SEVERITY[id];
4480
- }
4481
- function sortFindings(findings) {
4482
- return [...findings].sort(
4483
- (a, b) => a.key.localeCompare(b.key) || a.locale.localeCompare(b.locale) || a.ruleId.localeCompare(b.ruleId)
4484
- );
4485
- }
4486
- function countSeverities(findings) {
4487
- let error = 0, warn = 0;
4488
- for (const f of findings) {
4489
- if (f.suppressed) continue;
4490
- f.severity === "error" ? error++ : warn++;
4491
- }
4492
- return { error, warn };
4493
- }
4494
- async function loadSpellers(locales, config, load, warn) {
4495
- const map = /* @__PURE__ */ new Map();
4496
- for (const locale of locales) {
4497
- const dictId = config.spelling?.locales?.[locale] ?? locale;
4498
- const speller = await load(dictId);
4499
- if (speller) map.set(locale, speller);
4500
- else warn(`no dictionary for "${locale}", skipping spelling`);
4501
- }
4502
- return map;
4503
- }
4504
- async function runLint(state, options = {}) {
4505
- const config = state.config.lint ?? {};
4506
- const rules = options.rules ?? ALL_RULES;
4507
- const warn = options.warn ?? ((m) => console.warn(m));
4508
- const load = options.loadSpeller ?? defaultLoader;
4509
- const targetLocales = state.config.locales.filter((l) => l !== state.config.sourceLocale);
4510
- const isActive = (rule) => {
4511
- if (options.ruleIds && !options.ruleIds.includes(rule.id)) return false;
4512
- return resolveSeverity(rule.id, config) !== "off";
4513
- };
4514
- const active = rules.filter(isActive);
4515
- const spellingOn = active.some((r) => r.id === "spelling");
4516
- const spellers = spellingOn ? await loadSpellers(targetLocales, config, load, warn) : /* @__PURE__ */ new Map();
4517
- const allowWords = spellingOn ? ignoreWordsFor(state.glossary, state.config.spelling?.customWords) : /* @__PURE__ */ new Set();
4518
- const ctx = {
4519
- config,
4520
- sourceLocale: state.config.sourceLocale,
4521
- targetLocales,
4522
- glossary: state.glossary,
4523
- spellers,
4524
- allowWords
4525
- };
4526
- const ignoreRes = (config.ignore ?? []).map(globToRegExp);
4527
- const localeFilter = options.locales ? new Set(options.locales) : null;
4528
- const findings = [];
4529
- let suppressed = 0;
4530
- for (const rule of active) {
4531
- const severity = resolveSeverity(rule.id, config);
4532
- for (const raw of rule.run(state, ctx)) {
4533
- if (ignoreRes.some((re) => re.test(raw.key))) continue;
4534
- if (localeFilter && raw.locale !== "" && !localeFilter.has(raw.locale)) continue;
4535
- const entry = state.keys[raw.key];
4536
- if (raw.locale !== "" && entry && findSuppression(entry, state.config.sourceLocale, rule.id, raw.locale)) {
4537
- suppressed++;
4538
- if (options.includeSuppressed) findings.push({ ...raw, severity, suppressed: true });
4539
- continue;
4540
- }
4541
- findings.push({ ...raw, severity });
4542
- }
4543
- }
4544
- const sorted = sortFindings(findings);
4545
- const counts = { ...countSeverities(sorted), suppressed };
4546
- return { findings: sorted, counts, ok: counts.error === 0 };
4547
- }
4548
- var init_run2 = __esm({
4549
- "src/server/lint/run.ts"() {
4550
- "use strict";
4551
- init_glob();
4552
- init_registry();
4553
- init_rules();
4554
- init_spelling();
4555
- init_spell();
4556
- init_suppress();
4557
- }
4558
- });
4559
-
4560
- // src/server/lint/outputs.ts
4561
- import { readFileSync as readFileSync12, existsSync as existsSync11 } from "fs";
4562
- import { resolve as resolve8 } from "path";
4563
- function checkOutputs(state, root) {
4564
- const out = [];
4565
- for (const output of state.config.outputs) {
4566
- const result = getAdapter(output.adapter).export(state, output);
4567
- for (const file of result.files) {
4568
- const abs = resolve8(root, file.path);
4569
- const current = existsSync11(abs) ? readFileSync12(abs, "utf8") : null;
4570
- if (current === null) {
4571
- out.push({ ruleId: "output-stale", key: file.path, locale: "", severity: "error", message: "output file is missing; run `glotfile export`" });
4572
- } else if (current !== file.contents) {
4573
- out.push({ ruleId: "output-stale", key: file.path, locale: "", severity: "error", message: "output file is out of date; run `glotfile export`" });
4574
- }
4575
- }
4576
- }
4577
- return out;
4578
- }
4579
- var init_outputs = __esm({
4580
- "src/server/lint/outputs.ts"() {
4581
- "use strict";
4582
- init_adapters();
4583
- }
4584
- });
4585
-
4586
- // src/server/lint/accept.ts
4587
- var accept_exports = {};
4588
- __export(accept_exports, {
4589
- acceptFindings: () => acceptFindings
4590
- });
4591
- function acceptFindings(state, findings, opts = {}, clock = systemClock) {
4592
- const byRule = {};
4593
- let accepted = 0;
4594
- for (const f of findings) {
4595
- if (f.locale === "" || f.suppressed) continue;
4596
- if (f.severity === "error" && !opts.includeErrors) continue;
4597
- if (opts.rules && !opts.rules.includes(f.ruleId)) continue;
4598
- if (opts.locales && !opts.locales.includes(f.locale)) continue;
4599
- if (!state.keys[f.key]) continue;
4600
- addSuppression(state, f.key, f.ruleId, f.locale, clock);
4601
- byRule[f.ruleId] = (byRule[f.ruleId] ?? 0) + 1;
4602
- accepted++;
4603
- }
4604
- return { accepted, byRule };
4605
- }
4606
- var init_accept = __esm({
4607
- "src/server/lint/accept.ts"() {
4608
- "use strict";
4609
- init_state();
4610
- }
4611
- });
4612
-
4613
- // src/server/import/detect.ts
4614
- import { existsSync as existsSync12, readdirSync as readdirSync4, readFileSync as readFileSync13, statSync as statSync3 } from "fs";
4615
- import { join as join6 } from "path";
4616
- function safeIsDir(p) {
4617
- try {
4618
- return statSync3(p).isDirectory();
4619
- } catch {
4620
- return false;
4621
- }
4622
- }
4623
- function listDirs(dir) {
4624
- return readdirSync4(dir).filter((e) => safeIsDir(join6(dir, e)));
4625
- }
4626
- function fileCount(dir) {
4627
- try {
4628
- return readdirSync4(dir).length;
4629
- } catch {
4630
- return 0;
4631
- }
4632
- }
4633
- function pickSource(locales, sizeOf) {
4634
- if (locales.includes("en")) return "en";
4635
- return [...locales].sort((a, b) => sizeOf(b) - sizeOf(a) || a.localeCompare(b))[0] ?? "en";
4636
- }
4637
- function detectLaravel(root) {
4638
- const localeRoot = [join6(root, "resources", "lang"), join6(root, "lang")].find(safeIsDir);
4639
- if (!localeRoot) return null;
4640
- const locales = listDirs(localeRoot).filter((d) => LOCALE_RE.test(d));
4641
- if (locales.length === 0) return null;
4642
- const sourceLocale = pickSource(locales, (loc) => fileCount(join6(localeRoot, loc)));
4643
- return { format: "laravel-php", localeRoot, locales, sourceLocale };
4644
- }
4645
- function detectVue(root, forced = false) {
4646
- for (const rel of VUE_DIR_CANDIDATES) {
4647
- const localeRoot = join6(root, rel);
4648
- if (!safeIsDir(localeRoot)) continue;
4649
- const locales = readdirSync4(localeRoot).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)).filter((l) => LOCALE_RE.test(l));
4650
- const enough = locales.length >= 2 || locales.length === 1 && (forced || locales[0] === "en" || locales[0].startsWith("en-") || locales[0].startsWith("en_"));
4651
- if (enough) {
4652
- const sourceLocale = pickSource(locales, (loc) => {
4653
- try {
4654
- return statSync3(join6(localeRoot, `${loc}.json`)).size;
4655
- } catch {
4656
- return 0;
4657
- }
4658
- });
4659
- return { format: "vue-i18n-json", localeRoot, locales, sourceLocale };
4660
- }
4661
- }
4662
- return null;
4663
- }
4664
- function detectArb(root) {
4665
- for (const rel of ["lib/l10n", "l10n", "lib/src/l10n"]) {
4666
- const localeRoot = join6(root, rel);
4667
- if (!safeIsDir(localeRoot)) continue;
4668
- const locales = readdirSync4(localeRoot).map((f) => f.match(/^(?:app_)?(.+)\.arb$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l));
4669
- if (locales.length >= 1) {
4670
- return { format: "flutter-arb", localeRoot, locales, sourceLocale: pickSource(locales, () => 0) };
4671
- }
4672
- }
4673
- return null;
4674
- }
4675
- function lprojLocales(dir) {
4676
- return listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync12(join6(dir, `${l}.lproj`, "Localizable.strings")));
4677
- }
4678
- function detectApple(root) {
4679
- const candidates = [root, ...listDirs(root).map((d) => join6(root, d))];
4680
- let best = null;
4681
- for (const dir of candidates) {
4682
- const locales = lprojLocales(dir);
4683
- if (locales.length === 0) continue;
4684
- if (!best || locales.length > best.locales.length) {
4685
- best = {
4686
- format: "apple-strings",
4687
- localeRoot: dir,
4688
- locales,
4689
- sourceLocale: pickSource(locales, (loc) => {
4690
- try {
4691
- return statSync3(join6(dir, `${loc}.lproj`, "Localizable.strings")).size;
4692
- } catch {
4693
- return 0;
4694
- }
4695
- })
4696
- };
4697
- }
4698
- }
4699
- return best;
4700
- }
4701
- function detectAngularXliff(root) {
4702
- for (const rel of ANGULAR_DIR_CANDIDATES) {
4703
- const localeRoot = rel === "." ? root : join6(root, rel);
4704
- if (!safeIsDir(localeRoot)) continue;
4705
- const files = readdirSync4(localeRoot).filter((f) => /^messages(\..+)?\.xlf$/.test(f)).sort();
4706
- if (files.length === 0) continue;
4707
- const locales = files.map((f) => f.match(/^messages\.(.+)\.xlf$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l));
4708
- const attrFile = files.includes("messages.xlf") ? "messages.xlf" : files[0];
4709
- let sourceLocale;
4710
- try {
4711
- sourceLocale = readFileSync13(join6(localeRoot, attrFile), "utf8").match(/source-language="([^"]+)"/)?.[1];
4712
- } catch {
4713
- }
4714
- if (!sourceLocale && locales.length === 0) continue;
4715
- sourceLocale ??= pickSource(locales, () => 0);
4716
- if (!locales.includes(sourceLocale)) locales.unshift(sourceLocale);
4717
- return { format: "angular-xliff", localeRoot, locales, sourceLocale };
4718
- }
4719
- return null;
4720
- }
4721
- function detectRails(root) {
4722
- const localeRoot = join6(root, "config", "locales");
4723
- if (!safeIsDir(localeRoot)) return null;
4724
- const locales = [];
4725
- for (const file of readdirSync4(localeRoot).sort()) {
4726
- if (!/\.ya?ml$/.test(file)) continue;
4727
- let text;
4728
- try {
4729
- text = readFileSync13(join6(localeRoot, file), "utf8");
4730
- } catch {
4731
- continue;
4732
- }
4733
- for (const m of text.matchAll(/^(["']?)([A-Za-z][\w-]*)\1:\s*(?:#.*)?$/gm)) {
4734
- const token = m[2];
4735
- if (LOCALE_RE.test(token) && !locales.includes(token)) locales.push(token);
4736
- }
4737
- }
4738
- if (locales.length === 0) return null;
4739
- return { format: "rails-yaml", localeRoot, locales, sourceLocale: pickSource(locales, () => 0) };
4740
- }
4741
- function detectI18next(root) {
4742
- for (const rel of I18NEXT_DIR_CANDIDATES) {
4743
- const localeRoot = join6(root, rel);
4744
- if (!safeIsDir(localeRoot)) continue;
4745
- const locales = listDirs(localeRoot).filter(
4746
- (d) => LOCALE_RE.test(d) && readdirSync4(join6(localeRoot, d)).some((f) => f.endsWith(".json"))
4747
- );
4748
- if (locales.length === 0) continue;
4749
- const sourceLocale = pickSource(locales, (loc) => {
4750
- try {
4751
- return readdirSync4(join6(localeRoot, loc)).filter((f) => f.endsWith(".json")).reduce((sum, f) => sum + statSync3(join6(localeRoot, loc, f)).size, 0);
4752
- } catch {
4753
- return 0;
4754
- }
4755
- });
4756
- return { format: "i18next-json", localeRoot, locales, sourceLocale };
4757
- }
4758
- return null;
4759
- }
4760
- function gettextLocales(dir) {
4761
- const locales = [];
4762
- for (const entry of readdirSync4(dir).sort()) {
4763
- const flat = entry.match(/^(.+)\.po$/)?.[1];
4764
- if (flat && LOCALE_RE.test(flat)) {
4765
- if (!locales.includes(flat)) locales.push(flat);
4766
- continue;
4767
- }
4768
- if (!LOCALE_RE.test(entry) || !safeIsDir(join6(dir, entry))) continue;
4769
- const sub = join6(dir, entry);
4770
- const hasPo = (d) => {
4771
- try {
4772
- return readdirSync4(d).some((f) => f.endsWith(".po"));
4773
- } catch {
4774
- return false;
4334
+ return null;
4335
+ }
4336
+ function gettextLocales(dir) {
4337
+ const locales = [];
4338
+ for (const entry of readdirSync4(dir).sort()) {
4339
+ const flat = entry.match(/^(.+)\.po$/)?.[1];
4340
+ if (flat && LOCALE_RE.test(flat)) {
4341
+ if (!locales.includes(flat)) locales.push(flat);
4342
+ continue;
4343
+ }
4344
+ if (!LOCALE_RE.test(entry) || !safeIsDir(join6(dir, entry))) continue;
4345
+ const sub = join6(dir, entry);
4346
+ const hasPo = (d) => {
4347
+ try {
4348
+ return readdirSync4(d).some((f) => f.endsWith(".po"));
4349
+ } catch {
4350
+ return false;
4775
4351
  }
4776
4352
  };
4777
4353
  if (hasPo(join6(sub, "LC_MESSAGES")) || hasPo(sub)) {
@@ -4794,7 +4370,7 @@ function detectAppleStringsdict(root) {
4794
4370
  const candidates = [root, ...listDirs(root).map((d) => join6(root, d))];
4795
4371
  let best = null;
4796
4372
  for (const dir of candidates) {
4797
- const locales = listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync12(join6(dir, `${l}.lproj`, "Localizable.stringsdict")));
4373
+ const locales = listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync11(join6(dir, `${l}.lproj`, "Localizable.stringsdict")));
4798
4374
  if (locales.length === 0) continue;
4799
4375
  if (!best || locales.length > best.locales.length) {
4800
4376
  best = { format: "apple-stringsdict", localeRoot: dir, locales, sourceLocale: pickSource(locales, () => 0) };
@@ -4803,7 +4379,7 @@ function detectAppleStringsdict(root) {
4803
4379
  return best;
4804
4380
  }
4805
4381
  function detect(root, formatOverride) {
4806
- if (!existsSync12(root)) return null;
4382
+ if (!existsSync11(root)) return null;
4807
4383
  if (formatOverride) {
4808
4384
  const fn = BY_FORMAT[formatOverride];
4809
4385
  if (!fn) throw new Error(`Unknown format: ${formatOverride}`);
@@ -4877,7 +4453,7 @@ var init_flatten = __esm({
4877
4453
  });
4878
4454
 
4879
4455
  // src/server/import/parsers/vue-i18n-json.ts
4880
- import { readdirSync as readdirSync5, readFileSync as readFileSync14 } from "fs";
4456
+ import { readdirSync as readdirSync5, readFileSync as readFileSync13 } from "fs";
4881
4457
  import { join as join7 } from "path";
4882
4458
  var LOCALE_RE2, vueI18nJson2;
4883
4459
  var init_vue_i18n_json2 = __esm({
@@ -4898,7 +4474,7 @@ var init_vue_i18n_json2 = __esm({
4898
4474
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4899
4475
  let data;
4900
4476
  try {
4901
- data = JSON.parse(readFileSync14(join7(localeRoot, file), "utf8"));
4477
+ data = JSON.parse(readFileSync13(join7(localeRoot, file), "utf8"));
4902
4478
  } catch (e) {
4903
4479
  warnings.push(`vue-i18n-json: failed to parse ${file}: ${e.message}`);
4904
4480
  continue;
@@ -5003,7 +4579,7 @@ var init_laravel_php2 = __esm({
5003
4579
  });
5004
4580
 
5005
4581
  // src/server/import/parsers/flutter-arb.ts
5006
- import { readdirSync as readdirSync7, readFileSync as readFileSync15 } from "fs";
4582
+ import { readdirSync as readdirSync7, readFileSync as readFileSync14 } from "fs";
5007
4583
  import { join as join9 } from "path";
5008
4584
  function localeFromArbName(file) {
5009
4585
  const m = file.match(/^(.+)\.arb$/);
@@ -5044,7 +4620,7 @@ var init_flutter_arb2 = __esm({
5044
4620
  if (opts?.locales && !opts.locales.includes(locale)) continue;
5045
4621
  let data;
5046
4622
  try {
5047
- data = JSON.parse(readFileSync15(join9(localeRoot, file), "utf8"));
4623
+ data = JSON.parse(readFileSync14(join9(localeRoot, file), "utf8"));
5048
4624
  } catch (e) {
5049
4625
  warnings.push(`flutter-arb: failed to parse ${file}: ${e.message}`);
5050
4626
  continue;
@@ -5071,7 +4647,7 @@ var init_flutter_arb2 = __esm({
5071
4647
  });
5072
4648
 
5073
4649
  // src/server/import/parsers/apple-strings.ts
5074
- import { readdirSync as readdirSync8, readFileSync as readFileSync16, statSync as statSync5 } from "fs";
4650
+ import { readdirSync as readdirSync8, readFileSync as readFileSync15, statSync as statSync5 } from "fs";
5075
4651
  import { join as join10 } from "path";
5076
4652
  function localeFromLproj(dir) {
5077
4653
  const m = dir.match(/^(.+)\.lproj$/);
@@ -5184,7 +4760,7 @@ var init_apple_strings2 = __esm({
5184
4760
  let text;
5185
4761
  try {
5186
4762
  if (!statSync5(file).isFile()) continue;
5187
- text = readFileSync16(file, "utf8");
4763
+ text = readFileSync15(file, "utf8");
5188
4764
  } catch {
5189
4765
  continue;
5190
4766
  }
@@ -5204,7 +4780,7 @@ var init_apple_strings2 = __esm({
5204
4780
  });
5205
4781
 
5206
4782
  // src/server/import/parsers/angular-xliff.ts
5207
- import { readdirSync as readdirSync9, readFileSync as readFileSync17 } from "fs";
4783
+ import { readdirSync as readdirSync9, readFileSync as readFileSync16 } from "fs";
5208
4784
  import { join as join11 } from "path";
5209
4785
  function decodeEntities(s) {
5210
4786
  return s.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16))).replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d))).replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
@@ -5214,6 +4790,23 @@ function parseAttrs(s) {
5214
4790
  for (const m of s.matchAll(/([\w-]+)="([^"]*)"/g)) out[m[1]] = decodeEntities(m[2]);
5215
4791
  return out;
5216
4792
  }
4793
+ function decodeLocations(body) {
4794
+ const out = [];
4795
+ const seen = /* @__PURE__ */ new Set();
4796
+ for (const g of body.matchAll(/<context-group\b[^>]*\bpurpose="location"[^>]*>([\s\S]*?)<\/context-group>/g)) {
4797
+ const inner = g[1];
4798
+ const file = inner.match(/<context\b[^>]*context-type="sourcefile"[^>]*>([\s\S]*?)<\/context>/)?.[1];
4799
+ if (file === void 0) continue;
4800
+ const lineRaw = inner.match(/<context\b[^>]*context-type="linenumber"[^>]*>([\s\S]*?)<\/context>/)?.[1];
4801
+ const decodedFile = decodeEntities(file.trim());
4802
+ const line = lineRaw ? parseInt(lineRaw.trim(), 10) || 1 : 1;
4803
+ const dedup = `${decodedFile}:${line}`;
4804
+ if (seen.has(dedup)) continue;
4805
+ seen.add(dedup);
4806
+ out.push({ file: decodedFile, line });
4807
+ }
4808
+ return out;
4809
+ }
5217
4810
  function decodeInline(raw, addMeta) {
5218
4811
  let out = "";
5219
4812
  let last = 0;
@@ -5257,7 +4850,7 @@ var init_angular_xliff2 = __esm({
5257
4850
  if (fnameLocale !== void 0 && !LOCALE_RE5.test(fnameLocale)) continue;
5258
4851
  let xml;
5259
4852
  try {
5260
- xml = readFileSync17(join11(localeRoot, file), "utf8");
4853
+ xml = readFileSync16(join11(localeRoot, file), "utf8");
5261
4854
  } catch (e) {
5262
4855
  warnings.push(`angular-xliff: failed to read ${file}: ${e.message}`);
5263
4856
  continue;
@@ -5287,9 +4880,263 @@ var init_angular_xliff2 = __esm({
5287
4880
  entry.values[sourceLocale] = decodeInline(src[1], addMeta);
5288
4881
  seen(sourceLocale);
5289
4882
  }
5290
- if (tgt && tgt[2] !== "" && targetLocale !== void 0) {
5291
- entry.values[targetLocale] = decodeInline(tgt[2], addMeta);
5292
- seen(targetLocale);
4883
+ if (tgt && tgt[2] !== "" && targetLocale !== void 0) {
4884
+ entry.values[targetLocale] = decodeInline(tgt[2], addMeta);
4885
+ seen(targetLocale);
4886
+ }
4887
+ if (entry.locations === void 0) {
4888
+ const locs = decodeLocations(body);
4889
+ if (locs.length) entry.locations = locs;
4890
+ }
4891
+ }
4892
+ }
4893
+ return { locales, keys, warnings };
4894
+ }
4895
+ };
4896
+ }
4897
+ });
4898
+
4899
+ // src/server/import/parsers/gettext-po.ts
4900
+ import { readdirSync as readdirSync10, readFileSync as readFileSync17 } from "fs";
4901
+ import { join as join12 } from "path";
4902
+ function unescapePo(s) {
4903
+ return s.replace(
4904
+ /\\([\\"ntr])/g,
4905
+ (_, c) => c === "n" ? "\n" : c === "t" ? " " : c === "r" ? "\r" : c
4906
+ );
4907
+ }
4908
+ function parseEntries(text) {
4909
+ const entries = [];
4910
+ let cur = null;
4911
+ let append = null;
4912
+ const flush = () => {
4913
+ if (cur && cur.msgid !== void 0) entries.push(cur);
4914
+ cur = null;
4915
+ append = null;
4916
+ };
4917
+ for (const line of text.split("\n")) {
4918
+ if (line.trim() === "") {
4919
+ flush();
4920
+ continue;
4921
+ }
4922
+ if (line.startsWith("#")) continue;
4923
+ const m = line.match(DIRECTIVE_RE);
4924
+ if (m) {
4925
+ const kw = m[1];
4926
+ const idx = m[2];
4927
+ const body = unescapePo(m[3]);
4928
+ if (cur && (kw === "msgctxt" || kw === "msgid" && cur.msgid !== void 0)) flush();
4929
+ cur ??= { plurals: /* @__PURE__ */ new Map() };
4930
+ const entry = cur;
4931
+ if (kw === "msgctxt") {
4932
+ entry.msgctxt = body;
4933
+ append = (c) => {
4934
+ entry.msgctxt = (entry.msgctxt ?? "") + c;
4935
+ };
4936
+ } else if (kw === "msgid") {
4937
+ entry.msgid = body;
4938
+ append = (c) => {
4939
+ entry.msgid = (entry.msgid ?? "") + c;
4940
+ };
4941
+ } else if (kw === "msgid_plural") {
4942
+ entry.msgidPlural = body;
4943
+ append = (c) => {
4944
+ entry.msgidPlural = (entry.msgidPlural ?? "") + c;
4945
+ };
4946
+ } else if (idx !== void 0) {
4947
+ const i = Number(idx);
4948
+ entry.plurals.set(i, body);
4949
+ append = (c) => {
4950
+ entry.plurals.set(i, (entry.plurals.get(i) ?? "") + c);
4951
+ };
4952
+ } else {
4953
+ entry.msgstr = body;
4954
+ append = (c) => {
4955
+ entry.msgstr = (entry.msgstr ?? "") + c;
4956
+ };
4957
+ }
4958
+ continue;
4959
+ }
4960
+ const cont = line.match(CONT_RE);
4961
+ if (cont && append) append(unescapePo(cont[1]));
4962
+ }
4963
+ flush();
4964
+ return entries;
4965
+ }
4966
+ function discoverPoFiles(root) {
4967
+ const found = [];
4968
+ const entries = readdirSync10(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
4969
+ for (const e of entries) {
4970
+ if (e.isFile() && e.name.endsWith(".po")) {
4971
+ const base = e.name.slice(0, -3);
4972
+ found.push({ path: join12(root, e.name), rel: e.name, locale: LOCALE_RE6.test(base) ? base : null });
4973
+ } else if (e.isDirectory() && LOCALE_RE6.test(e.name)) {
4974
+ for (const sub of [join12(e.name, "LC_MESSAGES"), e.name]) {
4975
+ let names;
4976
+ try {
4977
+ names = readdirSync10(join12(root, sub)).sort();
4978
+ } catch {
4979
+ continue;
4980
+ }
4981
+ for (const f of names) {
4982
+ if (f.endsWith(".po")) found.push({ path: join12(root, sub, f), rel: join12(sub, f), locale: e.name });
4983
+ }
4984
+ }
4985
+ }
4986
+ }
4987
+ return found;
4988
+ }
4989
+ var LOCALE_RE6, DIRECTIVE_RE, CONT_RE, gettextPo2;
4990
+ var init_gettext_po2 = __esm({
4991
+ "src/server/import/parsers/gettext-po.ts"() {
4992
+ "use strict";
4993
+ init_plurals();
4994
+ LOCALE_RE6 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4995
+ DIRECTIVE_RE = /^(msgctxt|msgid_plural|msgid|msgstr)(?:\[(\d+)\])?[ \t]+"(.*)"\s*$/;
4996
+ CONT_RE = /^[ \t]*"(.*)"\s*$/;
4997
+ gettextPo2 = {
4998
+ name: "gettext-po",
4999
+ parse(localeRoot, opts) {
5000
+ const warnings = [];
5001
+ const keys = {};
5002
+ const locales = [];
5003
+ for (const file of discoverPoFiles(localeRoot)) {
5004
+ let entries;
5005
+ try {
5006
+ entries = parseEntries(readFileSync17(file.path, "utf8"));
5007
+ } catch (e) {
5008
+ warnings.push(`gettext-po: failed to parse ${file.rel}: ${e.message}`);
5009
+ continue;
5010
+ }
5011
+ const header = entries.find((e) => e.msgid === "" && e.msgctxt === void 0);
5012
+ const headerLang = header?.msgstr?.match(/^Language:[ \t]*([A-Za-z0-9_-]+)/m)?.[1];
5013
+ const locale = file.locale ?? headerLang;
5014
+ if (!locale) {
5015
+ warnings.push(`gettext-po: cannot determine locale for ${file.rel}; skipped`);
5016
+ continue;
5017
+ }
5018
+ if (opts?.locales && !opts.locales.includes(locale)) continue;
5019
+ if (!locales.includes(locale)) locales.push(locale);
5020
+ const cats = categoriesFor(locale);
5021
+ for (const entry of entries) {
5022
+ if (entry === header) continue;
5023
+ const key = entry.msgctxt ?? entry.msgid;
5024
+ if (!key) continue;
5025
+ if (entry.msgidPlural !== void 0) {
5026
+ const forms = {};
5027
+ for (const [i, body] of [...entry.plurals].sort((a, b) => a[0] - b[0])) {
5028
+ if (body === "") continue;
5029
+ const cat = cats[i];
5030
+ if (!cat) {
5031
+ warnings.push(
5032
+ `gettext-po: ${file.rel} "${key}": msgstr[${i}] exceeds the ${cats.length} plural forms of "${locale}"; ignored`
5033
+ );
5034
+ continue;
5035
+ }
5036
+ forms[cat] = body.split("%d").join("{count}");
5037
+ }
5038
+ if (!forms.other) continue;
5039
+ (keys[key] ??= { values: {} }).values[locale] = formsToIcu("count", forms);
5040
+ } else {
5041
+ if (!entry.msgstr) continue;
5042
+ (keys[key] ??= { values: {} }).values[locale] = entry.msgstr;
5043
+ }
5044
+ }
5045
+ }
5046
+ return { locales, keys, warnings };
5047
+ }
5048
+ };
5049
+ }
5050
+ });
5051
+
5052
+ // src/server/import/parsers/i18next-json.ts
5053
+ import { readdirSync as readdirSync11, readFileSync as readFileSync18, statSync as statSync6 } from "fs";
5054
+ import { join as join13 } from "path";
5055
+ function safeIsDir2(p) {
5056
+ try {
5057
+ return statSync6(p).isDirectory();
5058
+ } catch {
5059
+ return false;
5060
+ }
5061
+ }
5062
+ function fromI18next(value) {
5063
+ if (isIcuPluralOrSelect(value)) return value;
5064
+ return value.replace(/\{\{(\w+)\}\}/g, "{$1}");
5065
+ }
5066
+ function ingestFile(path, label, prefix, locale, keys, warnings) {
5067
+ let data;
5068
+ try {
5069
+ data = JSON.parse(readFileSync18(path, "utf8"));
5070
+ } catch (e) {
5071
+ warnings.push(`i18next-json: failed to parse ${label}: ${e.message}`);
5072
+ return false;
5073
+ }
5074
+ const fileWarnings = [];
5075
+ const flat = flattenObject(data, "", fileWarnings);
5076
+ for (const w of fileWarnings) warnings.push(`i18next-json: ${label}: ${w}`);
5077
+ const families = /* @__PURE__ */ new Set();
5078
+ for (const [k, v] of Object.entries(flat)) {
5079
+ const m = PLURAL_SUFFIX_RE.exec(k);
5080
+ if (m && m[2] === "other" && v !== "") families.add(m[1]);
5081
+ }
5082
+ const pluralForms = {};
5083
+ for (const [k, raw] of Object.entries(flat)) {
5084
+ if (raw === "") continue;
5085
+ const value = fromI18next(raw);
5086
+ const m = PLURAL_SUFFIX_RE.exec(k);
5087
+ if (m && families.has(m[1])) {
5088
+ (pluralForms[m[1]] ??= {})[m[2]] = value;
5089
+ continue;
5090
+ }
5091
+ if (families.has(k)) {
5092
+ warnings.push(
5093
+ `i18next-json: ${label}: key "${k}" collides with its own plural suffix family; the plural wins`
5094
+ );
5095
+ continue;
5096
+ }
5097
+ (keys[prefix + k] ??= { values: {} }).values[locale] = value;
5098
+ }
5099
+ for (const [base, forms] of Object.entries(pluralForms)) {
5100
+ (keys[prefix + base] ??= { values: {} }).values[locale] = formsToIcu(PLURAL_ARG, forms);
5101
+ }
5102
+ return true;
5103
+ }
5104
+ var LOCALE_RE7, PLURAL_SUFFIX_RE, PLURAL_ARG, DEFAULT_NAMESPACE, i18nextJson2;
5105
+ var init_i18next_json2 = __esm({
5106
+ "src/server/import/parsers/i18next-json.ts"() {
5107
+ "use strict";
5108
+ init_flatten();
5109
+ init_plurals();
5110
+ init_placeholders();
5111
+ LOCALE_RE7 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5112
+ PLURAL_SUFFIX_RE = /^(.+)_(zero|one|two|few|many|other)$/;
5113
+ PLURAL_ARG = "count";
5114
+ DEFAULT_NAMESPACE = "translation";
5115
+ i18nextJson2 = {
5116
+ name: "i18next-json",
5117
+ parse(localeRoot, opts) {
5118
+ const warnings = [];
5119
+ const keys = {};
5120
+ const locales = [];
5121
+ for (const entry of readdirSync11(localeRoot).sort()) {
5122
+ const full = join13(localeRoot, entry);
5123
+ if (safeIsDir2(full)) {
5124
+ if (!LOCALE_RE7.test(entry)) continue;
5125
+ if (opts?.locales && !opts.locales.includes(entry)) continue;
5126
+ let any = false;
5127
+ for (const file of readdirSync11(full).sort()) {
5128
+ if (!file.endsWith(".json")) continue;
5129
+ const ns = file.slice(0, -".json".length);
5130
+ const prefix = ns === DEFAULT_NAMESPACE ? "" : `${ns}.`;
5131
+ if (ingestFile(join13(full, file), `${entry}/${file}`, prefix, entry, keys, warnings)) any = true;
5132
+ }
5133
+ if (any && !locales.includes(entry)) locales.push(entry);
5134
+ } else if (entry.endsWith(".json")) {
5135
+ const locale = entry.slice(0, -".json".length);
5136
+ if (!LOCALE_RE7.test(locale)) continue;
5137
+ if (opts?.locales && !opts.locales.includes(locale)) continue;
5138
+ if (ingestFile(full, entry, "", locale, keys, warnings) && !locales.includes(locale)) {
5139
+ locales.push(locale);
5293
5140
  }
5294
5141
  }
5295
5142
  }
@@ -5299,151 +5146,239 @@ var init_angular_xliff2 = __esm({
5299
5146
  }
5300
5147
  });
5301
5148
 
5302
- // src/server/import/parsers/gettext-po.ts
5303
- import { readdirSync as readdirSync10, readFileSync as readFileSync18 } from "fs";
5304
- import { join as join12 } from "path";
5305
- function unescapePo(s) {
5306
- return s.replace(
5307
- /\\([\\"ntr])/g,
5308
- (_, c) => c === "n" ? "\n" : c === "t" ? " " : c === "r" ? "\r" : c
5309
- );
5149
+ // src/server/import/parsers/rails-yaml.ts
5150
+ import { readdirSync as readdirSync12, readFileSync as readFileSync19 } from "fs";
5151
+ import { join as join14 } from "path";
5152
+ function fromRuby(value) {
5153
+ return value.replace(/%\{(\w+)\}/g, "{$1}");
5310
5154
  }
5311
- function parseEntries(text) {
5312
- const entries = [];
5313
- let cur = null;
5314
- let append = null;
5315
- const flush = () => {
5316
- if (cur && cur.msgid !== void 0) entries.push(cur);
5317
- cur = null;
5318
- append = null;
5319
- };
5320
- for (const line of text.split("\n")) {
5321
- if (line.trim() === "") {
5322
- flush();
5155
+ function makeNode() {
5156
+ return /* @__PURE__ */ Object.create(null);
5157
+ }
5158
+ function decodeDouble(body) {
5159
+ let out = "";
5160
+ for (let i = 0; i < body.length; i++) {
5161
+ const c = body[i];
5162
+ if (c !== "\\") {
5163
+ out += c;
5323
5164
  continue;
5324
5165
  }
5325
- if (line.startsWith("#")) continue;
5326
- const m = line.match(DIRECTIVE_RE);
5327
- if (m) {
5328
- const kw = m[1];
5329
- const idx = m[2];
5330
- const body = unescapePo(m[3]);
5331
- if (cur && (kw === "msgctxt" || kw === "msgid" && cur.msgid !== void 0)) flush();
5332
- cur ??= { plurals: /* @__PURE__ */ new Map() };
5333
- const entry = cur;
5334
- if (kw === "msgctxt") {
5335
- entry.msgctxt = body;
5336
- append = (c) => {
5337
- entry.msgctxt = (entry.msgctxt ?? "") + c;
5338
- };
5339
- } else if (kw === "msgid") {
5340
- entry.msgid = body;
5341
- append = (c) => {
5342
- entry.msgid = (entry.msgid ?? "") + c;
5343
- };
5344
- } else if (kw === "msgid_plural") {
5345
- entry.msgidPlural = body;
5346
- append = (c) => {
5347
- entry.msgidPlural = (entry.msgidPlural ?? "") + c;
5348
- };
5349
- } else if (idx !== void 0) {
5350
- const i = Number(idx);
5351
- entry.plurals.set(i, body);
5352
- append = (c) => {
5353
- entry.plurals.set(i, (entry.plurals.get(i) ?? "") + c);
5354
- };
5166
+ const n = body[++i];
5167
+ if (n === void 0) break;
5168
+ out += n === "n" ? "\n" : n === "r" ? "\r" : n === "t" ? " " : n;
5169
+ }
5170
+ return out;
5171
+ }
5172
+ function scanQuoted(s, start) {
5173
+ const q = s[start];
5174
+ if (q === '"') {
5175
+ for (let i = start + 1; i < s.length; i++) {
5176
+ if (s[i] === "\\") i++;
5177
+ else if (s[i] === '"') return { text: decodeDouble(s.slice(start + 1, i)), end: i + 1 };
5178
+ }
5179
+ return null;
5180
+ }
5181
+ let out = "";
5182
+ for (let i = start + 1; i < s.length; i++) {
5183
+ if (s[i] === "'") {
5184
+ if (s[i + 1] === "'") {
5185
+ out += "'";
5186
+ i++;
5355
5187
  } else {
5356
- entry.msgstr = body;
5357
- append = (c) => {
5358
- entry.msgstr = (entry.msgstr ?? "") + c;
5359
- };
5188
+ return { text: out, end: i + 1 };
5360
5189
  }
5190
+ } else {
5191
+ out += s[i];
5192
+ }
5193
+ }
5194
+ return null;
5195
+ }
5196
+ function stripPlainComment(s) {
5197
+ const m = /(^|\s)#/.exec(s);
5198
+ return (m && m.index >= 0 ? s.slice(0, m.index) : s).trim();
5199
+ }
5200
+ function onlyTrailing(s) {
5201
+ return /^\s*(#.*)?$/.test(s);
5202
+ }
5203
+ function parseYamlSubset(text, file, warnings) {
5204
+ const roots = {};
5205
+ const lines = text.split(/\r?\n/);
5206
+ let stack = [];
5207
+ let skipDeeperThan = null;
5208
+ let lastLeafIndent = null;
5209
+ for (let n = 0; n < lines.length; n++) {
5210
+ const raw = lines[n];
5211
+ const lineNo = n + 1;
5212
+ if (raw.trim() === "" || raw.trim().startsWith("#")) continue;
5213
+ if (raw.trim() === "---") continue;
5214
+ const indentMatch = /^[ \t]*/.exec(raw)[0];
5215
+ if (indentMatch.includes(" ")) {
5216
+ warnings.push(`rails-yaml: ${file}:${lineNo}: tab in indentation; line skipped`);
5361
5217
  continue;
5362
5218
  }
5363
- const cont = line.match(CONT_RE);
5364
- if (cont && append) append(unescapePo(cont[1]));
5219
+ const indent = indentMatch.length;
5220
+ if (skipDeeperThan !== null) {
5221
+ if (indent > skipDeeperThan) continue;
5222
+ skipDeeperThan = null;
5223
+ }
5224
+ const content = raw.slice(indent);
5225
+ if (content.startsWith("- ") || content === "-") {
5226
+ warnings.push(`rails-yaml: ${file}:${lineNo}: sequences are not supported; node skipped`);
5227
+ skipDeeperThan = indent;
5228
+ continue;
5229
+ }
5230
+ let key;
5231
+ let rest;
5232
+ if (content[0] === '"' || content[0] === "'") {
5233
+ const k = scanQuoted(content, 0);
5234
+ if (!k || content[k.end] !== ":") {
5235
+ warnings.push(`rails-yaml: ${file}:${lineNo}: unparseable quoted key; line skipped`);
5236
+ skipDeeperThan = indent;
5237
+ continue;
5238
+ }
5239
+ key = k.text;
5240
+ rest = content.slice(k.end + 1);
5241
+ } else {
5242
+ const m = /^(.*?):(?=\s|$)/.exec(content);
5243
+ if (!m) {
5244
+ warnings.push(`rails-yaml: ${file}:${lineNo}: not a "key: value" mapping line; line skipped`);
5245
+ skipDeeperThan = indent;
5246
+ continue;
5247
+ }
5248
+ key = m[1].trim();
5249
+ rest = content.slice(m[0].length);
5250
+ }
5251
+ if (lastLeafIndent !== null && indent > lastLeafIndent) {
5252
+ warnings.push(`rails-yaml: ${file}:${lineNo}: unexpected indentation under a scalar; line skipped`);
5253
+ skipDeeperThan = indent - 1;
5254
+ continue;
5255
+ }
5256
+ while (stack.length > 0 && stack[stack.length - 1].indent >= indent) stack.pop();
5257
+ const trimmed = rest.trim();
5258
+ let value;
5259
+ if (trimmed === "" || trimmed.startsWith("#")) {
5260
+ value = null;
5261
+ } else if (trimmed[0] === "&" || trimmed[0] === "*") {
5262
+ warnings.push(`rails-yaml: ${file}:${lineNo}: YAML anchors/aliases are not supported; node skipped`);
5263
+ skipDeeperThan = indent;
5264
+ continue;
5265
+ } else if (trimmed[0] === "|" || trimmed[0] === ">") {
5266
+ warnings.push(`rails-yaml: ${file}:${lineNo}: block scalars are not supported; node skipped`);
5267
+ skipDeeperThan = indent;
5268
+ continue;
5269
+ } else if (trimmed[0] === "[" || trimmed[0] === "{") {
5270
+ warnings.push(`rails-yaml: ${file}:${lineNo}: flow collections are not supported; node skipped`);
5271
+ skipDeeperThan = indent;
5272
+ continue;
5273
+ } else if (trimmed[0] === '"' || trimmed[0] === "'") {
5274
+ const v = scanQuoted(trimmed, 0);
5275
+ if (!v || !onlyTrailing(trimmed.slice(v.end))) {
5276
+ warnings.push(`rails-yaml: ${file}:${lineNo}: unterminated or trailing-garbage quoted value; line skipped`);
5277
+ continue;
5278
+ }
5279
+ value = v.text;
5280
+ } else {
5281
+ value = stripPlainComment(trimmed);
5282
+ }
5283
+ if (stack.length === 0) {
5284
+ if (value !== null) {
5285
+ warnings.push(`rails-yaml: ${file}:${lineNo}: top-level key "${key}" has a scalar value; skipped`);
5286
+ lastLeafIndent = indent;
5287
+ continue;
5288
+ }
5289
+ const root = roots[key] ??= makeNode();
5290
+ stack = [{ indent, node: root }];
5291
+ lastLeafIndent = null;
5292
+ continue;
5293
+ }
5294
+ const parent = stack[stack.length - 1].node;
5295
+ if (key in parent) {
5296
+ warnings.push(`rails-yaml: ${file}:${lineNo}: duplicate key "${key}"; later value wins`);
5297
+ }
5298
+ if (value === null) {
5299
+ const child = makeNode();
5300
+ parent[key] = child;
5301
+ stack.push({ indent, node: child });
5302
+ lastLeafIndent = null;
5303
+ } else {
5304
+ parent[key] = value;
5305
+ lastLeafIndent = indent;
5306
+ }
5307
+ }
5308
+ return { roots };
5309
+ }
5310
+ function asPluralForms(node) {
5311
+ const entries = Object.entries(node);
5312
+ if (entries.length === 0) return null;
5313
+ const forms = {};
5314
+ for (const [k, v] of entries) {
5315
+ if (!CATEGORY_SET.has(k) || typeof v !== "string") return null;
5316
+ if (v !== "") forms[k] = v;
5365
5317
  }
5366
- flush();
5367
- return entries;
5318
+ if (!("other" in forms)) return null;
5319
+ return forms;
5368
5320
  }
5369
- function discoverPoFiles(root) {
5370
- const found = [];
5371
- const entries = readdirSync10(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
5372
- for (const e of entries) {
5373
- if (e.isFile() && e.name.endsWith(".po")) {
5374
- const base = e.name.slice(0, -3);
5375
- found.push({ path: join12(root, e.name), rel: e.name, locale: LOCALE_RE6.test(base) ? base : null });
5376
- } else if (e.isDirectory() && LOCALE_RE6.test(e.name)) {
5377
- for (const sub of [join12(e.name, "LC_MESSAGES"), e.name]) {
5378
- let names;
5379
- try {
5380
- names = readdirSync10(join12(root, sub)).sort();
5381
- } catch {
5382
- continue;
5383
- }
5384
- for (const f of names) {
5385
- if (f.endsWith(".po")) found.push({ path: join12(root, sub, f), rel: join12(sub, f), locale: e.name });
5386
- }
5387
- }
5321
+ function synthesizeIcu(forms, file, key, warnings) {
5322
+ const parts = [];
5323
+ for (const cat of PLURAL_CATEGORIES) {
5324
+ const body = forms[cat];
5325
+ if (body === void 0) continue;
5326
+ if (body.includes("#")) {
5327
+ warnings.push(
5328
+ `rails-yaml: ${file}: plural "${key}" form "${cat}" contains "#", which ICU reads as the count placeholder`
5329
+ );
5388
5330
  }
5331
+ parts.push(`${cat} {${fromRuby(body)}}`);
5389
5332
  }
5390
- return found;
5333
+ return `{count, plural, ${parts.join(" ")}}`;
5391
5334
  }
5392
- var LOCALE_RE6, DIRECTIVE_RE, CONT_RE, gettextPo2;
5393
- var init_gettext_po2 = __esm({
5394
- "src/server/import/parsers/gettext-po.ts"() {
5335
+ var LOCALE_RE8, CATEGORY_SET, railsYaml2;
5336
+ var init_rails_yaml2 = __esm({
5337
+ "src/server/import/parsers/rails-yaml.ts"() {
5395
5338
  "use strict";
5396
- init_plurals();
5397
- LOCALE_RE6 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5398
- DIRECTIVE_RE = /^(msgctxt|msgid_plural|msgid|msgstr)(?:\[(\d+)\])?[ \t]+"(.*)"\s*$/;
5399
- CONT_RE = /^[ \t]*"(.*)"\s*$/;
5400
- gettextPo2 = {
5401
- name: "gettext-po",
5339
+ init_schema();
5340
+ LOCALE_RE8 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/i;
5341
+ CATEGORY_SET = new Set(PLURAL_CATEGORIES);
5342
+ railsYaml2 = {
5343
+ name: "rails-yaml",
5402
5344
  parse(localeRoot, opts) {
5403
5345
  const warnings = [];
5404
5346
  const keys = {};
5405
5347
  const locales = [];
5406
- for (const file of discoverPoFiles(localeRoot)) {
5407
- let entries;
5348
+ const wanted = opts?.locales?.map((l) => l.toLowerCase());
5349
+ const addValue = (key, locale, value) => {
5350
+ (keys[key] ??= { values: {} }).values[locale] = value;
5351
+ };
5352
+ const flatten = (node, prefix, locale, file) => {
5353
+ for (const [k, v] of Object.entries(node)) {
5354
+ const key = prefix ? `${prefix}.${k}` : k;
5355
+ if (typeof v === "string") {
5356
+ if (v !== "") addValue(key, locale, fromRuby(v));
5357
+ continue;
5358
+ }
5359
+ const forms = asPluralForms(v);
5360
+ if (forms) addValue(key, locale, synthesizeIcu(forms, file, key, warnings));
5361
+ else flatten(v, key, locale, file);
5362
+ }
5363
+ };
5364
+ for (const file of readdirSync12(localeRoot).sort()) {
5365
+ if (!file.endsWith(".yml") && !file.endsWith(".yaml")) continue;
5366
+ let text;
5408
5367
  try {
5409
- entries = parseEntries(readFileSync18(file.path, "utf8"));
5368
+ text = readFileSync19(join14(localeRoot, file), "utf8");
5410
5369
  } catch (e) {
5411
- warnings.push(`gettext-po: failed to parse ${file.rel}: ${e.message}`);
5412
- continue;
5413
- }
5414
- const header = entries.find((e) => e.msgid === "" && e.msgctxt === void 0);
5415
- const headerLang = header?.msgstr?.match(/^Language:[ \t]*([A-Za-z0-9_-]+)/m)?.[1];
5416
- const locale = file.locale ?? headerLang;
5417
- if (!locale) {
5418
- warnings.push(`gettext-po: cannot determine locale for ${file.rel}; skipped`);
5370
+ warnings.push(`rails-yaml: failed to read ${file}: ${e.message}`);
5419
5371
  continue;
5420
5372
  }
5421
- if (opts?.locales && !opts.locales.includes(locale)) continue;
5422
- if (!locales.includes(locale)) locales.push(locale);
5423
- const cats = categoriesFor(locale);
5424
- for (const entry of entries) {
5425
- if (entry === header) continue;
5426
- const key = entry.msgctxt ?? entry.msgid;
5427
- if (!key) continue;
5428
- if (entry.msgidPlural !== void 0) {
5429
- const forms = {};
5430
- for (const [i, body] of [...entry.plurals].sort((a, b) => a[0] - b[0])) {
5431
- if (body === "") continue;
5432
- const cat = cats[i];
5433
- if (!cat) {
5434
- warnings.push(
5435
- `gettext-po: ${file.rel} "${key}": msgstr[${i}] exceeds the ${cats.length} plural forms of "${locale}"; ignored`
5436
- );
5437
- continue;
5438
- }
5439
- forms[cat] = body.split("%d").join("{count}");
5440
- }
5441
- if (!forms.other) continue;
5442
- (keys[key] ??= { values: {} }).values[locale] = formsToIcu("count", forms);
5443
- } else {
5444
- if (!entry.msgstr) continue;
5445
- (keys[key] ??= { values: {} }).values[locale] = entry.msgstr;
5373
+ const { roots } = parseYamlSubset(text, file, warnings);
5374
+ for (const token of Object.keys(roots).sort()) {
5375
+ if (!LOCALE_RE8.test(token)) {
5376
+ warnings.push(`rails-yaml: ${file}: top-level key "${token}" is not a locale; subtree skipped`);
5377
+ continue;
5446
5378
  }
5379
+ if (wanted && !wanted.includes(token.toLowerCase())) continue;
5380
+ if (!locales.includes(token)) locales.push(token);
5381
+ flatten(roots[token], "", token, file);
5447
5382
  }
5448
5383
  }
5449
5384
  return { locales, keys, warnings };
@@ -5452,95 +5387,178 @@ var init_gettext_po2 = __esm({
5452
5387
  }
5453
5388
  });
5454
5389
 
5455
- // src/server/import/parsers/i18next-json.ts
5456
- import { readdirSync as readdirSync11, readFileSync as readFileSync19, statSync as statSync6 } from "fs";
5457
- import { join as join13 } from "path";
5458
- function safeIsDir2(p) {
5459
- try {
5460
- return statSync6(p).isDirectory();
5461
- } catch {
5462
- return false;
5463
- }
5390
+ // src/server/import/parsers/apple-stringsdict.ts
5391
+ import { readdirSync as readdirSync13, readFileSync as readFileSync20, statSync as statSync7 } from "fs";
5392
+ import { join as join15 } from "path";
5393
+ function localeFromLproj2(dir) {
5394
+ const m = dir.match(/^(.+)\.lproj$/);
5395
+ if (!m) return null;
5396
+ return LOCALE_RE9.test(m[1]) ? m[1] : null;
5464
5397
  }
5465
- function fromI18next(value) {
5466
- if (isIcuPluralOrSelect(value)) return value;
5467
- return value.replace(/\{\{(\w+)\}\}/g, "{$1}");
5398
+ function decodeEntities2(s) {
5399
+ return s.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16))).replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d))).replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
5468
5400
  }
5469
- function ingestFile(path, label, prefix, locale, keys, warnings) {
5470
- let data;
5471
- try {
5472
- data = JSON.parse(readFileSync19(path, "utf8"));
5473
- } catch (e) {
5474
- warnings.push(`i18next-json: failed to parse ${label}: ${e.message}`);
5475
- return false;
5476
- }
5477
- const fileWarnings = [];
5478
- const flat = flattenObject(data, "", fileWarnings);
5479
- for (const w of fileWarnings) warnings.push(`i18next-json: ${label}: ${w}`);
5480
- const families = /* @__PURE__ */ new Set();
5481
- for (const [k, v] of Object.entries(flat)) {
5482
- const m = PLURAL_SUFFIX_RE.exec(k);
5483
- if (m && m[2] === "other" && v !== "") families.add(m[1]);
5484
- }
5485
- const pluralForms = {};
5486
- for (const [k, raw] of Object.entries(flat)) {
5487
- if (raw === "") continue;
5488
- const value = fromI18next(raw);
5489
- const m = PLURAL_SUFFIX_RE.exec(k);
5490
- if (m && families.has(m[1])) {
5491
- (pluralForms[m[1]] ??= {})[m[2]] = value;
5492
- continue;
5401
+ function parsePlistDict(xml) {
5402
+ let i = 0;
5403
+ const n = xml.length;
5404
+ const skipTrivia = () => {
5405
+ for (; ; ) {
5406
+ while (i < n && /\s/.test(xml[i])) i++;
5407
+ if (xml.startsWith("<!--", i)) {
5408
+ const end = xml.indexOf("-->", i + 4);
5409
+ if (end === -1) throw new Error("unterminated comment");
5410
+ i = end + 3;
5411
+ continue;
5412
+ }
5413
+ if (xml.startsWith("<?", i) || xml.startsWith("<!", i) && !xml.startsWith("<!--", i)) {
5414
+ const end = xml.indexOf(">", i);
5415
+ if (end === -1) throw new Error("unterminated declaration");
5416
+ i = end + 1;
5417
+ continue;
5418
+ }
5419
+ break;
5493
5420
  }
5494
- if (families.has(k)) {
5495
- warnings.push(
5496
- `i18next-json: ${label}: key "${k}" collides with its own plural suffix family; the plural wins`
5497
- );
5498
- continue;
5421
+ };
5422
+ const readTag = () => {
5423
+ if (xml[i] !== "<") throw new Error(`expected a tag at offset ${i}`);
5424
+ const end = xml.indexOf(">", i);
5425
+ if (end === -1) throw new Error("unterminated tag");
5426
+ let body = xml.slice(i + 1, end).trim();
5427
+ i = end + 1;
5428
+ const closing = body.startsWith("/");
5429
+ if (closing) body = body.slice(1).trim();
5430
+ const selfClosing = body.endsWith("/");
5431
+ if (selfClosing) body = body.slice(0, -1).trim();
5432
+ const name = body.split(/\s/)[0];
5433
+ if (!name) throw new Error(`empty tag at offset ${end}`);
5434
+ return { name, closing, selfClosing };
5435
+ };
5436
+ const readElementText = (name) => {
5437
+ const re = new RegExp(`</${name}\\s*>`, "g");
5438
+ re.lastIndex = i;
5439
+ const m = re.exec(xml);
5440
+ if (!m) throw new Error(`unterminated <${name}>`);
5441
+ const text = xml.slice(i, m.index);
5442
+ i = m.index + m[0].length;
5443
+ return decodeEntities2(text);
5444
+ };
5445
+ const readValue = (tag2) => {
5446
+ if (tag2.name === "dict") return tag2.selfClosing ? {} : readDict();
5447
+ if (tag2.name === "true" || tag2.name === "false") {
5448
+ if (!tag2.selfClosing) readElementText(tag2.name);
5449
+ return tag2.name;
5499
5450
  }
5500
- (keys[prefix + k] ??= { values: {} }).values[locale] = value;
5451
+ if (["string", "integer", "real", "date", "data"].includes(tag2.name)) {
5452
+ return tag2.selfClosing ? "" : readElementText(tag2.name);
5453
+ }
5454
+ throw new Error(`unsupported plist element <${tag2.name}>`);
5455
+ };
5456
+ const readDict = () => {
5457
+ const out = {};
5458
+ for (; ; ) {
5459
+ skipTrivia();
5460
+ const tag2 = readTag();
5461
+ if (tag2.closing) {
5462
+ if (tag2.name !== "dict") throw new Error(`unexpected </${tag2.name}> inside <dict>`);
5463
+ return out;
5464
+ }
5465
+ if (tag2.name !== "key") throw new Error(`expected <key> inside <dict>, got <${tag2.name}>`);
5466
+ const key = readElementText("key");
5467
+ skipTrivia();
5468
+ const vt = readTag();
5469
+ if (vt.closing) throw new Error(`<key>${key}</key> has no value`);
5470
+ out[key] = readValue(vt);
5471
+ }
5472
+ };
5473
+ skipTrivia();
5474
+ let tag = readTag();
5475
+ if (tag.name === "plist" && !tag.closing && !tag.selfClosing) {
5476
+ skipTrivia();
5477
+ tag = readTag();
5478
+ }
5479
+ if (tag.name !== "dict" || tag.closing) throw new Error("expected a root <dict>");
5480
+ return tag.selfClosing ? {} : readDict();
5481
+ }
5482
+ function entryToIcu(key, entry, file, warnings) {
5483
+ const warn = (msg) => {
5484
+ warnings.push(`apple-stringsdict: ${file}: key "${key}": ${msg}`);
5485
+ return null;
5486
+ };
5487
+ if (typeof entry !== "object") return warn("value is not a dict; skipped");
5488
+ const fmt = entry["NSStringLocalizedFormatKey"];
5489
+ if (typeof fmt !== "string") return warn("missing NSStringLocalizedFormatKey; skipped");
5490
+ const vars = [...fmt.matchAll(VAR_RE)];
5491
+ if (vars.length !== 1) {
5492
+ return warn(`format key has ${vars.length} %#@\u2026@ variables; only exactly one is supported; skipped`);
5501
5493
  }
5502
- for (const [base, forms] of Object.entries(pluralForms)) {
5503
- (keys[prefix + base] ??= { values: {} }).values[locale] = formsToIcu(PLURAL_ARG, forms);
5494
+ const arg = vars[0][1];
5495
+ if (!/^\w+$/.test(arg)) return warn(`variable name "${arg}" is not a valid ICU argument; skipped`);
5496
+ const prefix = fmt.slice(0, vars[0].index);
5497
+ const suffix = fmt.slice(vars[0].index + vars[0][0].length);
5498
+ const varDict = entry[arg];
5499
+ if (typeof varDict !== "object") return warn(`variable "${arg}" has no dict; skipped`);
5500
+ const specType = varDict["NSStringFormatSpecTypeKey"];
5501
+ if (specType !== void 0 && specType !== "NSStringPluralRuleType") {
5502
+ return warn(`variable "${arg}" is not a plural rule (${String(specType)}); skipped`);
5504
5503
  }
5505
- return true;
5504
+ const valueType = varDict["NSStringFormatValueTypeKey"];
5505
+ const token = `%${typeof valueType === "string" && valueType ? valueType : "d"}`;
5506
+ const forms = {};
5507
+ for (const cat of PLURAL_CATEGORIES) {
5508
+ const body = varDict[cat];
5509
+ if (typeof body !== "string") continue;
5510
+ forms[cat] = prefix + body.split(token).join(`{${arg}}`) + suffix;
5511
+ }
5512
+ if (forms.other === void 0) return warn(`variable "${arg}" has no "other" form; skipped`);
5513
+ return formsToIcu(arg, forms);
5506
5514
  }
5507
- var LOCALE_RE7, PLURAL_SUFFIX_RE, PLURAL_ARG, DEFAULT_NAMESPACE, i18nextJson2;
5508
- var init_i18next_json2 = __esm({
5509
- "src/server/import/parsers/i18next-json.ts"() {
5515
+ var LOCALE_RE9, TABLE2, VAR_RE, appleStringsdict2;
5516
+ var init_apple_stringsdict2 = __esm({
5517
+ "src/server/import/parsers/apple-stringsdict.ts"() {
5510
5518
  "use strict";
5511
- init_flatten();
5519
+ init_schema();
5512
5520
  init_plurals();
5513
- init_placeholders();
5514
- LOCALE_RE7 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5515
- PLURAL_SUFFIX_RE = /^(.+)_(zero|one|two|few|many|other)$/;
5516
- PLURAL_ARG = "count";
5517
- DEFAULT_NAMESPACE = "translation";
5518
- i18nextJson2 = {
5519
- name: "i18next-json",
5521
+ LOCALE_RE9 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5522
+ TABLE2 = "Localizable.stringsdict";
5523
+ VAR_RE = /%#@([^@]*)@/g;
5524
+ appleStringsdict2 = {
5525
+ name: "apple-stringsdict",
5520
5526
  parse(localeRoot, opts) {
5521
5527
  const warnings = [];
5522
5528
  const keys = {};
5523
5529
  const locales = [];
5524
- for (const entry of readdirSync11(localeRoot).sort()) {
5525
- const full = join13(localeRoot, entry);
5526
- if (safeIsDir2(full)) {
5527
- if (!LOCALE_RE7.test(entry)) continue;
5528
- if (opts?.locales && !opts.locales.includes(entry)) continue;
5529
- let any = false;
5530
- for (const file of readdirSync11(full).sort()) {
5531
- if (!file.endsWith(".json")) continue;
5532
- const ns = file.slice(0, -".json".length);
5533
- const prefix = ns === DEFAULT_NAMESPACE ? "" : `${ns}.`;
5534
- if (ingestFile(join13(full, file), `${entry}/${file}`, prefix, entry, keys, warnings)) any = true;
5535
- }
5536
- if (any && !locales.includes(entry)) locales.push(entry);
5537
- } else if (entry.endsWith(".json")) {
5538
- const locale = entry.slice(0, -".json".length);
5539
- if (!LOCALE_RE7.test(locale)) continue;
5540
- if (opts?.locales && !opts.locales.includes(locale)) continue;
5541
- if (ingestFile(full, entry, "", locale, keys, warnings) && !locales.includes(locale)) {
5542
- locales.push(locale);
5543
- }
5530
+ for (const dir of readdirSync13(localeRoot).sort()) {
5531
+ const locale = localeFromLproj2(dir);
5532
+ if (!locale) continue;
5533
+ if (opts?.locales && !opts.locales.includes(locale)) continue;
5534
+ const file = join15(localeRoot, dir, TABLE2);
5535
+ let text;
5536
+ try {
5537
+ if (!statSync7(file).isFile()) continue;
5538
+ text = readFileSync20(file, "utf8");
5539
+ } catch {
5540
+ continue;
5541
+ }
5542
+ locales.push(locale);
5543
+ const others = readdirSync13(join15(localeRoot, dir)).filter(
5544
+ (f) => f.endsWith(".stringsdict") && f !== TABLE2
5545
+ );
5546
+ if (others.length) {
5547
+ warnings.push(
5548
+ `apple-stringsdict: ${dir} has other .stringsdict tables (${others.join(", ")}); only ${TABLE2} is imported`
5549
+ );
5550
+ }
5551
+ let root;
5552
+ try {
5553
+ root = parsePlistDict(text);
5554
+ } catch (e) {
5555
+ warnings.push(`apple-stringsdict: failed to parse ${file}: ${e.message}`);
5556
+ continue;
5557
+ }
5558
+ for (const key of Object.keys(root).sort()) {
5559
+ const icu = entryToIcu(key, root[key], file, warnings);
5560
+ if (icu === null) continue;
5561
+ (keys[key] ??= { values: {} }).values[locale] = icu;
5544
5562
  }
5545
5563
  }
5546
5564
  return { locales, keys, warnings };
@@ -5549,457 +5567,500 @@ var init_i18next_json2 = __esm({
5549
5567
  }
5550
5568
  });
5551
5569
 
5552
- // src/server/import/parsers/rails-yaml.ts
5553
- import { readdirSync as readdirSync12, readFileSync as readFileSync20 } from "fs";
5554
- import { join as join14 } from "path";
5555
- function fromRuby(value) {
5556
- return value.replace(/%\{(\w+)\}/g, "{$1}");
5557
- }
5558
- function makeNode() {
5559
- return /* @__PURE__ */ Object.create(null);
5560
- }
5561
- function decodeDouble(body) {
5562
- let out = "";
5563
- for (let i = 0; i < body.length; i++) {
5564
- const c = body[i];
5565
- if (c !== "\\") {
5566
- out += c;
5567
- continue;
5568
- }
5569
- const n = body[++i];
5570
- if (n === void 0) break;
5571
- out += n === "n" ? "\n" : n === "r" ? "\r" : n === "t" ? " " : n;
5572
- }
5573
- return out;
5574
- }
5575
- function scanQuoted(s, start) {
5576
- const q = s[start];
5577
- if (q === '"') {
5578
- for (let i = start + 1; i < s.length; i++) {
5579
- if (s[i] === "\\") i++;
5580
- else if (s[i] === '"') return { text: decodeDouble(s.slice(start + 1, i)), end: i + 1 };
5581
- }
5582
- return null;
5583
- }
5584
- let out = "";
5585
- for (let i = start + 1; i < s.length; i++) {
5586
- if (s[i] === "'") {
5587
- if (s[i + 1] === "'") {
5588
- out += "'";
5589
- i++;
5590
- } else {
5591
- return { text: out, end: i + 1 };
5592
- }
5593
- } else {
5594
- out += s[i];
5595
- }
5596
- }
5597
- return null;
5598
- }
5599
- function stripPlainComment(s) {
5600
- const m = /(^|\s)#/.exec(s);
5601
- return (m && m.index >= 0 ? s.slice(0, m.index) : s).trim();
5602
- }
5603
- function onlyTrailing(s) {
5604
- return /^\s*(#.*)?$/.test(s);
5605
- }
5606
- function parseYamlSubset(text, file, warnings) {
5607
- const roots = {};
5608
- const lines = text.split(/\r?\n/);
5609
- let stack = [];
5610
- let skipDeeperThan = null;
5611
- let lastLeafIndent = null;
5612
- for (let n = 0; n < lines.length; n++) {
5613
- const raw = lines[n];
5614
- const lineNo = n + 1;
5615
- if (raw.trim() === "" || raw.trim().startsWith("#")) continue;
5616
- if (raw.trim() === "---") continue;
5617
- const indentMatch = /^[ \t]*/.exec(raw)[0];
5618
- if (indentMatch.includes(" ")) {
5619
- warnings.push(`rails-yaml: ${file}:${lineNo}: tab in indentation; line skipped`);
5620
- continue;
5621
- }
5622
- const indent = indentMatch.length;
5623
- if (skipDeeperThan !== null) {
5624
- if (indent > skipDeeperThan) continue;
5625
- skipDeeperThan = null;
5626
- }
5627
- const content = raw.slice(indent);
5628
- if (content.startsWith("- ") || content === "-") {
5629
- warnings.push(`rails-yaml: ${file}:${lineNo}: sequences are not supported; node skipped`);
5630
- skipDeeperThan = indent;
5631
- continue;
5632
- }
5633
- let key;
5634
- let rest;
5635
- if (content[0] === '"' || content[0] === "'") {
5636
- const k = scanQuoted(content, 0);
5637
- if (!k || content[k.end] !== ":") {
5638
- warnings.push(`rails-yaml: ${file}:${lineNo}: unparseable quoted key; line skipped`);
5639
- skipDeeperThan = indent;
5640
- continue;
5641
- }
5642
- key = k.text;
5643
- rest = content.slice(k.end + 1);
5644
- } else {
5645
- const m = /^(.*?):(?=\s|$)/.exec(content);
5646
- if (!m) {
5647
- warnings.push(`rails-yaml: ${file}:${lineNo}: not a "key: value" mapping line; line skipped`);
5648
- skipDeeperThan = indent;
5649
- continue;
5650
- }
5651
- key = m[1].trim();
5652
- rest = content.slice(m[0].length);
5653
- }
5654
- if (lastLeafIndent !== null && indent > lastLeafIndent) {
5655
- warnings.push(`rails-yaml: ${file}:${lineNo}: unexpected indentation under a scalar; line skipped`);
5656
- skipDeeperThan = indent - 1;
5657
- continue;
5658
- }
5659
- while (stack.length > 0 && stack[stack.length - 1].indent >= indent) stack.pop();
5660
- const trimmed = rest.trim();
5661
- let value;
5662
- if (trimmed === "" || trimmed.startsWith("#")) {
5663
- value = null;
5664
- } else if (trimmed[0] === "&" || trimmed[0] === "*") {
5665
- warnings.push(`rails-yaml: ${file}:${lineNo}: YAML anchors/aliases are not supported; node skipped`);
5666
- skipDeeperThan = indent;
5667
- continue;
5668
- } else if (trimmed[0] === "|" || trimmed[0] === ">") {
5669
- warnings.push(`rails-yaml: ${file}:${lineNo}: block scalars are not supported; node skipped`);
5670
- skipDeeperThan = indent;
5671
- continue;
5672
- } else if (trimmed[0] === "[" || trimmed[0] === "{") {
5673
- warnings.push(`rails-yaml: ${file}:${lineNo}: flow collections are not supported; node skipped`);
5674
- skipDeeperThan = indent;
5675
- continue;
5676
- } else if (trimmed[0] === '"' || trimmed[0] === "'") {
5677
- const v = scanQuoted(trimmed, 0);
5678
- if (!v || !onlyTrailing(trimmed.slice(v.end))) {
5679
- warnings.push(`rails-yaml: ${file}:${lineNo}: unterminated or trailing-garbage quoted value; line skipped`);
5680
- continue;
5681
- }
5682
- value = v.text;
5683
- } else {
5684
- value = stripPlainComment(trimmed);
5685
- }
5686
- if (stack.length === 0) {
5687
- if (value !== null) {
5688
- warnings.push(`rails-yaml: ${file}:${lineNo}: top-level key "${key}" has a scalar value; skipped`);
5689
- lastLeafIndent = indent;
5690
- continue;
5691
- }
5692
- const root = roots[key] ??= makeNode();
5693
- stack = [{ indent, node: root }];
5694
- lastLeafIndent = null;
5695
- continue;
5696
- }
5697
- const parent = stack[stack.length - 1].node;
5698
- if (key in parent) {
5699
- warnings.push(`rails-yaml: ${file}:${lineNo}: duplicate key "${key}"; later value wins`);
5700
- }
5701
- if (value === null) {
5702
- const child = makeNode();
5703
- parent[key] = child;
5704
- stack.push({ indent, node: child });
5705
- lastLeafIndent = null;
5706
- } else {
5707
- parent[key] = value;
5708
- lastLeafIndent = indent;
5570
+ // src/server/import/parsers/index.ts
5571
+ function getParser(name) {
5572
+ const p = REGISTRY[name];
5573
+ if (!p) throw new Error(`Unknown format: ${name} (known: ${Object.keys(REGISTRY).join(", ")})`);
5574
+ return p;
5575
+ }
5576
+ var REGISTRY;
5577
+ var init_parsers = __esm({
5578
+ "src/server/import/parsers/index.ts"() {
5579
+ "use strict";
5580
+ init_vue_i18n_json2();
5581
+ init_laravel_php2();
5582
+ init_flutter_arb2();
5583
+ init_apple_strings2();
5584
+ init_angular_xliff2();
5585
+ init_gettext_po2();
5586
+ init_i18next_json2();
5587
+ init_rails_yaml2();
5588
+ init_apple_stringsdict2();
5589
+ REGISTRY = {
5590
+ [vueI18nJson2.name]: vueI18nJson2,
5591
+ [laravelPhp2.name]: laravelPhp2,
5592
+ [flutterArb2.name]: flutterArb2,
5593
+ [appleStrings2.name]: appleStrings2,
5594
+ [angularXliff2.name]: angularXliff2,
5595
+ [gettextPo2.name]: gettextPo2,
5596
+ [i18nextJson2.name]: i18nextJson2,
5597
+ [railsYaml2.name]: railsYaml2,
5598
+ [appleStringsdict2.name]: appleStringsdict2
5599
+ };
5600
+ }
5601
+ });
5602
+
5603
+ // src/server/import/usage.ts
5604
+ function isLocationScannedState(state) {
5605
+ return state.config.outputs.some((o) => LOCATION_SCANNED_ADAPTERS.has(o.adapter));
5606
+ }
5607
+ function buildLocationUsageCache(parsed) {
5608
+ const files = {};
5609
+ for (const [key, pk] of Object.entries(parsed.keys)) {
5610
+ for (const loc of pk.locations ?? []) {
5611
+ const file = files[loc.file] ??= { mtime: 0, size: 0, refs: [], prefixes: [] };
5612
+ file.refs.push({ key, line: loc.line, col: 1, scanner: "angular-xliff" });
5709
5613
  }
5710
5614
  }
5711
- return { roots };
5615
+ return { version: CACHE_VERSION, scannedAt: (/* @__PURE__ */ new Date()).toISOString(), files };
5712
5616
  }
5713
- function asPluralForms(node) {
5714
- const entries = Object.entries(node);
5715
- if (entries.length === 0) return null;
5716
- const forms = {};
5717
- for (const [k, v] of entries) {
5718
- if (!CATEGORY_SET.has(k) || typeof v !== "string") return null;
5719
- if (v !== "") forms[k] = v;
5617
+ function usageCounts(cache2) {
5618
+ return {
5619
+ files: Object.keys(cache2.files).length,
5620
+ refs: Object.values(cache2.files).reduce((n, f) => n + f.refs.length, 0)
5621
+ };
5622
+ }
5623
+ function refreshLocationUsage(projectRoot, format) {
5624
+ const det = detect(projectRoot, format);
5625
+ if (!det) return null;
5626
+ const parsed = getParser(det.format).parse(det.localeRoot, { locales: [det.sourceLocale] });
5627
+ const cache2 = buildLocationUsageCache(parsed);
5628
+ saveUsageCache(projectRoot, cache2);
5629
+ return cache2;
5630
+ }
5631
+ var LOCATION_SCANNED_ADAPTERS;
5632
+ var init_usage = __esm({
5633
+ "src/server/import/usage.ts"() {
5634
+ "use strict";
5635
+ init_detect();
5636
+ init_parsers();
5637
+ init_scanner();
5638
+ init_scan();
5639
+ LOCATION_SCANNED_ADAPTERS = /* @__PURE__ */ new Set(["angular-xliff"]);
5720
5640
  }
5721
- if (!("other" in forms)) return null;
5722
- return forms;
5641
+ });
5642
+
5643
+ // src/server/spell.ts
5644
+ function spellTokens(value) {
5645
+ return value.replace(ICU_BLOCK, " ").replace(MASK, " ").match(WORD) ?? [];
5723
5646
  }
5724
- function synthesizeIcu(forms, file, key, warnings) {
5725
- const parts = [];
5726
- for (const cat of PLURAL_CATEGORIES) {
5727
- const body = forms[cat];
5728
- if (body === void 0) continue;
5729
- if (body.includes("#")) {
5730
- warnings.push(
5731
- `rails-yaml: ${file}: plural "${key}" form "${cat}" contains "#", which ICU reads as the count placeholder`
5732
- );
5647
+ function ignoreWordsFor(glossary, customWords = []) {
5648
+ const set = /* @__PURE__ */ new Set();
5649
+ const add = (text) => {
5650
+ for (const w of text.match(WORD) ?? []) set.add(w.toLowerCase());
5651
+ };
5652
+ for (const e of glossary) {
5653
+ add(e.term);
5654
+ for (const t of Object.values(e.translations ?? {})) add(t);
5655
+ }
5656
+ for (const w of customWords) add(w);
5657
+ return set;
5658
+ }
5659
+ async function getSpeller(dictId) {
5660
+ const key = norm(dictId);
5661
+ const existing = instances.get(key);
5662
+ if (existing) return existing;
5663
+ if (unavailable.has(key)) return null;
5664
+ try {
5665
+ const nspellMod = await import("nspell");
5666
+ const nspell = nspellMod.default ?? nspellMod;
5667
+ const dictMod = await import(`dictionary-${key}`);
5668
+ const dictExport = dictMod.default ?? dictMod;
5669
+ const dict = typeof dictExport === "function" ? await dictExport() : dictExport;
5670
+ const speller = nspell(dict);
5671
+ instances.set(key, speller);
5672
+ return speller;
5673
+ } catch {
5674
+ unavailable.add(key);
5675
+ return null;
5676
+ } finally {
5677
+ loading.delete(key);
5678
+ }
5679
+ }
5680
+ function spellValue(dictId, value, ignore) {
5681
+ const key = norm(dictId);
5682
+ if (unavailable.has(key)) return [];
5683
+ const spell = instances.get(key);
5684
+ if (!spell) {
5685
+ if (!loading.has(key)) {
5686
+ loading.add(key);
5687
+ void getSpeller(key);
5733
5688
  }
5734
- parts.push(`${cat} {${fromRuby(body)}}`);
5689
+ return null;
5735
5690
  }
5736
- return `{count, plural, ${parts.join(" ")}}`;
5691
+ const cacheKey = key + " " + value;
5692
+ let allBad = cache.get(cacheKey);
5693
+ if (!allBad) {
5694
+ allBad = spellTokens(value).filter((w) => !spell.correct(w));
5695
+ cache.set(cacheKey, allBad);
5696
+ }
5697
+ return allBad.filter((w) => !ignore.has(w.toLowerCase()));
5737
5698
  }
5738
- var LOCALE_RE8, CATEGORY_SET, railsYaml2;
5739
- var init_rails_yaml2 = __esm({
5740
- "src/server/import/parsers/rails-yaml.ts"() {
5699
+ var instances, loading, unavailable, cache, norm, ICU_BLOCK, MASK, WORD;
5700
+ var init_spell = __esm({
5701
+ "src/server/spell.ts"() {
5741
5702
  "use strict";
5742
- init_schema();
5743
- LOCALE_RE8 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/i;
5744
- CATEGORY_SET = new Set(PLURAL_CATEGORIES);
5745
- railsYaml2 = {
5746
- name: "rails-yaml",
5747
- parse(localeRoot, opts) {
5748
- const warnings = [];
5749
- const keys = {};
5750
- const locales = [];
5751
- const wanted = opts?.locales?.map((l) => l.toLowerCase());
5752
- const addValue = (key, locale, value) => {
5753
- (keys[key] ??= { values: {} }).values[locale] = value;
5754
- };
5755
- const flatten = (node, prefix, locale, file) => {
5756
- for (const [k, v] of Object.entries(node)) {
5757
- const key = prefix ? `${prefix}.${k}` : k;
5758
- if (typeof v === "string") {
5759
- if (v !== "") addValue(key, locale, fromRuby(v));
5760
- continue;
5703
+ instances = /* @__PURE__ */ new Map();
5704
+ loading = /* @__PURE__ */ new Set();
5705
+ unavailable = /* @__PURE__ */ new Set();
5706
+ cache = /* @__PURE__ */ new Map();
5707
+ norm = (dictId) => dictId.toLowerCase();
5708
+ ICU_BLOCK = /\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g;
5709
+ MASK = /\{[^}]*\}|<[^>]*>|:\w+|%[sd]/g;
5710
+ WORD = new RegExp("\\p{L}[\\p{L}''\u2019-]*", "gu");
5711
+ }
5712
+ });
5713
+
5714
+ // src/server/lint/spelling.ts
5715
+ var spellingRule, defaultLoader;
5716
+ var init_spelling = __esm({
5717
+ "src/server/lint/spelling.ts"() {
5718
+ "use strict";
5719
+ init_spell();
5720
+ spellingRule = {
5721
+ id: "spelling",
5722
+ run(state, ctx) {
5723
+ const out = [];
5724
+ for (const key of Object.keys(state.keys)) {
5725
+ const entry = state.keys[key];
5726
+ for (const locale of ctx.targetLocales) {
5727
+ const speller = ctx.spellers.get(locale);
5728
+ if (!speller) continue;
5729
+ const value = entry.values[locale]?.value;
5730
+ if (!value) continue;
5731
+ for (const word of spellTokens(value)) {
5732
+ if (ctx.allowWords.has(word.toLowerCase())) continue;
5733
+ if (!speller.correct(word)) {
5734
+ out.push({ ruleId: "spelling", key, locale, message: `possible misspelling: "${word}"` });
5735
+ }
5736
+ }
5737
+ }
5738
+ }
5739
+ return out;
5740
+ }
5741
+ };
5742
+ defaultLoader = (dictId) => getSpeller(dictId);
5743
+ }
5744
+ });
5745
+
5746
+ // src/server/lint/rules.ts
5747
+ var emptySourceRule, emptyTranslationRule, identicalToSourceRule, whitespaceRule, placeholderMismatchRule, icuMismatchRule, maxLengthRule, glossaryViolationRule, ALL_RULES;
5748
+ var init_rules = __esm({
5749
+ "src/server/lint/rules.ts"() {
5750
+ "use strict";
5751
+ init_scan();
5752
+ init_placeholders();
5753
+ init_glossary();
5754
+ init_spelling();
5755
+ emptySourceRule = {
5756
+ id: "empty-source",
5757
+ run(state, ctx) {
5758
+ const out = [];
5759
+ for (const key of Object.keys(state.keys)) {
5760
+ const entry = state.keys[key];
5761
+ const v = entry.plural ? entry.values[ctx.sourceLocale]?.forms?.other : entry.values[ctx.sourceLocale]?.value;
5762
+ if (!v || !v.trim()) out.push({ ruleId: "empty-source", key, locale: "", message: "source value is empty" });
5763
+ }
5764
+ return out;
5765
+ }
5766
+ };
5767
+ emptyTranslationRule = {
5768
+ id: "empty-translation",
5769
+ // findMissing is the shared "untranslated" walk (also behind the editor's
5770
+ // untranslated check and /scan/missing); a whitespace-only value counts as
5771
+ // missing there, so no separate whitespace pass is needed.
5772
+ run(state) {
5773
+ const out = [];
5774
+ for (const m of findMissing(state)) {
5775
+ out.push({ ruleId: "empty-translation", key: m.key, locale: m.locale, message: "translation is empty or missing" });
5776
+ }
5777
+ return out;
5778
+ }
5779
+ };
5780
+ identicalToSourceRule = {
5781
+ id: "identical-to-source",
5782
+ run(state, ctx) {
5783
+ const out = [];
5784
+ for (const key of Object.keys(state.keys)) {
5785
+ const entry = state.keys[key];
5786
+ if (entry.skipTranslate) continue;
5787
+ const src = entry.values[ctx.sourceLocale]?.value;
5788
+ if (!src) continue;
5789
+ for (const locale of ctx.targetLocales) {
5790
+ const v = entry.values[locale]?.value;
5791
+ if (v && v === src) out.push({ ruleId: "identical-to-source", key, locale, message: "translation is identical to the source" });
5792
+ }
5793
+ }
5794
+ return out;
5795
+ }
5796
+ };
5797
+ whitespaceRule = {
5798
+ id: "whitespace",
5799
+ run(state, ctx) {
5800
+ const out = [];
5801
+ for (const key of Object.keys(state.keys)) {
5802
+ const entry = state.keys[key];
5803
+ const src = entry.values[ctx.sourceLocale]?.value ?? "";
5804
+ const srcEdge = src !== src.trim();
5805
+ for (const locale of ctx.targetLocales) {
5806
+ const v = entry.values[locale]?.value;
5807
+ if (!v) continue;
5808
+ if (v !== v.trim() !== srcEdge) {
5809
+ out.push({ ruleId: "whitespace", key, locale, message: "leading/trailing whitespace differs from the source" });
5810
+ }
5811
+ }
5812
+ }
5813
+ return out;
5814
+ }
5815
+ };
5816
+ placeholderMismatchRule = {
5817
+ id: "placeholder-mismatch",
5818
+ run(state, ctx) {
5819
+ const out = [];
5820
+ for (const key of Object.keys(state.keys)) {
5821
+ const entry = state.keys[key];
5822
+ if (entry.plural) {
5823
+ const srcForm = entry.values[ctx.sourceLocale]?.forms?.other;
5824
+ if (!srcForm) continue;
5825
+ for (const locale of ctx.targetLocales) {
5826
+ const forms = entry.values[locale]?.forms;
5827
+ if (!forms) continue;
5828
+ const bad = Object.entries(forms).some(
5829
+ ([cat, form]) => form && !pluralFormPlaceholdersMatch(cat, srcForm, form)
5830
+ );
5831
+ if (bad) {
5832
+ out.push({ ruleId: "placeholder-mismatch", key, locale, message: "placeholders differ from the source" });
5833
+ }
5761
5834
  }
5762
- const forms = asPluralForms(v);
5763
- if (forms) addValue(key, locale, synthesizeIcu(forms, file, key, warnings));
5764
- else flatten(v, key, locale, file);
5765
- }
5766
- };
5767
- for (const file of readdirSync12(localeRoot).sort()) {
5768
- if (!file.endsWith(".yml") && !file.endsWith(".yaml")) continue;
5769
- let text;
5770
- try {
5771
- text = readFileSync20(join14(localeRoot, file), "utf8");
5772
- } catch (e) {
5773
- warnings.push(`rails-yaml: failed to read ${file}: ${e.message}`);
5774
5835
  continue;
5775
5836
  }
5776
- const { roots } = parseYamlSubset(text, file, warnings);
5777
- for (const token of Object.keys(roots).sort()) {
5778
- if (!LOCALE_RE8.test(token)) {
5779
- warnings.push(`rails-yaml: ${file}: top-level key "${token}" is not a locale; subtree skipped`);
5780
- continue;
5837
+ const src = entry.values[ctx.sourceLocale]?.value;
5838
+ if (!src) continue;
5839
+ for (const locale of ctx.targetLocales) {
5840
+ const v = entry.values[locale]?.value;
5841
+ if (!v) continue;
5842
+ if (!placeholdersMatch(src, v)) {
5843
+ out.push({ ruleId: "placeholder-mismatch", key, locale, message: "placeholders differ from the source" });
5781
5844
  }
5782
- if (wanted && !wanted.includes(token.toLowerCase())) continue;
5783
- if (!locales.includes(token)) locales.push(token);
5784
- flatten(roots[token], "", token, file);
5785
5845
  }
5786
5846
  }
5787
- return { locales, keys, warnings };
5847
+ return out;
5848
+ }
5849
+ };
5850
+ icuMismatchRule = {
5851
+ id: "icu-mismatch",
5852
+ run(state, ctx) {
5853
+ const out = [];
5854
+ for (const key of Object.keys(state.keys)) {
5855
+ const entry = state.keys[key];
5856
+ const src = entry.values[ctx.sourceLocale]?.value;
5857
+ if (!src) continue;
5858
+ const srcIcu = isIcuPluralOrSelect(src);
5859
+ for (const locale of ctx.targetLocales) {
5860
+ const v = entry.values[locale]?.value;
5861
+ if (!v) continue;
5862
+ if (isIcuPluralOrSelect(v) !== srcIcu) {
5863
+ out.push({
5864
+ ruleId: "icu-mismatch",
5865
+ key,
5866
+ locale,
5867
+ message: srcIcu ? "source is an ICU plural/select but the translation is not" : "translation is an ICU plural/select but the source is not"
5868
+ });
5869
+ }
5870
+ }
5871
+ }
5872
+ return out;
5873
+ }
5874
+ };
5875
+ maxLengthRule = {
5876
+ id: "max-length",
5877
+ run(state, ctx) {
5878
+ const out = [];
5879
+ for (const key of Object.keys(state.keys)) {
5880
+ const entry = state.keys[key];
5881
+ const max = entry.maxLength;
5882
+ if (max == null) continue;
5883
+ for (const locale of ctx.targetLocales) {
5884
+ const v = entry.values[locale]?.value;
5885
+ if (v && v.length > max) {
5886
+ out.push({ ruleId: "max-length", key, locale, message: `length ${v.length} exceeds maxLength ${max}` });
5887
+ }
5888
+ }
5889
+ }
5890
+ return out;
5891
+ }
5892
+ };
5893
+ glossaryViolationRule = {
5894
+ id: "glossary-violation",
5895
+ run(state, ctx) {
5896
+ const out = [];
5897
+ for (const key of Object.keys(state.keys)) {
5898
+ const entry = state.keys[key];
5899
+ const src = entry.values[ctx.sourceLocale]?.value;
5900
+ if (!src) continue;
5901
+ for (const locale of ctx.targetLocales) {
5902
+ const v = entry.values[locale]?.value;
5903
+ if (!v) continue;
5904
+ for (const viol of glossaryViolations(src, v, locale, ctx.glossary)) {
5905
+ out.push({
5906
+ ruleId: "glossary-violation",
5907
+ key,
5908
+ locale,
5909
+ message: viol.kind === "do-not-translate" ? `do-not-translate term "${viol.term}" is missing or altered` : `expected glossary translation "${viol.expected}" for "${viol.term}"`
5910
+ });
5911
+ }
5912
+ }
5913
+ }
5914
+ return out;
5788
5915
  }
5789
5916
  };
5917
+ ALL_RULES = [
5918
+ emptySourceRule,
5919
+ emptyTranslationRule,
5920
+ placeholderMismatchRule,
5921
+ icuMismatchRule,
5922
+ glossaryViolationRule,
5923
+ maxLengthRule,
5924
+ identicalToSourceRule,
5925
+ whitespaceRule,
5926
+ spellingRule
5927
+ ];
5790
5928
  }
5791
5929
  });
5792
5930
 
5793
- // src/server/import/parsers/apple-stringsdict.ts
5794
- import { readdirSync as readdirSync13, readFileSync as readFileSync21, statSync as statSync7 } from "fs";
5795
- import { join as join15 } from "path";
5796
- function localeFromLproj2(dir) {
5797
- const m = dir.match(/^(.+)\.lproj$/);
5798
- if (!m) return null;
5799
- return LOCALE_RE9.test(m[1]) ? m[1] : null;
5931
+ // src/server/lint/run.ts
5932
+ function resolveSeverity(id, config) {
5933
+ return config.rules?.[id] ?? DEFAULT_SEVERITY[id];
5800
5934
  }
5801
- function decodeEntities2(s) {
5802
- return s.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16))).replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d))).replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, "&");
5935
+ function sortFindings(findings) {
5936
+ return [...findings].sort(
5937
+ (a, b) => a.key.localeCompare(b.key) || a.locale.localeCompare(b.locale) || a.ruleId.localeCompare(b.ruleId)
5938
+ );
5803
5939
  }
5804
- function parsePlistDict(xml) {
5805
- let i = 0;
5806
- const n = xml.length;
5807
- const skipTrivia = () => {
5808
- for (; ; ) {
5809
- while (i < n && /\s/.test(xml[i])) i++;
5810
- if (xml.startsWith("<!--", i)) {
5811
- const end = xml.indexOf("-->", i + 4);
5812
- if (end === -1) throw new Error("unterminated comment");
5813
- i = end + 3;
5814
- continue;
5815
- }
5816
- if (xml.startsWith("<?", i) || xml.startsWith("<!", i) && !xml.startsWith("<!--", i)) {
5817
- const end = xml.indexOf(">", i);
5818
- if (end === -1) throw new Error("unterminated declaration");
5819
- i = end + 1;
5820
- continue;
5821
- }
5822
- break;
5823
- }
5824
- };
5825
- const readTag = () => {
5826
- if (xml[i] !== "<") throw new Error(`expected a tag at offset ${i}`);
5827
- const end = xml.indexOf(">", i);
5828
- if (end === -1) throw new Error("unterminated tag");
5829
- let body = xml.slice(i + 1, end).trim();
5830
- i = end + 1;
5831
- const closing = body.startsWith("/");
5832
- if (closing) body = body.slice(1).trim();
5833
- const selfClosing = body.endsWith("/");
5834
- if (selfClosing) body = body.slice(0, -1).trim();
5835
- const name = body.split(/\s/)[0];
5836
- if (!name) throw new Error(`empty tag at offset ${end}`);
5837
- return { name, closing, selfClosing };
5838
- };
5839
- const readElementText = (name) => {
5840
- const re = new RegExp(`</${name}\\s*>`, "g");
5841
- re.lastIndex = i;
5842
- const m = re.exec(xml);
5843
- if (!m) throw new Error(`unterminated <${name}>`);
5844
- const text = xml.slice(i, m.index);
5845
- i = m.index + m[0].length;
5846
- return decodeEntities2(text);
5940
+ function countSeverities(findings) {
5941
+ let error = 0, warn = 0;
5942
+ for (const f of findings) {
5943
+ if (f.suppressed) continue;
5944
+ f.severity === "error" ? error++ : warn++;
5945
+ }
5946
+ return { error, warn };
5947
+ }
5948
+ async function loadSpellers(locales, config, load, warn) {
5949
+ const map = /* @__PURE__ */ new Map();
5950
+ for (const locale of locales) {
5951
+ const dictId = config.spelling?.locales?.[locale] ?? locale;
5952
+ const speller = await load(dictId);
5953
+ if (speller) map.set(locale, speller);
5954
+ else warn(`no dictionary for "${locale}", skipping spelling`);
5955
+ }
5956
+ return map;
5957
+ }
5958
+ async function runLint(state, options = {}) {
5959
+ const config = state.config.lint ?? {};
5960
+ const rules = options.rules ?? ALL_RULES;
5961
+ const warn = options.warn ?? ((m) => console.warn(m));
5962
+ const load = options.loadSpeller ?? defaultLoader;
5963
+ const targetLocales = state.config.locales.filter((l) => l !== state.config.sourceLocale);
5964
+ const isActive = (rule) => {
5965
+ if (options.ruleIds && !options.ruleIds.includes(rule.id)) return false;
5966
+ return resolveSeverity(rule.id, config) !== "off";
5847
5967
  };
5848
- const readValue = (tag2) => {
5849
- if (tag2.name === "dict") return tag2.selfClosing ? {} : readDict();
5850
- if (tag2.name === "true" || tag2.name === "false") {
5851
- if (!tag2.selfClosing) readElementText(tag2.name);
5852
- return tag2.name;
5853
- }
5854
- if (["string", "integer", "real", "date", "data"].includes(tag2.name)) {
5855
- return tag2.selfClosing ? "" : readElementText(tag2.name);
5856
- }
5857
- throw new Error(`unsupported plist element <${tag2.name}>`);
5968
+ const active = rules.filter(isActive);
5969
+ const spellingOn = active.some((r) => r.id === "spelling");
5970
+ const spellers = spellingOn ? await loadSpellers(targetLocales, config, load, warn) : /* @__PURE__ */ new Map();
5971
+ const allowWords = spellingOn ? ignoreWordsFor(state.glossary, state.config.spelling?.customWords) : /* @__PURE__ */ new Set();
5972
+ const ctx = {
5973
+ config,
5974
+ sourceLocale: state.config.sourceLocale,
5975
+ targetLocales,
5976
+ glossary: state.glossary,
5977
+ spellers,
5978
+ allowWords
5858
5979
  };
5859
- const readDict = () => {
5860
- const out = {};
5861
- for (; ; ) {
5862
- skipTrivia();
5863
- const tag2 = readTag();
5864
- if (tag2.closing) {
5865
- if (tag2.name !== "dict") throw new Error(`unexpected </${tag2.name}> inside <dict>`);
5866
- return out;
5867
- }
5868
- if (tag2.name !== "key") throw new Error(`expected <key> inside <dict>, got <${tag2.name}>`);
5869
- const key = readElementText("key");
5870
- skipTrivia();
5871
- const vt = readTag();
5872
- if (vt.closing) throw new Error(`<key>${key}</key> has no value`);
5873
- out[key] = readValue(vt);
5980
+ const ignoreRes = (config.ignore ?? []).map(globToRegExp);
5981
+ const localeFilter = options.locales ? new Set(options.locales) : null;
5982
+ const findings = [];
5983
+ let suppressed = 0;
5984
+ for (const rule of active) {
5985
+ const severity = resolveSeverity(rule.id, config);
5986
+ for (const raw of rule.run(state, ctx)) {
5987
+ if (ignoreRes.some((re) => re.test(raw.key))) continue;
5988
+ if (localeFilter && raw.locale !== "" && !localeFilter.has(raw.locale)) continue;
5989
+ const entry = state.keys[raw.key];
5990
+ if (raw.locale !== "" && entry && findSuppression(entry, state.config.sourceLocale, rule.id, raw.locale)) {
5991
+ suppressed++;
5992
+ if (options.includeSuppressed) findings.push({ ...raw, severity, suppressed: true });
5993
+ continue;
5994
+ }
5995
+ findings.push({ ...raw, severity });
5874
5996
  }
5875
- };
5876
- skipTrivia();
5877
- let tag = readTag();
5878
- if (tag.name === "plist" && !tag.closing && !tag.selfClosing) {
5879
- skipTrivia();
5880
- tag = readTag();
5881
5997
  }
5882
- if (tag.name !== "dict" || tag.closing) throw new Error("expected a root <dict>");
5883
- return tag.selfClosing ? {} : readDict();
5998
+ const sorted = sortFindings(findings);
5999
+ const counts = { ...countSeverities(sorted), suppressed };
6000
+ return { findings: sorted, counts, ok: counts.error === 0 };
5884
6001
  }
5885
- function entryToIcu(key, entry, file, warnings) {
5886
- const warn = (msg) => {
5887
- warnings.push(`apple-stringsdict: ${file}: key "${key}": ${msg}`);
5888
- return null;
5889
- };
5890
- if (typeof entry !== "object") return warn("value is not a dict; skipped");
5891
- const fmt = entry["NSStringLocalizedFormatKey"];
5892
- if (typeof fmt !== "string") return warn("missing NSStringLocalizedFormatKey; skipped");
5893
- const vars = [...fmt.matchAll(VAR_RE)];
5894
- if (vars.length !== 1) {
5895
- return warn(`format key has ${vars.length} %#@\u2026@ variables; only exactly one is supported; skipped`);
5896
- }
5897
- const arg = vars[0][1];
5898
- if (!/^\w+$/.test(arg)) return warn(`variable name "${arg}" is not a valid ICU argument; skipped`);
5899
- const prefix = fmt.slice(0, vars[0].index);
5900
- const suffix = fmt.slice(vars[0].index + vars[0][0].length);
5901
- const varDict = entry[arg];
5902
- if (typeof varDict !== "object") return warn(`variable "${arg}" has no dict; skipped`);
5903
- const specType = varDict["NSStringFormatSpecTypeKey"];
5904
- if (specType !== void 0 && specType !== "NSStringPluralRuleType") {
5905
- return warn(`variable "${arg}" is not a plural rule (${String(specType)}); skipped`);
6002
+ var init_run2 = __esm({
6003
+ "src/server/lint/run.ts"() {
6004
+ "use strict";
6005
+ init_glob();
6006
+ init_registry();
6007
+ init_rules();
6008
+ init_spelling();
6009
+ init_spell();
6010
+ init_suppress();
5906
6011
  }
5907
- const valueType = varDict["NSStringFormatValueTypeKey"];
5908
- const token = `%${typeof valueType === "string" && valueType ? valueType : "d"}`;
5909
- const forms = {};
5910
- for (const cat of PLURAL_CATEGORIES) {
5911
- const body = varDict[cat];
5912
- if (typeof body !== "string") continue;
5913
- forms[cat] = prefix + body.split(token).join(`{${arg}}`) + suffix;
6012
+ });
6013
+
6014
+ // src/server/lint/outputs.ts
6015
+ import { readFileSync as readFileSync21, existsSync as existsSync12 } from "fs";
6016
+ import { resolve as resolve8 } from "path";
6017
+ function checkOutputs(state, root) {
6018
+ const out = [];
6019
+ for (const output of state.config.outputs) {
6020
+ const result = getAdapter(output.adapter).export(state, output);
6021
+ for (const file of result.files) {
6022
+ const abs = resolve8(root, file.path);
6023
+ const current = existsSync12(abs) ? readFileSync21(abs, "utf8") : null;
6024
+ if (current === null) {
6025
+ out.push({ ruleId: "output-stale", key: file.path, locale: "", severity: "error", message: "output file is missing; run `glotfile export`" });
6026
+ } else if (current !== file.contents) {
6027
+ out.push({ ruleId: "output-stale", key: file.path, locale: "", severity: "error", message: "output file is out of date; run `glotfile export`" });
6028
+ }
6029
+ }
5914
6030
  }
5915
- if (forms.other === void 0) return warn(`variable "${arg}" has no "other" form; skipped`);
5916
- return formsToIcu(arg, forms);
6031
+ return out;
5917
6032
  }
5918
- var LOCALE_RE9, TABLE2, VAR_RE, appleStringsdict2;
5919
- var init_apple_stringsdict2 = __esm({
5920
- "src/server/import/parsers/apple-stringsdict.ts"() {
6033
+ var init_outputs = __esm({
6034
+ "src/server/lint/outputs.ts"() {
5921
6035
  "use strict";
5922
- init_schema();
5923
- init_plurals();
5924
- LOCALE_RE9 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5925
- TABLE2 = "Localizable.stringsdict";
5926
- VAR_RE = /%#@([^@]*)@/g;
5927
- appleStringsdict2 = {
5928
- name: "apple-stringsdict",
5929
- parse(localeRoot, opts) {
5930
- const warnings = [];
5931
- const keys = {};
5932
- const locales = [];
5933
- for (const dir of readdirSync13(localeRoot).sort()) {
5934
- const locale = localeFromLproj2(dir);
5935
- if (!locale) continue;
5936
- if (opts?.locales && !opts.locales.includes(locale)) continue;
5937
- const file = join15(localeRoot, dir, TABLE2);
5938
- let text;
5939
- try {
5940
- if (!statSync7(file).isFile()) continue;
5941
- text = readFileSync21(file, "utf8");
5942
- } catch {
5943
- continue;
5944
- }
5945
- locales.push(locale);
5946
- const others = readdirSync13(join15(localeRoot, dir)).filter(
5947
- (f) => f.endsWith(".stringsdict") && f !== TABLE2
5948
- );
5949
- if (others.length) {
5950
- warnings.push(
5951
- `apple-stringsdict: ${dir} has other .stringsdict tables (${others.join(", ")}); only ${TABLE2} is imported`
5952
- );
5953
- }
5954
- let root;
5955
- try {
5956
- root = parsePlistDict(text);
5957
- } catch (e) {
5958
- warnings.push(`apple-stringsdict: failed to parse ${file}: ${e.message}`);
5959
- continue;
5960
- }
5961
- for (const key of Object.keys(root).sort()) {
5962
- const icu = entryToIcu(key, root[key], file, warnings);
5963
- if (icu === null) continue;
5964
- (keys[key] ??= { values: {} }).values[locale] = icu;
5965
- }
5966
- }
5967
- return { locales, keys, warnings };
5968
- }
5969
- };
6036
+ init_adapters();
5970
6037
  }
5971
6038
  });
5972
6039
 
5973
- // src/server/import/parsers/index.ts
5974
- function getParser(name) {
5975
- const p = REGISTRY[name];
5976
- if (!p) throw new Error(`Unknown format: ${name} (known: ${Object.keys(REGISTRY).join(", ")})`);
5977
- return p;
6040
+ // src/server/lint/accept.ts
6041
+ var accept_exports = {};
6042
+ __export(accept_exports, {
6043
+ acceptFindings: () => acceptFindings
6044
+ });
6045
+ function acceptFindings(state, findings, opts = {}, clock = systemClock) {
6046
+ const byRule = {};
6047
+ let accepted = 0;
6048
+ for (const f of findings) {
6049
+ if (f.locale === "" || f.suppressed) continue;
6050
+ if (f.severity === "error" && !opts.includeErrors) continue;
6051
+ if (opts.rules && !opts.rules.includes(f.ruleId)) continue;
6052
+ if (opts.locales && !opts.locales.includes(f.locale)) continue;
6053
+ if (!state.keys[f.key]) continue;
6054
+ addSuppression(state, f.key, f.ruleId, f.locale, clock);
6055
+ byRule[f.ruleId] = (byRule[f.ruleId] ?? 0) + 1;
6056
+ accepted++;
6057
+ }
6058
+ return { accepted, byRule };
5978
6059
  }
5979
- var REGISTRY;
5980
- var init_parsers = __esm({
5981
- "src/server/import/parsers/index.ts"() {
6060
+ var init_accept = __esm({
6061
+ "src/server/lint/accept.ts"() {
5982
6062
  "use strict";
5983
- init_vue_i18n_json2();
5984
- init_laravel_php2();
5985
- init_flutter_arb2();
5986
- init_apple_strings2();
5987
- init_angular_xliff2();
5988
- init_gettext_po2();
5989
- init_i18next_json2();
5990
- init_rails_yaml2();
5991
- init_apple_stringsdict2();
5992
- REGISTRY = {
5993
- [vueI18nJson2.name]: vueI18nJson2,
5994
- [laravelPhp2.name]: laravelPhp2,
5995
- [flutterArb2.name]: flutterArb2,
5996
- [appleStrings2.name]: appleStrings2,
5997
- [angularXliff2.name]: angularXliff2,
5998
- [gettextPo2.name]: gettextPo2,
5999
- [i18nextJson2.name]: i18nextJson2,
6000
- [railsYaml2.name]: railsYaml2,
6001
- [appleStringsdict2.name]: appleStringsdict2
6002
- };
6063
+ init_state();
6003
6064
  }
6004
6065
  });
6005
6066
 
@@ -6096,11 +6157,107 @@ var init_assemble = __esm({
6096
6157
  }
6097
6158
  });
6098
6159
 
6160
+ // src/server/import/merge.ts
6161
+ function hasContent(lv) {
6162
+ if (!lv) return false;
6163
+ return !!(lv.forms ? lv.forms.other?.trim() : lv.value?.trim());
6164
+ }
6165
+ function formsEqual(a, b) {
6166
+ const ak = Object.keys(a ?? {}).sort();
6167
+ const bk = Object.keys(b ?? {}).sort();
6168
+ if (ak.length !== bk.length || ak.some((k, i) => k !== bk[i])) return false;
6169
+ return ak.every((k) => a[k] === b[k]);
6170
+ }
6171
+ function sameSource(cur, inc, src) {
6172
+ if (!!cur.plural !== !!inc.plural) return false;
6173
+ const c = cur.values[src];
6174
+ const i = inc.values[src];
6175
+ return cur.plural ? formsEqual(c?.forms, i?.forms) : (c?.value ?? "") === (i?.value ?? "");
6176
+ }
6177
+ function applyIncomingSource(cur, inc, src, shapeChanged) {
6178
+ const incSrc = inc.values[src];
6179
+ if (shapeChanged) {
6180
+ if (inc.plural) cur.plural = { arg: inc.plural.arg };
6181
+ else delete cur.plural;
6182
+ cur.values = { [src]: { ...incSrc, state: "source" } };
6183
+ return;
6184
+ }
6185
+ const existing = cur.values[src];
6186
+ if (cur.plural) cur.values[src] = { ...existing, forms: incSrc?.forms, state: "source" };
6187
+ else cur.values[src] = { ...existing, value: incSrc?.value, state: "source" };
6188
+ }
6189
+ function cloneForAdd(inc, allowed) {
6190
+ const entry = structuredClone(inc);
6191
+ for (const loc of Object.keys(entry.values)) {
6192
+ if (!allowed.has(loc)) delete entry.values[loc];
6193
+ }
6194
+ return entry;
6195
+ }
6196
+ function mergeStates(existing, incoming, opts = {}) {
6197
+ const state = structuredClone(existing);
6198
+ const src = state.config.sourceLocale;
6199
+ const targets = state.config.locales.filter((l) => l !== src);
6200
+ const allowed = new Set(state.config.locales);
6201
+ const live = opts.liveKeys ?? new Set(Object.keys(incoming.keys));
6202
+ const plan = { added: [], sourceChanged: [], adopted: [], removed: [], unchanged: 0 };
6203
+ for (const [key, inc] of Object.entries(incoming.keys)) {
6204
+ if (!live.has(key)) continue;
6205
+ const cur = state.keys[key];
6206
+ if (!cur) {
6207
+ const entry = cloneForAdd(inc, allowed);
6208
+ if (!entry.createdAt) entry.createdAt = (/* @__PURE__ */ new Date()).toISOString();
6209
+ state.keys[key] = entry;
6210
+ plan.added.push(key);
6211
+ continue;
6212
+ }
6213
+ const shapeChanged = !!cur.plural !== !!inc.plural;
6214
+ const srcChanged = !sameSource(cur, inc, src);
6215
+ if (srcChanged) {
6216
+ applyIncomingSource(cur, inc, src, shapeChanged);
6217
+ plan.sourceChanged.push(key);
6218
+ for (const loc of targets) {
6219
+ const lv = cur.values[loc];
6220
+ if (lv && hasContent(lv)) lv.state = "needs-review";
6221
+ }
6222
+ }
6223
+ if (inc.placeholders) cur.placeholders = inc.placeholders;
6224
+ else delete cur.placeholders;
6225
+ if (!cur.description && inc.description) cur.description = inc.description;
6226
+ let adoptedHere = false;
6227
+ for (const loc of targets) {
6228
+ const incLv = inc.values[loc];
6229
+ if (!hasContent(incLv)) continue;
6230
+ if (hasContent(cur.values[loc])) continue;
6231
+ cur.values[loc] = { ...structuredClone(incLv), state: "reviewed" };
6232
+ plan.adopted.push({ key, locale: loc });
6233
+ adoptedHere = true;
6234
+ }
6235
+ if (!srcChanged && !adoptedHere) plan.unchanged++;
6236
+ }
6237
+ for (const key of Object.keys(state.keys)) {
6238
+ if (!live.has(key)) {
6239
+ plan.removed.push(key);
6240
+ if (opts.prune) delete state.keys[key];
6241
+ }
6242
+ }
6243
+ plan.added.sort();
6244
+ plan.sourceChanged.sort();
6245
+ plan.removed.sort();
6246
+ plan.adopted.sort((a, b) => a.key.localeCompare(b.key) || a.locale.localeCompare(b.locale));
6247
+ return { state, plan };
6248
+ }
6249
+ var init_merge = __esm({
6250
+ "src/server/import/merge.ts"() {
6251
+ "use strict";
6252
+ }
6253
+ });
6254
+
6099
6255
  // src/server/import/run.ts
6100
6256
  var run_exports = {};
6101
6257
  __export(run_exports, {
6102
6258
  previewImport: () => previewImport,
6103
- runImport: () => runImport
6259
+ runImport: () => runImport,
6260
+ runSync: () => runSync
6104
6261
  });
6105
6262
  import { relative as relative3 } from "path";
6106
6263
  function previewImport(projectRoot, format) {
@@ -6125,6 +6282,29 @@ function previewImport(projectRoot, format) {
6125
6282
  sampleKeys
6126
6283
  };
6127
6284
  }
6285
+ function runSync(opts) {
6286
+ const det = detect(opts.projectRoot, opts.format);
6287
+ if (!det) throw new Error(`No recognized locale files found in ${opts.projectRoot}`);
6288
+ const parser = getParser(det.format);
6289
+ const sourceLocale = opts.sourceLocale ?? det.sourceLocale;
6290
+ const parsed = parser.parse(
6291
+ det.localeRoot,
6292
+ opts.locales ? { locales: opts.locales } : void 0
6293
+ );
6294
+ const sourceParse = parser.parse(det.localeRoot, { locales: [sourceLocale] });
6295
+ const liveKeys = new Set(Object.keys(sourceParse.keys));
6296
+ const assembled = assemble2(parsed, {
6297
+ sourceLocale,
6298
+ format: det.format,
6299
+ cldr: opts.cldr,
6300
+ localeRootRel: relative3(opts.projectRoot, det.localeRoot)
6301
+ });
6302
+ const { warnings, ...rest } = assembled;
6303
+ const incoming = validate(rest);
6304
+ const existing = loadState(opts.statePath);
6305
+ const { state, plan } = mergeStates(existing, incoming, { prune: opts.prune, liveKeys });
6306
+ return { state, plan, warnings, keyCount: Object.keys(state.keys).length };
6307
+ }
6128
6308
  function runImport(opts) {
6129
6309
  const det = detect(opts.projectRoot, opts.format);
6130
6310
  if (!det) throw new Error(`No recognized locale files found in ${opts.projectRoot}`);
@@ -6154,6 +6334,8 @@ var init_run3 = __esm({
6154
6334
  init_detect();
6155
6335
  init_parsers();
6156
6336
  init_assemble();
6337
+ init_merge();
6338
+ init_state();
6157
6339
  init_schema();
6158
6340
  }
6159
6341
  });
@@ -7063,6 +7245,39 @@ function createApi(deps) {
7063
7245
  console.log(`[import] ${result.keyCount} key(s) across ${result.localeCount} locale(s)${result.warnings.length ? `, ${result.warnings.length} warning(s)` : ""}`);
7064
7246
  return c.json({ keyCount: result.keyCount, localeCount: result.localeCount, warnings: result.warnings });
7065
7247
  });
7248
+ app.post("/sync", async (c) => {
7249
+ if (Object.keys(load().keys).length === 0) {
7250
+ return c.json({ error: "nothing to sync into; import first" }, 400);
7251
+ }
7252
+ const body = await c.req.json().catch(() => ({}));
7253
+ let result;
7254
+ try {
7255
+ result = runSync({
7256
+ projectRoot,
7257
+ statePath: deps.statePath,
7258
+ format: body.format,
7259
+ sourceLocale: body.sourceLocale,
7260
+ locales: body.locales,
7261
+ cldr: body.cldr,
7262
+ prune: body.prune
7263
+ });
7264
+ } catch (e) {
7265
+ return c.json({ error: e.message }, 400);
7266
+ }
7267
+ if (body.apply !== true) {
7268
+ return c.json({ plan: result.plan, warnings: result.warnings });
7269
+ }
7270
+ persist(result.state);
7271
+ const usageCache = isLocationScannedState(result.state) ? refreshLocationUsage(projectRoot, body.format) : null;
7272
+ const usageRefs = usageCache ? usageCounts(usageCache).refs : void 0;
7273
+ const p = result.plan;
7274
+ logChange({
7275
+ kind: "import",
7276
+ summary: `Synced: +${p.added.length} added, ~${p.sourceChanged.length} changed, -${p.removed.length} removed${body.prune ? " (pruned)" : ""}`
7277
+ });
7278
+ console.log(`[sync] +${p.added.length} ~${p.sourceChanged.length} -${p.removed.length}${body.prune ? " pruned" : ""}`);
7279
+ return c.json({ applied: true, plan: result.plan, warnings: result.warnings, usageRefs });
7280
+ });
7066
7281
  app.post("/export", (c) => {
7067
7282
  const root = dirname3(resolve9(deps.statePath));
7068
7283
  const { written, skipped, deleted, warnings } = exportToDisk(load(), root);
@@ -7326,12 +7541,11 @@ function createApi(deps) {
7326
7541
  app.get("/log", (c) => c.json(readLog(projectRoot, 100)));
7327
7542
  app.post("/scan", async (c) => {
7328
7543
  const s = load();
7329
- const existing = loadUsageCache(projectRoot);
7330
- const result = runScan(projectRoot, s.config.scan ?? {}, existing);
7331
- const fileCount2 = Object.keys(result.files).length;
7332
- const refCount = Object.values(result.files).reduce((n, f) => n + f.refs.length, 0);
7333
- console.log(`[scan] ${fileCount2} file(s), ${refCount} reference(s)`);
7334
- return c.json({ files: fileCount2, refs: refCount, scannedAt: result.scannedAt });
7544
+ const result = isLocationScannedState(s) ? refreshLocationUsage(projectRoot) : runScan(projectRoot, s.config.scan ?? {}, loadUsageCache(projectRoot));
7545
+ if (!result) return c.json({ files: 0, refs: 0, scannedAt: (/* @__PURE__ */ new Date()).toISOString() });
7546
+ const { files, refs } = usageCounts(result);
7547
+ console.log(`[scan] ${files} file(s), ${refs} reference(s)`);
7548
+ return c.json({ files, refs, scannedAt: result.scannedAt });
7335
7549
  });
7336
7550
  app.get("/scan", (c) => {
7337
7551
  const cache2 = loadUsageCache(projectRoot);
@@ -7597,6 +7811,7 @@ var init_api = __esm({
7597
7811
  init_log();
7598
7812
  init_schema();
7599
7813
  init_run3();
7814
+ init_usage();
7600
7815
  init_export_run();
7601
7816
  init_ui_prefs();
7602
7817
  init_local_settings();
@@ -7698,11 +7913,16 @@ function backgroundScan(statePath) {
7698
7913
  const projectRoot = dirname4(resolve10(statePath));
7699
7914
  Promise.resolve().then(() => {
7700
7915
  const state = loadState(statePath);
7916
+ if (isLocationScannedState(state)) {
7917
+ const cache2 = refreshLocationUsage(projectRoot);
7918
+ const { files: files2, refs: refs2 } = cache2 ? usageCounts(cache2) : { files: 0, refs: 0 };
7919
+ console.log(`[scan] ${files2} file(s), ${refs2} reference(s) (from catalog locations)`);
7920
+ return;
7921
+ }
7701
7922
  const existing = loadUsageCache(projectRoot);
7702
7923
  const result = runScan(projectRoot, state.config.scan ?? {}, existing);
7703
- const fileCount2 = Object.keys(result.files).length;
7704
- const refCount = Object.values(result.files).reduce((n, f) => n + f.refs.length, 0);
7705
- console.log(`[scan] ${fileCount2} file(s), ${refCount} reference(s)`);
7924
+ const { files, refs } = usageCounts(result);
7925
+ console.log(`[scan] ${files} file(s), ${refs} reference(s)`);
7706
7926
  }).catch((err) => {
7707
7927
  console.warn("[scan] failed:", err instanceof Error ? err.message : String(err));
7708
7928
  });
@@ -7715,6 +7935,7 @@ var init_server = __esm({
7715
7935
  init_state();
7716
7936
  init_scan();
7717
7937
  init_scanner();
7938
+ init_usage();
7718
7939
  here = dirname4(fileURLToPath(import.meta.url));
7719
7940
  DEFAULT_UI_DIR = join17(here, "..", "ui");
7720
7941
  MIME = {
@@ -7762,6 +7983,7 @@ init_pricing();
7762
7983
  init_log();
7763
7984
  init_scan();
7764
7985
  init_scanner();
7986
+ init_usage();
7765
7987
  init_context();
7766
7988
  init_run2();
7767
7989
  init_outputs();
@@ -7833,7 +8055,7 @@ function formatSarif(report, rawText) {
7833
8055
  }
7834
8056
 
7835
8057
  // src/server/cli.ts
7836
- var COMMANDS = ["serve", "export", "translate", "lint", "check", "import", "build-context", "scan", "prune", "split", "skill", "batch"];
8058
+ var COMMANDS = ["serve", "export", "translate", "lint", "check", "import", "sync", "build-context", "scan", "prune", "split", "skill", "batch"];
7837
8059
  var isCommand = (s) => s != null && COMMANDS.includes(s);
7838
8060
  function parseArgs(argv) {
7839
8061
  const statePath = resolve11(process.cwd(), "glotfile.json");
@@ -7873,7 +8095,7 @@ function parseArgs(argv) {
7873
8095
  i++;
7874
8096
  } else if (flag === "--watch") args.watch = true;
7875
8097
  else if (flag === "--format" && next) {
7876
- if (args.command === "import") args.importFormat = next;
8098
+ if (args.command === "import" || args.command === "sync") args.importFormat = next;
7877
8099
  else args.format = next;
7878
8100
  i++;
7879
8101
  } else if (flag === "--source" && next) {
@@ -7884,6 +8106,8 @@ function parseArgs(argv) {
7884
8106
  i++;
7885
8107
  } else if (flag === "--force") args.importForce = true;
7886
8108
  else if (flag === "--cldr") args.importCldr = true;
8109
+ else if (flag === "--prune") args.prune = true;
8110
+ else if (flag === "--dry-run") args.dryRun = true;
7887
8111
  else if (flag === "--rule" && next) {
7888
8112
  args.ruleIds = next.split(",");
7889
8113
  i++;
@@ -8313,6 +8537,57 @@ async function runImportCmd(args) {
8313
8537
  saveState(out, result.state);
8314
8538
  console.log(`Imported ${result.keyCount} keys across ${result.localeCount} locales \u2192 ${out}`);
8315
8539
  }
8540
+ async function runSyncCmd(args) {
8541
+ const { runSync: runSync2 } = await Promise.resolve().then(() => (init_run3(), run_exports));
8542
+ const projectRoot = args.importSource ? resolve11(args.importSource) : dirname5(resolve11(args.statePath));
8543
+ if (detectFormat(args.statePath) === "none") {
8544
+ console.error(`No glotfile.json found at ${args.statePath}; run 'glotfile import' first.`);
8545
+ process.exitCode = 1;
8546
+ return;
8547
+ }
8548
+ let result;
8549
+ try {
8550
+ result = runSync2({
8551
+ projectRoot,
8552
+ statePath: args.statePath,
8553
+ format: args.importFormat,
8554
+ sourceLocale: args.importSourceLocale,
8555
+ locales: args.locales,
8556
+ cldr: args.importCldr,
8557
+ prune: args.prune
8558
+ });
8559
+ } catch (e) {
8560
+ console.error(e.message);
8561
+ process.exitCode = 1;
8562
+ return;
8563
+ }
8564
+ for (const w of result.warnings) console.error(`warning: ${w}`);
8565
+ const { plan } = result;
8566
+ console.log(
8567
+ `+${plan.added.length} added, ~${plan.sourceChanged.length} source-changed, \u2713${plan.adopted.length} adopted, -${plan.removed.length} removed${plan.removed.length && !args.prune ? " (pass --prune to delete)" : ""}.`
8568
+ );
8569
+ if (args.dryRun) {
8570
+ const list = (label, keys) => {
8571
+ if (keys.length) console.log(`
8572
+ ${label}:
8573
+ ${keys.join("\n ")}`);
8574
+ };
8575
+ list("Added", plan.added);
8576
+ list("Source changed", plan.sourceChanged);
8577
+ list("Adopted", plan.adopted.map((a) => `${a.key} [${a.locale}]`));
8578
+ list("Removed", plan.removed);
8579
+ console.log("\nDry run \u2014 nothing written.");
8580
+ return;
8581
+ }
8582
+ saveState(args.statePath, result.state);
8583
+ if (isLocationScannedState(result.state)) {
8584
+ const cache2 = refreshLocationUsage(projectRoot, args.importFormat);
8585
+ const refs = cache2 ? usageCounts(cache2).refs : 0;
8586
+ console.log(`Synced \u2192 ${args.statePath} (${result.keyCount} keys); usage index rebuilt from ${refs} location(s).`);
8587
+ } else {
8588
+ console.log(`Synced \u2192 ${args.statePath} (${result.keyCount} keys).`);
8589
+ }
8590
+ }
8316
8591
  async function runBuildContext(args) {
8317
8592
  const state = loadState(args.statePath);
8318
8593
  const projectRoot = dirname5(resolve11(args.statePath));
@@ -8413,6 +8688,12 @@ async function runBuildContext(args) {
8413
8688
  async function runScanCmd(args) {
8414
8689
  const state = loadState(args.statePath);
8415
8690
  const projectRoot = dirname5(resolve11(args.statePath));
8691
+ if (isLocationScannedState(state)) {
8692
+ const cache2 = refreshLocationUsage(projectRoot);
8693
+ const refs = cache2 ? usageCounts(cache2).refs : 0;
8694
+ console.log(`Rebuilt usage index from ${refs} catalog location(s) (code scan skipped for this format).`);
8695
+ return;
8696
+ }
8416
8697
  const existing = loadUsageCache(projectRoot);
8417
8698
  const result = runScan(projectRoot, state.config.scan ?? {}, existing);
8418
8699
  const fileCount2 = Object.keys(result.files).length;
@@ -8432,10 +8713,16 @@ async function runPrune(args) {
8432
8713
  }
8433
8714
  if (args.unused) {
8434
8715
  const projectRoot = dirname5(resolve11(args.statePath));
8435
- const cache2 = runScan(projectRoot, state.config.scan ?? {}, loadUsageCache(projectRoot));
8436
- const used = new Set(computeUsedKeys(state, cache2));
8437
- for (const k of Object.keys(state.keys)) {
8438
- if (!used.has(k)) toRemove.add(k);
8716
+ if (isLocationScannedState(state)) {
8717
+ const { runSync: runSync2 } = await Promise.resolve().then(() => (init_run3(), run_exports));
8718
+ const { plan } = runSync2({ projectRoot, statePath: args.statePath, prune: false });
8719
+ for (const k of plan.removed) toRemove.add(k);
8720
+ } else {
8721
+ const cache2 = runScan(projectRoot, state.config.scan ?? {}, loadUsageCache(projectRoot));
8722
+ const used = new Set(computeUsedKeys(state, cache2));
8723
+ for (const k of Object.keys(state.keys)) {
8724
+ if (!used.has(k)) toRemove.add(k);
8725
+ }
8439
8726
  }
8440
8727
  }
8441
8728
  const keys = [...toRemove].sort();
@@ -8539,6 +8826,19 @@ var COMMAND_HELP = {
8539
8826
  ["--force", "Overwrite an existing glotfile.json"]
8540
8827
  ]
8541
8828
  },
8829
+ sync: {
8830
+ summary: "Merge re-extracted locale files into the catalog, preserving glossary, context and translations.",
8831
+ usage: "glotfile sync [--format <name>] [--source <dir>] [--prune] [--dry-run]",
8832
+ options: [
8833
+ ["--format <name>", "Source layout adapter (auto-detected if omitted)"],
8834
+ ["--source <dir>", "Directory to read locale files from (default: the state file's directory)"],
8835
+ ["--source-locale <code>", "Locale to treat as the source"],
8836
+ ["--locales <list>", "Comma-separated locales to read"],
8837
+ ["--cldr", "Expand CLDR plural forms"],
8838
+ ["--prune", "Delete keys that are gone from the import (default: report only)"],
8839
+ ["--dry-run", "Show the changeset without writing anything"]
8840
+ ]
8841
+ },
8542
8842
  "build-context": {
8543
8843
  summary: "AI-generate per-key context to improve translation (requires a prior scan).",
8544
8844
  usage: "glotfile build-context [--all] [--key <glob>] [--limit <n>] [--since <date>] [--batch]",
@@ -8635,6 +8935,7 @@ async function main(argv) {
8635
8935
  if (args.command === "lint") return runLintCmd(args);
8636
8936
  if (args.command === "check") return runCheck(args);
8637
8937
  if (args.command === "import") return runImportCmd(args);
8938
+ if (args.command === "sync") return runSyncCmd(args);
8638
8939
  if (args.command === "build-context") return runBuildContext(args);
8639
8940
  if (args.command === "scan") return runScanCmd(args);
8640
8941
  if (args.command === "prune") return runPrune(args);