@x18n/core 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -286,26 +286,51 @@ function fuzzyScore(query, text) {
286
286
  }
287
287
  //#endregion
288
288
  //#region src/keys.ts
289
- /** Canonical string form of a key: `ns:a.b.c`, or `a.b.c` without namespace. */
290
- function joinKey(namespace, path) {
291
- const dotted = path.join(".");
292
- return namespace === void 0 ? dotted : `${namespace}:${dotted}`;
289
+ /** The text between path segments in a key string: the separator, or `.` when keys are literal. */
290
+ function keyJoiner(separator) {
291
+ return separator === false ? "." : separator;
293
292
  }
294
- /** Splits a key string. `namespaced` is false for projects without namespaces, where `:` is part of the key. */
295
- function splitKey(key, namespaced = true) {
296
- const index = namespaced ? key.indexOf(":") : -1;
297
- const namespace = index === -1 ? void 0 : key.slice(0, index);
298
- const rest = index === -1 ? key : key.slice(index + 1);
293
+ /** Path segments of the part of a key after its namespace. With `false` the whole text is one segment. */
294
+ function splitKeyPath(text, separator = ".") {
295
+ if (text === "") return [];
296
+ return separator === false ? [text] : text.split(separator);
297
+ }
298
+ /** Canonical string form of a key: `ns:a.b.c`, or `a.b.c` without namespace. `separator` joins the path (`.` by default). */
299
+ function joinKey(namespace, path, separator = ".") {
300
+ const joined = path.join(keyJoiner(separator));
301
+ return namespace === void 0 ? joined : `${namespace}:${joined}`;
302
+ }
303
+ /**
304
+ * Splits a key string. `namespaced` is false for projects without namespaces, where `:` is part of the key.
305
+ * The namespace ends at the first `:`, so the rest of the key may hold more. With `separator: false` the path is one literal segment.
306
+ */
307
+ function splitKey(key, namespaced = true, separator = ".") {
308
+ const { namespace, rest } = splitNamespace(key, namespaced);
299
309
  return {
300
310
  namespace,
301
- path: rest === "" ? [] : rest.split(".")
311
+ path: splitKeyPath(rest, separator)
312
+ };
313
+ }
314
+ /** The namespace of a key string and the text after it. */
315
+ function splitNamespace(key, namespaced) {
316
+ const index = namespaced ? key.indexOf(":") : -1;
317
+ return index === -1 ? {
318
+ namespace: void 0,
319
+ rest: key
320
+ } : {
321
+ namespace: key.slice(0, index),
322
+ rest: key.slice(index + 1)
302
323
  };
303
324
  }
304
- /** True when `key` is `prefix` itself or lies under it (`a.b` is under `a`). */
305
- function isKeyUnder(key, prefix) {
325
+ /** The key separator of a resource: its own (from its resource spec), else the config's. */
326
+ function keySeparatorOf(resource, fallback) {
327
+ return resource.keySeparator ?? fallback;
328
+ }
329
+ /** True when `key` is `prefix` itself or lies under it (`a.b` is under `a`). A literal key (`separator: false`) has nothing under it but in its namespace. */
330
+ function isKeyUnder(key, prefix, separator = ".") {
306
331
  if (key === prefix) return true;
307
- const sep = prefix.endsWith(":") ? "" : ".";
308
- return key.startsWith(prefix + sep);
332
+ if (prefix.endsWith(":")) return key.startsWith(prefix);
333
+ return separator !== false && key.startsWith(prefix + separator);
309
334
  }
310
335
  /** Replaces the `from` prefix of `key` with `to`. */
311
336
  function replaceKeyPrefix(key, from, to) {
@@ -354,10 +379,10 @@ function globToRegExp(glob, separator = "/") {
354
379
  }
355
380
  return new RegExp(`^${out}$`);
356
381
  }
357
- /** Matcher over key globs: `*` stays inside a segment, `**` spans segments. */
358
- function keyMatcher(globs) {
382
+ /** Matcher over key globs: `*` stays inside a segment, `**` spans segments. With literal keys (`separator: false`) `*` spans the whole key. */
383
+ function keyMatcher(globs, separator = ".") {
359
384
  if (globs.length === 0) return () => false;
360
- const regexes = globs.map((glob) => globToRegExp(glob, "."));
385
+ const regexes = globs.map((glob) => globToRegExp(glob, separator === false ? "\0" : separator));
361
386
  return (key) => regexes.some((re) => re.test(key));
362
387
  }
363
388
  /** Matcher over posix file paths. */
@@ -381,6 +406,7 @@ function createLibraryPlugin(options) {
381
406
  };
382
407
  if (options.resources) draft.resources = [...draft.resources, ...options.resources.filter((spec) => !draft.resources.some((r) => r.pattern === spec.pattern))];
383
408
  if (options.keyStyle && draft.keyStyle === "auto" && !ctx.user.keyStyle) draft.keyStyle = options.keyStyle;
409
+ if (options.keySeparator !== void 0 && ctx.user.keySeparator === void 0) draft.keySeparator = options.keySeparator;
384
410
  draft.usage = {
385
411
  ...draft.usage,
386
412
  calls: [...draft.usage.calls, ...options.calls ?? []],
@@ -666,7 +692,8 @@ function uniqueById(items, kind) {
666
692
  function createRegistry(input, config) {
667
693
  const plugins = sortByEnforce(input);
668
694
  const formats = plugins.flatMap((plugin) => [...plugin.formats ?? [], ...plugin.lazyFormats ?? []]);
669
- const scanners = plugins.flatMap((plugin) => [...plugin.scanners ?? [], ...plugin.lazyScanners ?? []]);
695
+ const lazyIds = /* @__PURE__ */ new Set();
696
+ const scanners = plugins.flatMap((plugin) => [...plugin.scanners ?? [], ...(plugin.lazyScanners ?? []).filter((entry) => !lazyIds.has(entry.id) && lazyIds.add(entry.id))]);
670
697
  const finders = [...uniqueById(plugins.flatMap((plugin) => plugin.localeFinders ?? []), "Locale finder").values()];
671
698
  const checks = uniqueById(plugins.flatMap((plugin) => plugin.checks ?? []), "Check");
672
699
  const reporters = uniqueById(plugins.flatMap((plugin) => plugin.reporters ?? []), "Reporter");
@@ -802,6 +829,7 @@ const callSpec = v.strictObject({
802
829
  keyPrefix: v.optional(argSource),
803
830
  params: v.optional(paramsSource),
804
831
  defaultValue: v.optional(v.array(argSource)),
832
+ description: v.optional(v.string()),
805
833
  factory: v.optional(v.boolean()),
806
834
  returns: v.optional(strings),
807
835
  types: v.optional(strings)
@@ -810,12 +838,14 @@ const regexSpec = v.strictObject({
810
838
  extensions: strings,
811
839
  patterns: strings
812
840
  });
841
+ const keySeparator = v.union([v.pipe(v.string(), v.minLength(1)), v.literal(false)]);
813
842
  const resourceSpec = v.strictObject({
814
843
  pattern: v.string(),
815
844
  format: v.optional(v.string()),
816
845
  namespace: v.optional(v.string()),
817
846
  locale: v.optional(v.string()),
818
- execute: v.optional(v.boolean())
847
+ execute: v.optional(v.boolean()),
848
+ keySeparator: v.optional(keySeparator)
819
849
  });
820
850
  const pluginSpecifier = v.union([v.string(), v.tuple([v.string(), jsonObject])]);
821
851
  const extractTemplate = v.strictObject({
@@ -852,12 +882,13 @@ const extractOptions = v.strictObject({
852
882
  const glossaryEntry = v.strictObject({
853
883
  term: v.string(),
854
884
  translations: v.optional(v.record(v.string(), v.string())),
885
+ forms: v.optional(v.record(v.string(), v.array(v.string()))),
855
886
  note: v.optional(v.string()),
856
887
  doNotTranslate: v.optional(v.boolean())
857
888
  });
858
889
  const checkSetting = v.union([level, v.tuple([level, jsonObject])]);
859
890
  const namespaceOptions = v.strictObject({
860
- separator: v.optional(v.string()),
891
+ separator: v.optional(v.union([v.pipe(v.string(), v.minLength(1)), v.literal(false)])),
861
892
  default: v.optional(v.string()),
862
893
  fallback: v.optional(strings),
863
894
  byFile: v.optional(v.record(v.string(), v.string())),
@@ -889,10 +920,12 @@ const ConfigSchema = v.strictObject({
889
920
  "nested",
890
921
  "flat"
891
922
  ])),
923
+ keySeparator: v.optional(keySeparator),
892
924
  namespace: v.optional(namespaceOptions),
893
925
  usage: v.optional(v.strictObject({
894
926
  include: v.optional(strings),
895
927
  exclude: v.optional(strings),
928
+ gitignore: v.optional(v.boolean()),
896
929
  calls: v.optional(v.array(callSpec)),
897
930
  regex: v.optional(v.array(regexSpec)),
898
931
  ignoreCallees: v.optional(strings),
@@ -937,7 +970,8 @@ const PluginFileSchema = v.strictObject({
937
970
  "auto",
938
971
  "nested",
939
972
  "flat"
940
- ]))
973
+ ])),
974
+ keySeparator: v.optional(keySeparator)
941
975
  })),
942
976
  scanners: v.optional(v.strictObject({
943
977
  calls: v.optional(v.array(callSpec)),
@@ -1137,46 +1171,55 @@ function innermost(scopes, range) {
1137
1171
  for (const scope of scopes) if (scope.range.start <= range.start && scope.range.end >= range.end && (!best || scope.range.end - scope.range.start < best.range.end - best.range.start)) best = scope;
1138
1172
  return best;
1139
1173
  }
1174
+ function joinPrefix(prefix, text, joiner) {
1175
+ return text ? `${prefix}${joiner}${text}` : prefix;
1176
+ }
1177
+ /** The first prefix whose key exists. A prefix usage (`items.${id}`) ends in an empty segment, so its parent key decides. */
1178
+ function definedPrefix(prefixes, text, joiner, dynamicPrefix, exists) {
1179
+ return prefixes.find((prefix) => {
1180
+ const prefixed = joinPrefix(prefix, text, joiner);
1181
+ return exists(dynamicPrefix && prefixed.endsWith(joiner) ? prefixed.slice(0, -joiner.length) : prefixed);
1182
+ });
1183
+ }
1140
1184
  function createUsageResolver(config, namespaced, normalizers) {
1141
1185
  const { separator, fallback } = config.namespace;
1186
+ const joiner = keyJoiner(config.keySeparator);
1187
+ const keyOf = (namespace, text) => namespace === void 0 ? text : `${namespace}:${text}`;
1188
+ const normalize = (raw, ctx) => {
1189
+ for (const normalizer of normalizers) {
1190
+ const result = normalizer(raw, ctx);
1191
+ if (result !== void 0) return result;
1192
+ }
1193
+ return raw;
1194
+ };
1142
1195
  const byFile = Object.entries(config.namespace.byFile).map(([glob, namespace]) => ({
1143
1196
  matches: pathMatcher([glob]),
1144
1197
  namespace
1145
1198
  }));
1146
1199
  return { resolve(candidate, scopes, path, scanner, has) {
1147
1200
  const scope = innermost(scopes, candidate.range);
1148
- const ctx = { path };
1149
- let text = candidate.raw;
1150
- for (const normalize of normalizers) {
1151
- const result = normalize(text, ctx);
1152
- if (result !== void 0) {
1153
- text = result;
1154
- break;
1155
- }
1156
- }
1201
+ let text = normalize(candidate.raw, { path });
1157
1202
  const scopeNamespace = candidate.namespace ?? scope?.namespace ?? byFile.find((rule) => rule.matches(path))?.namespace;
1158
1203
  let scopePrefix = candidate.keyPrefix ?? scope?.keyPrefix;
1159
1204
  let namespace;
1160
1205
  let explicit = false;
1161
1206
  if (namespaced) {
1162
- const index = text.indexOf(separator);
1207
+ const index = separator === false ? -1 : text.indexOf(separator);
1163
1208
  explicit = index > 0;
1164
1209
  if (explicit) {
1165
1210
  namespace = text.slice(0, index);
1166
- text = text.slice(index + separator.length);
1211
+ text = text.slice(index + (separator || "").length);
1167
1212
  } else namespace = scopeNamespace ?? config.namespace.default;
1168
1213
  }
1169
1214
  if (scopePrefix && !explicit) {
1170
- const prefixed = text ? `${scopePrefix}.${text}` : scopePrefix;
1171
- const lookup = candidate.dynamic === "prefix" ? prefixed.replace(/\.$/, "") : prefixed;
1172
- if (candidate.keyPrefix || !scope?.optionalPrefix || has(joinKey(namespace, lookup.split(".")))) text = prefixed;
1173
- else scopePrefix = void 0;
1215
+ const chosen = !candidate.keyPrefix && scope?.optionalPrefix ? definedPrefix([scopePrefix, ...scope.otherPrefixes ?? []], text, joiner, candidate.dynamic === "prefix", (lookup) => (scope.prefixInFallback ? [namespace, ...fallback] : [namespace]).some((ns) => has(keyOf(ns, lookup)))) : scopePrefix;
1216
+ scopePrefix = chosen;
1217
+ if (chosen) text = joinPrefix(chosen, text, joiner);
1174
1218
  }
1175
- const keyPath = text === "" ? [] : text.split(".");
1176
1219
  let key;
1177
1220
  if (candidate.dynamic === "unknown") key = void 0;
1178
- else key = joinKey(namespace, keyPath);
1179
- if (key !== void 0 && namespaced && !explicit && candidate.dynamic === false && !has(key)) key = fallback.map((ns) => joinKey(ns, keyPath)).find(has) ?? key;
1221
+ else key = keyOf(namespace, text);
1222
+ if (key !== void 0 && namespaced && !explicit && candidate.dynamic === false && !has(key)) key = fallback.map((ns) => keyOf(ns, text)).find(has) ?? key;
1180
1223
  return {
1181
1224
  key,
1182
1225
  raw: candidate.raw,
@@ -1185,6 +1228,7 @@ function createUsageResolver(config, namespaced, normalizers) {
1185
1228
  expression: candidate.expression,
1186
1229
  ...candidate.params ? { params: candidate.params } : {},
1187
1230
  ...candidate.defaultValue === void 0 ? {} : { defaultValue: candidate.defaultValue },
1231
+ ...candidate.description === void 0 ? {} : { description: candidate.description },
1188
1232
  scanner,
1189
1233
  kind: candidate.kind,
1190
1234
  dynamic: candidate.dynamic,
@@ -1328,12 +1372,19 @@ function computeCoverage(state, options, sourceKeys) {
1328
1372
  const missing = [];
1329
1373
  const empty = [];
1330
1374
  const outdated = [];
1375
+ const needsReview = [];
1376
+ const reviewed = [];
1331
1377
  for (const key of reference) {
1332
- const value = messages.get(key)?.get(locale)?.[0]?.value;
1378
+ const message = messages.get(key)?.get(locale)?.[0];
1379
+ const value = message?.value;
1333
1380
  if (value === void 0 || value === "") {
1334
1381
  if (inherited(key)) continue;
1335
1382
  (value === void 0 ? missing : empty).push(key);
1336
- } else if (locale !== sourceLocale && isOutdated(state.history(key, sourceLocale), state.history(key, locale)) && !state.fresh(key, locale)) outdated.push(key);
1383
+ continue;
1384
+ }
1385
+ if (locale !== sourceLocale && isOutdated(state.history(key, sourceLocale), state.history(key, locale)) && !state.fresh(key, locale)) outdated.push(key);
1386
+ if (message?.state === "needs-review") needsReview.push(key);
1387
+ else if (message?.state === "reviewed") reviewed.push(key);
1337
1388
  }
1338
1389
  return {
1339
1390
  locale,
@@ -1341,7 +1392,9 @@ function computeCoverage(state, options, sourceKeys) {
1341
1392
  translated: reference.length - missing.length - empty.length,
1342
1393
  missing,
1343
1394
  empty,
1344
- outdated
1395
+ outdated,
1396
+ needsReview,
1397
+ reviewed
1345
1398
  };
1346
1399
  });
1347
1400
  }
@@ -1425,8 +1478,8 @@ function runChecks(input) {
1425
1478
  function createAnalyzer(options) {
1426
1479
  const { config, registry } = options;
1427
1480
  let memo;
1428
- const ignored = keyMatcher(config.ignoreKeys);
1429
- const inUse = keyMatcher(config.usage.keysInUse);
1481
+ const ignored = keyMatcher(config.ignoreKeys, config.keySeparator);
1482
+ const inUse = keyMatcher(config.usage.keysInUse, config.keySeparator);
1430
1483
  const placeholderCache = /* @__PURE__ */ new Map();
1431
1484
  function analyze() {
1432
1485
  const state = options.state();
@@ -1684,19 +1737,19 @@ function nodeAt(tree, path) {
1684
1737
  return node;
1685
1738
  }
1686
1739
  /** True for flat files (`{ "a.b": "x" }`), false for nested ones, undefined when it cannot tell. */
1687
- function isFlatTree(tree) {
1740
+ function isFlatTree(tree, separator) {
1688
1741
  const entries = Object.entries(tree);
1689
1742
  if (entries.length === 0) return void 0;
1690
1743
  if (entries.some(([, value]) => !isMessageLeaf(value))) return false;
1691
- return entries.some(([key]) => key.includes(".")) ? true : void 0;
1744
+ return entries.some(([key]) => key.includes(separator)) ? true : void 0;
1692
1745
  }
1693
1746
  /** The longest key prefix a tree shares with `path`. */
1694
- function longestPrefix(tree, prefix, path) {
1747
+ function longestPrefix(tree, prefix, path, separator) {
1695
1748
  let best = 0;
1696
1749
  for (const [segment, child] of Object.entries(tree)) {
1697
- const next = [...prefix, ...segment.split(".")];
1750
+ const next = [...prefix, ...splitKeyPath(segment, separator)];
1698
1751
  best = Math.max(best, commonPrefix(next, path));
1699
- if (!isMessageLeaf(child)) best = Math.max(best, longestPrefix(child, next, path));
1752
+ if (!isMessageLeaf(child)) best = Math.max(best, longestPrefix(child, next, path, separator));
1700
1753
  }
1701
1754
  return best;
1702
1755
  }
@@ -1729,6 +1782,10 @@ var PlanBuilder = class {
1729
1782
  answers;
1730
1783
  work = /* @__PURE__ */ new Map();
1731
1784
  created = /* @__PURE__ */ new Map();
1785
+ /** New files that a finder's `resolveKey` gave one key. */
1786
+ keyFiles = /* @__PURE__ */ new Map();
1787
+ /** Resources that are the file of a key deleted from them: removed once empty. */
1788
+ keyFilesLeft = /* @__PURE__ */ new Set();
1732
1789
  sourceEdits = /* @__PURE__ */ new Map();
1733
1790
  sourceImports = /* @__PURE__ */ new Map();
1734
1791
  decisions = [];
@@ -1755,6 +1812,7 @@ var PlanBuilder = class {
1755
1812
  path: s.resource.path,
1756
1813
  locale: s.resource.locale,
1757
1814
  namespace: s.resource.namespace,
1815
+ keySeparator: s.resource.keySeparator,
1758
1816
  format,
1759
1817
  state: s
1760
1818
  };
@@ -1779,24 +1837,36 @@ var PlanBuilder = class {
1779
1837
  return s;
1780
1838
  }
1781
1839
  keyId(key) {
1782
- const id = splitKey(key, this.state.namespaced);
1840
+ const id = splitKey(key, this.state.namespaced, this.config.keySeparator);
1783
1841
  return {
1784
1842
  namespace: id.namespace,
1785
1843
  path: [...id.path]
1786
1844
  };
1787
1845
  }
1788
- /** Key path relative to a resource, or undefined when the key does not belong there. */
1846
+ separatorOf(target) {
1847
+ return keySeparatorOf(target, this.config.keySeparator);
1848
+ }
1849
+ /**
1850
+ * Key path relative to a resource, or undefined when the key does not belong there.
1851
+ * A resource of literal keys (`keySeparator: false`) gets the whole rest of the key as one segment.
1852
+ */
1789
1853
  relativePath(key, target) {
1790
- const { namespace, path } = this.keyId(key);
1854
+ const { namespace, rest } = splitNamespace(key, this.state.namespaced);
1855
+ const literal = this.separatorOf(target) === false;
1856
+ const joiner = keyJoiner(this.config.keySeparator);
1857
+ const split = (text) => splitKeyPath(text, literal ? false : joiner);
1791
1858
  const fallback = this.config.namespace.default;
1792
- if (this.state.namespaced) return (namespace ?? fallback) === (target.namespace ?? fallback) ? path : void 0;
1793
- const prefix = target.namespace ? target.namespace.split(".") : [];
1794
- if (path.length <= prefix.length || prefix.some((segment, i) => path[i] !== segment)) return void 0;
1795
- return path.slice(prefix.length);
1859
+ if (this.state.namespaced) return (namespace ?? fallback) === (target.namespace ?? fallback) ? split(rest) : void 0;
1860
+ if (!target.namespace) return rest === "" ? void 0 : split(rest);
1861
+ const prefix = target.namespace.split(".").join(joiner) + joiner;
1862
+ if (rest.length <= prefix.length || !rest.startsWith(prefix)) return void 0;
1863
+ return split(rest.slice(prefix.length));
1796
1864
  }
1797
1865
  storedPathFor(entry, relative) {
1798
1866
  const stored = this.options.toStored(entry.target, relative);
1799
- return stored.length === relative.length && (isFlatTree(entry.tree) ?? this.config.keyStyle === "flat") ? [stored.join(".")] : stored;
1867
+ const separator = this.separatorOf(entry.target);
1868
+ if (separator === false) return stored;
1869
+ return stored.length === relative.length && (isFlatTree(entry.tree, separator) ?? this.config.keyStyle === "flat") ? [stored.join(separator)] : stored;
1800
1870
  }
1801
1871
  createTarget(key, locale) {
1802
1872
  const { resources, sourceLocale, namespaced } = this.state;
@@ -1816,6 +1886,7 @@ var PlanBuilder = class {
1816
1886
  path: resolved,
1817
1887
  locale,
1818
1888
  namespace,
1889
+ keySeparator: model?.resource.keySeparator,
1819
1890
  format,
1820
1891
  state: void 0
1821
1892
  };
@@ -1835,14 +1906,47 @@ var PlanBuilder = class {
1835
1906
  path,
1836
1907
  locale,
1837
1908
  namespace: namespace ?? model.resource.namespace,
1909
+ keySeparator: model.resource.keySeparator,
1838
1910
  format,
1839
1911
  state: void 0
1840
1912
  };
1841
1913
  this.created.set(path, target);
1842
1914
  return target;
1843
1915
  }
1844
- /** Placement: the namespace's resource, then the longest shared prefix, then the first; a tie is a decision. */
1916
+ /** The file a locale finder gives the key, created when it does not exist. */
1917
+ keyTarget(key, locale) {
1918
+ const file = this.options.resolveKey(key, locale);
1919
+ if (!file) return void 0;
1920
+ const existing = this.state.resources.find((s) => s.resource.path === file.path && !s.resource.block);
1921
+ if (existing) {
1922
+ if (existing.resource.locale !== locale || this.relativePath(key, existing.resource) === void 0) throw errors.X18N4005({
1923
+ key,
1924
+ locale
1925
+ });
1926
+ return this.targetOf(existing);
1927
+ }
1928
+ const pending = this.keyFiles.get(file.path);
1929
+ if (pending) return pending;
1930
+ const format = file.format === void 0 ? this.options.registry.formatFor(file.path) : this.options.registry.formatById(file.format);
1931
+ if (!format?.serialize) throw errors.X18N4005({
1932
+ key,
1933
+ locale
1934
+ });
1935
+ const target = {
1936
+ id: file.path,
1937
+ path: file.path,
1938
+ locale,
1939
+ namespace: this.state.namespaced ? this.keyId(key).namespace ?? this.config.namespace.default : void 0,
1940
+ format,
1941
+ state: void 0
1942
+ };
1943
+ this.keyFiles.set(file.path, target);
1944
+ return target;
1945
+ }
1946
+ /** Placement: the key's own file, then the namespace's resource, then the longest shared prefix, then the first; a tie is a decision. */
1845
1947
  pickTarget(key, locale) {
1948
+ const own = this.keyTarget(key, locale);
1949
+ if (own) return own;
1846
1950
  let inLocale = this.state.resources.filter((s) => s.resource.locale === locale && this.relativePath(key, s.resource) !== void 0);
1847
1951
  const source = this.options.message(key, this.state.sourceLocale);
1848
1952
  const sourceNamespace = source && this.stateById.get(source.resource)?.resource.namespace;
@@ -1854,7 +1958,7 @@ var PlanBuilder = class {
1854
1958
  const prefix = s.resource.namespace && !this.state.namespaced ? path.slice(0, s.resource.namespace.split(".").length) : [];
1855
1959
  return {
1856
1960
  s,
1857
- best: longestPrefix(s.tree, prefix, path)
1961
+ best: longestPrefix(s.tree, prefix, path, this.separatorOf(s.resource))
1858
1962
  };
1859
1963
  });
1860
1964
  const top = Math.max(...scores.map((x) => x.best));
@@ -1901,7 +2005,9 @@ var PlanBuilder = class {
1901
2005
  });
1902
2006
  }
1903
2007
  deleteMessage(m) {
1904
- const entry = this.workFor(this.targetOf(this.writable(m.resource)));
2008
+ const s = this.writable(m.resource);
2009
+ if (!s.resource.block && this.options.resolveKey(m.key, m.locale)?.path === s.resource.path) this.keyFilesLeft.add(s.resource.id);
2010
+ const entry = this.workFor(this.targetOf(s));
1905
2011
  let path = [...m.storedPath];
1906
2012
  while (path.length > 1) {
1907
2013
  const parent = nodeAt(entry.tree, path.slice(0, -1));
@@ -1960,14 +2066,16 @@ var PlanBuilder = class {
1960
2066
  if (renamed.has(m.resource)) continue;
1961
2067
  const s = this.writable(m.resource);
1962
2068
  const relative = this.relativePath(to, s.resource);
1963
- if (!relative) {
2069
+ const own = this.options.resolveKey(to, locale);
2070
+ if (!relative || own && own.path !== s.resource.path) {
1964
2071
  this.deleteMessage(m);
1965
2072
  this.setValue(to, locale, m.value);
1966
2073
  continue;
1967
2074
  }
1968
2075
  const entry = this.workFor(this.targetOf(s));
1969
- const flat = m.storedPath.length === 1 && m.storedPath[0].includes(".");
1970
- const next = flat ? [relative.join(".")] : this.options.toStored(s.resource, relative);
2076
+ const separator = this.separatorOf(s.resource);
2077
+ const flat = separator !== false && m.storedPath.length === 1 && m.storedPath[0].includes(separator);
2078
+ const next = flat ? [relative.join(separator)] : this.options.toStored(s.resource, relative);
1971
2079
  const sameParent = next.length === m.storedPath.length && next.slice(0, -1).every((segment, i) => segment === m.storedPath[i]);
1972
2080
  const last = next.at(-1);
1973
2081
  if (sameParent && m.range && (flat ? safeFlat : safeSegment).test(last)) {
@@ -1999,16 +2107,17 @@ var PlanBuilder = class {
1999
2107
  const scope = usage.key.slice(0, usage.key.length - usage.raw.length);
2000
2108
  if (next.startsWith(`${scope}.`)) return next.slice(scope.length);
2001
2109
  }
2002
- const { namespace, path } = this.keyId(next);
2003
- let rest = path.join(".");
2110
+ const { namespace, rest: text } = splitNamespace(next, this.state.namespaced);
2111
+ let rest = text;
2004
2112
  const separator = this.config.namespace.separator;
2005
- const explicit = this.state.namespaced && usage.raw.indexOf(separator) > 0;
2113
+ const explicit = this.state.namespaced && separator !== false && usage.raw.indexOf(separator) > 0;
2006
2114
  if (usage.scopePrefix && !explicit) {
2007
- if (!rest.startsWith(`${usage.scopePrefix}.`)) return void 0;
2008
- rest = rest.slice(usage.scopePrefix.length + 1);
2115
+ const prefix = usage.scopePrefix + keyJoiner(this.config.keySeparator);
2116
+ if (!rest.startsWith(prefix)) return void 0;
2117
+ rest = rest.slice(prefix.length);
2009
2118
  }
2010
2119
  if (!this.state.namespaced) return rest;
2011
- if (explicit || namespace !== (usage.scopeNamespace ?? this.config.namespace.default)) return `${namespace}${separator}${rest}`;
2120
+ if (explicit || namespace !== (usage.scopeNamespace ?? this.config.namespace.default)) return separator === false ? void 0 : `${namespace}${separator}${rest}`;
2012
2121
  return rest;
2013
2122
  }
2014
2123
  editSource(path, edit) {
@@ -2053,7 +2162,7 @@ var PlanBuilder = class {
2053
2162
  if (op.from === op.to) return;
2054
2163
  const all = this.options.allKeys();
2055
2164
  const isLeaf = this.options.messages(op.from) !== void 0;
2056
- const under = all.filter((key) => isKeyUnder(key, op.from));
2165
+ const under = all.filter((key) => isKeyUnder(key, op.from, this.config.keySeparator));
2057
2166
  const derived = all.filter((key) => this.options.basesOf(key).includes(op.from));
2058
2167
  let keys;
2059
2168
  if (op.type === "rename-prefix") keys = under;
@@ -2073,7 +2182,7 @@ var PlanBuilder = class {
2073
2182
  this.rewritePrefixUsages(op.from, op.to);
2074
2183
  }
2075
2184
  delete(op) {
2076
- const keys = this.options.messages(op.key) ? [op.key] : this.options.allKeys().filter((key) => isKeyUnder(key, op.key));
2185
+ const keys = this.options.messages(op.key) ? [op.key] : this.options.allKeys().filter((key) => isKeyUnder(key, op.key, this.config.keySeparator));
2077
2186
  if (keys.length === 0) throw errors.X18N4006({ key: op.key });
2078
2187
  for (const key of keys) {
2079
2188
  this.touched.add(key);
@@ -2267,6 +2376,14 @@ var PlanBuilder = class {
2267
2376
  texts.set(target.path, void 0);
2268
2377
  continue;
2269
2378
  }
2379
+ if (this.keyFilesLeft.has(s.resource.id) && Object.keys(entry.tree).length === 0) {
2380
+ changes.push({
2381
+ type: "delete",
2382
+ path: target.path
2383
+ });
2384
+ texts.set(target.path, this.state.fileTexts.get(target.path));
2385
+ continue;
2386
+ }
2270
2387
  const edits = diffText(s.content, this.nextContent(entry, s)).map((e) => ({
2271
2388
  range: {
2272
2389
  start: e.range.start + s.offset,
@@ -2403,7 +2520,7 @@ function createPlanner(options) {
2403
2520
  for (const change of fresh.plan.changes) {
2404
2521
  const current = await options.host.readFile(change.path);
2405
2522
  const expected = fresh.texts.get(change.path);
2406
- if (change.type === "edit" && expected !== void 0 && current !== expected || change.type === "create" && current !== void 0) throw errors.X18N4004({ path: change.path });
2523
+ if (change.type !== "create" && expected !== void 0 && current !== expected || change.type === "create" && current !== void 0) throw errors.X18N4004({ path: change.path });
2407
2524
  }
2408
2525
  const events = [];
2409
2526
  for (const change of fresh.plan.changes) events.push(await write(change));
@@ -2417,6 +2534,10 @@ function createPlanner(options) {
2417
2534
  }
2418
2535
  //#endregion
2419
2536
  //#region src/project/prompt.ts
2537
+ /** Related locales shown per message, at most. */
2538
+ const RELATED_LIMIT = 2;
2539
+ /** Characters of a description or a related value, at most; longer text is cut. */
2540
+ const TEXT_LIMIT = 300;
2420
2541
  function text(value) {
2421
2542
  return value === void 0 || value === null ? void 0 : String(value);
2422
2543
  }
@@ -2451,10 +2572,36 @@ function reportedValues(project, diagnostics, to, check) {
2451
2572
  }
2452
2573
  return reported;
2453
2574
  }
2575
+ function clip(value) {
2576
+ return value.length > TEXT_LIMIT ? `${value.slice(0, 299)}…` : value;
2577
+ }
2578
+ const subtagsOf = (code) => code.toLowerCase().split(/[-_]/);
2579
+ /**
2580
+ * The locales whose values help translate to `to`, nearest first: the fallback chain of `to`, then the other locales of
2581
+ * the same language, those that share more subtags with `to` first, then the shorter codes (`pt` and `pt-PT` for `pt-BR`,
2582
+ * `pt-BR` for `pt`). The source locale and `to` itself are never related.
2583
+ */
2584
+ function relatedLocales(to, locales, sourceLocale, fallbacks) {
2585
+ const target = subtagsOf(to);
2586
+ const shared = (code) => {
2587
+ const tags = subtagsOf(code);
2588
+ let n = 0;
2589
+ while (n < tags.length && tags[n] === target[n]) n++;
2590
+ return n;
2591
+ };
2592
+ const chain = fallbackChain(fallbacks, to).filter((code) => code !== sourceLocale && code !== to);
2593
+ const language = locales.filter((code) => code !== to && code !== sourceLocale && !chain.includes(code) && shared(code) > 0).sort((a, b) => shared(b) - shared(a) || subtagsOf(a).length - subtagsOf(b).length || a.localeCompare(b));
2594
+ return [...chain, ...language];
2595
+ }
2596
+ /** The description of a key: from the source resource, else another locale's, else a usage's. */
2597
+ function descriptionOf(project, key, from) {
2598
+ const trimmed = (project.message(key, from)?.description ?? project.locales.map((locale) => project.message(key, locale)?.description).find(Boolean) ?? project.usages({ key }).map(({ usage }) => usage.description).find(Boolean))?.replace(/\s+/g, " ").trim();
2599
+ return trimmed ? clip(trimmed) : void 0;
2600
+ }
2454
2601
  /** Builds the parts of a translate or review prompt from project state. */
2455
2602
  function buildPrompt(project, kind, to, options, extra) {
2456
2603
  const from = project.sourceLocale;
2457
- const match = typeof options.keys === "string" ? keyMatcher([options.keys]) : void 0;
2604
+ const match = typeof options.keys === "string" ? keyMatcher([options.keys], extra.config.keySeparator) : void 0;
2458
2605
  const list = Array.isArray(options.keys) ? new Set(options.keys) : void 0;
2459
2606
  const analysis = project.analyze();
2460
2607
  const outdated = new Set(analysis.coverage.find((c) => c.locale === to)?.outdated);
@@ -2475,12 +2622,24 @@ function buildPrompt(project, kind, to, options, extra) {
2475
2622
  const end = start + page.length;
2476
2623
  const syntaxes = project.placeholderSyntaxes;
2477
2624
  const info = project.localeInfo(to);
2625
+ const related = options.related === false ? [] : Array.isArray(options.related) ? options.related.filter((code) => code !== to && code !== from) : relatedLocales(to, project.locales, from, extra.fallbacks);
2626
+ const relatedOf = (key, source) => related.flatMap((locale) => {
2627
+ const value = text(project.message(key, locale)?.value);
2628
+ return value && value !== source ? [{
2629
+ locale,
2630
+ value: clip(value)
2631
+ }] : [];
2632
+ }).slice(0, RELATED_LIMIT);
2633
+ const relatedValues = new Map(page.map((key) => [key, relatedOf(key, text(project.message(key, from)?.value) ?? "")]));
2634
+ const descriptions = new Map(page.map((key) => [key, descriptionOf(project, key, from)]));
2478
2635
  const draft = {
2479
2636
  rules: [],
2480
2637
  notes: []
2481
2638
  };
2482
2639
  if (syntaxes.length > 0) draft.rules.push(`Keep every placeholder exactly as written (${syntaxes.map((s) => `\`${s.example ?? s.id}\``).join(", ")}); do not translate their names.`);
2483
2640
  draft.rules.push("Keep markup tags and their attributes as they are.", "Match the tone and wording of the existing messages in the target locale.", "Do not translate glossary terms marked \"do not translate\".");
2641
+ if ([...descriptions.values()].some(Boolean)) draft.rules.push("A description tells what a message means or where it appears: follow it, and do not translate it.");
2642
+ if ([...relatedValues.values()].some((values) => values.length > 0)) draft.rules.push(`Values in related locales are references: keep what fits ${to}, and change the spelling, words and forms that differ.`);
2484
2643
  if (reported.size > 0) draft.rules.push(`A message with a reported problem has a current ${to} value: fix the problem. When the current value is right as it is, such as a word that is the same in both languages, leave the key out of the results.`);
2485
2644
  if (info.pluralCategories.length > 1) draft.notes.push(`${to} uses the plural categories ${info.pluralCategories.join(", ")}.`);
2486
2645
  for (const hook of extra.hooks) hook(draft, {
@@ -2501,10 +2660,13 @@ function buildPrompt(project, kind, to, options, extra) {
2501
2660
  const source = text(project.message(key, from)?.value) ?? "";
2502
2661
  const translated = project.history(key, to);
2503
2662
  const previous = translated && stale(key) ? text(project.valueAt(key, from, translated.updatedAt)) : void 0;
2663
+ const values = relatedValues.get(key) ?? [];
2504
2664
  return {
2505
2665
  key,
2506
2666
  source,
2507
2667
  placeholders: [...new Set(syntaxes.flatMap((s) => s.find(source)).map((p) => p.text))],
2668
+ description: descriptions.get(key),
2669
+ related: values.length > 0 ? values : void 0,
2508
2670
  existing: text(project.message(key, to)?.value) || void 0,
2509
2671
  previousSource: previous === source ? void 0 : previous,
2510
2672
  issues: reported.get(key)?.map((d) => ({
@@ -2553,6 +2715,8 @@ function renderItem(item, to) {
2553
2715
  `- Source: ${JSON.stringify(item.source)}`
2554
2716
  ];
2555
2717
  if (item.placeholders.length) out.push(`- Placeholders: ${item.placeholders.map((p) => `\`${p}\``).join(", ")}`);
2718
+ if (item.description !== void 0) out.push(`- Description: ${JSON.stringify(item.description)}`);
2719
+ for (const value of item.related ?? []) out.push(`- In ${value.locale}: ${JSON.stringify(value.value)}`);
2556
2720
  if (item.previousSource !== void 0) out.push(`- Source when ${to} last changed: ${JSON.stringify(item.previousSource)}`);
2557
2721
  if (item.existing !== void 0) out.push(`- Current ${to}: ${JSON.stringify(item.existing)}`);
2558
2722
  for (const issue of item.issues ?? []) out.push(`- Reported: ${issue.message} (\`${issue.code}\`)`);
@@ -2584,6 +2748,80 @@ function renderPrompt(data) {
2584
2748
  return out.join("\n");
2585
2749
  }
2586
2750
  //#endregion
2751
+ //#region src/project/unused.ts
2752
+ /**
2753
+ * True when a fully dynamic usage (`t(key)`) may reach `key`: the key lies in the usage's scope namespace and under its
2754
+ * scope key prefix. Undefined for a usage without a scope, which may reach any key.
2755
+ */
2756
+ function scopeMatcher(usage, namespaced) {
2757
+ const namespace = namespaced ? usage.scopeNamespace : void 0;
2758
+ const prefix = usage.scopePrefix;
2759
+ if (namespace === void 0 && !prefix) return void 0;
2760
+ return (key) => {
2761
+ const id = splitKey(key, namespaced);
2762
+ if (namespace !== void 0 && id.namespace !== namespace) return false;
2763
+ if (!prefix) return true;
2764
+ const path = id.path.join(".");
2765
+ return path === prefix || path.startsWith(`${prefix}.`);
2766
+ };
2767
+ }
2768
+ function planUnusedDeletion(options, query = {}) {
2769
+ const ignored = keyMatcher(options.ignoreKeys);
2770
+ const match = query.glob ? keyMatcher([query.glob]) : void 0;
2771
+ const candidates = [...options.unused].filter((key) => !ignored(key) && (match === void 0 || match(key)) && (query.namespace === void 0 || splitKey(key, options.namespaced).namespace === query.namespace)).sort();
2772
+ const scoped = [];
2773
+ const unscoped = [];
2774
+ for (const usage of options.usages) {
2775
+ if (usage.dynamic !== "unknown") continue;
2776
+ const reaches = scopeMatcher(usage, options.namespaced);
2777
+ if (reaches) scoped.push({
2778
+ usage,
2779
+ reaches
2780
+ });
2781
+ else unscoped.push(usage);
2782
+ }
2783
+ const keys = [];
2784
+ const kept = [];
2785
+ for (const key of candidates) {
2786
+ const usages = scoped.filter((s) => s.reaches(key)).map((s) => s.usage);
2787
+ if (usages.length > 0) {
2788
+ kept.push({
2789
+ key,
2790
+ usages
2791
+ });
2792
+ continue;
2793
+ }
2794
+ keys.push({
2795
+ key,
2796
+ messages: options.locations(key).filter((node) => node.leaf).map((node) => ({
2797
+ locale: node.locale,
2798
+ path: node.path
2799
+ }))
2800
+ });
2801
+ }
2802
+ const plan = options.plan(keys.map(({ key }) => ({
2803
+ type: "delete",
2804
+ key,
2805
+ locales: "all"
2806
+ })));
2807
+ if (unscoped.length === 0 || keys.length === 0) return {
2808
+ plan,
2809
+ keys,
2810
+ kept
2811
+ };
2812
+ const files = [...new Set(unscoped.map((usage) => usage.path))].sort();
2813
+ const shown = files.length > 5 ? `${files.slice(0, 5).join(", ")} and ${files.length - 5} more files` : files.join(", ");
2814
+ const warning = `${unscoped.length} usage${unscoped.length === 1 ? " has" : "s have"} a fully dynamic key (in ${shown}) that may reach any key; list the keys they use in \`usage.keysInUse\` to keep them.`;
2815
+ return {
2816
+ plan: {
2817
+ ...plan,
2818
+ warnings: [...plan.warnings, warning]
2819
+ },
2820
+ keys,
2821
+ kept
2822
+ };
2823
+ }
2824
+ //#endregion
2587
2825
  //#region src/project/project.ts
2588
2826
  const ioConcurrency = 64;
2589
2827
  async function forEachLimit(items, limit, fn) {
@@ -2691,12 +2929,15 @@ function createProject(options) {
2691
2929
  const source = message(key, sourceLocale)?.value;
2692
2930
  return recorded !== void 0 && source !== void 0 && recorded === sourceHash(source);
2693
2931
  }
2694
- async function discover() {
2695
- const ctx = {
2932
+ function finderContext() {
2933
+ return {
2696
2934
  host,
2697
2935
  config,
2698
2936
  extensions: [...new Set(registry.formats.flatMap((format) => format.extensions))]
2699
2937
  };
2938
+ }
2939
+ async function discover() {
2940
+ const ctx = finderContext();
2700
2941
  const seen = /* @__PURE__ */ new Map();
2701
2942
  const out = [];
2702
2943
  duplicates = [];
@@ -2725,7 +2966,8 @@ function createProject(options) {
2725
2966
  format: candidate.format,
2726
2967
  block: candidate.block,
2727
2968
  readonly: candidate.readonly === true,
2728
- finder: finder.id
2969
+ finder: finder.id,
2970
+ keySeparator: candidate.keySeparator === config.keySeparator ? void 0 : candidate.keySeparator
2729
2971
  });
2730
2972
  }
2731
2973
  finderResults.push({
@@ -2735,10 +2977,22 @@ function createProject(options) {
2735
2977
  }
2736
2978
  return out;
2737
2979
  }
2980
+ /** Namespace and key path of a stored path: each stored segment is split by the resource's key separator. */
2981
+ function keyIdFor(resource, stored) {
2982
+ const separator = keySeparatorOf(resource, config.keySeparator);
2983
+ const logical = stored.flatMap((segment) => splitKeyPath(segment, separator));
2984
+ if (namespaced) return {
2985
+ namespace: resource.namespace ?? config.namespace.default,
2986
+ path: logical
2987
+ };
2988
+ return {
2989
+ namespace: void 0,
2990
+ path: [...resource.namespace ? resource.namespace.split(".") : [], ...logical]
2991
+ };
2992
+ }
2738
2993
  function keyFor(resource, stored) {
2739
- const logical = stored.flatMap((segment) => segment.split("."));
2740
- if (namespaced) return joinKey(resource.namespace ?? config.namespace.default, logical);
2741
- return joinKey(void 0, [...resource.namespace ? resource.namespace.split(".") : [], ...logical]);
2994
+ const { namespace, path } = keyIdFor(resource, stored);
2995
+ return joinKey(namespace, path, config.keySeparator);
2742
2996
  }
2743
2997
  /** Key path to stored path through the transforms that apply to a resource, last first. */
2744
2998
  function storedPathOf(ctx) {
@@ -2756,7 +3010,8 @@ function createProject(options) {
2756
3010
  format: format?.id ?? "unknown",
2757
3011
  block: candidate.block,
2758
3012
  readonly: candidate.readonly || !format || !format.edit && !format.serialize,
2759
- finder: candidate.finder
3013
+ finder: candidate.finder,
3014
+ ...candidate.keySeparator === void 0 ? {} : { keySeparator: candidate.keySeparator }
2760
3015
  };
2761
3016
  const content = candidate.block ? text.slice(candidate.block.range.start, candidate.block.range.end) : text;
2762
3017
  const offset = candidate.block?.range.start ?? 0;
@@ -2778,18 +3033,29 @@ function createProject(options) {
2778
3033
  tree: {},
2779
3034
  rawTree: {},
2780
3035
  ranges: /* @__PURE__ */ new Map(),
3036
+ descriptions: /* @__PURE__ */ new Map(),
3037
+ states: /* @__PURE__ */ new Map(),
3038
+ labels: /* @__PURE__ */ new Map(),
2781
3039
  error: errors.X18N2001({ path: candidate.path })
2782
3040
  };
2783
3041
  try {
2784
3042
  const parsed = format.parse(content, ctx);
2785
3043
  let tree = parsed.tree;
2786
- for (const transform of transforms) if (transform.applies?.(ctx) !== false) tree = transform.read(tree, ctx);
3044
+ const descriptions = new Map(parsed.descriptions);
3045
+ for (const transform of transforms) {
3046
+ if (transform.applies?.(ctx) === false) continue;
3047
+ tree = transform.read(tree, ctx);
3048
+ for (const [id, description] of transform.describe?.(parsed.tree, ctx) ?? []) descriptions.set(id, description);
3049
+ }
2787
3050
  if (!isMessageTree(tree)) throw errors.X18N2003({ path: candidate.path });
2788
3051
  return {
2789
3052
  ...base,
2790
3053
  tree,
2791
3054
  rawTree: parsed.tree,
2792
3055
  ranges: parsed.ranges ?? /* @__PURE__ */ new Map(),
3056
+ descriptions,
3057
+ states: parsed.states ?? /* @__PURE__ */ new Map(),
3058
+ labels: parsed.labels ?? /* @__PURE__ */ new Map(),
2793
3059
  error: void 0
2794
3060
  };
2795
3061
  } catch (error) {
@@ -2803,6 +3069,9 @@ function createProject(options) {
2803
3069
  tree: {},
2804
3070
  rawTree: {},
2805
3071
  ranges: /* @__PURE__ */ new Map(),
3072
+ descriptions: /* @__PURE__ */ new Map(),
3073
+ states: /* @__PURE__ */ new Map(),
3074
+ labels: /* @__PURE__ */ new Map(),
2806
3075
  error: coded
2807
3076
  };
2808
3077
  }
@@ -2820,11 +3089,7 @@ function createProject(options) {
2820
3089
  }
2821
3090
  /** A path for a new resource, from the locale finders. */
2822
3091
  function newResourcePath(locale, namespace, model) {
2823
- const ctx = {
2824
- host,
2825
- config,
2826
- extensions: [...new Set(registry.formats.flatMap((format) => format.extensions))]
2827
- };
3092
+ const ctx = finderContext();
2828
3093
  for (const finder of registry.finders) {
2829
3094
  const path = finder.resolveNew?.({
2830
3095
  locale,
@@ -2834,6 +3099,19 @@ function createProject(options) {
2834
3099
  if (path) return path;
2835
3100
  }
2836
3101
  }
3102
+ /** The file of one key, from the locale finders. */
3103
+ function keyFile(key, locale) {
3104
+ const finders = registry.finders.filter((finder) => finder.resolveKey);
3105
+ if (finders.length === 0) return void 0;
3106
+ const ctx = finderContext();
3107
+ for (const finder of finders) {
3108
+ const file = finder.resolveKey?.({
3109
+ key,
3110
+ locale
3111
+ }, ctx);
3112
+ if (file) return file;
3113
+ }
3114
+ }
2837
3115
  async function loadResources(candidates, reread) {
2838
3116
  const next = /* @__PURE__ */ new Map();
2839
3117
  await forEachLimit([...new Set(candidates.map((c) => c.path))], ioConcurrency, async (path) => {
@@ -2851,7 +3129,7 @@ function createProject(options) {
2851
3129
  const id = candidate.block ? `${candidate.path}#${candidate.block.index}` : candidate.path;
2852
3130
  const previous = resourceStates.get(id);
2853
3131
  const content = candidate.block ? text.slice(candidate.block.range.start, candidate.block.range.end) : text;
2854
- if (previous && previous.hash === hashText(content) && previous.resource.locale === candidate.locale && previous.resource.namespace === candidate.namespace && previous.offset === (candidate.block?.range.start ?? 0)) next.set(id, previous);
3132
+ if (previous && previous.hash === hashText(content) && previous.resource.locale === candidate.locale && previous.resource.namespace === candidate.namespace && previous.resource.keySeparator === candidate.keySeparator && previous.offset === (candidate.block?.range.start ?? 0)) next.set(id, previous);
2855
3133
  else next.set(id, parseResource(candidate, text));
2856
3134
  }
2857
3135
  resourceStates.clear();
@@ -2963,7 +3241,7 @@ function createProject(options) {
2963
3241
  usageEligible = (path) => registry.scannersFor(path).length > 0 && include(path) && !exclude(path);
2964
3242
  await scanPaths((await host.glob(config.usage.include, {
2965
3243
  ignore: config.usage.exclude,
2966
- gitignore: true
3244
+ gitignore: config.usage.gitignore
2967
3245
  })).filter((path) => registry.scannersFor(path).length > 0));
2968
3246
  }
2969
3247
  let index;
@@ -2985,7 +3263,8 @@ function createProject(options) {
2985
3263
  const path = [...stored, segment];
2986
3264
  const key = keyFor(resource, path);
2987
3265
  const storedPath = state.toStored(path);
2988
- const raw = state.ranges.get(pathId(storedPath));
3266
+ const id = pathId(storedPath);
3267
+ const raw = state.ranges.get(id);
2989
3268
  const range = raw ? {
2990
3269
  key: shift(raw.key),
2991
3270
  value: shift(raw.value)
@@ -3002,24 +3281,21 @@ function createProject(options) {
3002
3281
  messages.set(key, byLocale);
3003
3282
  const list = byLocale.get(resource.locale) ?? [];
3004
3283
  byLocale.set(resource.locale, list);
3284
+ const description = state.descriptions.get(id);
3285
+ const translation = state.states.get(id);
3286
+ const label = state.labels.get(id);
3005
3287
  list.push({
3006
3288
  key,
3007
3289
  locale: resource.locale,
3008
3290
  value: child,
3009
3291
  resource: resource.id,
3010
3292
  storedPath,
3011
- range
3293
+ range,
3294
+ ...description === void 0 ? {} : { description },
3295
+ ...translation === void 0 ? {} : { state: translation },
3296
+ ...label === void 0 ? {} : { label }
3012
3297
  });
3013
- if (!keyPaths.has(key)) {
3014
- const logical = path.flatMap((s) => s.split("."));
3015
- keyPaths.set(key, namespaced ? {
3016
- namespace: resource.namespace ?? config.namespace.default,
3017
- path: logical
3018
- } : {
3019
- namespace: void 0,
3020
- path: [...resource.namespace ? resource.namespace.split(".") : [], ...logical]
3021
- });
3022
- }
3298
+ if (!keyPaths.has(key)) keyPaths.set(key, keyIdFor(resource, path));
3023
3299
  } else {
3024
3300
  parents.add(key);
3025
3301
  visit(child, path);
@@ -3028,7 +3304,7 @@ function createProject(options) {
3028
3304
  };
3029
3305
  visit(state.tree, []);
3030
3306
  }
3031
- for (const { namespace, path } of keyPaths.values()) for (let i = 1; i < path.length; i++) parents.add(joinKey(namespace, path.slice(0, i)));
3307
+ for (const { namespace, path } of keyPaths.values()) for (let i = 1; i < path.length; i++) parents.add(joinKey(namespace, path.slice(0, i), config.keySeparator));
3032
3308
  const derived = /* @__PURE__ */ new Map();
3033
3309
  if (config.usage.derivedKeys.length > 0) for (const key of messages.keys()) for (const base of basesOf(key)) {
3034
3310
  const list = derived.get(base) ?? [];
@@ -3116,7 +3392,7 @@ function createProject(options) {
3116
3392
  }
3117
3393
  if (query.prefix !== void 0) {
3118
3394
  const prefix = query.prefix;
3119
- return [...usages.filter((usage) => inPath(usage) && usage.dynamic === false && usage.key !== void 0 && isKeyUnder(usage.key, prefix)).map((usage) => ({
3395
+ return [...usages.filter((usage) => inPath(usage) && usage.dynamic === false && usage.key !== void 0 && isKeyUnder(usage.key, prefix, config.keySeparator)).map((usage) => ({
3120
3396
  usage,
3121
3397
  possible: false
3122
3398
  })), ...prefixUsages.filter((usage) => inPath(usage) && usage.key !== void 0 && (usage.key.startsWith(prefix) || prefix.startsWith(usage.key))).map((usage) => ({
@@ -3168,7 +3444,7 @@ function createProject(options) {
3168
3444
  });
3169
3445
  function keys(query = {}) {
3170
3446
  const { messages, keyPaths } = idx();
3171
- const match = query.glob ? keyMatcher([query.glob]) : void 0;
3447
+ const match = query.glob ? keyMatcher([query.glob], config.keySeparator) : void 0;
3172
3448
  let list = [...messages.keys()];
3173
3449
  if (match) list = list.filter((key) => match(key));
3174
3450
  if (query.namespace !== void 0) list = list.filter((key) => keyPaths.get(key)?.namespace === query.namespace);
@@ -3218,6 +3494,7 @@ function createProject(options) {
3218
3494
  key,
3219
3495
  namespace: id.namespace,
3220
3496
  path: id.path,
3497
+ label: list.find((m) => m.label !== void 0)?.label,
3221
3498
  messages: list,
3222
3499
  missing: locales.filter((locale) => !byLocale.has(locale) && needs(key, locale)),
3223
3500
  usages: usageMatches({ key }),
@@ -3370,6 +3647,7 @@ function createProject(options) {
3370
3647
  basesOf,
3371
3648
  toStored: (target, path) => storedPathOf(target)(path),
3372
3649
  resolveNew: newResourcePath,
3650
+ resolveKey: keyFile,
3373
3651
  update
3374
3652
  });
3375
3653
  const ready = (async () => {
@@ -3409,9 +3687,12 @@ function createProject(options) {
3409
3687
  parseKey(input) {
3410
3688
  const separator = config.namespace.separator;
3411
3689
  if (!namespaced) return input;
3412
- if (input.indexOf(":") > 0) return input;
3413
- const custom = separator !== ":" ? input.indexOf(separator) : -1;
3414
- if (custom > 0) return `${input.slice(0, custom)}:${input.slice(custom + separator.length)}`;
3690
+ const index = input.indexOf(":");
3691
+ if (index > 0 && (separator !== false || [...resourceStates.values()].some((state) => state.resource.namespace === input.slice(0, index)))) return input;
3692
+ if (separator !== ":" && separator !== false) {
3693
+ const custom = input.indexOf(separator);
3694
+ if (custom > 0) return `${input.slice(0, custom)}:${input.slice(custom + separator.length)}`;
3695
+ }
3415
3696
  return config.namespace.default ? `${config.namespace.default}:${input}` : input;
3416
3697
  },
3417
3698
  usages: usageMatches,
@@ -3453,6 +3734,7 @@ function createProject(options) {
3453
3734
  },
3454
3735
  prompt: (kind, locale, promptOptions = {}) => buildPrompt(project, kind, locale, promptOptions, {
3455
3736
  config,
3737
+ fallbacks,
3456
3738
  hooks: registry.plugins.flatMap((plugin) => plugin.translatePrompt ? [plugin.translatePrompt] : [])
3457
3739
  }),
3458
3740
  explain,
@@ -3460,6 +3742,14 @@ function createProject(options) {
3460
3742
  text: (path) => fileTexts.get(path) ?? files.get(path)?.text ?? (path === reviewFile ? reviewText : void 0),
3461
3743
  owns: (path) => fileTexts.has(path) || files.has(path) || usageEligible(path),
3462
3744
  plan: (ops) => planner.plan(ops),
3745
+ planUnused: (query) => planUnusedDeletion({
3746
+ unused: analyzer.analyze().unused,
3747
+ usages: idx().usages,
3748
+ namespaced,
3749
+ ignoreKeys: config.ignoreKeys,
3750
+ locations: (key) => project.locations(key),
3751
+ plan: (ops) => planner.plan(ops)
3752
+ }, query),
3463
3753
  apply: (plan, applyOptions) => planner.apply(plan, applyOptions?.answers ?? {}),
3464
3754
  update,
3465
3755
  watch() {
@@ -3504,6 +3794,7 @@ function defaultDraft() {
3504
3794
  resources: [],
3505
3795
  formats: {},
3506
3796
  keyStyle: "auto",
3797
+ keySeparator: ".",
3507
3798
  namespace: {
3508
3799
  separator: ".",
3509
3800
  default: void 0,
@@ -3514,6 +3805,7 @@ function defaultDraft() {
3514
3805
  usage: {
3515
3806
  include: ["**/*"],
3516
3807
  exclude: defaultExclude,
3808
+ gitignore: true,
3517
3809
  calls: [],
3518
3810
  regex: [],
3519
3811
  ignoreCallees: [],
@@ -3593,6 +3885,10 @@ function applyLayer(draft, layer, origin, origins) {
3593
3885
  draft.keyStyle = layer.keyStyle;
3594
3886
  mark("keyStyle");
3595
3887
  }
3888
+ if (layer.keySeparator !== void 0) {
3889
+ draft.keySeparator = layer.keySeparator;
3890
+ mark("keySeparator");
3891
+ }
3596
3892
  if (layer.namespace !== void 0) {
3597
3893
  draft.namespace = {
3598
3894
  separator: layer.namespace.separator ?? draft.namespace.separator,
@@ -3611,6 +3907,7 @@ function applyLayer(draft, layer, origin, origins) {
3611
3907
  draft.usage = {
3612
3908
  include: usage.include ?? draft.usage.include,
3613
3909
  exclude: [...draft.usage.exclude, ...usage.exclude ?? []],
3910
+ gitignore: usage.gitignore ?? draft.usage.gitignore,
3614
3911
  calls: [...draft.usage.calls, ...usage.calls ?? []],
3615
3912
  regex: [...draft.usage.regex, ...usage.regex ?? []],
3616
3913
  ignoreCallees: [...draft.usage.ignoreCallees, ...usage.ignoreCallees ?? []],
@@ -3828,4 +4125,4 @@ async function resolveConfig(options) {
3828
4125
  };
3829
4126
  }
3830
4127
  //#endregion
3831
- export { ConfigSchema, PluginFileSchema, ReviewFileSchema, applyMessageOps, applyTextEdits, buildTree, contractVersion, createLibraryPlugin, createLineIndex, createMemoryHost, createOverlayHost, createPlaceholderComparer, createProject, createRegistry, defineConfig, definePlugin, definePreset, diffText, envelope, errors, extensionOf, fuzzyScore, globToRegExp, hashText, isJsonObject, isKeyUnder, isMessageLeaf, isMessageTree, isPreset, isX18nError, joinKey, keyMatcher, localeSpellingKey, parseLocaleInfo, parseMessageBatch, pathId, pathMatcher, placeholderProblems, rangeContains, renderExplanation, renderPrompt, replaceKeyPrefix, resolveConfig, sortTree, splitKey, toErrorInfo, toWireDiagnostic, toWireRange, validate, walkLeaves };
4128
+ export { ConfigSchema, PluginFileSchema, ReviewFileSchema, applyMessageOps, applyTextEdits, buildTree, contractVersion, createLibraryPlugin, createLineIndex, createMemoryHost, createOverlayHost, createPlaceholderComparer, createProject, createRegistry, defineConfig, definePlugin, definePreset, diffText, envelope, errors, extensionOf, fuzzyScore, globToRegExp, hashText, isJsonObject, isKeyUnder, isMessageLeaf, isMessageTree, isPreset, isX18nError, joinKey, keyMatcher, localeSpellingKey, parseLocaleInfo, parseMessageBatch, pathId, pathMatcher, placeholderProblems, rangeContains, relatedLocales, renderExplanation, renderPrompt, replaceKeyPrefix, resolveConfig, sortTree, splitKey, toErrorInfo, toWireDiagnostic, toWireRange, validate, walkLeaves };