@x18n/core 0.1.6 → 0.1.7

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)
302
312
  };
303
313
  }
304
- /** True when `key` is `prefix` itself or lies under it (`a.b` is under `a`). */
305
- function isKeyUnder(key, prefix) {
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)
323
+ };
324
+ }
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({
@@ -857,7 +887,7 @@ const glossaryEntry = v.strictObject({
857
887
  });
858
888
  const checkSetting = v.union([level, v.tuple([level, jsonObject])]);
859
889
  const namespaceOptions = v.strictObject({
860
- separator: v.optional(v.string()),
890
+ separator: v.optional(v.union([v.pipe(v.string(), v.minLength(1)), v.literal(false)])),
861
891
  default: v.optional(v.string()),
862
892
  fallback: v.optional(strings),
863
893
  byFile: v.optional(v.record(v.string(), v.string())),
@@ -889,6 +919,7 @@ const ConfigSchema = v.strictObject({
889
919
  "nested",
890
920
  "flat"
891
921
  ])),
922
+ keySeparator: v.optional(keySeparator),
892
923
  namespace: v.optional(namespaceOptions),
893
924
  usage: v.optional(v.strictObject({
894
925
  include: v.optional(strings),
@@ -937,7 +968,8 @@ const PluginFileSchema = v.strictObject({
937
968
  "auto",
938
969
  "nested",
939
970
  "flat"
940
- ]))
971
+ ])),
972
+ keySeparator: v.optional(keySeparator)
941
973
  })),
942
974
  scanners: v.optional(v.strictObject({
943
975
  calls: v.optional(v.array(callSpec)),
@@ -1137,46 +1169,55 @@ function innermost(scopes, range) {
1137
1169
  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
1170
  return best;
1139
1171
  }
1172
+ function joinPrefix(prefix, text, joiner) {
1173
+ return text ? `${prefix}${joiner}${text}` : prefix;
1174
+ }
1175
+ /** The first prefix whose key exists. A prefix usage (`items.${id}`) ends in an empty segment, so its parent key decides. */
1176
+ function definedPrefix(prefixes, text, joiner, dynamicPrefix, exists) {
1177
+ return prefixes.find((prefix) => {
1178
+ const prefixed = joinPrefix(prefix, text, joiner);
1179
+ return exists(dynamicPrefix && prefixed.endsWith(joiner) ? prefixed.slice(0, -joiner.length) : prefixed);
1180
+ });
1181
+ }
1140
1182
  function createUsageResolver(config, namespaced, normalizers) {
1141
1183
  const { separator, fallback } = config.namespace;
1184
+ const joiner = keyJoiner(config.keySeparator);
1185
+ const keyOf = (namespace, text) => namespace === void 0 ? text : `${namespace}:${text}`;
1186
+ const normalize = (raw, ctx) => {
1187
+ for (const normalizer of normalizers) {
1188
+ const result = normalizer(raw, ctx);
1189
+ if (result !== void 0) return result;
1190
+ }
1191
+ return raw;
1192
+ };
1142
1193
  const byFile = Object.entries(config.namespace.byFile).map(([glob, namespace]) => ({
1143
1194
  matches: pathMatcher([glob]),
1144
1195
  namespace
1145
1196
  }));
1146
1197
  return { resolve(candidate, scopes, path, scanner, has) {
1147
1198
  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
- }
1199
+ let text = normalize(candidate.raw, { path });
1157
1200
  const scopeNamespace = candidate.namespace ?? scope?.namespace ?? byFile.find((rule) => rule.matches(path))?.namespace;
1158
1201
  let scopePrefix = candidate.keyPrefix ?? scope?.keyPrefix;
1159
1202
  let namespace;
1160
1203
  let explicit = false;
1161
1204
  if (namespaced) {
1162
- const index = text.indexOf(separator);
1205
+ const index = separator === false ? -1 : text.indexOf(separator);
1163
1206
  explicit = index > 0;
1164
1207
  if (explicit) {
1165
1208
  namespace = text.slice(0, index);
1166
- text = text.slice(index + separator.length);
1209
+ text = text.slice(index + (separator || "").length);
1167
1210
  } else namespace = scopeNamespace ?? config.namespace.default;
1168
1211
  }
1169
1212
  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;
1213
+ 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;
1214
+ scopePrefix = chosen;
1215
+ if (chosen) text = joinPrefix(chosen, text, joiner);
1174
1216
  }
1175
- const keyPath = text === "" ? [] : text.split(".");
1176
1217
  let key;
1177
1218
  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;
1219
+ else key = keyOf(namespace, text);
1220
+ if (key !== void 0 && namespaced && !explicit && candidate.dynamic === false && !has(key)) key = fallback.map((ns) => keyOf(ns, text)).find(has) ?? key;
1180
1221
  return {
1181
1222
  key,
1182
1223
  raw: candidate.raw,
@@ -1185,6 +1226,7 @@ function createUsageResolver(config, namespaced, normalizers) {
1185
1226
  expression: candidate.expression,
1186
1227
  ...candidate.params ? { params: candidate.params } : {},
1187
1228
  ...candidate.defaultValue === void 0 ? {} : { defaultValue: candidate.defaultValue },
1229
+ ...candidate.description === void 0 ? {} : { description: candidate.description },
1188
1230
  scanner,
1189
1231
  kind: candidate.kind,
1190
1232
  dynamic: candidate.dynamic,
@@ -1425,8 +1467,8 @@ function runChecks(input) {
1425
1467
  function createAnalyzer(options) {
1426
1468
  const { config, registry } = options;
1427
1469
  let memo;
1428
- const ignored = keyMatcher(config.ignoreKeys);
1429
- const inUse = keyMatcher(config.usage.keysInUse);
1470
+ const ignored = keyMatcher(config.ignoreKeys, config.keySeparator);
1471
+ const inUse = keyMatcher(config.usage.keysInUse, config.keySeparator);
1430
1472
  const placeholderCache = /* @__PURE__ */ new Map();
1431
1473
  function analyze() {
1432
1474
  const state = options.state();
@@ -1684,19 +1726,19 @@ function nodeAt(tree, path) {
1684
1726
  return node;
1685
1727
  }
1686
1728
  /** True for flat files (`{ "a.b": "x" }`), false for nested ones, undefined when it cannot tell. */
1687
- function isFlatTree(tree) {
1729
+ function isFlatTree(tree, separator) {
1688
1730
  const entries = Object.entries(tree);
1689
1731
  if (entries.length === 0) return void 0;
1690
1732
  if (entries.some(([, value]) => !isMessageLeaf(value))) return false;
1691
- return entries.some(([key]) => key.includes(".")) ? true : void 0;
1733
+ return entries.some(([key]) => key.includes(separator)) ? true : void 0;
1692
1734
  }
1693
1735
  /** The longest key prefix a tree shares with `path`. */
1694
- function longestPrefix(tree, prefix, path) {
1736
+ function longestPrefix(tree, prefix, path, separator) {
1695
1737
  let best = 0;
1696
1738
  for (const [segment, child] of Object.entries(tree)) {
1697
- const next = [...prefix, ...segment.split(".")];
1739
+ const next = [...prefix, ...splitKeyPath(segment, separator)];
1698
1740
  best = Math.max(best, commonPrefix(next, path));
1699
- if (!isMessageLeaf(child)) best = Math.max(best, longestPrefix(child, next, path));
1741
+ if (!isMessageLeaf(child)) best = Math.max(best, longestPrefix(child, next, path, separator));
1700
1742
  }
1701
1743
  return best;
1702
1744
  }
@@ -1755,6 +1797,7 @@ var PlanBuilder = class {
1755
1797
  path: s.resource.path,
1756
1798
  locale: s.resource.locale,
1757
1799
  namespace: s.resource.namespace,
1800
+ keySeparator: s.resource.keySeparator,
1758
1801
  format,
1759
1802
  state: s
1760
1803
  };
@@ -1779,24 +1822,36 @@ var PlanBuilder = class {
1779
1822
  return s;
1780
1823
  }
1781
1824
  keyId(key) {
1782
- const id = splitKey(key, this.state.namespaced);
1825
+ const id = splitKey(key, this.state.namespaced, this.config.keySeparator);
1783
1826
  return {
1784
1827
  namespace: id.namespace,
1785
1828
  path: [...id.path]
1786
1829
  };
1787
1830
  }
1788
- /** Key path relative to a resource, or undefined when the key does not belong there. */
1831
+ separatorOf(target) {
1832
+ return keySeparatorOf(target, this.config.keySeparator);
1833
+ }
1834
+ /**
1835
+ * Key path relative to a resource, or undefined when the key does not belong there.
1836
+ * A resource of literal keys (`keySeparator: false`) gets the whole rest of the key as one segment.
1837
+ */
1789
1838
  relativePath(key, target) {
1790
- const { namespace, path } = this.keyId(key);
1839
+ const { namespace, rest } = splitNamespace(key, this.state.namespaced);
1840
+ const literal = this.separatorOf(target) === false;
1841
+ const joiner = keyJoiner(this.config.keySeparator);
1842
+ const split = (text) => splitKeyPath(text, literal ? false : joiner);
1791
1843
  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);
1844
+ if (this.state.namespaced) return (namespace ?? fallback) === (target.namespace ?? fallback) ? split(rest) : void 0;
1845
+ if (!target.namespace) return rest === "" ? void 0 : split(rest);
1846
+ const prefix = target.namespace.split(".").join(joiner) + joiner;
1847
+ if (rest.length <= prefix.length || !rest.startsWith(prefix)) return void 0;
1848
+ return split(rest.slice(prefix.length));
1796
1849
  }
1797
1850
  storedPathFor(entry, relative) {
1798
1851
  const stored = this.options.toStored(entry.target, relative);
1799
- return stored.length === relative.length && (isFlatTree(entry.tree) ?? this.config.keyStyle === "flat") ? [stored.join(".")] : stored;
1852
+ const separator = this.separatorOf(entry.target);
1853
+ if (separator === false) return stored;
1854
+ return stored.length === relative.length && (isFlatTree(entry.tree, separator) ?? this.config.keyStyle === "flat") ? [stored.join(separator)] : stored;
1800
1855
  }
1801
1856
  createTarget(key, locale) {
1802
1857
  const { resources, sourceLocale, namespaced } = this.state;
@@ -1816,6 +1871,7 @@ var PlanBuilder = class {
1816
1871
  path: resolved,
1817
1872
  locale,
1818
1873
  namespace,
1874
+ keySeparator: model?.resource.keySeparator,
1819
1875
  format,
1820
1876
  state: void 0
1821
1877
  };
@@ -1835,6 +1891,7 @@ var PlanBuilder = class {
1835
1891
  path,
1836
1892
  locale,
1837
1893
  namespace: namespace ?? model.resource.namespace,
1894
+ keySeparator: model.resource.keySeparator,
1838
1895
  format,
1839
1896
  state: void 0
1840
1897
  };
@@ -1854,7 +1911,7 @@ var PlanBuilder = class {
1854
1911
  const prefix = s.resource.namespace && !this.state.namespaced ? path.slice(0, s.resource.namespace.split(".").length) : [];
1855
1912
  return {
1856
1913
  s,
1857
- best: longestPrefix(s.tree, prefix, path)
1914
+ best: longestPrefix(s.tree, prefix, path, this.separatorOf(s.resource))
1858
1915
  };
1859
1916
  });
1860
1917
  const top = Math.max(...scores.map((x) => x.best));
@@ -1966,8 +2023,9 @@ var PlanBuilder = class {
1966
2023
  continue;
1967
2024
  }
1968
2025
  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);
2026
+ const separator = this.separatorOf(s.resource);
2027
+ const flat = separator !== false && m.storedPath.length === 1 && m.storedPath[0].includes(separator);
2028
+ const next = flat ? [relative.join(separator)] : this.options.toStored(s.resource, relative);
1971
2029
  const sameParent = next.length === m.storedPath.length && next.slice(0, -1).every((segment, i) => segment === m.storedPath[i]);
1972
2030
  const last = next.at(-1);
1973
2031
  if (sameParent && m.range && (flat ? safeFlat : safeSegment).test(last)) {
@@ -1999,16 +2057,17 @@ var PlanBuilder = class {
1999
2057
  const scope = usage.key.slice(0, usage.key.length - usage.raw.length);
2000
2058
  if (next.startsWith(`${scope}.`)) return next.slice(scope.length);
2001
2059
  }
2002
- const { namespace, path } = this.keyId(next);
2003
- let rest = path.join(".");
2060
+ const { namespace, rest: text } = splitNamespace(next, this.state.namespaced);
2061
+ let rest = text;
2004
2062
  const separator = this.config.namespace.separator;
2005
- const explicit = this.state.namespaced && usage.raw.indexOf(separator) > 0;
2063
+ const explicit = this.state.namespaced && separator !== false && usage.raw.indexOf(separator) > 0;
2006
2064
  if (usage.scopePrefix && !explicit) {
2007
- if (!rest.startsWith(`${usage.scopePrefix}.`)) return void 0;
2008
- rest = rest.slice(usage.scopePrefix.length + 1);
2065
+ const prefix = usage.scopePrefix + keyJoiner(this.config.keySeparator);
2066
+ if (!rest.startsWith(prefix)) return void 0;
2067
+ rest = rest.slice(prefix.length);
2009
2068
  }
2010
2069
  if (!this.state.namespaced) return rest;
2011
- if (explicit || namespace !== (usage.scopeNamespace ?? this.config.namespace.default)) return `${namespace}${separator}${rest}`;
2070
+ if (explicit || namespace !== (usage.scopeNamespace ?? this.config.namespace.default)) return separator === false ? void 0 : `${namespace}${separator}${rest}`;
2012
2071
  return rest;
2013
2072
  }
2014
2073
  editSource(path, edit) {
@@ -2053,7 +2112,7 @@ var PlanBuilder = class {
2053
2112
  if (op.from === op.to) return;
2054
2113
  const all = this.options.allKeys();
2055
2114
  const isLeaf = this.options.messages(op.from) !== void 0;
2056
- const under = all.filter((key) => isKeyUnder(key, op.from));
2115
+ const under = all.filter((key) => isKeyUnder(key, op.from, this.config.keySeparator));
2057
2116
  const derived = all.filter((key) => this.options.basesOf(key).includes(op.from));
2058
2117
  let keys;
2059
2118
  if (op.type === "rename-prefix") keys = under;
@@ -2073,7 +2132,7 @@ var PlanBuilder = class {
2073
2132
  this.rewritePrefixUsages(op.from, op.to);
2074
2133
  }
2075
2134
  delete(op) {
2076
- const keys = this.options.messages(op.key) ? [op.key] : this.options.allKeys().filter((key) => isKeyUnder(key, op.key));
2135
+ const keys = this.options.messages(op.key) ? [op.key] : this.options.allKeys().filter((key) => isKeyUnder(key, op.key, this.config.keySeparator));
2077
2136
  if (keys.length === 0) throw errors.X18N4006({ key: op.key });
2078
2137
  for (const key of keys) {
2079
2138
  this.touched.add(key);
@@ -2417,6 +2476,10 @@ function createPlanner(options) {
2417
2476
  }
2418
2477
  //#endregion
2419
2478
  //#region src/project/prompt.ts
2479
+ /** Related locales shown per message, at most. */
2480
+ const RELATED_LIMIT = 2;
2481
+ /** Characters of a description or a related value, at most; longer text is cut. */
2482
+ const TEXT_LIMIT = 300;
2420
2483
  function text(value) {
2421
2484
  return value === void 0 || value === null ? void 0 : String(value);
2422
2485
  }
@@ -2451,10 +2514,36 @@ function reportedValues(project, diagnostics, to, check) {
2451
2514
  }
2452
2515
  return reported;
2453
2516
  }
2517
+ function clip(value) {
2518
+ return value.length > TEXT_LIMIT ? `${value.slice(0, 299)}…` : value;
2519
+ }
2520
+ const subtagsOf = (code) => code.toLowerCase().split(/[-_]/);
2521
+ /**
2522
+ * The locales whose values help translate to `to`, nearest first: the fallback chain of `to`, then the other locales of
2523
+ * the same language, those that share more subtags with `to` first, then the shorter codes (`pt` and `pt-PT` for `pt-BR`,
2524
+ * `pt-BR` for `pt`). The source locale and `to` itself are never related.
2525
+ */
2526
+ function relatedLocales(to, locales, sourceLocale, fallbacks) {
2527
+ const target = subtagsOf(to);
2528
+ const shared = (code) => {
2529
+ const tags = subtagsOf(code);
2530
+ let n = 0;
2531
+ while (n < tags.length && tags[n] === target[n]) n++;
2532
+ return n;
2533
+ };
2534
+ const chain = fallbackChain(fallbacks, to).filter((code) => code !== sourceLocale && code !== to);
2535
+ 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));
2536
+ return [...chain, ...language];
2537
+ }
2538
+ /** The description of a key: from the source resource, else another locale's, else a usage's. */
2539
+ function descriptionOf(project, key, from) {
2540
+ 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();
2541
+ return trimmed ? clip(trimmed) : void 0;
2542
+ }
2454
2543
  /** Builds the parts of a translate or review prompt from project state. */
2455
2544
  function buildPrompt(project, kind, to, options, extra) {
2456
2545
  const from = project.sourceLocale;
2457
- const match = typeof options.keys === "string" ? keyMatcher([options.keys]) : void 0;
2546
+ const match = typeof options.keys === "string" ? keyMatcher([options.keys], extra.config.keySeparator) : void 0;
2458
2547
  const list = Array.isArray(options.keys) ? new Set(options.keys) : void 0;
2459
2548
  const analysis = project.analyze();
2460
2549
  const outdated = new Set(analysis.coverage.find((c) => c.locale === to)?.outdated);
@@ -2475,12 +2564,24 @@ function buildPrompt(project, kind, to, options, extra) {
2475
2564
  const end = start + page.length;
2476
2565
  const syntaxes = project.placeholderSyntaxes;
2477
2566
  const info = project.localeInfo(to);
2567
+ const related = options.related === false ? [] : Array.isArray(options.related) ? options.related.filter((code) => code !== to && code !== from) : relatedLocales(to, project.locales, from, extra.fallbacks);
2568
+ const relatedOf = (key, source) => related.flatMap((locale) => {
2569
+ const value = text(project.message(key, locale)?.value);
2570
+ return value && value !== source ? [{
2571
+ locale,
2572
+ value: clip(value)
2573
+ }] : [];
2574
+ }).slice(0, RELATED_LIMIT);
2575
+ const relatedValues = new Map(page.map((key) => [key, relatedOf(key, text(project.message(key, from)?.value) ?? "")]));
2576
+ const descriptions = new Map(page.map((key) => [key, descriptionOf(project, key, from)]));
2478
2577
  const draft = {
2479
2578
  rules: [],
2480
2579
  notes: []
2481
2580
  };
2482
2581
  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
2582
  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\".");
2583
+ 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.");
2584
+ 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
2585
  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
2586
  if (info.pluralCategories.length > 1) draft.notes.push(`${to} uses the plural categories ${info.pluralCategories.join(", ")}.`);
2486
2587
  for (const hook of extra.hooks) hook(draft, {
@@ -2501,10 +2602,13 @@ function buildPrompt(project, kind, to, options, extra) {
2501
2602
  const source = text(project.message(key, from)?.value) ?? "";
2502
2603
  const translated = project.history(key, to);
2503
2604
  const previous = translated && stale(key) ? text(project.valueAt(key, from, translated.updatedAt)) : void 0;
2605
+ const values = relatedValues.get(key) ?? [];
2504
2606
  return {
2505
2607
  key,
2506
2608
  source,
2507
2609
  placeholders: [...new Set(syntaxes.flatMap((s) => s.find(source)).map((p) => p.text))],
2610
+ description: descriptions.get(key),
2611
+ related: values.length > 0 ? values : void 0,
2508
2612
  existing: text(project.message(key, to)?.value) || void 0,
2509
2613
  previousSource: previous === source ? void 0 : previous,
2510
2614
  issues: reported.get(key)?.map((d) => ({
@@ -2553,6 +2657,8 @@ function renderItem(item, to) {
2553
2657
  `- Source: ${JSON.stringify(item.source)}`
2554
2658
  ];
2555
2659
  if (item.placeholders.length) out.push(`- Placeholders: ${item.placeholders.map((p) => `\`${p}\``).join(", ")}`);
2660
+ if (item.description !== void 0) out.push(`- Description: ${JSON.stringify(item.description)}`);
2661
+ for (const value of item.related ?? []) out.push(`- In ${value.locale}: ${JSON.stringify(value.value)}`);
2556
2662
  if (item.previousSource !== void 0) out.push(`- Source when ${to} last changed: ${JSON.stringify(item.previousSource)}`);
2557
2663
  if (item.existing !== void 0) out.push(`- Current ${to}: ${JSON.stringify(item.existing)}`);
2558
2664
  for (const issue of item.issues ?? []) out.push(`- Reported: ${issue.message} (\`${issue.code}\`)`);
@@ -2584,6 +2690,80 @@ function renderPrompt(data) {
2584
2690
  return out.join("\n");
2585
2691
  }
2586
2692
  //#endregion
2693
+ //#region src/project/unused.ts
2694
+ /**
2695
+ * True when a fully dynamic usage (`t(key)`) may reach `key`: the key lies in the usage's scope namespace and under its
2696
+ * scope key prefix. Undefined for a usage without a scope, which may reach any key.
2697
+ */
2698
+ function scopeMatcher(usage, namespaced) {
2699
+ const namespace = namespaced ? usage.scopeNamespace : void 0;
2700
+ const prefix = usage.scopePrefix;
2701
+ if (namespace === void 0 && !prefix) return void 0;
2702
+ return (key) => {
2703
+ const id = splitKey(key, namespaced);
2704
+ if (namespace !== void 0 && id.namespace !== namespace) return false;
2705
+ if (!prefix) return true;
2706
+ const path = id.path.join(".");
2707
+ return path === prefix || path.startsWith(`${prefix}.`);
2708
+ };
2709
+ }
2710
+ function planUnusedDeletion(options, query = {}) {
2711
+ const ignored = keyMatcher(options.ignoreKeys);
2712
+ const match = query.glob ? keyMatcher([query.glob]) : void 0;
2713
+ 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();
2714
+ const scoped = [];
2715
+ const unscoped = [];
2716
+ for (const usage of options.usages) {
2717
+ if (usage.dynamic !== "unknown") continue;
2718
+ const reaches = scopeMatcher(usage, options.namespaced);
2719
+ if (reaches) scoped.push({
2720
+ usage,
2721
+ reaches
2722
+ });
2723
+ else unscoped.push(usage);
2724
+ }
2725
+ const keys = [];
2726
+ const kept = [];
2727
+ for (const key of candidates) {
2728
+ const usages = scoped.filter((s) => s.reaches(key)).map((s) => s.usage);
2729
+ if (usages.length > 0) {
2730
+ kept.push({
2731
+ key,
2732
+ usages
2733
+ });
2734
+ continue;
2735
+ }
2736
+ keys.push({
2737
+ key,
2738
+ messages: options.locations(key).filter((node) => node.leaf).map((node) => ({
2739
+ locale: node.locale,
2740
+ path: node.path
2741
+ }))
2742
+ });
2743
+ }
2744
+ const plan = options.plan(keys.map(({ key }) => ({
2745
+ type: "delete",
2746
+ key,
2747
+ locales: "all"
2748
+ })));
2749
+ if (unscoped.length === 0 || keys.length === 0) return {
2750
+ plan,
2751
+ keys,
2752
+ kept
2753
+ };
2754
+ const files = [...new Set(unscoped.map((usage) => usage.path))].sort();
2755
+ const shown = files.length > 5 ? `${files.slice(0, 5).join(", ")} and ${files.length - 5} more files` : files.join(", ");
2756
+ 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.`;
2757
+ return {
2758
+ plan: {
2759
+ ...plan,
2760
+ warnings: [...plan.warnings, warning]
2761
+ },
2762
+ keys,
2763
+ kept
2764
+ };
2765
+ }
2766
+ //#endregion
2587
2767
  //#region src/project/project.ts
2588
2768
  const ioConcurrency = 64;
2589
2769
  async function forEachLimit(items, limit, fn) {
@@ -2725,7 +2905,8 @@ function createProject(options) {
2725
2905
  format: candidate.format,
2726
2906
  block: candidate.block,
2727
2907
  readonly: candidate.readonly === true,
2728
- finder: finder.id
2908
+ finder: finder.id,
2909
+ keySeparator: candidate.keySeparator === config.keySeparator ? void 0 : candidate.keySeparator
2729
2910
  });
2730
2911
  }
2731
2912
  finderResults.push({
@@ -2735,10 +2916,22 @@ function createProject(options) {
2735
2916
  }
2736
2917
  return out;
2737
2918
  }
2919
+ /** Namespace and key path of a stored path: each stored segment is split by the resource's key separator. */
2920
+ function keyIdFor(resource, stored) {
2921
+ const separator = keySeparatorOf(resource, config.keySeparator);
2922
+ const logical = stored.flatMap((segment) => splitKeyPath(segment, separator));
2923
+ if (namespaced) return {
2924
+ namespace: resource.namespace ?? config.namespace.default,
2925
+ path: logical
2926
+ };
2927
+ return {
2928
+ namespace: void 0,
2929
+ path: [...resource.namespace ? resource.namespace.split(".") : [], ...logical]
2930
+ };
2931
+ }
2738
2932
  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]);
2933
+ const { namespace, path } = keyIdFor(resource, stored);
2934
+ return joinKey(namespace, path, config.keySeparator);
2742
2935
  }
2743
2936
  /** Key path to stored path through the transforms that apply to a resource, last first. */
2744
2937
  function storedPathOf(ctx) {
@@ -2756,7 +2949,8 @@ function createProject(options) {
2756
2949
  format: format?.id ?? "unknown",
2757
2950
  block: candidate.block,
2758
2951
  readonly: candidate.readonly || !format || !format.edit && !format.serialize,
2759
- finder: candidate.finder
2952
+ finder: candidate.finder,
2953
+ ...candidate.keySeparator === void 0 ? {} : { keySeparator: candidate.keySeparator }
2760
2954
  };
2761
2955
  const content = candidate.block ? text.slice(candidate.block.range.start, candidate.block.range.end) : text;
2762
2956
  const offset = candidate.block?.range.start ?? 0;
@@ -2778,18 +2972,25 @@ function createProject(options) {
2778
2972
  tree: {},
2779
2973
  rawTree: {},
2780
2974
  ranges: /* @__PURE__ */ new Map(),
2975
+ descriptions: /* @__PURE__ */ new Map(),
2781
2976
  error: errors.X18N2001({ path: candidate.path })
2782
2977
  };
2783
2978
  try {
2784
2979
  const parsed = format.parse(content, ctx);
2785
2980
  let tree = parsed.tree;
2786
- for (const transform of transforms) if (transform.applies?.(ctx) !== false) tree = transform.read(tree, ctx);
2981
+ const descriptions = new Map(parsed.descriptions);
2982
+ for (const transform of transforms) {
2983
+ if (transform.applies?.(ctx) === false) continue;
2984
+ tree = transform.read(tree, ctx);
2985
+ for (const [id, description] of transform.describe?.(parsed.tree, ctx) ?? []) descriptions.set(id, description);
2986
+ }
2787
2987
  if (!isMessageTree(tree)) throw errors.X18N2003({ path: candidate.path });
2788
2988
  return {
2789
2989
  ...base,
2790
2990
  tree,
2791
2991
  rawTree: parsed.tree,
2792
2992
  ranges: parsed.ranges ?? /* @__PURE__ */ new Map(),
2993
+ descriptions,
2793
2994
  error: void 0
2794
2995
  };
2795
2996
  } catch (error) {
@@ -2803,6 +3004,7 @@ function createProject(options) {
2803
3004
  tree: {},
2804
3005
  rawTree: {},
2805
3006
  ranges: /* @__PURE__ */ new Map(),
3007
+ descriptions: /* @__PURE__ */ new Map(),
2806
3008
  error: coded
2807
3009
  };
2808
3010
  }
@@ -2851,7 +3053,7 @@ function createProject(options) {
2851
3053
  const id = candidate.block ? `${candidate.path}#${candidate.block.index}` : candidate.path;
2852
3054
  const previous = resourceStates.get(id);
2853
3055
  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);
3056
+ 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
3057
  else next.set(id, parseResource(candidate, text));
2856
3058
  }
2857
3059
  resourceStates.clear();
@@ -3002,24 +3204,17 @@ function createProject(options) {
3002
3204
  messages.set(key, byLocale);
3003
3205
  const list = byLocale.get(resource.locale) ?? [];
3004
3206
  byLocale.set(resource.locale, list);
3207
+ const description = state.descriptions.get(pathId(storedPath));
3005
3208
  list.push({
3006
3209
  key,
3007
3210
  locale: resource.locale,
3008
3211
  value: child,
3009
3212
  resource: resource.id,
3010
3213
  storedPath,
3011
- range
3214
+ range,
3215
+ ...description === void 0 ? {} : { description }
3012
3216
  });
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
- }
3217
+ if (!keyPaths.has(key)) keyPaths.set(key, keyIdFor(resource, path));
3023
3218
  } else {
3024
3219
  parents.add(key);
3025
3220
  visit(child, path);
@@ -3028,7 +3223,7 @@ function createProject(options) {
3028
3223
  };
3029
3224
  visit(state.tree, []);
3030
3225
  }
3031
- for (const { namespace, path } of keyPaths.values()) for (let i = 1; i < path.length; i++) parents.add(joinKey(namespace, path.slice(0, i)));
3226
+ 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
3227
  const derived = /* @__PURE__ */ new Map();
3033
3228
  if (config.usage.derivedKeys.length > 0) for (const key of messages.keys()) for (const base of basesOf(key)) {
3034
3229
  const list = derived.get(base) ?? [];
@@ -3116,7 +3311,7 @@ function createProject(options) {
3116
3311
  }
3117
3312
  if (query.prefix !== void 0) {
3118
3313
  const prefix = query.prefix;
3119
- return [...usages.filter((usage) => inPath(usage) && usage.dynamic === false && usage.key !== void 0 && isKeyUnder(usage.key, prefix)).map((usage) => ({
3314
+ return [...usages.filter((usage) => inPath(usage) && usage.dynamic === false && usage.key !== void 0 && isKeyUnder(usage.key, prefix, config.keySeparator)).map((usage) => ({
3120
3315
  usage,
3121
3316
  possible: false
3122
3317
  })), ...prefixUsages.filter((usage) => inPath(usage) && usage.key !== void 0 && (usage.key.startsWith(prefix) || prefix.startsWith(usage.key))).map((usage) => ({
@@ -3168,7 +3363,7 @@ function createProject(options) {
3168
3363
  });
3169
3364
  function keys(query = {}) {
3170
3365
  const { messages, keyPaths } = idx();
3171
- const match = query.glob ? keyMatcher([query.glob]) : void 0;
3366
+ const match = query.glob ? keyMatcher([query.glob], config.keySeparator) : void 0;
3172
3367
  let list = [...messages.keys()];
3173
3368
  if (match) list = list.filter((key) => match(key));
3174
3369
  if (query.namespace !== void 0) list = list.filter((key) => keyPaths.get(key)?.namespace === query.namespace);
@@ -3409,9 +3604,12 @@ function createProject(options) {
3409
3604
  parseKey(input) {
3410
3605
  const separator = config.namespace.separator;
3411
3606
  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)}`;
3607
+ const index = input.indexOf(":");
3608
+ if (index > 0 && (separator !== false || [...resourceStates.values()].some((state) => state.resource.namespace === input.slice(0, index)))) return input;
3609
+ if (separator !== ":" && separator !== false) {
3610
+ const custom = input.indexOf(separator);
3611
+ if (custom > 0) return `${input.slice(0, custom)}:${input.slice(custom + separator.length)}`;
3612
+ }
3415
3613
  return config.namespace.default ? `${config.namespace.default}:${input}` : input;
3416
3614
  },
3417
3615
  usages: usageMatches,
@@ -3453,6 +3651,7 @@ function createProject(options) {
3453
3651
  },
3454
3652
  prompt: (kind, locale, promptOptions = {}) => buildPrompt(project, kind, locale, promptOptions, {
3455
3653
  config,
3654
+ fallbacks,
3456
3655
  hooks: registry.plugins.flatMap((plugin) => plugin.translatePrompt ? [plugin.translatePrompt] : [])
3457
3656
  }),
3458
3657
  explain,
@@ -3460,6 +3659,14 @@ function createProject(options) {
3460
3659
  text: (path) => fileTexts.get(path) ?? files.get(path)?.text ?? (path === reviewFile ? reviewText : void 0),
3461
3660
  owns: (path) => fileTexts.has(path) || files.has(path) || usageEligible(path),
3462
3661
  plan: (ops) => planner.plan(ops),
3662
+ planUnused: (query) => planUnusedDeletion({
3663
+ unused: analyzer.analyze().unused,
3664
+ usages: idx().usages,
3665
+ namespaced,
3666
+ ignoreKeys: config.ignoreKeys,
3667
+ locations: (key) => project.locations(key),
3668
+ plan: (ops) => planner.plan(ops)
3669
+ }, query),
3463
3670
  apply: (plan, applyOptions) => planner.apply(plan, applyOptions?.answers ?? {}),
3464
3671
  update,
3465
3672
  watch() {
@@ -3504,6 +3711,7 @@ function defaultDraft() {
3504
3711
  resources: [],
3505
3712
  formats: {},
3506
3713
  keyStyle: "auto",
3714
+ keySeparator: ".",
3507
3715
  namespace: {
3508
3716
  separator: ".",
3509
3717
  default: void 0,
@@ -3593,6 +3801,10 @@ function applyLayer(draft, layer, origin, origins) {
3593
3801
  draft.keyStyle = layer.keyStyle;
3594
3802
  mark("keyStyle");
3595
3803
  }
3804
+ if (layer.keySeparator !== void 0) {
3805
+ draft.keySeparator = layer.keySeparator;
3806
+ mark("keySeparator");
3807
+ }
3596
3808
  if (layer.namespace !== void 0) {
3597
3809
  draft.namespace = {
3598
3810
  separator: layer.namespace.separator ?? draft.namespace.separator,
@@ -3828,4 +4040,4 @@ async function resolveConfig(options) {
3828
4040
  };
3829
4041
  }
3830
4042
  //#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 };
4043
+ 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 };