glotfile 0.5.3 → 0.6.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.
@@ -122,6 +122,7 @@ function validate(raw) {
122
122
  if (o.indent !== void 0 && typeof o.indent !== "number") fail("config.outputs[].indent must be a number");
123
123
  if (o.finalNewline !== void 0 && typeof o.finalNewline !== "boolean") fail("config.outputs[].finalNewline must be a boolean");
124
124
  if (o.includeLocale !== void 0 && typeof o.includeLocale !== "boolean") fail("config.outputs[].includeLocale must be a boolean");
125
+ if (o.skipSourceLocale !== void 0 && typeof o.skipSourceLocale !== "boolean") fail("config.outputs[].skipSourceLocale must be a boolean");
125
126
  if (o.localeAliases !== void 0) {
126
127
  if (!isObject(o.localeAliases)) fail("config.outputs[].localeAliases must be an object");
127
128
  for (const [k, v] of Object.entries(o.localeAliases)) {
@@ -1574,27 +1575,41 @@ var init_vue_i18n_json = __esm({
1574
1575
  function xmlEscape2(s) {
1575
1576
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1576
1577
  }
1577
- function renderInterpolations(text, ids) {
1578
+ function attrEscape(s) {
1579
+ return xmlEscape2(s).replace(/"/g, "&quot;");
1580
+ }
1581
+ function angularXMeta(placeholders, name) {
1582
+ return /^[A-Z][A-Z0-9_]*$/.test(name) ? placeholders?.[name] : void 0;
1583
+ }
1584
+ function renderInterpolations(text, ids, placeholders) {
1578
1585
  let out = "";
1579
1586
  let last = 0;
1580
1587
  for (const m of text.matchAll(/\{(\w+)\}/g)) {
1581
1588
  const name = m[1];
1582
- let id = ids.get(name);
1583
- if (id === void 0) {
1584
- id = ids.size === 0 ? "INTERPOLATION" : `INTERPOLATION_${ids.size}`;
1585
- ids.set(name, id);
1589
+ out += xmlEscape2(text.slice(last, m.index));
1590
+ const meta = angularXMeta(placeholders, name);
1591
+ if (meta) {
1592
+ const ctype = meta.type ? ` ctype="${attrEscape(meta.type)}"` : "";
1593
+ const equiv = meta.example !== void 0 ? ` equiv-text="${attrEscape(meta.example)}"` : "";
1594
+ out += `<x id="${attrEscape(name)}"${ctype}${equiv}/>`;
1595
+ } else {
1596
+ let id = ids.get(name);
1597
+ if (id === void 0) {
1598
+ id = ids.size === 0 ? "INTERPOLATION" : `INTERPOLATION_${ids.size}`;
1599
+ ids.set(name, id);
1600
+ }
1601
+ out += `<x id="${id}" equiv-text="{{${name}}}"/>`;
1586
1602
  }
1587
- out += xmlEscape2(text.slice(last, m.index)) + `<x id="${id}" equiv-text="{{${name}}}"/>`;
1588
1603
  last = m.index + m[0].length;
1589
1604
  }
1590
1605
  return out + xmlEscape2(text.slice(last));
1591
1606
  }
1592
- function renderPluralIcu(forms, ids) {
1607
+ function renderPluralIcu(forms, ids, placeholders) {
1593
1608
  const cats = [
1594
1609
  ...Object.keys(forms).filter((c) => c.startsWith("=")),
1595
1610
  ...PLURAL_CATEGORIES.filter((c) => forms[c] !== void 0)
1596
1611
  ];
1597
- const branches = cats.map((cat) => `${cat} {${renderInterpolations(forms[cat] ?? "", ids)}}`);
1612
+ const branches = cats.map((cat) => `${cat} {${renderInterpolations(forms[cat] ?? "", ids, placeholders)}}`);
1598
1613
  return `{VAR_PLURAL, plural, ${branches.join(" ")}}`;
1599
1614
  }
1600
1615
  function renderEmbeddedIcu(value) {
@@ -1604,8 +1619,8 @@ function renderEmbeddedIcu(value) {
1604
1619
  );
1605
1620
  return xmlEscape2(renamed);
1606
1621
  }
1607
- function renderScalar(value, ids) {
1608
- return isIcuPluralOrSelect(value) ? renderEmbeddedIcu(value) : renderInterpolations(value, ids);
1622
+ function renderScalar(value, ids, placeholders) {
1623
+ return isIcuPluralOrSelect(value) ? renderEmbeddedIcu(value) : renderInterpolations(value, ids, placeholders);
1609
1624
  }
1610
1625
  var DEFAULT_LOCALE_CASE8, angularXliff;
1611
1626
  var init_angular_xliff = __esm({
@@ -1636,6 +1651,7 @@ var init_angular_xliff = __esm({
1636
1651
  const emptyAs = resolveEmptyAs(output, "source");
1637
1652
  const keys = Object.keys(state.keys).sort();
1638
1653
  for (const locale of state.config.locales) {
1654
+ if (output.skipSourceLocale && locale === sourceLocale) continue;
1639
1655
  const token = resolveLocaleToken(output, locale, DEFAULT_LOCALE_CASE8);
1640
1656
  const units = [];
1641
1657
  for (const key of keys) {
@@ -1646,17 +1662,18 @@ var init_angular_xliff = __esm({
1646
1662
  if (entry.plural) {
1647
1663
  const targetForms = resolveForms(entry, locale, sourceLocale, emptyAs);
1648
1664
  if (targetForms === null) continue;
1649
- source = renderPluralIcu(entry.values[sourceLocale]?.forms ?? {}, ids);
1650
- target = renderPluralIcu(targetForms, ids);
1665
+ source = renderPluralIcu(entry.values[sourceLocale]?.forms ?? {}, ids, entry.placeholders);
1666
+ target = renderPluralIcu(targetForms, ids, entry.placeholders);
1651
1667
  } else {
1652
1668
  const targetValue = resolveScalar(entry, locale, sourceLocale, emptyAs);
1653
1669
  if (targetValue === null) continue;
1654
- source = renderScalar(entry.values[sourceLocale]?.value ?? "", ids);
1655
- target = renderScalar(targetValue, ids);
1670
+ source = renderScalar(entry.values[sourceLocale]?.value ?? "", ids, entry.placeholders);
1671
+ target = renderScalar(targetValue, ids, entry.placeholders);
1656
1672
  }
1673
+ const translated = locale === sourceLocale || (entry.plural ? entry.values[locale]?.forms !== void 0 : !!entry.values[locale]?.value);
1657
1674
  units.push(` <trans-unit id="${xmlEscape2(key)}" datatype="html">`);
1658
1675
  units.push(` <source>${source}</source>`);
1659
- units.push(` <target>${target}</target>`);
1676
+ units.push(` <target${translated ? "" : ' state="new"'}>${target}</target>`);
1660
1677
  if (entry.description) {
1661
1678
  units.push(` <note priority="1" from="description">${xmlEscape2(entry.description)}</note>`);
1662
1679
  }
@@ -2744,6 +2761,46 @@ var init_local_settings = __esm({
2744
2761
  }
2745
2762
  });
2746
2763
 
2764
+ // src/server/glossary.ts
2765
+ function contains(haystack, needle, caseSensitive) {
2766
+ return caseSensitive ? haystack.includes(needle) : haystack.toLowerCase().includes(needle.toLowerCase());
2767
+ }
2768
+ function relevantGlossary(source, targetLocale, glossary) {
2769
+ const hints = [];
2770
+ for (const entry of glossary) {
2771
+ if (!contains(source, entry.term, entry.caseSensitive)) continue;
2772
+ hints.push({
2773
+ term: entry.term,
2774
+ doNotTranslate: entry.doNotTranslate,
2775
+ forced: entry.translations?.[targetLocale],
2776
+ notes: entry.notes
2777
+ });
2778
+ }
2779
+ return hints;
2780
+ }
2781
+ function glossaryViolations(source, value, targetLocale, glossary) {
2782
+ const out = [];
2783
+ for (const entry of glossary) {
2784
+ if (!contains(source, entry.term, entry.caseSensitive)) continue;
2785
+ if (entry.doNotTranslate) {
2786
+ if (!contains(value, entry.term, entry.caseSensitive)) {
2787
+ out.push({ term: entry.term, expected: entry.term, kind: "do-not-translate" });
2788
+ }
2789
+ continue;
2790
+ }
2791
+ const forced = entry.translations?.[targetLocale];
2792
+ if (forced && !contains(value, forced, entry.caseSensitive)) {
2793
+ out.push({ term: entry.term, expected: forced, kind: "forced" });
2794
+ }
2795
+ }
2796
+ return out;
2797
+ }
2798
+ var init_glossary = __esm({
2799
+ "src/server/glossary.ts"() {
2800
+ "use strict";
2801
+ }
2802
+ });
2803
+
2747
2804
  // src/server/glob.ts
2748
2805
  function globToRegExp(glob) {
2749
2806
  const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
@@ -2815,21 +2872,6 @@ function selectRequests(state, opts) {
2815
2872
  }
2816
2873
  return reqs;
2817
2874
  }
2818
- function relevantGlossary(source, targetLocale, glossary) {
2819
- const hints = [];
2820
- for (const entry of glossary) {
2821
- const haystack = entry.caseSensitive ? source : source.toLowerCase();
2822
- const needle = entry.caseSensitive ? entry.term : entry.term.toLowerCase();
2823
- if (!haystack.includes(needle)) continue;
2824
- hints.push({
2825
- term: entry.term,
2826
- doNotTranslate: entry.doNotTranslate,
2827
- forced: entry.translations?.[targetLocale],
2828
- notes: entry.notes
2829
- });
2830
- }
2831
- return hints;
2832
- }
2833
2875
  function attachScreenshots(reqs, state, projectRoot) {
2834
2876
  const cache2 = /* @__PURE__ */ new Map();
2835
2877
  for (const req of reqs) {
@@ -2936,6 +2978,7 @@ var MEDIA_TYPES, MAX_IMAGE_BYTES, DEFAULT_LOCALE_CONCURRENCY;
2936
2978
  var init_run = __esm({
2937
2979
  "src/server/ai/run.ts"() {
2938
2980
  "use strict";
2981
+ init_glossary();
2939
2982
  init_placeholders();
2940
2983
  init_plurals();
2941
2984
  init_state();
@@ -3113,7 +3156,7 @@ function findMissing(state) {
3113
3156
  const entry = state.keys[key];
3114
3157
  if (entry.skipTranslate) continue;
3115
3158
  for (const locale of targets) {
3116
- const v = entry.plural ? entry.values[locale]?.forms?.other?.trim() : entry.values[locale]?.value;
3159
+ const v = entry.plural ? entry.values[locale]?.forms?.other?.trim() : entry.values[locale]?.value?.trim();
3117
3160
  if (!v) out.push({ key, locale });
3118
3161
  }
3119
3162
  }
@@ -3581,24 +3624,83 @@ var init_context = __esm({
3581
3624
  }
3582
3625
  });
3583
3626
 
3584
- // src/server/lint/spelling.ts
3585
- function tokenize(text) {
3586
- return text.match(new RegExp("\\p{L}[\\p{L}'']*", "gu")) ?? [];
3627
+ // src/server/spell.ts
3628
+ function spellTokens(value) {
3629
+ return value.replace(ICU_BLOCK, " ").replace(MASK, " ").match(WORD) ?? [];
3587
3630
  }
3588
- function buildAllowWords(glossary, dictionary2 = []) {
3631
+ function ignoreWordsFor(glossary, customWords = []) {
3589
3632
  const set = /* @__PURE__ */ new Set();
3590
- const add = (s) => {
3591
- for (const w of tokenize(s)) set.add(w.toLowerCase());
3633
+ const add = (text) => {
3634
+ for (const w of text.match(WORD) ?? []) set.add(w.toLowerCase());
3592
3635
  };
3593
- for (const g of glossary) add(g.term);
3594
- for (const w of dictionary2) add(w);
3636
+ for (const e of glossary) {
3637
+ add(e.term);
3638
+ for (const t of Object.values(e.translations ?? {})) add(t);
3639
+ }
3640
+ for (const w of customWords) add(w);
3595
3641
  return set;
3596
3642
  }
3643
+ async function getSpeller(dictId) {
3644
+ const key = norm(dictId);
3645
+ const existing = instances.get(key);
3646
+ if (existing) return existing;
3647
+ if (unavailable.has(key)) return null;
3648
+ try {
3649
+ const nspellMod = await import("nspell");
3650
+ const nspell = nspellMod.default ?? nspellMod;
3651
+ const dictMod = await import(`dictionary-${key}`);
3652
+ const dictExport = dictMod.default ?? dictMod;
3653
+ const dict = typeof dictExport === "function" ? await dictExport() : dictExport;
3654
+ const speller = nspell(dict);
3655
+ instances.set(key, speller);
3656
+ return speller;
3657
+ } catch {
3658
+ unavailable.add(key);
3659
+ return null;
3660
+ } finally {
3661
+ loading.delete(key);
3662
+ }
3663
+ }
3664
+ function spellValue(dictId, value, ignore) {
3665
+ const key = norm(dictId);
3666
+ if (unavailable.has(key)) return [];
3667
+ const spell = instances.get(key);
3668
+ if (!spell) {
3669
+ if (!loading.has(key)) {
3670
+ loading.add(key);
3671
+ void getSpeller(key);
3672
+ }
3673
+ return null;
3674
+ }
3675
+ const cacheKey = key + " " + value;
3676
+ let allBad = cache.get(cacheKey);
3677
+ if (!allBad) {
3678
+ allBad = spellTokens(value).filter((w) => !spell.correct(w));
3679
+ cache.set(cacheKey, allBad);
3680
+ }
3681
+ return allBad.filter((w) => !ignore.has(w.toLowerCase()));
3682
+ }
3683
+ var instances, loading, unavailable, cache, norm, ICU_BLOCK, MASK, WORD;
3684
+ var init_spell = __esm({
3685
+ "src/server/spell.ts"() {
3686
+ "use strict";
3687
+ instances = /* @__PURE__ */ new Map();
3688
+ loading = /* @__PURE__ */ new Set();
3689
+ unavailable = /* @__PURE__ */ new Set();
3690
+ cache = /* @__PURE__ */ new Map();
3691
+ norm = (dictId) => dictId.toLowerCase();
3692
+ ICU_BLOCK = /\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g;
3693
+ MASK = /\{[^}]*\}|<[^>]*>|:\w+|%[sd]/g;
3694
+ WORD = new RegExp("\\p{L}[\\p{L}''\u2019-]*", "gu");
3695
+ }
3696
+ });
3697
+
3698
+ // src/server/lint/spelling.ts
3597
3699
  var spellingRule, defaultLoader;
3598
3700
  var init_spelling = __esm({
3599
3701
  "src/server/lint/spelling.ts"() {
3600
3702
  "use strict";
3601
- init_placeholders();
3703
+ init_spell();
3602
3704
  spellingRule = {
3603
3705
  id: "spelling",
3604
3706
  run(state, ctx) {
@@ -3610,10 +3712,8 @@ var init_spelling = __esm({
3610
3712
  if (!speller) continue;
3611
3713
  const value = entry.values[locale]?.value;
3612
3714
  if (!value) continue;
3613
- const placeholders = new Set(extractPlaceholders(value).map((p) => p.toLowerCase()));
3614
- for (const word of tokenize(value)) {
3615
- const lower = word.toLowerCase();
3616
- if (placeholders.has(lower) || ctx.allowWords.has(lower)) continue;
3715
+ for (const word of spellTokens(value)) {
3716
+ if (ctx.allowWords.has(word.toLowerCase())) continue;
3617
3717
  if (!speller.correct(word)) {
3618
3718
  out.push({ ruleId: "spelling", key, locale, message: `possible misspelling: "${word}"` });
3619
3719
  }
@@ -3623,18 +3723,7 @@ var init_spelling = __esm({
3623
3723
  return out;
3624
3724
  }
3625
3725
  };
3626
- defaultLoader = async (dictId) => {
3627
- try {
3628
- const nspellMod = await import("nspell");
3629
- const nspell2 = nspellMod.default ?? nspellMod;
3630
- const dictMod = await import(`dictionary-${dictId.toLowerCase()}`);
3631
- const dictExport = dictMod.default ?? dictMod;
3632
- const dict = typeof dictExport === "function" ? await dictExport() : dictExport;
3633
- return nspell2(dict);
3634
- } catch {
3635
- return null;
3636
- }
3637
- };
3726
+ defaultLoader = (dictId) => getSpeller(dictId);
3638
3727
  }
3639
3728
  });
3640
3729
 
@@ -3645,7 +3734,7 @@ var init_rules = __esm({
3645
3734
  "use strict";
3646
3735
  init_scan();
3647
3736
  init_placeholders();
3648
- init_run();
3737
+ init_glossary();
3649
3738
  init_spelling();
3650
3739
  emptySourceRule = {
3651
3740
  id: "empty-source",
@@ -3661,17 +3750,14 @@ var init_rules = __esm({
3661
3750
  };
3662
3751
  emptyTranslationRule = {
3663
3752
  id: "empty-translation",
3664
- run(state, ctx) {
3753
+ // findMissing is the shared "untranslated" walk (also behind the editor's
3754
+ // untranslated check and /scan/missing); a whitespace-only value counts as
3755
+ // missing there, so no separate whitespace pass is needed.
3756
+ run(state) {
3665
3757
  const out = [];
3666
3758
  for (const m of findMissing(state)) {
3667
3759
  out.push({ ruleId: "empty-translation", key: m.key, locale: m.locale, message: "translation is empty or missing" });
3668
3760
  }
3669
- for (const key of Object.keys(state.keys)) {
3670
- for (const locale of ctx.targetLocales) {
3671
- const v = state.keys[key].values[locale]?.value;
3672
- if (v && !v.trim()) out.push({ ruleId: "empty-translation", key, locale, message: "translation is whitespace-only" });
3673
- }
3674
- }
3675
3761
  return out;
3676
3762
  }
3677
3763
  };
@@ -3799,13 +3885,13 @@ var init_rules = __esm({
3799
3885
  for (const locale of ctx.targetLocales) {
3800
3886
  const v = entry.values[locale]?.value;
3801
3887
  if (!v) continue;
3802
- for (const hint of relevantGlossary(src, locale, ctx.glossary)) {
3803
- if (hint.doNotTranslate && !v.includes(hint.term)) {
3804
- out.push({ ruleId: "glossary-violation", key, locale, message: `do-not-translate term "${hint.term}" is missing or altered` });
3805
- }
3806
- if (hint.forced && !v.includes(hint.forced)) {
3807
- out.push({ ruleId: "glossary-violation", key, locale, message: `expected glossary translation "${hint.forced}" for "${hint.term}"` });
3808
- }
3888
+ for (const viol of glossaryViolations(src, v, locale, ctx.glossary)) {
3889
+ out.push({
3890
+ ruleId: "glossary-violation",
3891
+ key,
3892
+ locale,
3893
+ message: viol.kind === "do-not-translate" ? `do-not-translate term "${viol.term}" is missing or altered` : `expected glossary translation "${viol.expected}" for "${viol.term}"`
3894
+ });
3809
3895
  }
3810
3896
  }
3811
3897
  }
@@ -3866,7 +3952,7 @@ async function runLint(state, options = {}) {
3866
3952
  const active = rules.filter(isActive);
3867
3953
  const spellingOn = active.some((r) => r.id === "spelling");
3868
3954
  const spellers = spellingOn ? await loadSpellers(targetLocales, config, load, warn) : /* @__PURE__ */ new Map();
3869
- const allowWords = spellingOn ? buildAllowWords(state.glossary, state.config.spelling?.customWords) : /* @__PURE__ */ new Set();
3955
+ const allowWords = spellingOn ? ignoreWordsFor(state.glossary, state.config.spelling?.customWords) : /* @__PURE__ */ new Set();
3870
3956
  const ctx = {
3871
3957
  config,
3872
3958
  sourceLocale: state.config.sourceLocale,
@@ -3904,6 +3990,7 @@ var init_run2 = __esm({
3904
3990
  init_registry();
3905
3991
  init_rules();
3906
3992
  init_spelling();
3993
+ init_spell();
3907
3994
  init_suppress();
3908
3995
  }
3909
3996
  });
@@ -3962,7 +4049,7 @@ var init_accept = __esm({
3962
4049
  });
3963
4050
 
3964
4051
  // src/server/import/detect.ts
3965
- import { existsSync as existsSync10, readdirSync as readdirSync4, statSync as statSync3 } from "fs";
4052
+ import { existsSync as existsSync10, readdirSync as readdirSync4, readFileSync as readFileSync11, statSync as statSync3 } from "fs";
3966
4053
  import { join as join4 } from "path";
3967
4054
  function safeIsDir(p) {
3968
4055
  try {
@@ -4049,6 +4136,110 @@ function detectApple(root) {
4049
4136
  }
4050
4137
  return best;
4051
4138
  }
4139
+ function detectAngularXliff(root) {
4140
+ for (const rel of ANGULAR_DIR_CANDIDATES) {
4141
+ const localeRoot = rel === "." ? root : join4(root, rel);
4142
+ if (!safeIsDir(localeRoot)) continue;
4143
+ const files = readdirSync4(localeRoot).filter((f) => /^messages(\..+)?\.xlf$/.test(f)).sort();
4144
+ if (files.length === 0) continue;
4145
+ const locales = files.map((f) => f.match(/^messages\.(.+)\.xlf$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l));
4146
+ const attrFile = files.includes("messages.xlf") ? "messages.xlf" : files[0];
4147
+ let sourceLocale;
4148
+ try {
4149
+ sourceLocale = readFileSync11(join4(localeRoot, attrFile), "utf8").match(/source-language="([^"]+)"/)?.[1];
4150
+ } catch {
4151
+ }
4152
+ if (!sourceLocale && locales.length === 0) continue;
4153
+ sourceLocale ??= pickSource(locales, () => 0);
4154
+ if (!locales.includes(sourceLocale)) locales.unshift(sourceLocale);
4155
+ return { format: "angular-xliff", localeRoot, locales, sourceLocale };
4156
+ }
4157
+ return null;
4158
+ }
4159
+ function detectRails(root) {
4160
+ const localeRoot = join4(root, "config", "locales");
4161
+ if (!safeIsDir(localeRoot)) return null;
4162
+ const locales = [];
4163
+ for (const file of readdirSync4(localeRoot).sort()) {
4164
+ if (!/\.ya?ml$/.test(file)) continue;
4165
+ let text;
4166
+ try {
4167
+ text = readFileSync11(join4(localeRoot, file), "utf8");
4168
+ } catch {
4169
+ continue;
4170
+ }
4171
+ for (const m of text.matchAll(/^(["']?)([A-Za-z][\w-]*)\1:\s*(?:#.*)?$/gm)) {
4172
+ const token = m[2];
4173
+ if (LOCALE_RE.test(token) && !locales.includes(token)) locales.push(token);
4174
+ }
4175
+ }
4176
+ if (locales.length === 0) return null;
4177
+ return { format: "rails-yaml", localeRoot, locales, sourceLocale: pickSource(locales, () => 0) };
4178
+ }
4179
+ function detectI18next(root) {
4180
+ for (const rel of I18NEXT_DIR_CANDIDATES) {
4181
+ const localeRoot = join4(root, rel);
4182
+ if (!safeIsDir(localeRoot)) continue;
4183
+ const locales = listDirs(localeRoot).filter(
4184
+ (d) => LOCALE_RE.test(d) && readdirSync4(join4(localeRoot, d)).some((f) => f.endsWith(".json"))
4185
+ );
4186
+ if (locales.length === 0) continue;
4187
+ const sourceLocale = pickSource(locales, (loc) => {
4188
+ try {
4189
+ return readdirSync4(join4(localeRoot, loc)).filter((f) => f.endsWith(".json")).reduce((sum, f) => sum + statSync3(join4(localeRoot, loc, f)).size, 0);
4190
+ } catch {
4191
+ return 0;
4192
+ }
4193
+ });
4194
+ return { format: "i18next-json", localeRoot, locales, sourceLocale };
4195
+ }
4196
+ return null;
4197
+ }
4198
+ function gettextLocales(dir) {
4199
+ const locales = [];
4200
+ for (const entry of readdirSync4(dir).sort()) {
4201
+ const flat = entry.match(/^(.+)\.po$/)?.[1];
4202
+ if (flat && LOCALE_RE.test(flat)) {
4203
+ if (!locales.includes(flat)) locales.push(flat);
4204
+ continue;
4205
+ }
4206
+ if (!LOCALE_RE.test(entry) || !safeIsDir(join4(dir, entry))) continue;
4207
+ const sub = join4(dir, entry);
4208
+ const hasPo = (d) => {
4209
+ try {
4210
+ return readdirSync4(d).some((f) => f.endsWith(".po"));
4211
+ } catch {
4212
+ return false;
4213
+ }
4214
+ };
4215
+ if (hasPo(join4(sub, "LC_MESSAGES")) || hasPo(sub)) {
4216
+ if (!locales.includes(entry)) locales.push(entry);
4217
+ }
4218
+ }
4219
+ return locales;
4220
+ }
4221
+ function detectGettext(root) {
4222
+ for (const rel of GETTEXT_DIR_CANDIDATES) {
4223
+ const localeRoot = join4(root, rel);
4224
+ if (!safeIsDir(localeRoot)) continue;
4225
+ const locales = gettextLocales(localeRoot);
4226
+ if (locales.length === 0) continue;
4227
+ return { format: "gettext-po", localeRoot, locales, sourceLocale: pickSource(locales, () => 0) };
4228
+ }
4229
+ return null;
4230
+ }
4231
+ function detectAppleStringsdict(root) {
4232
+ const candidates = [root, ...listDirs(root).map((d) => join4(root, d))];
4233
+ let best = null;
4234
+ for (const dir of candidates) {
4235
+ const locales = listDirs(dir).map((d) => d.match(/^(.+)\.lproj$/)?.[1]).filter((l) => !!l && LOCALE_RE.test(l) && existsSync10(join4(dir, `${l}.lproj`, "Localizable.stringsdict")));
4236
+ if (locales.length === 0) continue;
4237
+ if (!best || locales.length > best.locales.length) {
4238
+ best = { format: "apple-stringsdict", localeRoot: dir, locales, sourceLocale: pickSource(locales, () => 0) };
4239
+ }
4240
+ }
4241
+ return best;
4242
+ }
4052
4243
  function detect(root, formatOverride) {
4053
4244
  if (!existsSync10(root)) return null;
4054
4245
  if (formatOverride) {
@@ -4062,18 +4253,36 @@ function detect(root, formatOverride) {
4062
4253
  }
4063
4254
  return null;
4064
4255
  }
4065
- var LOCALE_RE, VUE_DIR_CANDIDATES, DETECTORS, BY_FORMAT;
4256
+ var LOCALE_RE, VUE_DIR_CANDIDATES, ANGULAR_DIR_CANDIDATES, I18NEXT_DIR_CANDIDATES, GETTEXT_DIR_CANDIDATES, DETECTORS, BY_FORMAT;
4066
4257
  var init_detect = __esm({
4067
4258
  "src/server/import/detect.ts"() {
4068
4259
  "use strict";
4069
4260
  LOCALE_RE = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4070
4261
  VUE_DIR_CANDIDATES = ["src/locale", "src/locales", "src/i18n/locales", "locales", "lang"];
4071
- DETECTORS = [detectLaravel, detectVue, detectArb, detectApple];
4262
+ ANGULAR_DIR_CANDIDATES = [".", "src/locale", "src/locales", "src/i18n", "locale", "locales", "i18n", "translations"];
4263
+ I18NEXT_DIR_CANDIDATES = ["public/locales", "static/locales", "locales", "src/locales", "src/i18n/locales"];
4264
+ GETTEXT_DIR_CANDIDATES = ["locale", "locales", "po", "translations"];
4265
+ DETECTORS = [
4266
+ detectLaravel,
4267
+ detectVue,
4268
+ detectArb,
4269
+ detectApple,
4270
+ detectAngularXliff,
4271
+ detectRails,
4272
+ detectI18next,
4273
+ detectGettext,
4274
+ detectAppleStringsdict
4275
+ ];
4072
4276
  BY_FORMAT = {
4073
4277
  "laravel-php": detectLaravel,
4074
4278
  "vue-i18n-json": (root) => detectVue(root, true),
4075
4279
  "flutter-arb": detectArb,
4076
- "apple-strings": detectApple
4280
+ "apple-strings": detectApple,
4281
+ "angular-xliff": detectAngularXliff,
4282
+ "rails-yaml": detectRails,
4283
+ "i18next-json": detectI18next,
4284
+ "gettext-po": detectGettext,
4285
+ "apple-stringsdict": detectAppleStringsdict
4077
4286
  };
4078
4287
  }
4079
4288
  });
@@ -4106,7 +4315,7 @@ var init_flatten = __esm({
4106
4315
  });
4107
4316
 
4108
4317
  // src/server/import/parsers/vue-i18n-json.ts
4109
- import { readdirSync as readdirSync5, readFileSync as readFileSync11 } from "fs";
4318
+ import { readdirSync as readdirSync5, readFileSync as readFileSync12 } from "fs";
4110
4319
  import { join as join5 } from "path";
4111
4320
  var LOCALE_RE2, vueI18nJson2;
4112
4321
  var init_vue_i18n_json2 = __esm({
@@ -4127,7 +4336,7 @@ var init_vue_i18n_json2 = __esm({
4127
4336
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4128
4337
  let data;
4129
4338
  try {
4130
- data = JSON.parse(readFileSync11(join5(localeRoot, file), "utf8"));
4339
+ data = JSON.parse(readFileSync12(join5(localeRoot, file), "utf8"));
4131
4340
  } catch (e) {
4132
4341
  warnings.push(`vue-i18n-json: failed to parse ${file}: ${e.message}`);
4133
4342
  continue;
@@ -4232,7 +4441,7 @@ var init_laravel_php2 = __esm({
4232
4441
  });
4233
4442
 
4234
4443
  // src/server/import/parsers/flutter-arb.ts
4235
- import { readdirSync as readdirSync7, readFileSync as readFileSync12 } from "fs";
4444
+ import { readdirSync as readdirSync7, readFileSync as readFileSync13 } from "fs";
4236
4445
  import { join as join7 } from "path";
4237
4446
  function localeFromArbName(file) {
4238
4447
  const m = file.match(/^(.+)\.arb$/);
@@ -4273,7 +4482,7 @@ var init_flutter_arb2 = __esm({
4273
4482
  if (opts?.locales && !opts.locales.includes(locale)) continue;
4274
4483
  let data;
4275
4484
  try {
4276
- data = JSON.parse(readFileSync12(join7(localeRoot, file), "utf8"));
4485
+ data = JSON.parse(readFileSync13(join7(localeRoot, file), "utf8"));
4277
4486
  } catch (e) {
4278
4487
  warnings.push(`flutter-arb: failed to parse ${file}: ${e.message}`);
4279
4488
  continue;
@@ -4300,7 +4509,7 @@ var init_flutter_arb2 = __esm({
4300
4509
  });
4301
4510
 
4302
4511
  // src/server/import/parsers/apple-strings.ts
4303
- import { readdirSync as readdirSync8, readFileSync as readFileSync13, statSync as statSync5 } from "fs";
4512
+ import { readdirSync as readdirSync8, readFileSync as readFileSync14, statSync as statSync5 } from "fs";
4304
4513
  import { join as join8 } from "path";
4305
4514
  function localeFromLproj(dir) {
4306
4515
  const m = dir.match(/^(.+)\.lproj$/);
@@ -4413,7 +4622,7 @@ var init_apple_strings2 = __esm({
4413
4622
  let text;
4414
4623
  try {
4415
4624
  if (!statSync5(file).isFile()) continue;
4416
- text = readFileSync13(file, "utf8");
4625
+ text = readFileSync14(file, "utf8");
4417
4626
  } catch {
4418
4627
  continue;
4419
4628
  }
@@ -4432,6 +4641,773 @@ var init_apple_strings2 = __esm({
4432
4641
  }
4433
4642
  });
4434
4643
 
4644
+ // src/server/import/parsers/angular-xliff.ts
4645
+ import { readdirSync as readdirSync9, readFileSync as readFileSync15 } from "fs";
4646
+ import { join as join9 } from "path";
4647
+ function decodeEntities(s) {
4648
+ 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, "&");
4649
+ }
4650
+ function parseAttrs(s) {
4651
+ const out = {};
4652
+ for (const m of s.matchAll(/([\w-]+)="([^"]*)"/g)) out[m[1]] = decodeEntities(m[2]);
4653
+ return out;
4654
+ }
4655
+ function decodeInline(raw, addMeta) {
4656
+ let out = "";
4657
+ let last = 0;
4658
+ for (const m of raw.matchAll(/<x\b([^>]*?)\/>/g)) {
4659
+ out += decodeEntities(raw.slice(last, m.index));
4660
+ const attrs = parseAttrs(m[1]);
4661
+ const id = attrs["id"] ?? "X";
4662
+ const equiv = attrs["equiv-text"];
4663
+ const simple = equiv?.match(/^\{\{\s*(\w+)\s*\}\}$/);
4664
+ if (simple) {
4665
+ out += `{${simple[1]}}`;
4666
+ } else {
4667
+ out += `{${id}}`;
4668
+ const meta = {};
4669
+ if (attrs["ctype"]) meta.type = attrs["ctype"];
4670
+ if (equiv !== void 0) meta.example = equiv;
4671
+ addMeta(id, meta);
4672
+ }
4673
+ last = m.index + m[0].length;
4674
+ }
4675
+ return out + decodeEntities(raw.slice(last));
4676
+ }
4677
+ var LOCALE_RE5, FILE_RE, angularXliff2;
4678
+ var init_angular_xliff2 = __esm({
4679
+ "src/server/import/parsers/angular-xliff.ts"() {
4680
+ "use strict";
4681
+ LOCALE_RE5 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4682
+ FILE_RE = /^messages(?:\.(.+))?\.xlf$/;
4683
+ angularXliff2 = {
4684
+ name: "angular-xliff",
4685
+ parse(localeRoot, opts) {
4686
+ const warnings = [];
4687
+ const keys = {};
4688
+ const locales = [];
4689
+ const seen = (loc) => {
4690
+ if (!locales.includes(loc)) locales.push(loc);
4691
+ };
4692
+ const files = readdirSync9(localeRoot).filter((f) => FILE_RE.test(f)).sort((a, b) => (a === "messages.xlf" ? -1 : 0) - (b === "messages.xlf" ? -1 : 0) || a.localeCompare(b));
4693
+ for (const file of files) {
4694
+ const fnameLocale = file.match(FILE_RE)[1];
4695
+ if (fnameLocale !== void 0 && !LOCALE_RE5.test(fnameLocale)) continue;
4696
+ let xml;
4697
+ try {
4698
+ xml = readFileSync15(join9(localeRoot, file), "utf8");
4699
+ } catch (e) {
4700
+ warnings.push(`angular-xliff: failed to read ${file}: ${e.message}`);
4701
+ continue;
4702
+ }
4703
+ const sourceLocale = xml.match(/source-language="([^"]+)"/)?.[1];
4704
+ if (!sourceLocale) {
4705
+ warnings.push(`angular-xliff: ${file} has no source-language attribute; skipped`);
4706
+ continue;
4707
+ }
4708
+ const targetLocale = xml.match(/target-language="([^"]+)"/)?.[1] ?? fnameLocale;
4709
+ if (opts?.locales && !opts.locales.includes(targetLocale ?? sourceLocale)) continue;
4710
+ for (const unit of xml.matchAll(/<trans-unit\b([^>]*)>([\s\S]*?)<\/trans-unit>/g)) {
4711
+ const id = parseAttrs(unit[1])["id"];
4712
+ if (!id) {
4713
+ warnings.push(`angular-xliff: ${file} has a trans-unit without an id; skipped`);
4714
+ continue;
4715
+ }
4716
+ const body = unit[2];
4717
+ const src = body.match(/<source\b[^>]*>([\s\S]*?)<\/source>/);
4718
+ let tgt = body.match(/<target\b([^>]*)>([\s\S]*?)<\/target>/);
4719
+ if (tgt && /\bstate="new"/.test(tgt[1])) tgt = null;
4720
+ const entry = keys[id] ??= { values: {} };
4721
+ const addMeta = (name, meta) => {
4722
+ (entry.placeholders ??= {})[name] ??= meta;
4723
+ };
4724
+ if (src && entry.values[sourceLocale] === void 0) {
4725
+ entry.values[sourceLocale] = decodeInline(src[1], addMeta);
4726
+ seen(sourceLocale);
4727
+ }
4728
+ if (tgt && tgt[2] !== "" && targetLocale !== void 0) {
4729
+ entry.values[targetLocale] = decodeInline(tgt[2], addMeta);
4730
+ seen(targetLocale);
4731
+ }
4732
+ }
4733
+ }
4734
+ return { locales, keys, warnings };
4735
+ }
4736
+ };
4737
+ }
4738
+ });
4739
+
4740
+ // src/server/import/parsers/gettext-po.ts
4741
+ import { readdirSync as readdirSync10, readFileSync as readFileSync16 } from "fs";
4742
+ import { join as join10 } from "path";
4743
+ function unescapePo(s) {
4744
+ return s.replace(
4745
+ /\\([\\"ntr])/g,
4746
+ (_, c) => c === "n" ? "\n" : c === "t" ? " " : c === "r" ? "\r" : c
4747
+ );
4748
+ }
4749
+ function parseEntries(text) {
4750
+ const entries = [];
4751
+ let cur = null;
4752
+ let append = null;
4753
+ const flush = () => {
4754
+ if (cur && cur.msgid !== void 0) entries.push(cur);
4755
+ cur = null;
4756
+ append = null;
4757
+ };
4758
+ for (const line of text.split("\n")) {
4759
+ if (line.trim() === "") {
4760
+ flush();
4761
+ continue;
4762
+ }
4763
+ if (line.startsWith("#")) continue;
4764
+ const m = line.match(DIRECTIVE_RE);
4765
+ if (m) {
4766
+ const kw = m[1];
4767
+ const idx = m[2];
4768
+ const body = unescapePo(m[3]);
4769
+ if (cur && (kw === "msgctxt" || kw === "msgid" && cur.msgid !== void 0)) flush();
4770
+ cur ??= { plurals: /* @__PURE__ */ new Map() };
4771
+ const entry = cur;
4772
+ if (kw === "msgctxt") {
4773
+ entry.msgctxt = body;
4774
+ append = (c) => {
4775
+ entry.msgctxt = (entry.msgctxt ?? "") + c;
4776
+ };
4777
+ } else if (kw === "msgid") {
4778
+ entry.msgid = body;
4779
+ append = (c) => {
4780
+ entry.msgid = (entry.msgid ?? "") + c;
4781
+ };
4782
+ } else if (kw === "msgid_plural") {
4783
+ entry.msgidPlural = body;
4784
+ append = (c) => {
4785
+ entry.msgidPlural = (entry.msgidPlural ?? "") + c;
4786
+ };
4787
+ } else if (idx !== void 0) {
4788
+ const i = Number(idx);
4789
+ entry.plurals.set(i, body);
4790
+ append = (c) => {
4791
+ entry.plurals.set(i, (entry.plurals.get(i) ?? "") + c);
4792
+ };
4793
+ } else {
4794
+ entry.msgstr = body;
4795
+ append = (c) => {
4796
+ entry.msgstr = (entry.msgstr ?? "") + c;
4797
+ };
4798
+ }
4799
+ continue;
4800
+ }
4801
+ const cont = line.match(CONT_RE);
4802
+ if (cont && append) append(unescapePo(cont[1]));
4803
+ }
4804
+ flush();
4805
+ return entries;
4806
+ }
4807
+ function discoverPoFiles(root) {
4808
+ const found = [];
4809
+ const entries = readdirSync10(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
4810
+ for (const e of entries) {
4811
+ if (e.isFile() && e.name.endsWith(".po")) {
4812
+ const base = e.name.slice(0, -3);
4813
+ found.push({ path: join10(root, e.name), rel: e.name, locale: LOCALE_RE6.test(base) ? base : null });
4814
+ } else if (e.isDirectory() && LOCALE_RE6.test(e.name)) {
4815
+ for (const sub of [join10(e.name, "LC_MESSAGES"), e.name]) {
4816
+ let names;
4817
+ try {
4818
+ names = readdirSync10(join10(root, sub)).sort();
4819
+ } catch {
4820
+ continue;
4821
+ }
4822
+ for (const f of names) {
4823
+ if (f.endsWith(".po")) found.push({ path: join10(root, sub, f), rel: join10(sub, f), locale: e.name });
4824
+ }
4825
+ }
4826
+ }
4827
+ }
4828
+ return found;
4829
+ }
4830
+ var LOCALE_RE6, DIRECTIVE_RE, CONT_RE, gettextPo2;
4831
+ var init_gettext_po2 = __esm({
4832
+ "src/server/import/parsers/gettext-po.ts"() {
4833
+ "use strict";
4834
+ init_plurals();
4835
+ LOCALE_RE6 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4836
+ DIRECTIVE_RE = /^(msgctxt|msgid_plural|msgid|msgstr)(?:\[(\d+)\])?[ \t]+"(.*)"\s*$/;
4837
+ CONT_RE = /^[ \t]*"(.*)"\s*$/;
4838
+ gettextPo2 = {
4839
+ name: "gettext-po",
4840
+ parse(localeRoot, opts) {
4841
+ const warnings = [];
4842
+ const keys = {};
4843
+ const locales = [];
4844
+ for (const file of discoverPoFiles(localeRoot)) {
4845
+ let entries;
4846
+ try {
4847
+ entries = parseEntries(readFileSync16(file.path, "utf8"));
4848
+ } catch (e) {
4849
+ warnings.push(`gettext-po: failed to parse ${file.rel}: ${e.message}`);
4850
+ continue;
4851
+ }
4852
+ const header = entries.find((e) => e.msgid === "" && e.msgctxt === void 0);
4853
+ const headerLang = header?.msgstr?.match(/^Language:[ \t]*([A-Za-z0-9_-]+)/m)?.[1];
4854
+ const locale = file.locale ?? headerLang;
4855
+ if (!locale) {
4856
+ warnings.push(`gettext-po: cannot determine locale for ${file.rel}; skipped`);
4857
+ continue;
4858
+ }
4859
+ if (opts?.locales && !opts.locales.includes(locale)) continue;
4860
+ if (!locales.includes(locale)) locales.push(locale);
4861
+ const cats = categoriesFor(locale);
4862
+ for (const entry of entries) {
4863
+ if (entry === header) continue;
4864
+ const key = entry.msgctxt ?? entry.msgid;
4865
+ if (!key) continue;
4866
+ if (entry.msgidPlural !== void 0) {
4867
+ const forms = {};
4868
+ for (const [i, body] of [...entry.plurals].sort((a, b) => a[0] - b[0])) {
4869
+ if (body === "") continue;
4870
+ const cat = cats[i];
4871
+ if (!cat) {
4872
+ warnings.push(
4873
+ `gettext-po: ${file.rel} "${key}": msgstr[${i}] exceeds the ${cats.length} plural forms of "${locale}"; ignored`
4874
+ );
4875
+ continue;
4876
+ }
4877
+ forms[cat] = body.split("%d").join("{count}");
4878
+ }
4879
+ if (!forms.other) continue;
4880
+ (keys[key] ??= { values: {} }).values[locale] = formsToIcu("count", forms);
4881
+ } else {
4882
+ if (!entry.msgstr) continue;
4883
+ (keys[key] ??= { values: {} }).values[locale] = entry.msgstr;
4884
+ }
4885
+ }
4886
+ }
4887
+ return { locales, keys, warnings };
4888
+ }
4889
+ };
4890
+ }
4891
+ });
4892
+
4893
+ // src/server/import/parsers/i18next-json.ts
4894
+ import { readdirSync as readdirSync11, readFileSync as readFileSync17, statSync as statSync6 } from "fs";
4895
+ import { join as join11 } from "path";
4896
+ function safeIsDir2(p) {
4897
+ try {
4898
+ return statSync6(p).isDirectory();
4899
+ } catch {
4900
+ return false;
4901
+ }
4902
+ }
4903
+ function fromI18next(value) {
4904
+ if (isIcuPluralOrSelect(value)) return value;
4905
+ return value.replace(/\{\{(\w+)\}\}/g, "{$1}");
4906
+ }
4907
+ function ingestFile(path, label, prefix, locale, keys, warnings) {
4908
+ let data;
4909
+ try {
4910
+ data = JSON.parse(readFileSync17(path, "utf8"));
4911
+ } catch (e) {
4912
+ warnings.push(`i18next-json: failed to parse ${label}: ${e.message}`);
4913
+ return false;
4914
+ }
4915
+ const fileWarnings = [];
4916
+ const flat = flattenObject(data, "", fileWarnings);
4917
+ for (const w of fileWarnings) warnings.push(`i18next-json: ${label}: ${w}`);
4918
+ const families = /* @__PURE__ */ new Set();
4919
+ for (const [k, v] of Object.entries(flat)) {
4920
+ const m = PLURAL_SUFFIX_RE.exec(k);
4921
+ if (m && m[2] === "other" && v !== "") families.add(m[1]);
4922
+ }
4923
+ const pluralForms = {};
4924
+ for (const [k, raw] of Object.entries(flat)) {
4925
+ if (raw === "") continue;
4926
+ const value = fromI18next(raw);
4927
+ const m = PLURAL_SUFFIX_RE.exec(k);
4928
+ if (m && families.has(m[1])) {
4929
+ (pluralForms[m[1]] ??= {})[m[2]] = value;
4930
+ continue;
4931
+ }
4932
+ if (families.has(k)) {
4933
+ warnings.push(
4934
+ `i18next-json: ${label}: key "${k}" collides with its own plural suffix family; the plural wins`
4935
+ );
4936
+ continue;
4937
+ }
4938
+ (keys[prefix + k] ??= { values: {} }).values[locale] = value;
4939
+ }
4940
+ for (const [base, forms] of Object.entries(pluralForms)) {
4941
+ (keys[prefix + base] ??= { values: {} }).values[locale] = formsToIcu(PLURAL_ARG, forms);
4942
+ }
4943
+ return true;
4944
+ }
4945
+ var LOCALE_RE7, PLURAL_SUFFIX_RE, PLURAL_ARG, DEFAULT_NAMESPACE, i18nextJson2;
4946
+ var init_i18next_json2 = __esm({
4947
+ "src/server/import/parsers/i18next-json.ts"() {
4948
+ "use strict";
4949
+ init_flatten();
4950
+ init_plurals();
4951
+ init_placeholders();
4952
+ LOCALE_RE7 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4953
+ PLURAL_SUFFIX_RE = /^(.+)_(zero|one|two|few|many|other)$/;
4954
+ PLURAL_ARG = "count";
4955
+ DEFAULT_NAMESPACE = "translation";
4956
+ i18nextJson2 = {
4957
+ name: "i18next-json",
4958
+ parse(localeRoot, opts) {
4959
+ const warnings = [];
4960
+ const keys = {};
4961
+ const locales = [];
4962
+ for (const entry of readdirSync11(localeRoot).sort()) {
4963
+ const full = join11(localeRoot, entry);
4964
+ if (safeIsDir2(full)) {
4965
+ if (!LOCALE_RE7.test(entry)) continue;
4966
+ if (opts?.locales && !opts.locales.includes(entry)) continue;
4967
+ let any = false;
4968
+ for (const file of readdirSync11(full).sort()) {
4969
+ if (!file.endsWith(".json")) continue;
4970
+ const ns = file.slice(0, -".json".length);
4971
+ const prefix = ns === DEFAULT_NAMESPACE ? "" : `${ns}.`;
4972
+ if (ingestFile(join11(full, file), `${entry}/${file}`, prefix, entry, keys, warnings)) any = true;
4973
+ }
4974
+ if (any && !locales.includes(entry)) locales.push(entry);
4975
+ } else if (entry.endsWith(".json")) {
4976
+ const locale = entry.slice(0, -".json".length);
4977
+ if (!LOCALE_RE7.test(locale)) continue;
4978
+ if (opts?.locales && !opts.locales.includes(locale)) continue;
4979
+ if (ingestFile(full, entry, "", locale, keys, warnings) && !locales.includes(locale)) {
4980
+ locales.push(locale);
4981
+ }
4982
+ }
4983
+ }
4984
+ return { locales, keys, warnings };
4985
+ }
4986
+ };
4987
+ }
4988
+ });
4989
+
4990
+ // src/server/import/parsers/rails-yaml.ts
4991
+ import { readdirSync as readdirSync12, readFileSync as readFileSync18 } from "fs";
4992
+ import { join as join12 } from "path";
4993
+ function fromRuby(value) {
4994
+ return value.replace(/%\{(\w+)\}/g, "{$1}");
4995
+ }
4996
+ function makeNode() {
4997
+ return /* @__PURE__ */ Object.create(null);
4998
+ }
4999
+ function decodeDouble(body) {
5000
+ let out = "";
5001
+ for (let i = 0; i < body.length; i++) {
5002
+ const c = body[i];
5003
+ if (c !== "\\") {
5004
+ out += c;
5005
+ continue;
5006
+ }
5007
+ const n = body[++i];
5008
+ if (n === void 0) break;
5009
+ out += n === "n" ? "\n" : n === "r" ? "\r" : n === "t" ? " " : n;
5010
+ }
5011
+ return out;
5012
+ }
5013
+ function scanQuoted(s, start) {
5014
+ const q = s[start];
5015
+ if (q === '"') {
5016
+ for (let i = start + 1; i < s.length; i++) {
5017
+ if (s[i] === "\\") i++;
5018
+ else if (s[i] === '"') return { text: decodeDouble(s.slice(start + 1, i)), end: i + 1 };
5019
+ }
5020
+ return null;
5021
+ }
5022
+ let out = "";
5023
+ for (let i = start + 1; i < s.length; i++) {
5024
+ if (s[i] === "'") {
5025
+ if (s[i + 1] === "'") {
5026
+ out += "'";
5027
+ i++;
5028
+ } else {
5029
+ return { text: out, end: i + 1 };
5030
+ }
5031
+ } else {
5032
+ out += s[i];
5033
+ }
5034
+ }
5035
+ return null;
5036
+ }
5037
+ function stripPlainComment(s) {
5038
+ const m = /(^|\s)#/.exec(s);
5039
+ return (m && m.index >= 0 ? s.slice(0, m.index) : s).trim();
5040
+ }
5041
+ function onlyTrailing(s) {
5042
+ return /^\s*(#.*)?$/.test(s);
5043
+ }
5044
+ function parseYamlSubset(text, file, warnings) {
5045
+ const roots = {};
5046
+ const lines = text.split(/\r?\n/);
5047
+ let stack = [];
5048
+ let skipDeeperThan = null;
5049
+ let lastLeafIndent = null;
5050
+ for (let n = 0; n < lines.length; n++) {
5051
+ const raw = lines[n];
5052
+ const lineNo = n + 1;
5053
+ if (raw.trim() === "" || raw.trim().startsWith("#")) continue;
5054
+ if (raw.trim() === "---") continue;
5055
+ const indentMatch = /^[ \t]*/.exec(raw)[0];
5056
+ if (indentMatch.includes(" ")) {
5057
+ warnings.push(`rails-yaml: ${file}:${lineNo}: tab in indentation; line skipped`);
5058
+ continue;
5059
+ }
5060
+ const indent = indentMatch.length;
5061
+ if (skipDeeperThan !== null) {
5062
+ if (indent > skipDeeperThan) continue;
5063
+ skipDeeperThan = null;
5064
+ }
5065
+ const content = raw.slice(indent);
5066
+ if (content.startsWith("- ") || content === "-") {
5067
+ warnings.push(`rails-yaml: ${file}:${lineNo}: sequences are not supported; node skipped`);
5068
+ skipDeeperThan = indent;
5069
+ continue;
5070
+ }
5071
+ let key;
5072
+ let rest;
5073
+ if (content[0] === '"' || content[0] === "'") {
5074
+ const k = scanQuoted(content, 0);
5075
+ if (!k || content[k.end] !== ":") {
5076
+ warnings.push(`rails-yaml: ${file}:${lineNo}: unparseable quoted key; line skipped`);
5077
+ skipDeeperThan = indent;
5078
+ continue;
5079
+ }
5080
+ key = k.text;
5081
+ rest = content.slice(k.end + 1);
5082
+ } else {
5083
+ const m = /^(.*?):(?=\s|$)/.exec(content);
5084
+ if (!m) {
5085
+ warnings.push(`rails-yaml: ${file}:${lineNo}: not a "key: value" mapping line; line skipped`);
5086
+ skipDeeperThan = indent;
5087
+ continue;
5088
+ }
5089
+ key = m[1].trim();
5090
+ rest = content.slice(m[0].length);
5091
+ }
5092
+ if (lastLeafIndent !== null && indent > lastLeafIndent) {
5093
+ warnings.push(`rails-yaml: ${file}:${lineNo}: unexpected indentation under a scalar; line skipped`);
5094
+ skipDeeperThan = indent - 1;
5095
+ continue;
5096
+ }
5097
+ while (stack.length > 0 && stack[stack.length - 1].indent >= indent) stack.pop();
5098
+ const trimmed = rest.trim();
5099
+ let value;
5100
+ if (trimmed === "" || trimmed.startsWith("#")) {
5101
+ value = null;
5102
+ } else if (trimmed[0] === "&" || trimmed[0] === "*") {
5103
+ warnings.push(`rails-yaml: ${file}:${lineNo}: YAML anchors/aliases are not supported; node skipped`);
5104
+ skipDeeperThan = indent;
5105
+ continue;
5106
+ } else if (trimmed[0] === "|" || trimmed[0] === ">") {
5107
+ warnings.push(`rails-yaml: ${file}:${lineNo}: block scalars are not supported; node skipped`);
5108
+ skipDeeperThan = indent;
5109
+ continue;
5110
+ } else if (trimmed[0] === "[" || trimmed[0] === "{") {
5111
+ warnings.push(`rails-yaml: ${file}:${lineNo}: flow collections are not supported; node skipped`);
5112
+ skipDeeperThan = indent;
5113
+ continue;
5114
+ } else if (trimmed[0] === '"' || trimmed[0] === "'") {
5115
+ const v = scanQuoted(trimmed, 0);
5116
+ if (!v || !onlyTrailing(trimmed.slice(v.end))) {
5117
+ warnings.push(`rails-yaml: ${file}:${lineNo}: unterminated or trailing-garbage quoted value; line skipped`);
5118
+ continue;
5119
+ }
5120
+ value = v.text;
5121
+ } else {
5122
+ value = stripPlainComment(trimmed);
5123
+ }
5124
+ if (stack.length === 0) {
5125
+ if (value !== null) {
5126
+ warnings.push(`rails-yaml: ${file}:${lineNo}: top-level key "${key}" has a scalar value; skipped`);
5127
+ lastLeafIndent = indent;
5128
+ continue;
5129
+ }
5130
+ const root = roots[key] ??= makeNode();
5131
+ stack = [{ indent, node: root }];
5132
+ lastLeafIndent = null;
5133
+ continue;
5134
+ }
5135
+ const parent = stack[stack.length - 1].node;
5136
+ if (key in parent) {
5137
+ warnings.push(`rails-yaml: ${file}:${lineNo}: duplicate key "${key}"; later value wins`);
5138
+ }
5139
+ if (value === null) {
5140
+ const child = makeNode();
5141
+ parent[key] = child;
5142
+ stack.push({ indent, node: child });
5143
+ lastLeafIndent = null;
5144
+ } else {
5145
+ parent[key] = value;
5146
+ lastLeafIndent = indent;
5147
+ }
5148
+ }
5149
+ return { roots };
5150
+ }
5151
+ function asPluralForms(node) {
5152
+ const entries = Object.entries(node);
5153
+ if (entries.length === 0) return null;
5154
+ const forms = {};
5155
+ for (const [k, v] of entries) {
5156
+ if (!CATEGORY_SET.has(k) || typeof v !== "string") return null;
5157
+ if (v !== "") forms[k] = v;
5158
+ }
5159
+ if (!("other" in forms)) return null;
5160
+ return forms;
5161
+ }
5162
+ function synthesizeIcu(forms, file, key, warnings) {
5163
+ const parts = [];
5164
+ for (const cat of PLURAL_CATEGORIES) {
5165
+ const body = forms[cat];
5166
+ if (body === void 0) continue;
5167
+ if (body.includes("#")) {
5168
+ warnings.push(
5169
+ `rails-yaml: ${file}: plural "${key}" form "${cat}" contains "#", which ICU reads as the count placeholder`
5170
+ );
5171
+ }
5172
+ parts.push(`${cat} {${fromRuby(body)}}`);
5173
+ }
5174
+ return `{count, plural, ${parts.join(" ")}}`;
5175
+ }
5176
+ var LOCALE_RE8, CATEGORY_SET, railsYaml2;
5177
+ var init_rails_yaml2 = __esm({
5178
+ "src/server/import/parsers/rails-yaml.ts"() {
5179
+ "use strict";
5180
+ init_schema();
5181
+ LOCALE_RE8 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/i;
5182
+ CATEGORY_SET = new Set(PLURAL_CATEGORIES);
5183
+ railsYaml2 = {
5184
+ name: "rails-yaml",
5185
+ parse(localeRoot, opts) {
5186
+ const warnings = [];
5187
+ const keys = {};
5188
+ const locales = [];
5189
+ const wanted = opts?.locales?.map((l) => l.toLowerCase());
5190
+ const addValue = (key, locale, value) => {
5191
+ (keys[key] ??= { values: {} }).values[locale] = value;
5192
+ };
5193
+ const flatten = (node, prefix, locale, file) => {
5194
+ for (const [k, v] of Object.entries(node)) {
5195
+ const key = prefix ? `${prefix}.${k}` : k;
5196
+ if (typeof v === "string") {
5197
+ if (v !== "") addValue(key, locale, fromRuby(v));
5198
+ continue;
5199
+ }
5200
+ const forms = asPluralForms(v);
5201
+ if (forms) addValue(key, locale, synthesizeIcu(forms, file, key, warnings));
5202
+ else flatten(v, key, locale, file);
5203
+ }
5204
+ };
5205
+ for (const file of readdirSync12(localeRoot).sort()) {
5206
+ if (!file.endsWith(".yml") && !file.endsWith(".yaml")) continue;
5207
+ let text;
5208
+ try {
5209
+ text = readFileSync18(join12(localeRoot, file), "utf8");
5210
+ } catch (e) {
5211
+ warnings.push(`rails-yaml: failed to read ${file}: ${e.message}`);
5212
+ continue;
5213
+ }
5214
+ const { roots } = parseYamlSubset(text, file, warnings);
5215
+ for (const token of Object.keys(roots).sort()) {
5216
+ if (!LOCALE_RE8.test(token)) {
5217
+ warnings.push(`rails-yaml: ${file}: top-level key "${token}" is not a locale; subtree skipped`);
5218
+ continue;
5219
+ }
5220
+ if (wanted && !wanted.includes(token.toLowerCase())) continue;
5221
+ if (!locales.includes(token)) locales.push(token);
5222
+ flatten(roots[token], "", token, file);
5223
+ }
5224
+ }
5225
+ return { locales, keys, warnings };
5226
+ }
5227
+ };
5228
+ }
5229
+ });
5230
+
5231
+ // src/server/import/parsers/apple-stringsdict.ts
5232
+ import { readdirSync as readdirSync13, readFileSync as readFileSync19, statSync as statSync7 } from "fs";
5233
+ import { join as join13 } from "path";
5234
+ function localeFromLproj2(dir) {
5235
+ const m = dir.match(/^(.+)\.lproj$/);
5236
+ if (!m) return null;
5237
+ return LOCALE_RE9.test(m[1]) ? m[1] : null;
5238
+ }
5239
+ function decodeEntities2(s) {
5240
+ 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, "&");
5241
+ }
5242
+ function parsePlistDict(xml) {
5243
+ let i = 0;
5244
+ const n = xml.length;
5245
+ const skipTrivia = () => {
5246
+ for (; ; ) {
5247
+ while (i < n && /\s/.test(xml[i])) i++;
5248
+ if (xml.startsWith("<!--", i)) {
5249
+ const end = xml.indexOf("-->", i + 4);
5250
+ if (end === -1) throw new Error("unterminated comment");
5251
+ i = end + 3;
5252
+ continue;
5253
+ }
5254
+ if (xml.startsWith("<?", i) || xml.startsWith("<!", i) && !xml.startsWith("<!--", i)) {
5255
+ const end = xml.indexOf(">", i);
5256
+ if (end === -1) throw new Error("unterminated declaration");
5257
+ i = end + 1;
5258
+ continue;
5259
+ }
5260
+ break;
5261
+ }
5262
+ };
5263
+ const readTag = () => {
5264
+ if (xml[i] !== "<") throw new Error(`expected a tag at offset ${i}`);
5265
+ const end = xml.indexOf(">", i);
5266
+ if (end === -1) throw new Error("unterminated tag");
5267
+ let body = xml.slice(i + 1, end).trim();
5268
+ i = end + 1;
5269
+ const closing = body.startsWith("/");
5270
+ if (closing) body = body.slice(1).trim();
5271
+ const selfClosing = body.endsWith("/");
5272
+ if (selfClosing) body = body.slice(0, -1).trim();
5273
+ const name = body.split(/\s/)[0];
5274
+ if (!name) throw new Error(`empty tag at offset ${end}`);
5275
+ return { name, closing, selfClosing };
5276
+ };
5277
+ const readElementText = (name) => {
5278
+ const re = new RegExp(`</${name}\\s*>`, "g");
5279
+ re.lastIndex = i;
5280
+ const m = re.exec(xml);
5281
+ if (!m) throw new Error(`unterminated <${name}>`);
5282
+ const text = xml.slice(i, m.index);
5283
+ i = m.index + m[0].length;
5284
+ return decodeEntities2(text);
5285
+ };
5286
+ const readValue = (tag2) => {
5287
+ if (tag2.name === "dict") return tag2.selfClosing ? {} : readDict();
5288
+ if (tag2.name === "true" || tag2.name === "false") {
5289
+ if (!tag2.selfClosing) readElementText(tag2.name);
5290
+ return tag2.name;
5291
+ }
5292
+ if (["string", "integer", "real", "date", "data"].includes(tag2.name)) {
5293
+ return tag2.selfClosing ? "" : readElementText(tag2.name);
5294
+ }
5295
+ throw new Error(`unsupported plist element <${tag2.name}>`);
5296
+ };
5297
+ const readDict = () => {
5298
+ const out = {};
5299
+ for (; ; ) {
5300
+ skipTrivia();
5301
+ const tag2 = readTag();
5302
+ if (tag2.closing) {
5303
+ if (tag2.name !== "dict") throw new Error(`unexpected </${tag2.name}> inside <dict>`);
5304
+ return out;
5305
+ }
5306
+ if (tag2.name !== "key") throw new Error(`expected <key> inside <dict>, got <${tag2.name}>`);
5307
+ const key = readElementText("key");
5308
+ skipTrivia();
5309
+ const vt = readTag();
5310
+ if (vt.closing) throw new Error(`<key>${key}</key> has no value`);
5311
+ out[key] = readValue(vt);
5312
+ }
5313
+ };
5314
+ skipTrivia();
5315
+ let tag = readTag();
5316
+ if (tag.name === "plist" && !tag.closing && !tag.selfClosing) {
5317
+ skipTrivia();
5318
+ tag = readTag();
5319
+ }
5320
+ if (tag.name !== "dict" || tag.closing) throw new Error("expected a root <dict>");
5321
+ return tag.selfClosing ? {} : readDict();
5322
+ }
5323
+ function entryToIcu(key, entry, file, warnings) {
5324
+ const warn = (msg) => {
5325
+ warnings.push(`apple-stringsdict: ${file}: key "${key}": ${msg}`);
5326
+ return null;
5327
+ };
5328
+ if (typeof entry !== "object") return warn("value is not a dict; skipped");
5329
+ const fmt = entry["NSStringLocalizedFormatKey"];
5330
+ if (typeof fmt !== "string") return warn("missing NSStringLocalizedFormatKey; skipped");
5331
+ const vars = [...fmt.matchAll(VAR_RE)];
5332
+ if (vars.length !== 1) {
5333
+ return warn(`format key has ${vars.length} %#@\u2026@ variables; only exactly one is supported; skipped`);
5334
+ }
5335
+ const arg = vars[0][1];
5336
+ if (!/^\w+$/.test(arg)) return warn(`variable name "${arg}" is not a valid ICU argument; skipped`);
5337
+ const prefix = fmt.slice(0, vars[0].index);
5338
+ const suffix = fmt.slice(vars[0].index + vars[0][0].length);
5339
+ const varDict = entry[arg];
5340
+ if (typeof varDict !== "object") return warn(`variable "${arg}" has no dict; skipped`);
5341
+ const specType = varDict["NSStringFormatSpecTypeKey"];
5342
+ if (specType !== void 0 && specType !== "NSStringPluralRuleType") {
5343
+ return warn(`variable "${arg}" is not a plural rule (${String(specType)}); skipped`);
5344
+ }
5345
+ const valueType = varDict["NSStringFormatValueTypeKey"];
5346
+ const token = `%${typeof valueType === "string" && valueType ? valueType : "d"}`;
5347
+ const forms = {};
5348
+ for (const cat of PLURAL_CATEGORIES) {
5349
+ const body = varDict[cat];
5350
+ if (typeof body !== "string") continue;
5351
+ forms[cat] = prefix + body.split(token).join(`{${arg}}`) + suffix;
5352
+ }
5353
+ if (forms.other === void 0) return warn(`variable "${arg}" has no "other" form; skipped`);
5354
+ return formsToIcu(arg, forms);
5355
+ }
5356
+ var LOCALE_RE9, TABLE2, VAR_RE, appleStringsdict2;
5357
+ var init_apple_stringsdict2 = __esm({
5358
+ "src/server/import/parsers/apple-stringsdict.ts"() {
5359
+ "use strict";
5360
+ init_schema();
5361
+ init_plurals();
5362
+ LOCALE_RE9 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
5363
+ TABLE2 = "Localizable.stringsdict";
5364
+ VAR_RE = /%#@([^@]*)@/g;
5365
+ appleStringsdict2 = {
5366
+ name: "apple-stringsdict",
5367
+ parse(localeRoot, opts) {
5368
+ const warnings = [];
5369
+ const keys = {};
5370
+ const locales = [];
5371
+ for (const dir of readdirSync13(localeRoot).sort()) {
5372
+ const locale = localeFromLproj2(dir);
5373
+ if (!locale) continue;
5374
+ if (opts?.locales && !opts.locales.includes(locale)) continue;
5375
+ const file = join13(localeRoot, dir, TABLE2);
5376
+ let text;
5377
+ try {
5378
+ if (!statSync7(file).isFile()) continue;
5379
+ text = readFileSync19(file, "utf8");
5380
+ } catch {
5381
+ continue;
5382
+ }
5383
+ locales.push(locale);
5384
+ const others = readdirSync13(join13(localeRoot, dir)).filter(
5385
+ (f) => f.endsWith(".stringsdict") && f !== TABLE2
5386
+ );
5387
+ if (others.length) {
5388
+ warnings.push(
5389
+ `apple-stringsdict: ${dir} has other .stringsdict tables (${others.join(", ")}); only ${TABLE2} is imported`
5390
+ );
5391
+ }
5392
+ let root;
5393
+ try {
5394
+ root = parsePlistDict(text);
5395
+ } catch (e) {
5396
+ warnings.push(`apple-stringsdict: failed to parse ${file}: ${e.message}`);
5397
+ continue;
5398
+ }
5399
+ for (const key of Object.keys(root).sort()) {
5400
+ const icu = entryToIcu(key, root[key], file, warnings);
5401
+ if (icu === null) continue;
5402
+ (keys[key] ??= { values: {} }).values[locale] = icu;
5403
+ }
5404
+ }
5405
+ return { locales, keys, warnings };
5406
+ }
5407
+ };
5408
+ }
5409
+ });
5410
+
4435
5411
  // src/server/import/parsers/index.ts
4436
5412
  function getParser(name) {
4437
5413
  const p = REGISTRY[name];
@@ -4446,11 +5422,21 @@ var init_parsers = __esm({
4446
5422
  init_laravel_php2();
4447
5423
  init_flutter_arb2();
4448
5424
  init_apple_strings2();
5425
+ init_angular_xliff2();
5426
+ init_gettext_po2();
5427
+ init_i18next_json2();
5428
+ init_rails_yaml2();
5429
+ init_apple_stringsdict2();
4449
5430
  REGISTRY = {
4450
5431
  [vueI18nJson2.name]: vueI18nJson2,
4451
5432
  [laravelPhp2.name]: laravelPhp2,
4452
5433
  [flutterArb2.name]: flutterArb2,
4453
- [appleStrings2.name]: appleStrings2
5434
+ [appleStrings2.name]: appleStrings2,
5435
+ [angularXliff2.name]: angularXliff2,
5436
+ [gettextPo2.name]: gettextPo2,
5437
+ [i18nextJson2.name]: i18nextJson2,
5438
+ [railsYaml2.name]: railsYaml2,
5439
+ [appleStringsdict2.name]: appleStringsdict2
4454
5440
  };
4455
5441
  }
4456
5442
  });
@@ -4536,7 +5522,14 @@ var init_assemble = __esm({
4536
5522
  "laravel-php": { adapter: "laravel-php", path: "lang/{locale}/{namespace}.php" },
4537
5523
  "vue-i18n-json": { adapter: "vue-i18n-json", path: "src/locale/{locale}.json" },
4538
5524
  "flutter-arb": { adapter: "flutter-arb", path: "lib/l10n/app_{locale}.arb" },
4539
- "apple-strings": { adapter: "apple-strings", path: "{locale}.lproj/Localizable.strings", rootRelative: true }
5525
+ "apple-strings": { adapter: "apple-strings", path: "{locale}.lproj/Localizable.strings", rootRelative: true },
5526
+ // skipSourceLocale: ng extract-i18n owns messages.xlf (the source file); glotfile
5527
+ // only writes the translation files back next to it.
5528
+ "angular-xliff": { adapter: "angular-xliff", path: "messages.{locale}.xlf", rootRelative: true, skipSourceLocale: true },
5529
+ "gettext-po": { adapter: "gettext-po", path: "{locale}.po", rootRelative: true },
5530
+ "i18next-json": { adapter: "i18next-json", path: "{locale}/translation.json", rootRelative: true },
5531
+ "rails-yaml": { adapter: "rails-yaml", path: "config/locales/{locale}.yml" },
5532
+ "apple-stringsdict": { adapter: "apple-stringsdict", path: "{locale}.lproj/Localizable.stringsdict", rootRelative: true }
4540
5533
  };
4541
5534
  }
4542
5535
  });
@@ -4718,120 +5711,22 @@ var init_stats = __esm({
4718
5711
  }
4719
5712
  });
4720
5713
 
4721
- // node_modules/dictionary-en/index.js
4722
- var dictionary_en_exports = {};
4723
- __export(dictionary_en_exports, {
4724
- default: () => dictionary_en_default
4725
- });
4726
- import fs from "fs/promises";
4727
- var aff, dic, dictionary, dictionary_en_default;
4728
- var init_dictionary_en = __esm({
4729
- async "node_modules/dictionary-en/index.js"() {
4730
- "use strict";
4731
- aff = await fs.readFile(new URL("index.aff", import.meta.url));
4732
- dic = await fs.readFile(new URL("index.dic", import.meta.url));
4733
- dictionary = { aff, dic };
4734
- dictionary_en_default = dictionary;
4735
- }
4736
- });
4737
-
4738
- // src/server/spell.ts
4739
- import nspell from "nspell";
4740
- async function loadDictionary(locale) {
4741
- const key = norm(locale);
4742
- if (instances.has(key) || unavailable.has(key)) return;
4743
- const loader = LOADERS[key];
4744
- if (!loader) {
4745
- unavailable.add(key);
4746
- return;
4747
- }
4748
- try {
4749
- const { default: dict } = await loader();
4750
- instances.set(key, nspell(dict));
4751
- } catch {
4752
- unavailable.add(key);
4753
- } finally {
4754
- loading.delete(key);
4755
- }
4756
- }
4757
- function spellValue(locale, value, ignore) {
4758
- const key = norm(locale);
4759
- if (unavailable.has(key)) return [];
4760
- const spell = instances.get(key);
4761
- if (!spell) {
4762
- if (!LOADERS[key]) {
4763
- unavailable.add(key);
4764
- return [];
4765
- }
4766
- if (!loading.has(key)) {
4767
- loading.add(key);
4768
- void loadDictionary(key);
4769
- }
4770
- return null;
4771
- }
4772
- const cacheKey = key + " " + value;
4773
- let allBad = cache.get(cacheKey);
4774
- if (!allBad) {
4775
- const words = value.replace(ICU_BLOCK, " ").replace(MASK, " ").match(WORD) ?? [];
4776
- allBad = words.filter((w) => !spell.correct(w));
4777
- cache.set(cacheKey, allBad);
4778
- }
4779
- return allBad.filter((w) => !ignore.has(w.toLowerCase()));
4780
- }
4781
- var LOADERS, instances, loading, unavailable, cache, norm, ICU_BLOCK, MASK, WORD;
4782
- var init_spell = __esm({
4783
- "src/server/spell.ts"() {
4784
- "use strict";
4785
- LOADERS = {
4786
- en: () => init_dictionary_en().then(() => dictionary_en_exports),
4787
- es: () => import("dictionary-es"),
4788
- fr: () => import("dictionary-fr")
4789
- };
4790
- instances = /* @__PURE__ */ new Map();
4791
- loading = /* @__PURE__ */ new Set();
4792
- unavailable = /* @__PURE__ */ new Set();
4793
- cache = /* @__PURE__ */ new Map();
4794
- norm = (locale) => locale.toLowerCase();
4795
- ICU_BLOCK = /\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g;
4796
- MASK = /\{[^}]*\}|<[^>]*>|:\w+|%[sd]/g;
4797
- WORD = new RegExp("\\p{L}[\\p{L}''\u2019-]*", "gu");
4798
- }
4799
- });
4800
-
4801
5714
  // src/server/checks.ts
4802
- function contains(haystack, needle, caseSensitive) {
4803
- return caseSensitive ? haystack.includes(needle) : haystack.toLowerCase().includes(needle.toLowerCase());
4804
- }
4805
5715
  function runChecks(state, opts = {}) {
4806
5716
  const ruleOff = (id) => state.config.lint?.rules?.[CHECK_RULE[id]] === "off";
4807
5717
  const on = (id) => (!opts.only || opts.only.includes(id)) && !ruleOff(id);
4808
5718
  const issues = [];
4809
5719
  let spellPending = false;
4810
5720
  const { sourceLocale } = state.config;
4811
- const byTerm = new Map(state.glossary.map((e) => [e.term, e]));
4812
- const ignore = /* @__PURE__ */ new Set();
4813
- for (const e of state.glossary) {
4814
- for (const w of e.term.toLowerCase().split(/\s+/)) if (w) ignore.add(w);
4815
- for (const t of Object.values(e.translations ?? {})) {
4816
- for (const w of t.toLowerCase().split(/\s+/)) if (w) ignore.add(w);
5721
+ const ignore = ignoreWordsFor(state.glossary, state.config.spelling?.customWords);
5722
+ if (on("untranslated")) {
5723
+ for (const m of findMissing(state)) {
5724
+ issues.push({ key: m.key, locale: m.locale, check: "untranslated", message: "Not translated yet" });
4817
5725
  }
4818
5726
  }
4819
- for (const word of state.config.spelling?.customWords ?? []) {
4820
- const w = word.trim().toLowerCase();
4821
- if (w) ignore.add(w);
4822
- }
4823
- const targetLocales = state.config.locales.filter((l) => l !== sourceLocale);
4824
5727
  for (const key of Object.keys(state.keys).sort()) {
4825
5728
  const entry = state.keys[key];
4826
5729
  const source = entry.values[sourceLocale]?.value ?? "";
4827
- if (on("untranslated") && !entry.skipTranslate) {
4828
- for (const locale of targetLocales) {
4829
- const translated = entry.plural ? (entry.values[locale]?.forms?.other ?? "").trim() !== "" : (entry.values[locale]?.value ?? "").trim() !== "";
4830
- if (!translated) {
4831
- issues.push({ key, locale, check: "untranslated", message: "Not translated yet" });
4832
- }
4833
- }
4834
- }
4835
5730
  if (entry.plural) {
4836
5731
  if (on("placeholder")) {
4837
5732
  const sourceForm = entry.values[sourceLocale]?.forms?.other ?? "";
@@ -4875,7 +5770,8 @@ function runChecks(state, opts = {}) {
4875
5770
  });
4876
5771
  }
4877
5772
  if (on("spelling") && !blank) {
4878
- const bad = spellValue(locale, value, ignore);
5773
+ const dictId = state.config.lint?.spelling?.locales?.[locale] ?? locale;
5774
+ const bad = spellValue(dictId, value, ignore);
4879
5775
  if (bad === null) spellPending = true;
4880
5776
  else if (bad.length) {
4881
5777
  issues.push({
@@ -4905,29 +5801,14 @@ function runChecks(state, opts = {}) {
4905
5801
  });
4906
5802
  }
4907
5803
  if (on("glossary") && source) {
4908
- for (const hint of relevantGlossary(source, locale, state.glossary)) {
4909
- const cs = byTerm.get(hint.term)?.caseSensitive;
4910
- if (hint.doNotTranslate) {
4911
- if (!contains(value, hint.term, cs)) {
4912
- issues.push({
4913
- key,
4914
- locale,
4915
- check: "glossary",
4916
- message: `Do-not-translate term "${hint.term}" is missing from the translation`,
4917
- detail: [hint.term]
4918
- });
4919
- }
4920
- } else if (hint.forced) {
4921
- if (!contains(value, hint.forced, cs)) {
4922
- issues.push({
4923
- key,
4924
- locale,
4925
- check: "glossary",
4926
- message: `Should use "${hint.forced}" for "${hint.term}"`,
4927
- detail: [hint.forced]
4928
- });
4929
- }
4930
- }
5804
+ for (const viol of glossaryViolations(source, value, locale, state.glossary)) {
5805
+ issues.push({
5806
+ key,
5807
+ locale,
5808
+ check: "glossary",
5809
+ message: viol.kind === "do-not-translate" ? `Do-not-translate term "${viol.term}" is missing from the translation` : `Should use "${viol.expected}" for "${viol.term}"`,
5810
+ detail: [viol.expected]
5811
+ });
4931
5812
  }
4932
5813
  }
4933
5814
  }
@@ -4943,7 +5824,8 @@ var init_checks = __esm({
4943
5824
  "src/server/checks.ts"() {
4944
5825
  "use strict";
4945
5826
  init_placeholders();
4946
- init_run();
5827
+ init_glossary();
5828
+ init_scan();
4947
5829
  init_spell();
4948
5830
  init_suppress();
4949
5831
  CHECK_IDS = ["untranslated", "placeholder", "spelling", "length", "glossary"];
@@ -4958,12 +5840,12 @@ var init_checks = __esm({
4958
5840
  });
4959
5841
 
4960
5842
  // src/server/ui-prefs.ts
4961
- import { readFileSync as readFileSync14 } from "fs";
5843
+ import { readFileSync as readFileSync20 } from "fs";
4962
5844
  import { homedir } from "os";
4963
- import { join as join9 } from "path";
5845
+ import { join as join14 } from "path";
4964
5846
  function readJson2(path) {
4965
5847
  try {
4966
- const parsed = JSON.parse(readFileSync14(path, "utf8"));
5848
+ const parsed = JSON.parse(readFileSync20(path, "utf8"));
4967
5849
  return parsed && typeof parsed === "object" ? parsed : {};
4968
5850
  } catch {
4969
5851
  return {};
@@ -4988,7 +5870,7 @@ var init_ui_prefs = __esm({
4988
5870
  THEMES = ["system", "light", "dark"];
4989
5871
  isThemeMode = (v) => THEMES.includes(v);
4990
5872
  isPanelWidth = (v) => typeof v === "number" && Number.isFinite(v) && v >= 120 && v <= 1200;
4991
- defaultUiPrefsPath = () => join9(homedir(), ".glotfile", "ui.json");
5873
+ defaultUiPrefsPath = () => join14(homedir(), ".glotfile", "ui.json");
4992
5874
  DEFAULTS = { theme: "system" };
4993
5875
  }
4994
5876
  });
@@ -4996,13 +5878,13 @@ var init_ui_prefs = __esm({
4996
5878
  // src/server/api.ts
4997
5879
  import { Hono } from "hono";
4998
5880
  import { streamSSE } from "hono/streaming";
4999
- import { readFileSync as readFileSync15, existsSync as existsSync11, readdirSync as readdirSync9, statSync as statSync6, rmSync as rmSync4 } from "fs";
5881
+ import { readFileSync as readFileSync21, existsSync as existsSync11, readdirSync as readdirSync14, statSync as statSync8, rmSync as rmSync4 } from "fs";
5000
5882
  import { dirname as dirname3, resolve as resolve9, basename, relative as relative4, sep as sep2 } from "path";
5001
5883
  function projectName(root) {
5002
5884
  const nameFile = resolve9(root, ".idea", ".name");
5003
5885
  if (existsSync11(nameFile)) {
5004
5886
  try {
5005
- const name = readFileSync15(nameFile, "utf8").trim();
5887
+ const name = readFileSync21(nameFile, "utf8").trim();
5006
5888
  if (name) return name;
5007
5889
  } catch {
5008
5890
  }
@@ -5127,7 +6009,7 @@ function createApi(deps) {
5127
6009
  if (depth > 4) return;
5128
6010
  let entries = [];
5129
6011
  try {
5130
- entries = readdirSync9(dir);
6012
+ entries = readdirSync14(dir);
5131
6013
  } catch {
5132
6014
  return;
5133
6015
  }
@@ -5141,7 +6023,7 @@ function createApi(deps) {
5141
6023
  filePath = abs;
5142
6024
  } else {
5143
6025
  try {
5144
- if (statSync6(abs).isDirectory()) walk(abs, depth + 1);
6026
+ if (statSync8(abs).isDirectory()) walk(abs, depth + 1);
5145
6027
  } catch {
5146
6028
  }
5147
6029
  continue;
@@ -5959,7 +6841,7 @@ __export(server_exports, {
5959
6841
  import { Hono as Hono2 } from "hono";
5960
6842
  import { serve } from "@hono/node-server";
5961
6843
  import { fileURLToPath } from "url";
5962
- import { dirname as dirname4, join as join10, resolve as resolve10, extname as extname3, sep as sep3 } from "path";
6844
+ import { dirname as dirname4, join as join15, resolve as resolve10, extname as extname3, sep as sep3 } from "path";
5963
6845
  import { readFile, stat } from "fs/promises";
5964
6846
  import { createServer } from "net";
5965
6847
  import open from "open";
@@ -6002,7 +6884,7 @@ function buildApp(opts) {
6002
6884
  const file = await readFileResponse(target);
6003
6885
  if (file) return file;
6004
6886
  }
6005
- const index = await readFileResponse(join10(root, "index.html"));
6887
+ const index = await readFileResponse(join15(root, "index.html"));
6006
6888
  if (index) return index;
6007
6889
  return c.notFound();
6008
6890
  });
@@ -6060,7 +6942,7 @@ var init_server = __esm({
6060
6942
  init_scan();
6061
6943
  init_scanner();
6062
6944
  here = dirname4(fileURLToPath(import.meta.url));
6063
- DEFAULT_UI_DIR = join10(here, "..", "ui");
6945
+ DEFAULT_UI_DIR = join15(here, "..", "ui");
6064
6946
  MIME = {
6065
6947
  ".html": "text/html; charset=utf-8",
6066
6948
  ".js": "text/javascript; charset=utf-8",
@@ -6104,8 +6986,8 @@ init_scanner();
6104
6986
  init_context();
6105
6987
  init_run2();
6106
6988
  init_outputs();
6107
- import { resolve as resolve11, dirname as dirname5, join as join11 } from "path";
6108
- import { readFileSync as readFileSync16, existsSync as existsSync12, mkdirSync as mkdirSync4, cpSync } from "fs";
6989
+ import { resolve as resolve11, dirname as dirname5, join as join16 } from "path";
6990
+ import { readFileSync as readFileSync22, existsSync as existsSync12, mkdirSync as mkdirSync4, cpSync } from "fs";
6109
6991
  import { fileURLToPath as fileURLToPath2 } from "url";
6110
6992
 
6111
6993
  // src/server/lint/locate.ts
@@ -6180,6 +7062,9 @@ function parseArgs(argv) {
6180
7062
  if (first === "help" || first === "--help" || first === "-h") {
6181
7063
  return isCommand(argv[1]) ? { command: argv[1], statePath, help: true } : { command: "help", statePath };
6182
7064
  }
7065
+ if (first === "version" || first === "--version" || first === "-v") {
7066
+ return { command: "version", statePath };
7067
+ }
6183
7068
  if (first !== void 0 && !first.startsWith("-") && !isCommand(first)) {
6184
7069
  return { command: "serve", statePath, unknownCommand: first };
6185
7070
  }
@@ -6413,7 +7298,7 @@ async function runLintCmd(args) {
6413
7298
  }
6414
7299
  return;
6415
7300
  }
6416
- const rawText = existsSync12(args.statePath) ? readFileSync16(args.statePath, "utf8") : "";
7301
+ const rawText = existsSync12(args.statePath) ? readFileSync22(args.statePath, "utf8") : "";
6417
7302
  const report = await runLint(state, {
6418
7303
  locales: args.locales,
6419
7304
  ruleIds: args.ruleIds,
@@ -6437,7 +7322,7 @@ async function runCheck(args) {
6437
7322
  process.exitCode = 1;
6438
7323
  return;
6439
7324
  }
6440
- const rawText = existsSync12(args.statePath) ? readFileSync16(args.statePath, "utf8") : "";
7325
+ const rawText = existsSync12(args.statePath) ? readFileSync22(args.statePath, "utf8") : "";
6441
7326
  const root = dirname5(resolve11(args.statePath));
6442
7327
  const lint = await runLint(state, {});
6443
7328
  const findings = sortFindings([...lint.findings, ...checkOutputs(state, root)]);
@@ -6598,10 +7483,10 @@ function runSplit(args) {
6598
7483
  `Split catalog into ${splitDirFor(args.statePath)}/ (config.json, keys.json, locales/ \u2014 up to ${state.config.locales.length} locale files). Removed ${args.statePath}.`
6599
7484
  );
6600
7485
  }
6601
- var SKILL_SRC = join11(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "skill");
7486
+ var SKILL_SRC = join16(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "skill");
6602
7487
  function runSkill(args) {
6603
7488
  if (args.print) {
6604
- console.log(readFileSync16(join11(SKILL_SRC, "SKILL.md"), "utf8").trimEnd());
7489
+ console.log(readFileSync22(join16(SKILL_SRC, "SKILL.md"), "utf8").trimEnd());
6605
7490
  return;
6606
7491
  }
6607
7492
  const dest = resolve11(process.cwd(), ".claude", "skills", "glotfile");
@@ -6732,12 +7617,16 @@ ${formatOpts([...options, ...GLOBAL_OPTS])}`);
6732
7617
  formatOpts(commands),
6733
7618
  "",
6734
7619
  "Global options:",
6735
- formatOpts(GLOBAL_OPTS),
7620
+ formatOpts([...GLOBAL_OPTS, ["-v, --version", "Print the glotfile version"]]),
6736
7621
  "",
6737
7622
  "Run `glotfile <command> --help` for a command's options."
6738
7623
  ].join("\n")
6739
7624
  );
6740
7625
  }
7626
+ function printVersion() {
7627
+ const pkgPath = join16(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "package.json");
7628
+ console.log(JSON.parse(readFileSync22(pkgPath, "utf8")).version);
7629
+ }
6741
7630
  async function main(argv) {
6742
7631
  const args = parseArgs(argv);
6743
7632
  if (args.unknownCommand) {
@@ -6746,6 +7635,7 @@ async function main(argv) {
6746
7635
  return;
6747
7636
  }
6748
7637
  if (args.command === "help") return printHelp();
7638
+ if (args.command === "version") return printVersion();
6749
7639
  if (args.help) return printHelp(args.command);
6750
7640
  loadDotEnv();
6751
7641
  if (args.command === "export") return runExport(args);