@x18n/core 0.0.13 → 0.0.15

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
@@ -138,7 +138,9 @@ function defineConfig(config) {
138
138
  }
139
139
  //#endregion
140
140
  //#region src/errors.ts
141
- const at = (p) => p.line === void 0 ? p.path : `${p.path}:${p.line}`;
141
+ function at(p) {
142
+ return p.line === void 0 ? p.path : `${p.path}:${p.line}`;
143
+ }
142
144
  /**
143
145
  * Every coded error. Groups: 0xxx config, 1xxx plugin, 2xxx discovery and parse,
144
146
  * 3xxx scanning, 4xxx edits, 5xxx reviews. `fix` stays static so the docs can show it.
@@ -223,6 +225,22 @@ const errorDefinitions = {
223
225
  X18N4008: {
224
226
  why: (p) => `Invalid edit input: ${p.reason}`,
225
227
  fix: "Check the input shape against the docs."
228
+ },
229
+ X18N5001: {
230
+ why: (p) => `Review file ${p.path} could not be read: ${p.reason}`,
231
+ fix: "Fix the file against `schema/reviews.json`, or restore it from git."
232
+ },
233
+ X18N5003: {
234
+ why: (p) => `Reviews are not available: ${p.reason}`,
235
+ fix: "Set `review.enabled` to true, and fix the review file if x18n reports it."
236
+ },
237
+ X18N5004: {
238
+ why: (p) => `Review not found: ${p.id}`,
239
+ fix: "List review ids with `x18n review list` (agents: the `x18n_reviews` tool)."
240
+ },
241
+ X18N5005: {
242
+ why: (p) => `Review ${p.id} is not an open suggestion`,
243
+ fix: "Apply only open reviews of type `suggestion`."
226
244
  }
227
245
  };
228
246
  /** Raise with `throw errors.X18N2002({ path, reason })`. Core has no reporters: surfaces print what they catch. */
@@ -249,6 +267,24 @@ function toErrorInfo(error) {
249
267
  };
250
268
  }
251
269
  //#endregion
270
+ //#region src/fuzzy.ts
271
+ /** Subsequence match score of `query` in `text`, case-insensitive; higher is better, -1 when it does not match. Substring hits rank above every subsequence. */
272
+ function fuzzyScore(query, text) {
273
+ const q = query.toLowerCase();
274
+ const s = text.toLowerCase();
275
+ const exact = s.indexOf(q);
276
+ if (exact !== -1) return 2e3 - Math.min(exact, 1e3);
277
+ let score = 0;
278
+ let at = -1;
279
+ for (const char of q) {
280
+ const next = s.indexOf(char, at + 1);
281
+ if (next === -1) return -1;
282
+ score += next === at + 1 ? 2 : 1;
283
+ at = next;
284
+ }
285
+ return score;
286
+ }
287
+ //#endregion
252
288
  //#region src/keys.ts
253
289
  /** Canonical string form of a key: `ns:a.b.c`, or `a.b.c` without namespace. */
254
290
  function joinKey(namespace, path) {
@@ -331,6 +367,40 @@ function pathMatcher(globs) {
331
367
  return (path) => regexes.some((re) => re.test(path));
332
368
  }
333
369
  //#endregion
370
+ //#region src/library.ts
371
+ /** One plugin holding a library's call specs, regexes, namespace rules, defaults and extraction templates. */
372
+ function createLibraryPlugin(options) {
373
+ return {
374
+ name: options.name,
375
+ async config(draft, ctx) {
376
+ if (options.namespace) draft.namespace = {
377
+ ...draft.namespace,
378
+ separator: options.namespace.separator ?? draft.namespace.separator,
379
+ default: draft.namespace.default ?? options.namespace.default,
380
+ fromPath: options.namespace.fromPath ?? draft.namespace.fromPath
381
+ };
382
+ if (options.resources) draft.resources = [...draft.resources, ...options.resources.filter((spec) => !draft.resources.some((r) => r.pattern === spec.pattern))];
383
+ if (options.keyStyle && draft.keyStyle === "auto" && !ctx.user.keyStyle) draft.keyStyle = options.keyStyle;
384
+ draft.usage = {
385
+ ...draft.usage,
386
+ calls: [...draft.usage.calls, ...options.calls ?? []],
387
+ regex: [...draft.usage.regex, ...options.regex ?? []],
388
+ derivedKeys: [...draft.usage.derivedKeys, ...options.derivedKeys ?? []]
389
+ };
390
+ await options.extra?.(draft, ctx);
391
+ },
392
+ placeholders: options.placeholders,
393
+ localeFinders: options.finders,
394
+ normalizeKey: options.normalizeKey,
395
+ transformResource: options.transformResource,
396
+ translatePrompt: options.translatePrompt,
397
+ extractors: options.templates ? [{
398
+ id: options.name,
399
+ templates: options.templates
400
+ }] : void 0
401
+ };
402
+ }
403
+ //#endregion
334
404
  //#region src/locale.ts
335
405
  const defaultRegions = {
336
406
  ar: "SA",
@@ -585,8 +655,8 @@ function uniqueById(items, kind) {
585
655
  }
586
656
  function createRegistry(input, config) {
587
657
  const plugins = sortByEnforce(input);
588
- const formats = plugins.flatMap((plugin) => plugin.formats ?? []);
589
- const scanners = plugins.flatMap((plugin) => plugin.scanners ?? []);
658
+ const formats = plugins.flatMap((plugin) => [...plugin.formats ?? [], ...plugin.lazyFormats ?? []]);
659
+ const scanners = plugins.flatMap((plugin) => [...plugin.scanners ?? [], ...plugin.lazyScanners ?? []]);
590
660
  const finders = [...uniqueById(plugins.flatMap((plugin) => plugin.localeFinders ?? []), "Locale finder").values()];
591
661
  const checks = uniqueById(plugins.flatMap((plugin) => plugin.checks ?? []), "Check");
592
662
  const reporters = uniqueById(plugins.flatMap((plugin) => plugin.reporters ?? []), "Reporter");
@@ -602,8 +672,58 @@ function createRegistry(input, config) {
602
672
  });
603
673
  else byExtension.set(extension, format);
604
674
  }
605
- const formatById = (id) => formats.find((format) => format.id === id);
675
+ const isLazy = (format) => "load" in format;
676
+ const loaded = new Map(formats.filter((format) => !isLazy(format)).map((format) => [format.id, format]));
677
+ const loading = /* @__PURE__ */ new Map();
678
+ const formatById = (id) => loaded.get(id);
679
+ const formatIdFor = (path, hint) => {
680
+ if (hint) return hint;
681
+ const extension = extensionOf(path);
682
+ return config.formats[extension] ?? byExtension.get(extension)?.id;
683
+ };
684
+ const loadFormat = (id) => {
685
+ const ready = loaded.get(id);
686
+ if (ready) return Promise.resolve(ready);
687
+ const lazy = formats.find((format) => format.id === id);
688
+ if (!lazy || !isLazy(lazy)) return Promise.resolve(void 0);
689
+ let pending = loading.get(id);
690
+ if (!pending) {
691
+ pending = lazy.load().then((mod) => {
692
+ const format = mod.default().formats?.find((item) => item.id === id && !isLazy(item));
693
+ if (format) loaded.set(id, format);
694
+ return format;
695
+ });
696
+ loading.set(id, pending);
697
+ }
698
+ return pending;
699
+ };
606
700
  const scannerCache = /* @__PURE__ */ new Map();
701
+ const loadedScanners = new Map(scanners.filter((scanner) => !("load" in scanner)).map((scanner) => [scanner.id, scanner]));
702
+ const loadingScanners = /* @__PURE__ */ new Map();
703
+ const loadScanner = (entry) => {
704
+ if (!("load" in entry)) return Promise.resolve(entry);
705
+ const ready = loadedScanners.get(entry.id);
706
+ if (ready) return Promise.resolve(ready);
707
+ let pending = loadingScanners.get(entry.id);
708
+ if (!pending) {
709
+ pending = entry.load().then((mod) => {
710
+ const scanner = mod.default().scanners?.find((item) => item.id === entry.id);
711
+ if (scanner) loadedScanners.set(entry.id, scanner);
712
+ return scanner;
713
+ });
714
+ loadingScanners.set(entry.id, pending);
715
+ }
716
+ return pending;
717
+ };
718
+ const scannersFor = (path) => {
719
+ const extension = extensionOf(path);
720
+ let list = scannerCache.get(extension);
721
+ if (!list) {
722
+ list = extension ? scanners.filter((scanner) => scanner.extensions.includes(extension) || scanner.extensions.includes("*")) : [];
723
+ scannerCache.set(extension, list);
724
+ }
725
+ return list;
726
+ };
607
727
  return {
608
728
  plugins,
609
729
  formats,
@@ -615,22 +735,21 @@ function createRegistry(input, config) {
615
735
  manifests: plugins.flatMap((plugin) => plugin.manifests ?? []),
616
736
  extractors: plugins.flatMap((plugin) => plugin.extractors ?? []),
617
737
  histories: plugins.flatMap((plugin) => plugin.histories ?? []),
738
+ reviewStores: plugins.flatMap((plugin) => plugin.reviewStores ?? []),
618
739
  conflicts,
619
740
  formatById,
741
+ formatIdFor,
620
742
  formatFor(path, hint) {
621
- if (hint) return formatById(hint);
622
- const extension = extensionOf(path);
623
- const mapped = config.formats[extension];
624
- return mapped ? formatById(mapped) : byExtension.get(extension);
743
+ const id = formatIdFor(path, hint);
744
+ return id === void 0 ? void 0 : formatById(id);
625
745
  },
626
- scannersFor(path) {
627
- const extension = extensionOf(path);
628
- let list = scannerCache.get(extension);
629
- if (!list) {
630
- list = extension ? scanners.filter((scanner) => scanner.extensions.includes(extension) || scanner.extensions.includes("*")) : [];
631
- scannerCache.set(extension, list);
632
- }
633
- return list;
746
+ async loadFormatFor(path, hint) {
747
+ const id = formatIdFor(path, hint);
748
+ return id === void 0 ? void 0 : loadFormat(id);
749
+ },
750
+ scannersFor,
751
+ async loadScannersFor(path) {
752
+ return (await Promise.all(scannersFor(path).map(loadScanner))).filter((scanner) => scanner !== void 0);
634
753
  }
635
754
  };
636
755
  }
@@ -665,7 +784,7 @@ const callSpec = v.strictObject({
665
784
  "accessor"
666
785
  ])),
667
786
  key: v.optional(v.union([v.number(), argSource])),
668
- namespace: v.optional(argSource),
787
+ namespace: v.optional(v.union([argSource, v.string()])),
669
788
  keyPrefix: v.optional(argSource),
670
789
  factory: v.optional(v.boolean()),
671
790
  returns: v.optional(strings),
@@ -724,12 +843,18 @@ const checkSetting = v.union([level, v.tuple([level, jsonObject])]);
724
843
  const namespaceOptions = v.strictObject({
725
844
  separator: v.optional(v.string()),
726
845
  default: v.optional(v.string()),
846
+ fallback: v.optional(strings),
847
+ byFile: v.optional(v.record(v.string(), v.string())),
727
848
  fromPath: v.optional(v.picklist([
728
849
  "dot",
729
850
  "slash",
730
851
  "camel"
731
852
  ]))
732
853
  });
854
+ const reviewAuthor = v.strictObject({
855
+ name: v.optional(v.string()),
856
+ email: v.optional(v.string())
857
+ });
733
858
  /** Validates YAML and JSON configs, and the serializable part of TS configs. */
734
859
  const ConfigSchema = v.strictObject({
735
860
  $schema: v.optional(v.string()),
@@ -769,6 +894,11 @@ const ConfigSchema = v.strictObject({
769
894
  ignoreKeys: v.optional(strings),
770
895
  glossary: v.optional(v.array(glossaryEntry)),
771
896
  history: v.optional(v.union([v.boolean(), v.record(v.string(), v.union([v.literal(false), v.looseObject({})]))])),
897
+ review: v.optional(v.strictObject({
898
+ enabled: v.optional(v.boolean()),
899
+ file: v.optional(v.string()),
900
+ author: v.optional(reviewAuthor)
901
+ })),
772
902
  projects: v.optional(strings)
773
903
  });
774
904
  const PluginFileSchema = v.strictObject({
@@ -810,6 +940,27 @@ const PluginFileSchema = v.strictObject({
810
940
  }))),
811
941
  extract: v.optional(v.strictObject({ templates: v.optional(extractTemplates) }))
812
942
  });
943
+ const reviewComment = v.strictObject({
944
+ id: v.string(),
945
+ type: v.picklist([
946
+ "comment",
947
+ "approve",
948
+ "request-change",
949
+ "suggestion"
950
+ ]),
951
+ author: v.optional(reviewAuthor),
952
+ time: v.string(),
953
+ text: v.optional(v.string()),
954
+ suggestion: v.optional(v.string()),
955
+ value: v.optional(v.string()),
956
+ resolved: v.optional(v.boolean())
957
+ });
958
+ const ReviewFileSchema = v.strictObject({
959
+ $schema: v.optional(v.string()),
960
+ x18n: v.literal(1),
961
+ threads: v.optional(v.record(v.string(), v.record(v.string(), v.array(reviewComment)))),
962
+ sources: v.optional(v.record(v.string(), v.record(v.string(), v.string())))
963
+ });
813
964
  /** Validates data against a schema and returns readable issues instead of throwing. */
814
965
  function validate(schema, data) {
815
966
  const result = v.safeParse(schema, data);
@@ -889,6 +1040,28 @@ function placeholderProblems(project, ops) {
889
1040
  });
890
1041
  }
891
1042
  //#endregion
1043
+ //#region src/project/explain.ts
1044
+ /** `project.explain()` as the text `x18n doctor` prints. `root` and `configFile` are shown as given. */
1045
+ function renderExplanation(e, configFile) {
1046
+ const lines = [
1047
+ `# ${e.name} (${e.root})`,
1048
+ `config: ${configFile ?? "none (detection only)"}`,
1049
+ "presets:"
1050
+ ];
1051
+ const applied = e.presets.filter((p) => p.reason === "detected" || p.reason === "listed");
1052
+ lines.push(...applied.map((p) => ` ✓ ${p.name} (${p.reason})`));
1053
+ if (applied.length === 0) lines.push(" none");
1054
+ if (e.presets.length > applied.length) lines.push(` ${e.presets.length - applied.length} others not applied`);
1055
+ lines.push("finders:");
1056
+ for (const f of e.finders) lines.push(` ${f.id}: ${f.resources.length} resources`, ...f.resources.slice(0, 20).map((path) => ` ${path}`));
1057
+ lines.push("formats:", ...e.formats.filter((f) => f.resources > 0).map((f) => ` ${f.id} (${f.extensions.join(", ")}): ${f.resources} resources`));
1058
+ lines.push("scanners:", ...e.scanners.map((s) => ` ${s.id} (${s.extensions.join(", ") || "none"}): ${s.files} files`));
1059
+ if (e.conflicts.length) lines.push("conflicts:", ...e.conflicts.map((c) => ` .${c.extension}: ${c.winner} wins over ${c.loser}`));
1060
+ if (e.errors.length) lines.push("errors:", ...e.errors.map((err) => ` ${err.name} ${err.why}`));
1061
+ lines.push("config origins:", ...Object.entries(e.config.origins).map(([key, origin]) => ` ${key}: ${origin}`));
1062
+ return lines.join("\n");
1063
+ }
1064
+ //#endregion
892
1065
  //#region src/project/history.ts
893
1066
  const localChange = { updatedAt: Number.POSITIVE_INFINITY };
894
1067
  /** A target message is stale when it last changed before its source message. */
@@ -949,8 +1122,12 @@ function innermost(scopes, range) {
949
1122
  return best;
950
1123
  }
951
1124
  function createUsageResolver(config, namespaced, normalizers) {
952
- const separator = config.namespace.separator;
953
- return { resolve(candidate, scopes, path, scanner) {
1125
+ const { separator, fallback } = config.namespace;
1126
+ const byFile = Object.entries(config.namespace.byFile).map(([glob, namespace]) => ({
1127
+ matches: pathMatcher([glob]),
1128
+ namespace
1129
+ }));
1130
+ return { resolve(candidate, scopes, path, scanner, has) {
954
1131
  const scope = innermost(scopes, candidate.range);
955
1132
  const ctx = { path };
956
1133
  let text = candidate.raw;
@@ -961,8 +1138,8 @@ function createUsageResolver(config, namespaced, normalizers) {
961
1138
  break;
962
1139
  }
963
1140
  }
964
- const scopeNamespace = candidate.namespace ?? scope?.namespace;
965
- const scopePrefix = candidate.keyPrefix ?? scope?.keyPrefix;
1141
+ const scopeNamespace = candidate.namespace ?? scope?.namespace ?? byFile.find((rule) => rule.matches(path))?.namespace;
1142
+ let scopePrefix = candidate.keyPrefix ?? scope?.keyPrefix;
966
1143
  let namespace;
967
1144
  let explicit = false;
968
1145
  if (namespaced) {
@@ -973,10 +1150,17 @@ function createUsageResolver(config, namespaced, normalizers) {
973
1150
  text = text.slice(index + separator.length);
974
1151
  } else namespace = scopeNamespace ?? config.namespace.default;
975
1152
  }
976
- if (scopePrefix && !explicit) text = text ? `${scopePrefix}.${text}` : scopePrefix;
1153
+ if (scopePrefix && !explicit) {
1154
+ const prefixed = text ? `${scopePrefix}.${text}` : scopePrefix;
1155
+ const lookup = candidate.dynamic === "prefix" ? prefixed.replace(/\.$/, "") : prefixed;
1156
+ if (candidate.keyPrefix || !scope?.optionalPrefix || has(joinKey(namespace, lookup.split(".")))) text = prefixed;
1157
+ else scopePrefix = void 0;
1158
+ }
1159
+ const keyPath = text === "" ? [] : text.split(".");
977
1160
  let key;
978
1161
  if (candidate.dynamic === "unknown") key = void 0;
979
- else key = joinKey(namespace, text === "" ? [] : text.split("."));
1162
+ else key = joinKey(namespace, keyPath);
1163
+ if (key !== void 0 && namespaced && !explicit && candidate.dynamic === false && !has(key)) key = fallback.map((ns) => joinKey(ns, keyPath)).find(has) ?? key;
980
1164
  return {
981
1165
  key,
982
1166
  raw: candidate.raw,
@@ -1127,7 +1311,7 @@ function computeCoverage(state, options, sourceKeys) {
1127
1311
  if (value === void 0 || value === "") {
1128
1312
  if (inherited(key)) continue;
1129
1313
  (value === void 0 ? missing : empty).push(key);
1130
- } else if (locale !== sourceLocale && isOutdated(state.history(key, sourceLocale), state.history(key, locale))) outdated.push(key);
1314
+ } else if (locale !== sourceLocale && isOutdated(state.history(key, sourceLocale), state.history(key, locale)) && !state.fresh(key, locale)) outdated.push(key);
1131
1315
  }
1132
1316
  return {
1133
1317
  locale,
@@ -1278,6 +1462,7 @@ function createAnalyzer(options) {
1278
1462
  localeInfo: options.localeInfo,
1279
1463
  text: options.text,
1280
1464
  history: state.history,
1465
+ reviews: state.reviews,
1281
1466
  *hardStrings() {
1282
1467
  for (const [path, file] of state.files) for (const hard of file.result.hardStrings ?? []) yield {
1283
1468
  ...hard,
@@ -1305,6 +1490,161 @@ function createAnalyzer(options) {
1305
1490
  return { analyze };
1306
1491
  }
1307
1492
  //#endregion
1493
+ //#region src/project/reviews.ts
1494
+ const noReviews = {
1495
+ threads: {},
1496
+ sources: {}
1497
+ };
1498
+ /** What a review compares: message values as text. */
1499
+ function valueText(value) {
1500
+ return value === null ? "" : String(value);
1501
+ }
1502
+ /** The hash recorded for a source value in `sources`. */
1503
+ function sourceHash(value) {
1504
+ return hashText(JSON.stringify(value));
1505
+ }
1506
+ function reviewState(comments) {
1507
+ const open = comments.filter((comment) => !comment.resolved);
1508
+ const decisive = open.findLast((comment) => comment.type === "approve" || comment.type === "request-change");
1509
+ if (decisive) return decisive.type === "request-change" ? "changes-requested" : decisive.stale ? "stale-approval" : "approved";
1510
+ if (open.some((comment) => comment.type === "suggestion")) return "suggested";
1511
+ return open.length > 0 ? "commented" : void 0;
1512
+ }
1513
+ /** A short random id that `taken` does not know. */
1514
+ function reviewId(taken) {
1515
+ let id;
1516
+ do
1517
+ id = Math.random().toString(36).slice(2, 8);
1518
+ while (id.length < 6 || taken(id));
1519
+ return id;
1520
+ }
1521
+ function byCode(a, b) {
1522
+ return a < b ? -1 : a > b ? 1 : 0;
1523
+ }
1524
+ /** Threads in key order, then in the order of `locales`, then any other locale. */
1525
+ function listThreads(data, filter, locales, current) {
1526
+ const rank = (locale) => {
1527
+ const i = locales.indexOf(locale);
1528
+ return i < 0 ? locales.length : i;
1529
+ };
1530
+ const out = [];
1531
+ const keys = filter.key === void 0 ? Object.keys(data.threads).sort(byCode) : [filter.key];
1532
+ for (const key of keys) {
1533
+ const byLocale = data.threads[key] ?? {};
1534
+ const codes = Object.keys(byLocale).filter((locale) => filter.locale === void 0 || locale === filter.locale).sort((a, b) => rank(a) - rank(b) || byCode(a, b));
1535
+ for (const locale of codes) {
1536
+ const value = current(key, locale);
1537
+ const comments = byLocale[locale].filter((comment) => !filter.open || !comment.resolved).map((comment) => ({
1538
+ ...comment,
1539
+ stale: comment.type === "approve" && comment.value !== void 0 && value !== void 0 && valueText(value) !== comment.value
1540
+ }));
1541
+ if (comments.length > 0) out.push({
1542
+ key,
1543
+ locale,
1544
+ comments,
1545
+ state: reviewState(comments)
1546
+ });
1547
+ }
1548
+ }
1549
+ return out;
1550
+ }
1551
+ function findReview(data, id) {
1552
+ for (const [key, byLocale] of Object.entries(data.threads)) for (const [locale, comments] of Object.entries(byLocale)) {
1553
+ const comment = comments.find((c) => c.id === id);
1554
+ if (comment) return {
1555
+ key,
1556
+ locale,
1557
+ comment
1558
+ };
1559
+ }
1560
+ }
1561
+ function sortSection(section) {
1562
+ return Object.fromEntries(Object.entries(section).filter(([, byLocale]) => Object.keys(byLocale).length > 0).sort(([a], [b]) => byCode(a, b)).map(([key, byLocale]) => [key, Object.fromEntries(Object.entries(byLocale).sort(([a], [b]) => byCode(a, b)))]));
1563
+ }
1564
+ /** Sorted keys and locales, so edits from two branches merge well. */
1565
+ function sortReviews(data) {
1566
+ return {
1567
+ threads: sortSection(data.threads),
1568
+ sources: sortSection(data.sources)
1569
+ };
1570
+ }
1571
+ function rekey(section, from, to) {
1572
+ const { [from]: entry, ...rest } = section;
1573
+ return entry ? {
1574
+ ...rest,
1575
+ [to]: entry
1576
+ } : section;
1577
+ }
1578
+ function without(section, key, locales) {
1579
+ const { [key]: byLocale, ...rest } = section;
1580
+ if (!byLocale || locales === "all") return rest;
1581
+ return {
1582
+ ...rest,
1583
+ [key]: Object.fromEntries(Object.entries(byLocale).filter(([locale]) => !locales.includes(locale)))
1584
+ };
1585
+ }
1586
+ /** Review data being changed by an edit plan. */
1587
+ var ReviewDraft = class {
1588
+ data;
1589
+ changed = false;
1590
+ constructor(data) {
1591
+ this.data = data;
1592
+ }
1593
+ update(next) {
1594
+ this.changed = true;
1595
+ this.data = next;
1596
+ }
1597
+ thread(key, locale, comments) {
1598
+ const { threads } = this.data;
1599
+ this.update({
1600
+ ...this.data,
1601
+ threads: {
1602
+ ...threads,
1603
+ [key]: {
1604
+ ...threads[key],
1605
+ [locale]: comments
1606
+ }
1607
+ }
1608
+ });
1609
+ }
1610
+ add(key, locale, comment) {
1611
+ this.thread(key, locale, [...this.data.threads[key]?.[locale] ?? [], comment]);
1612
+ }
1613
+ resolve(id) {
1614
+ const found = findReview(this.data, id);
1615
+ if (found) this.thread(found.key, found.locale, this.data.threads[found.key][found.locale].map((c) => c.id === id ? {
1616
+ ...c,
1617
+ resolved: true
1618
+ } : c));
1619
+ }
1620
+ record(key, locale, hash) {
1621
+ const { sources } = this.data;
1622
+ if (sources[key]?.[locale] !== hash) this.update({
1623
+ ...this.data,
1624
+ sources: {
1625
+ ...sources,
1626
+ [key]: {
1627
+ ...sources[key],
1628
+ [locale]: hash
1629
+ }
1630
+ }
1631
+ });
1632
+ }
1633
+ /** Threads and records follow a renamed key. */
1634
+ move(from, to) {
1635
+ if (this.data.threads[from] || this.data.sources[from]) this.update({
1636
+ threads: rekey(this.data.threads, from, to),
1637
+ sources: rekey(this.data.sources, from, to)
1638
+ });
1639
+ }
1640
+ remove(key, locales) {
1641
+ if (this.data.threads[key] || this.data.sources[key]) this.update({
1642
+ threads: without(this.data.threads, key, locales),
1643
+ sources: without(this.data.sources, key, locales)
1644
+ });
1645
+ }
1646
+ };
1647
+ //#endregion
1308
1648
  //#region src/project/planner.ts
1309
1649
  const safeSegment = /^[\w-]+$/;
1310
1650
  const safeFlat = /^[\w.-]+$/;
@@ -1345,7 +1685,8 @@ function mirrorPath(path, from, to) {
1345
1685
  function importEdit(text, lines) {
1346
1686
  const missing = lines.filter((line) => !text.includes(line.trim()));
1347
1687
  if (missing.length === 0) return void 0;
1348
- const script = /<script\b[^>]*>\r?\n?/.exec(text);
1688
+ const open = /^\s*---\r?\n/.exec(text);
1689
+ const script = (open && /^---\s*$/m.test(text.slice(open[0].length)) ? open : void 0) ?? /<script\b[^>]*>\r?\n?/.exec(text);
1349
1690
  let at = 0;
1350
1691
  if (script) at = script.index + script[0].length;
1351
1692
  else {
@@ -1373,6 +1714,9 @@ var PlanBuilder = class {
1373
1714
  touched = /* @__PURE__ */ new Set();
1374
1715
  stateById;
1375
1716
  config;
1717
+ /** Source values set by this plan, for the source records of targets set after them. */
1718
+ plannedSource = /* @__PURE__ */ new Map();
1719
+ draft;
1376
1720
  constructor(options, state, answers) {
1377
1721
  this.options = options;
1378
1722
  this.state = state;
@@ -1439,7 +1783,9 @@ var PlanBuilder = class {
1439
1783
  const sourceState = source && this.stateById.get(source.resource);
1440
1784
  const namespace = namespaced ? this.keyId(key).namespace ?? this.config.namespace.default : sourceState?.resource.namespace;
1441
1785
  const exists = (path) => resources.some((r) => r.resource.path === path);
1442
- const resolved = this.options.resolveNew(locale, namespace);
1786
+ const sibling = (r) => r.resource.locale === locale && r.resource.namespace !== void 0 && namespace !== void 0;
1787
+ const model = (sourceState && fits(sourceState) ? sourceState : void 0) ?? resources.find((s) => s.resource.locale === sourceLocale && fits(s)) ?? resources.find(fits) ?? resources.find(sibling);
1788
+ const resolved = this.options.resolveNew(locale, namespace, model?.resource);
1443
1789
  if (resolved && !exists(resolved)) {
1444
1790
  const format = this.options.registry.formatFor(resolved);
1445
1791
  if (format?.serialize) {
@@ -1455,8 +1801,6 @@ var PlanBuilder = class {
1455
1801
  return target;
1456
1802
  }
1457
1803
  }
1458
- const sibling = (r) => r.resource.locale === locale && r.resource.namespace !== void 0 && namespace !== void 0;
1459
- const model = (sourceState && fits(sourceState) ? sourceState : void 0) ?? resources.find((s) => s.resource.locale === sourceLocale && fits(s)) ?? resources.find(fits) ?? resources.find(sibling);
1460
1804
  const format = model && this.options.registry.formatById(model.resource.format);
1461
1805
  let path = model && mirrorPath(model.resource.path, model.resource.locale, locale);
1462
1806
  if (model && path && namespace !== void 0 && model.resource.namespace !== void 0 && model.resource.namespace !== namespace) path = mirrorPath(path, model.resource.namespace, namespace);
@@ -1699,6 +2043,7 @@ var PlanBuilder = class {
1699
2043
  const renamed = keys.includes(op.from) ? /* @__PURE__ */ new Set() : this.renameNode(op.from, op.to);
1700
2044
  for (const [from, to] of pairs) {
1701
2045
  this.moveKey(from, to, renamed);
2046
+ this.reviewDraft()?.move(from, to);
1702
2047
  if (op.usages !== false) this.rewriteUsages(from, to);
1703
2048
  }
1704
2049
  if (op.usages === false) return;
@@ -1710,6 +2055,7 @@ var PlanBuilder = class {
1710
2055
  if (keys.length === 0) throw errors.X18N4006({ key: op.key });
1711
2056
  for (const key of keys) {
1712
2057
  this.touched.add(key);
2058
+ this.reviewDraft()?.remove(key, op.locales ?? "all");
1713
2059
  for (const [locale, list] of this.options.messages(key) ?? []) if (op.locales === void 0 || op.locales === "all" || op.locales.includes(locale)) list.forEach((m) => this.deleteMessage(m));
1714
2060
  const used = this.options.usageMatches({ key }).filter((match) => !match.possible).length;
1715
2061
  if (used > 0) this.warnings.push(`"${key}" still has ${used} usage${used === 1 ? "" : "s"}.`);
@@ -1729,13 +2075,75 @@ var PlanBuilder = class {
1729
2075
  }
1730
2076
  }
1731
2077
  }
2078
+ /** The review draft, or undefined when reviews are off. */
2079
+ reviewDraft() {
2080
+ const reviews = this.state.reviews;
2081
+ if (reviews) this.draft ??= new ReviewDraft(reviews.data);
2082
+ return this.draft;
2083
+ }
2084
+ requireReviews() {
2085
+ const draft = this.reviewDraft();
2086
+ if (!draft) throw errors.X18N5003({ reason: this.state.reviewProblem ?? "reviews are off" });
2087
+ return draft;
2088
+ }
2089
+ /** Records the source value a target was written against, once the project keeps a review file. */
2090
+ recordSource(key, locale, force = false) {
2091
+ if (locale === this.state.sourceLocale || !force && this.state.reviews?.text === void 0) return;
2092
+ const source = this.plannedSource.has(key) ? this.plannedSource.get(key) : this.options.message(key, this.state.sourceLocale)?.value;
2093
+ if (source !== void 0) this.reviewDraft()?.record(key, locale, sourceHash(source));
2094
+ }
2095
+ review(op) {
2096
+ const draft = this.requireReviews();
2097
+ if (!this.options.messages(op.key)) throw errors.X18N4006({ key: op.key });
2098
+ if (!this.state.locales.includes(op.locale)) throw errors.X18N4008({ reason: `unknown locale "${op.locale}"` });
2099
+ if (op.review === "suggestion" && op.suggestion === void 0) throw errors.X18N4008({ reason: "a suggestion needs a value" });
2100
+ const current = this.options.message(op.key, op.locale)?.value;
2101
+ this.touched.add(op.key);
2102
+ draft.add(op.key, op.locale, {
2103
+ id: op.id,
2104
+ type: op.review,
2105
+ ...op.author && { author: op.author },
2106
+ time: op.time,
2107
+ ...op.text !== void 0 && { text: op.text },
2108
+ ...op.review === "suggestion" && { suggestion: op.suggestion },
2109
+ ...op.review === "approve" && current !== void 0 && { value: valueText(current) }
2110
+ });
2111
+ }
2112
+ applySuggestion(id) {
2113
+ const draft = this.requireReviews();
2114
+ const found = findReview(draft.data, id);
2115
+ if (!found) throw errors.X18N5004({ id });
2116
+ const { key, locale, comment } = found;
2117
+ if (comment.type !== "suggestion" || comment.suggestion === void 0 || comment.resolved) throw errors.X18N5005({ id });
2118
+ this.setValue(key, locale, comment.suggestion);
2119
+ this.recordSource(key, locale, true);
2120
+ draft.resolve(id);
2121
+ }
2122
+ resolveReview(id) {
2123
+ const draft = this.requireReviews();
2124
+ const found = findReview(draft.data, id);
2125
+ if (!found) throw errors.X18N5004({ id });
2126
+ this.touched.add(found.key);
2127
+ draft.resolve(id);
2128
+ }
2129
+ markFresh(op) {
2130
+ this.requireReviews();
2131
+ if (this.options.message(op.key, this.state.sourceLocale) === void 0) throw errors.X18N4006({ key: op.key });
2132
+ const locales = op.locales ?? this.state.locales.filter((locale) => this.options.message(op.key, locale) !== void 0);
2133
+ this.touched.add(op.key);
2134
+ for (const locale of locales) this.recordSource(op.key, locale, true);
2135
+ }
2136
+ set(op) {
2137
+ this.setValue(op.key, op.locale, op.value);
2138
+ if (op.locale === this.state.sourceLocale) this.plannedSource.set(op.key, op.value);
2139
+ else if (op.value !== "") this.recordSource(op.key, op.locale);
2140
+ if (!this.config.write.keepFilled) return;
2141
+ for (const locale of this.state.locales) if (locale !== op.locale && !this.state.fallbacks[locale] && !this.options.message(op.key, locale)) this.setValue(op.key, locale, "");
2142
+ }
1732
2143
  run(ops) {
1733
2144
  for (const op of ops) switch (op.type) {
1734
2145
  case "set":
1735
- this.setValue(op.key, op.locale, op.value);
1736
- if (this.config.write.keepFilled) {
1737
- for (const locale of this.state.locales) if (locale !== op.locale && !this.state.fallbacks[locale] && !this.options.message(op.key, locale)) this.setValue(op.key, locale, "");
1738
- }
2146
+ this.set(op);
1739
2147
  break;
1740
2148
  case "delete":
1741
2149
  this.delete(op);
@@ -1747,7 +2155,19 @@ var PlanBuilder = class {
1747
2155
  case "extract":
1748
2156
  this.extract(op.items);
1749
2157
  break;
1750
- case "format": for (const s of this.state.resources) if ((!op.paths || op.paths.includes(s.resource.path)) && !s.resource.readonly && !s.error) this.workFor(this.targetOf(s)).formatOnly = true;
2158
+ case "format":
2159
+ for (const s of this.state.resources) if ((!op.paths || op.paths.includes(s.resource.path)) && !s.resource.readonly && !s.error) this.workFor(this.targetOf(s)).formatOnly = true;
2160
+ break;
2161
+ case "review":
2162
+ this.review(op);
2163
+ break;
2164
+ case "resolve-review":
2165
+ this.resolveReview(op.id);
2166
+ break;
2167
+ case "apply-suggestion":
2168
+ this.applySuggestion(op.id);
2169
+ break;
2170
+ case "mark-fresh": this.markFresh(op);
1751
2171
  }
1752
2172
  }
1753
2173
  /** ---- Changes ---- */
@@ -1856,16 +2276,52 @@ var PlanBuilder = class {
1856
2276
  });
1857
2277
  texts.set(path, text);
1858
2278
  }
2279
+ const review = this.reviewChange();
2280
+ if (review) {
2281
+ changes.push(review);
2282
+ texts.set(review.path, this.state.reviews?.text);
2283
+ }
1859
2284
  return {
1860
2285
  changes,
1861
2286
  texts
1862
2287
  };
1863
2288
  }
2289
+ reviewChange() {
2290
+ const reviews = this.state.reviews;
2291
+ if (!reviews || !this.draft?.changed) return void 0;
2292
+ const next = reviews.store.serialize(sortReviews(this.draft.data));
2293
+ if (reviews.text === void 0) return {
2294
+ type: "create",
2295
+ path: reviews.file,
2296
+ content: next
2297
+ };
2298
+ return next === reviews.text ? void 0 : {
2299
+ type: "edit",
2300
+ path: reviews.file,
2301
+ edits: diffText(reviews.text, next)
2302
+ };
2303
+ }
1864
2304
  };
1865
2305
  function createPlanner(options) {
1866
2306
  const texts = /* @__PURE__ */ new Map();
1867
- function planWith(ops, answers) {
2307
+ /** Gives new reviews an id and a time, so planning again (after decisions) writes the same comments. */
2308
+ function withReviewIds(ops, data) {
2309
+ const used = /* @__PURE__ */ new Set();
2310
+ const time = (/* @__PURE__ */ new Date()).toISOString();
2311
+ return ops.map((op) => {
2312
+ if (op.type !== "review" || op.id !== void 0 && op.time !== void 0) return op;
2313
+ const id = op.id ?? reviewId((id) => used.has(id) || findReview(data, id) !== void 0);
2314
+ used.add(id);
2315
+ return {
2316
+ ...op,
2317
+ id,
2318
+ time: op.time ?? time
2319
+ };
2320
+ });
2321
+ }
2322
+ function planWith(input, answers) {
1868
2323
  const state = options.state();
2324
+ const ops = withReviewIds(input, state.reviews?.data ?? noReviews);
1869
2325
  const builder = new PlanBuilder(options, state, answers);
1870
2326
  builder.run(ops);
1871
2327
  const result = builder.changes();
@@ -1965,7 +2421,8 @@ function buildPrompt(project, kind, to, options, extra) {
1965
2421
  const from = project.sourceLocale;
1966
2422
  const match = typeof options.keys === "string" ? keyMatcher([options.keys]) : void 0;
1967
2423
  const list = Array.isArray(options.keys) ? new Set(options.keys) : void 0;
1968
- const stale = (key) => isOutdated(project.history(key, from), project.history(key, to));
2424
+ const outdated = new Set(project.analyze().coverage.find((c) => c.locale === to)?.outdated);
2425
+ const stale = (key) => outdated.has(key);
1969
2426
  const candidates = project.keys({ namespace: options.namespace }).filter((key) => {
1970
2427
  if (match && !match(key)) return false;
1971
2428
  if (list && !list.has(key)) return false;
@@ -2020,7 +2477,11 @@ function buildPrompt(project, kind, to, options, extra) {
2020
2477
  `Write a JSON file \`{ "${to}": { "<key>": "<translation>" } }\` and run \`x18n apply <file> --json\`, or run \`x18n set <key> --locale ${to} --value <text>\` per key.`,
2021
2478
  `Then run \`x18n check --only placeholder-mismatch,invalid-syntax --locale ${to} --json\` and fix the reported keys.`,
2022
2479
  nextCursor ? `${candidates.length - end} more keys remain: \`x18n prompt translate --to ${to} --cursor ${nextCursor}\`.` : `To find more work: \`x18n keys --missing ${to} --json --limit 50\`.`
2023
- ] : [`Fix wrong translations with \`x18n set <key> --locale ${to} --value <text>\`, then list what you changed and why.`, `Then run \`x18n check --only placeholder-mismatch,invalid-syntax --locale ${to} --json\`.`];
2480
+ ] : project.reviewFile === void 0 ? [`Fix wrong translations with \`x18n set <key> --locale ${to} --value <text>\`, then list what you changed and why.`, `Then run \`x18n check --only placeholder-mismatch,invalid-syntax --locale ${to} --json\`.`] : [
2481
+ `Leave fixes for a human to accept: \`x18n review suggest <key> --locale ${to} --value <text> --text <why>\`. Keep every placeholder in the value.`,
2482
+ `For a problem without a clear fix, run \`x18n review request-change <key> --locale ${to} --text <why>\`.`,
2483
+ `Then list what you reviewed; \`x18n review list --locale ${to} --open\` shows the open reviews.`
2484
+ ];
2024
2485
  return {
2025
2486
  kind,
2026
2487
  project: project.name,
@@ -2102,6 +2563,10 @@ function createProject(options) {
2102
2563
  let usageEligible = () => false;
2103
2564
  let resourceIndex;
2104
2565
  let resolved = /* @__PURE__ */ new WeakMap();
2566
+ const reviewFile = config.review.file;
2567
+ let reviewText;
2568
+ let reviewData = noReviews;
2569
+ let reviewError;
2105
2570
  const normalizers = registry.plugins.flatMap((plugin) => plugin.normalizeKey ? [plugin.normalizeKey] : []);
2106
2571
  const transforms = registry.plugins.flatMap((plugin) => plugin.transformResource ? [plugin.transformResource] : []);
2107
2572
  const localeInfoHooks = registry.plugins.flatMap((plugin) => plugin.localeInfo ? [plugin.localeInfo] : []);
@@ -2138,6 +2603,44 @@ function createProject(options) {
2138
2603
  const form = formOf(key);
2139
2604
  return !form || categoriesOf(localeInfo(locale), form.type).includes(form.category);
2140
2605
  }
2606
+ const reviewStore = config.review.enabled ? registry.reviewStores.at(-1) : void 0;
2607
+ async function loadReviews() {
2608
+ if (!reviewStore) return;
2609
+ reviewText = await host.readFile(reviewFile);
2610
+ reviewError = void 0;
2611
+ reviewData = noReviews;
2612
+ if (reviewText === void 0) return;
2613
+ try {
2614
+ reviewData = reviewStore.parse(reviewText);
2615
+ } catch (error) {
2616
+ reviewError = errors.X18N5001({
2617
+ path: reviewFile,
2618
+ reason: error instanceof Error ? error.message : String(error),
2619
+ cause: error
2620
+ });
2621
+ }
2622
+ }
2623
+ /** Reviews the planner may change: none while the file cannot be read, so a write never drops what is in it. */
2624
+ function reviewSource() {
2625
+ return reviewStore && !reviewError ? {
2626
+ store: reviewStore,
2627
+ file: reviewFile,
2628
+ text: reviewText,
2629
+ data: reviewData
2630
+ } : void 0;
2631
+ }
2632
+ function reviewProblem() {
2633
+ return reviewError?.message ?? (config.review.enabled ? "no review store is registered" : "`review.enabled` is false");
2634
+ }
2635
+ function reviews(filter = {}) {
2636
+ return listThreads(reviewData, filter, locales, (key, locale) => message(key, locale)?.value);
2637
+ }
2638
+ /** A target recorded as written against the current source value. */
2639
+ function fresh(key, locale) {
2640
+ const recorded = reviewData.sources[key]?.[locale];
2641
+ const source = message(key, sourceLocale)?.value;
2642
+ return recorded !== void 0 && source !== void 0 && recorded === sourceHash(source);
2643
+ }
2141
2644
  async function discover() {
2142
2645
  const ctx = {
2143
2646
  host,
@@ -2265,6 +2768,22 @@ function createProject(options) {
2265
2768
  if (state.error) return void 0;
2266
2769
  return new Map([...walkLeaves(state.tree)].map((leaf) => [keyFor(state.resource, leaf.path), leaf.value]));
2267
2770
  }
2771
+ /** A path for a new resource, from the locale finders. */
2772
+ function newResourcePath(locale, namespace, model) {
2773
+ const ctx = {
2774
+ host,
2775
+ config,
2776
+ extensions: [...new Set(registry.formats.flatMap((format) => format.extensions))]
2777
+ };
2778
+ for (const finder of registry.finders) {
2779
+ const path = finder.resolveNew?.({
2780
+ locale,
2781
+ namespace,
2782
+ model
2783
+ }, ctx);
2784
+ if (path) return path;
2785
+ }
2786
+ }
2268
2787
  async function loadResources(candidates, reread) {
2269
2788
  const next = /* @__PURE__ */ new Map();
2270
2789
  const paths = [...new Set(candidates.map((c) => c.path))];
@@ -2276,6 +2795,7 @@ function createProject(options) {
2276
2795
  }
2277
2796
  }));
2278
2797
  namespaced = config.namespace.separator !== "." && candidates.some((c) => c.namespace !== void 0);
2798
+ await Promise.all(candidates.map((candidate) => registry.loadFormatFor(candidate.path, candidate.format)));
2279
2799
  for (const candidate of candidates) {
2280
2800
  const text = fileTexts.get(candidate.path);
2281
2801
  if (text === void 0) continue;
@@ -2296,6 +2816,11 @@ function createProject(options) {
2296
2816
  resolver = createUsageResolver(config, namespaced, normalizers);
2297
2817
  resolved = /* @__PURE__ */ new WeakMap();
2298
2818
  resourceIndex = void 0;
2819
+ const namespaces = /* @__PURE__ */ new Set([void 0, ...[...resourceStates.values()].map((state) => state.resource.namespace)]);
2820
+ await Promise.all([...namespaces].map((namespace) => {
2821
+ const path = newResourcePath(sourceLocale, namespace, void 0);
2822
+ return path ? registry.loadFormatFor(path) : void 0;
2823
+ }));
2299
2824
  }
2300
2825
  async function scanOne(file) {
2301
2826
  const usages = [];
@@ -2303,7 +2828,7 @@ function createProject(options) {
2303
2828
  const keySlots = [];
2304
2829
  const hardStrings = [];
2305
2830
  const calls = config.usage.calls.filter((spec) => ![spec.callee].flat().some((name) => config.usage.ignoreCallees.includes(name)));
2306
- for (const scanner of registry.scannersFor(file.path)) {
2831
+ for (const scanner of await registry.loadScannersFor(file.path)) {
2307
2832
  const result = await scanner.scan(file, {
2308
2833
  config,
2309
2834
  calls
@@ -2470,12 +2995,13 @@ function createProject(options) {
2470
2995
  };
2471
2996
  }
2472
2997
  function buildIndex() {
2473
- resourceIndex ??= buildResourceIndex();
2998
+ const { messages, parents, derived } = resourceIndex ??= buildResourceIndex();
2999
+ const has = (key) => messages.has(key) || parents.has(key) || derived.has(key);
2474
3000
  const usages = [];
2475
3001
  for (const [path, state] of [...files].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
2476
3002
  let list = resolved.get(state);
2477
3003
  if (!list) {
2478
- list = state.result.usages.map((candidate) => resolver.resolve(candidate, state.result.scopes ?? [], path, "scanner"));
3004
+ list = state.result.usages.map((candidate) => resolver.resolve(candidate, state.result.scopes ?? [], path, "scanner", has));
2479
3005
  resolved.set(state, list);
2480
3006
  }
2481
3007
  usages.push(...list);
@@ -2571,7 +3097,10 @@ function createProject(options) {
2571
3097
  error: s.error,
2572
3098
  path
2573
3099
  }] : []),
2574
- discoveryErrors,
3100
+ discoveryErrors: [...discoveryErrors, ...reviewError ? [{
3101
+ error: reviewError,
3102
+ path: reviewFile
3103
+ }] : []],
2575
3104
  messages: idx().messages,
2576
3105
  parents: idx().parents,
2577
3106
  usages: idx().usages,
@@ -2579,7 +3108,9 @@ function createProject(options) {
2579
3108
  prefixUsages: idx().prefixUsages,
2580
3109
  derived: idx().derived,
2581
3110
  files,
2582
- history: history.get
3111
+ history: history.get,
3112
+ fresh,
3113
+ reviews
2583
3114
  }),
2584
3115
  basesOf,
2585
3116
  formOf,
@@ -2605,6 +3136,29 @@ function createProject(options) {
2605
3136
  }
2606
3137
  return list.sort();
2607
3138
  }
3139
+ function search(query, options = {}) {
3140
+ if (!query) return [];
3141
+ const lower = query.toLowerCase();
3142
+ const score = (value) => !options.exact || value.toLowerCase().includes(lower) ? fuzzyScore(query, value) : -1;
3143
+ const found = [];
3144
+ for (const key of idx().messages.keys()) {
3145
+ let best;
3146
+ for (const locale of locales) {
3147
+ const leaf = message(key, locale)?.value;
3148
+ if (leaf === void 0 || leaf === null) continue;
3149
+ const value = String(leaf);
3150
+ const s = score(value);
3151
+ if (s >= 0 && (!best || s > best.score)) best = {
3152
+ key,
3153
+ locale,
3154
+ value,
3155
+ score: s
3156
+ };
3157
+ }
3158
+ if (best) found.push(best);
3159
+ }
3160
+ return found.sort((a, b) => b.score - a.score || a.value.length - b.value.length || a.key.localeCompare(b.key));
3161
+ }
2608
3162
  function get(key) {
2609
3163
  const { messages, keyPaths } = idx();
2610
3164
  const byLocale = messages.get(key);
@@ -2618,7 +3172,8 @@ function createProject(options) {
2618
3172
  messages: list,
2619
3173
  missing: locales.filter((locale) => !byLocale.has(locale) && needs(key, locale)),
2620
3174
  usages: usageMatches({ key }),
2621
- diagnostics: analyzer.analyze().diagnostics.filter((d) => d.key === key)
3175
+ diagnostics: analyzer.analyze().diagnostics.filter((d) => d.key === key),
3176
+ reviews: reviews({ key })
2622
3177
  };
2623
3178
  }
2624
3179
  function keyAt(path, offset) {
@@ -2672,6 +3227,10 @@ function createProject(options) {
2672
3227
  conflicts: registry.conflicts,
2673
3228
  errors: [
2674
3229
  ...discoveryErrors,
3230
+ ...reviewError ? [{
3231
+ error: reviewError,
3232
+ path: reviewFile
3233
+ }] : [],
2675
3234
  ...resources.flatMap((state) => state.error ? [{
2676
3235
  error: state.error,
2677
3236
  path: state.resource.path
@@ -2701,15 +3260,24 @@ function createProject(options) {
2701
3260
  collect(idx());
2702
3261
  return [...out];
2703
3262
  }
3263
+ /** Reloads the review file and returns the keys whose reviews or records it had or has. */
3264
+ async function reloadReviews() {
3265
+ const keysOf = (data) => [...Object.keys(data.threads), ...Object.keys(data.sources)];
3266
+ const old = keysOf(reviewData);
3267
+ await loadReviews();
3268
+ return [...old, ...keysOf(reviewData)];
3269
+ }
2704
3270
  let current = Promise.resolve();
2705
3271
  async function update(events) {
2706
3272
  const run = current.then(async () => {
2707
3273
  if (events.length === 0) return void 0;
2708
3274
  const before = idx();
2709
3275
  const paths = new Set(events.map((event) => event.path));
3276
+ const reviewKeys = paths.has(reviewFile) ? await reloadReviews() : [];
2710
3277
  const resourcePaths = new Set([...resourceStates.values()].map((state) => state.resource.path));
2711
- const maybeResource = (path) => !resourcePaths.has(path) && registry.formatFor(path) !== void 0 && !usageEligible(path);
2712
- const structural = events.some((event) => event.type !== "change" || maybeResource(event.path));
3278
+ const maybeResource = (path) => path !== reviewFile && !resourcePaths.has(path) && registry.formatIdFor(path) !== void 0 && !usageEligible(path);
3279
+ const blockPaths = new Set([...resourceStates.values()].filter((state) => state.resource.block).map((state) => state.resource.path));
3280
+ const structural = events.some((event) => event.type !== "change" || maybeResource(event.path) || blockPaths.has(event.path));
2713
3281
  if (structural || [...paths].some((path) => resourcePaths.has(path))) await loadResources(structural ? await discover() : [...resourceStates.values()].map((state) => ({
2714
3282
  ...state.resource,
2715
3283
  format: state.resource.format === "unknown" ? void 0 : state.resource.format
@@ -2721,7 +3289,7 @@ function createProject(options) {
2721
3289
  const change = {
2722
3290
  revision,
2723
3291
  paths: [...paths],
2724
- keys: touchedKeys(paths, before)
3292
+ keys: [.../* @__PURE__ */ new Set([...touchedKeys(paths, before), ...reviewKeys])]
2725
3293
  };
2726
3294
  emit(change);
2727
3295
  return change;
@@ -2740,7 +3308,9 @@ function createProject(options) {
2740
3308
  fallbacks,
2741
3309
  namespaced,
2742
3310
  resources: [...resourceStates.values()],
2743
- fileTexts
3311
+ fileTexts,
3312
+ reviews: reviewSource(),
3313
+ reviewProblem: reviewProblem()
2744
3314
  }),
2745
3315
  message: (key, locale) => message(key, locale),
2746
3316
  messages: (key) => idx().messages.get(key),
@@ -2750,24 +3320,12 @@ function createProject(options) {
2750
3320
  text: (path) => fileTexts.get(path) ?? files.get(path)?.text,
2751
3321
  basesOf,
2752
3322
  toStored: (target, path) => storedPathOf(target)(path),
2753
- resolveNew(locale, namespace) {
2754
- const ctx = {
2755
- host,
2756
- config,
2757
- extensions: [...new Set(registry.formats.flatMap((format) => format.extensions))]
2758
- };
2759
- for (const finder of registry.finders) {
2760
- const path = finder.resolveNew?.({
2761
- locale,
2762
- namespace
2763
- }, ctx);
2764
- if (path) return path;
2765
- }
2766
- },
3323
+ resolveNew: newResourcePath,
2767
3324
  update
2768
3325
  });
2769
3326
  const ready = (async () => {
2770
- await loadResources(await discover(), "all");
3327
+ const candidates = await discover();
3328
+ await Promise.all([loadResources(candidates, "all"), loadReviews()]);
2771
3329
  await scanAll();
2772
3330
  revision++;
2773
3331
  })();
@@ -2796,6 +3354,7 @@ function createProject(options) {
2796
3354
  placeholderSyntaxes: registry.placeholders,
2797
3355
  extractTemplates: [...(config.extract.templates ?? []).map((template) => typeof template === "string" ? { template } : template), ...registry.extractors.flatMap((extractor) => extractor.templates)],
2798
3356
  keys,
3357
+ search,
2799
3358
  get,
2800
3359
  message,
2801
3360
  parseKey(input) {
@@ -2838,13 +3397,18 @@ function createProject(options) {
2838
3397
  },
2839
3398
  history: history.get,
2840
3399
  valueAt: history.valueAt,
3400
+ reviews,
3401
+ reviewFile: reviewStore && reviewFile,
3402
+ async reviewAuthor() {
3403
+ return config.review.author ?? await host.identity?.();
3404
+ },
2841
3405
  prompt: (kind, locale, promptOptions = {}) => buildPrompt(project, kind, locale, promptOptions, {
2842
3406
  config,
2843
3407
  hooks: registry.plugins.flatMap((plugin) => plugin.translatePrompt ? [plugin.translatePrompt] : [])
2844
3408
  }),
2845
3409
  explain,
2846
3410
  localeInfo,
2847
- text: (path) => fileTexts.get(path) ?? files.get(path)?.text,
3411
+ text: (path) => fileTexts.get(path) ?? files.get(path)?.text ?? (path === reviewFile ? reviewText : void 0),
2848
3412
  owns: (path) => fileTexts.has(path) || files.has(path) || usageEligible(path),
2849
3413
  plan: (ops) => planner.plan(ops),
2850
3414
  apply: (plan, applyOptions) => planner.apply(plan, applyOptions?.answers ?? {}),
@@ -2894,6 +3458,8 @@ function defaultDraft() {
2894
3458
  namespace: {
2895
3459
  separator: ".",
2896
3460
  default: void 0,
3461
+ fallback: [],
3462
+ byFile: {},
2897
3463
  fromPath: "dot"
2898
3464
  },
2899
3465
  usage: {
@@ -2916,6 +3482,11 @@ function defaultDraft() {
2916
3482
  ignoreKeys: [],
2917
3483
  glossary: [],
2918
3484
  history: { providers: [] },
3485
+ review: {
3486
+ enabled: true,
3487
+ file: ".x18n/reviews.yml",
3488
+ author: void 0
3489
+ },
2919
3490
  pluginConfigs: {}
2920
3491
  };
2921
3492
  }
@@ -2977,6 +3548,11 @@ function applyLayer(draft, layer, origin, origins) {
2977
3548
  draft.namespace = {
2978
3549
  separator: layer.namespace.separator ?? draft.namespace.separator,
2979
3550
  default: layer.namespace.default ?? draft.namespace.default,
3551
+ fallback: layer.namespace.fallback ?? draft.namespace.fallback,
3552
+ byFile: {
3553
+ ...draft.namespace.byFile,
3554
+ ...layer.namespace.byFile
3555
+ },
2980
3556
  fromPath: layer.namespace.fromPath ?? draft.namespace.fromPath
2981
3557
  };
2982
3558
  mark("namespace");
@@ -3038,6 +3614,14 @@ function applyLayer(draft, layer, origin, origins) {
3038
3614
  };
3039
3615
  mark("history");
3040
3616
  }
3617
+ if (layer.review !== void 0) {
3618
+ draft.review = {
3619
+ enabled: layer.review.enabled ?? draft.review.enabled,
3620
+ file: layer.review.file ?? draft.review.file,
3621
+ author: layer.review.author ?? draft.review.author
3622
+ };
3623
+ mark("review");
3624
+ }
3041
3625
  if (layer.pluginConfigs !== void 0) {
3042
3626
  draft.pluginConfigs = {
3043
3627
  ...draft.pluginConfigs,
@@ -3081,8 +3665,8 @@ function createLoader(options) {
3081
3665
  if (option === false || option === void 0) return void 0;
3082
3666
  if (typeof option === "object" && !Array.isArray(option)) return option;
3083
3667
  const [specifier, pluginOptions] = typeof option === "string" ? [option, void 0] : [option[0], option[1]];
3084
- const factory = options.builtins.presets[specifier];
3085
- if (factory) return factory(pluginOptions);
3668
+ const entry = options.builtins.presets[specifier];
3669
+ if (entry) return (await entry.load()).default(pluginOptions);
3086
3670
  if (!options.loadPlugin) throw errors.X18N1003({ preset: specifier });
3087
3671
  return options.loadPlugin(specifier, pluginOptions);
3088
3672
  };
@@ -3118,7 +3702,17 @@ async function expandPresets(presets, userPlugins, listed, detectCtx) {
3118
3702
  name: preset.name,
3119
3703
  reason: "after-exclusive"
3120
3704
  });
3121
- else stopped = await flatten(preset, listed);
3705
+ else if ("entry" in preset) {
3706
+ const applies = listed || (preset.entry.detect ? await preset.entry.detect(detectCtx) : false);
3707
+ explanations.push({
3708
+ name: preset.name,
3709
+ reason: applies ? listed ? "listed" : "detected" : "not-detected"
3710
+ });
3711
+ if (!applies) continue;
3712
+ const loaded = (await preset.entry.load()).default();
3713
+ for (const child of loaded.plugins) await flatten(child, false);
3714
+ stopped = preset.entry.exclusive === true || loaded.exclusive === true;
3715
+ } else stopped = await flatten(preset, listed);
3122
3716
  for (const plugin of userPlugins) await flatten(plugin, true);
3123
3717
  return {
3124
3718
  plugins,
@@ -3141,7 +3735,10 @@ async function resolveConfig(options) {
3141
3735
  const userPlugins = await loadAll(load, [...options.parent?.plugins ?? [], ...user.plugins ?? []]);
3142
3736
  const presetOption = user.presets ?? options.parent?.presets ?? "auto";
3143
3737
  const auto = presetOption === "auto";
3144
- const presets = auto ? Object.values(builtins.presets).map((factory) => factory()) : (await loadAll(load, presetOption)).map((item) => isPreset(item) ? item : {
3738
+ const presets = auto ? Object.entries(builtins.presets).map(([name, entry]) => ({
3739
+ name,
3740
+ entry
3741
+ })) : (await loadAll(load, presetOption)).map((item) => isPreset(item) ? item : {
3145
3742
  name: item.name,
3146
3743
  plugins: [item]
3147
3744
  });
@@ -3182,4 +3779,4 @@ async function resolveConfig(options) {
3182
3779
  };
3183
3780
  }
3184
3781
  //#endregion
3185
- export { ConfigSchema, PluginFileSchema, applyMessageOps, applyTextEdits, buildTree, contractVersion, createLineIndex, createMemoryHost, createOverlayHost, createPlaceholderComparer, createProject, createRegistry, defineConfig, definePlugin, definePreset, diffText, envelope, errors, extensionOf, globToRegExp, hashText, isJsonObject, isKeyUnder, isMessageLeaf, isMessageTree, isPreset, isX18nError, joinKey, keyMatcher, localeSpellingKey, parseLocaleInfo, parseMessageBatch, pathId, pathMatcher, placeholderProblems, rangeContains, renderPrompt, replaceKeyPrefix, resolveConfig, sortTree, splitKey, toErrorInfo, toWireDiagnostic, toWireRange, validate, walkLeaves };
3782
+ 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 };