@x18n/core 0.1.8 → 0.1.10

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.d.mts CHANGED
@@ -561,6 +561,8 @@ interface CheckContext {
561
561
  readonly usages: () => Iterable<Usage>;
562
562
  readonly coverage: () => readonly LocaleCoverage[];
563
563
  readonly unused: () => readonly KeyString[];
564
+ /** Fully dynamic usages in a scope (`keyPrefix`, namespace) that may reach the key. `planUnused` keeps a key they reach. */
565
+ readonly dynamicUsages: (key: KeyString) => readonly Usage[];
564
566
  /** Usages whose key is not defined in the source locale. */
565
567
  readonly undefinedUsages: () => readonly Usage[];
566
568
  readonly placeholders: (value: string) => readonly Placeholder[];
@@ -597,6 +599,20 @@ interface ReportInput {
597
599
  readonly diagnostics: readonly Diagnostic[];
598
600
  readonly coverage: readonly LocaleCoverage[];
599
601
  readonly summary: ReportSummary;
602
+ /** Set with `x18n check --fix`; `diagnostics` are then the ones left after the fixes (or before them, in a dry run). */
603
+ readonly fix?: FixSummary | undefined;
604
+ }
605
+ /** What `x18n check --fix` did in a project. */
606
+ interface FixSummary {
607
+ /** False in a dry run. */
608
+ readonly applied: boolean;
609
+ /** Diagnostics whose fix is in the plan. */
610
+ readonly fixed: number;
611
+ /** Diagnostics left out because their only bulk fix is `unsafe` (pass `--fix=unsafe`). */
612
+ readonly skipped: number;
613
+ /** Files the plan writes, relative to the project root. */
614
+ readonly files: readonly string[];
615
+ readonly warnings: readonly string[];
600
616
  }
601
617
  /** What a check baseline left out (`x18n check --baseline`). */
602
618
  interface BaselineSummary {
@@ -650,6 +666,29 @@ interface ResourceTransform {
650
666
  /** Descriptions that the stored tree holds beside the messages, such as `{ "name": { "message": "...", "description": "..." } }`, keyed by `pathId(storedPath)`. */
651
667
  readonly describe?: ((tree: MessageTree, ctx: ResourceTransformContext) => ReadonlyMap<string, string>) | undefined;
652
668
  }
669
+ /** A review edit of a plan, after it is applied to the review data. */
670
+ interface ReviewEdit {
671
+ /** The review type of a new comment, or the op that resolved or applied one. */
672
+ readonly type: ReviewType | "resolve-review" | "apply-suggestion";
673
+ readonly key: KeyString;
674
+ readonly locale: LocaleCode;
675
+ /** The new comment, or the comment that was resolved or applied. */
676
+ readonly comment: ReviewComment;
677
+ /** The thread of the message after the edit, oldest first. */
678
+ readonly comments: readonly ReviewComment[];
679
+ }
680
+ interface ReviewEditContext {
681
+ /** A message before the plan. */
682
+ readonly message: (key: KeyString, locale: LocaleCode) => Message | undefined;
683
+ readonly resource: (id: ResourceId) => Resource | undefined;
684
+ /** The text of a project file as the plan leaves it so far, including earlier writes of `reviewEdit` hooks. */
685
+ readonly text: (path: string) => string | undefined;
686
+ }
687
+ /** The full new content of a file, which core puts into the plan. */
688
+ interface FileWrite {
689
+ readonly path: string;
690
+ readonly content: string;
691
+ }
653
692
  interface PromptDraft {
654
693
  readonly rules: string[];
655
694
  readonly notes: string[];
@@ -688,6 +727,8 @@ interface Plugin {
688
727
  readonly derivedKeys?: readonly string[] | undefined;
689
728
  readonly transformResource?: ResourceTransform | undefined;
690
729
  readonly translatePrompt?: ((draft: PromptDraft, ctx: PromptContext) => void) | undefined;
730
+ /** Called for each review edit of a plan, in op order. The writes it returns go into the same plan. */
731
+ readonly reviewEdit?: ((edit: ReviewEdit, ctx: ReviewEditContext) => readonly FileWrite[] | undefined) | undefined;
691
732
  /** Display data for a locale code. The first plugin that returns a value wins. */
692
733
  readonly localeInfo?: ((code: LocaleCode) => Partial<LocaleInfo> | undefined) | undefined;
693
734
  }
@@ -917,7 +958,7 @@ interface SerializableConfig {
917
958
  } | undefined;
918
959
  /** Turn every warning into an error. */
919
960
  readonly strict?: boolean | undefined;
920
- /** Keys (globs) excluded from all checks. */
961
+ /** Keys (globs) excluded from all checks. `x18n.config.ts` also takes regular expressions. */
921
962
  readonly ignoreKeys?: readonly string[] | undefined;
922
963
  readonly glossary?: readonly GlossaryEntry[] | undefined;
923
964
  /** History providers for stale translations. `false` turns every provider off. Options of a provider sit under its key, such as `git: { maxDepth, since }` (or `git: false`). */
@@ -940,9 +981,13 @@ interface HistorySettings extends HistoryOptions {
940
981
  }
941
982
  type PresetOption = Preset | PluginSpecifier | false | undefined;
942
983
  type PluginOption = Plugin | Preset | PluginSpecifier | false | undefined;
984
+ /** A key glob (`*` inside a segment, `**` across segments), or a regular expression tested against the whole key. */
985
+ type KeyPattern = string | RegExp;
943
986
  /** The config accepted by `defineConfig`: the serializable part plus plugin objects. */
944
- interface UserConfig extends Omit<SerializableConfig, "presets" | "plugins" | "checks" | "history"> {
987
+ interface UserConfig extends Omit<SerializableConfig, "presets" | "plugins" | "checks" | "history" | "ignoreKeys"> {
945
988
  readonly presets?: "auto" | readonly PresetOption[] | undefined;
989
+ /** Keys excluded from all checks: globs, or regular expressions tested against the whole key (`ns:a.b` when namespaced). */
990
+ readonly ignoreKeys?: readonly KeyPattern[] | undefined;
946
991
  /** History providers for stale translations: `false` turns them off; provider options such as `git` sit under their key; `providers` adds providers asked first. */
947
992
  readonly history?: boolean | (HistoryOptions & {
948
993
  readonly providers?: readonly HistoryProvider[] | undefined;
@@ -998,7 +1043,7 @@ interface ResolvedConfig {
998
1043
  readonly [checkId: string]: CheckSetting;
999
1044
  };
1000
1045
  readonly strict: boolean;
1001
- readonly ignoreKeys: readonly string[];
1046
+ readonly ignoreKeys: readonly KeyPattern[];
1002
1047
  readonly glossary: readonly GlossaryEntry[];
1003
1048
  readonly history: false | HistorySettings;
1004
1049
  readonly review: {
@@ -1128,11 +1173,13 @@ interface Usage {
1128
1173
  readonly scopePrefix: string | undefined;
1129
1174
  }
1130
1175
  type Severity = "error" | "warning" | "info" | "hint";
1176
+ /** A `set` with `replace: false` is skipped, with a warning, when it would replace other keys or a message on its path. */
1131
1177
  type EditOp = {
1132
1178
  readonly type: "set";
1133
1179
  readonly key: KeyString;
1134
1180
  readonly locale: LocaleCode;
1135
1181
  readonly value: MessageLeaf;
1182
+ readonly replace?: boolean;
1136
1183
  } | {
1137
1184
  readonly type: "rename";
1138
1185
  readonly from: KeyString;
@@ -1186,9 +1233,15 @@ interface ExtractItem {
1186
1233
  /** Lines to add once per file when missing, for example an import. */
1187
1234
  readonly imports?: readonly string[];
1188
1235
  }
1236
+ /**
1237
+ * How `x18n check --fix` may apply a fix. `safe` fixes only add, so `--fix` applies them; `unsafe` fixes can lose data, so
1238
+ * only `--fix=unsafe` applies them. A fix without a level is offered in the editor and never applied in bulk.
1239
+ */
1240
+ type FixLevel = "safe" | "unsafe";
1189
1241
  interface Fix {
1190
1242
  readonly title: string;
1191
1243
  readonly ops: readonly EditOp[];
1244
+ readonly level?: FixLevel | undefined;
1192
1245
  }
1193
1246
  interface Location {
1194
1247
  readonly path: string;
@@ -1283,8 +1336,11 @@ export declare function replaceKeyPrefix(key: KeyString, from: KeyString, to: Ke
1283
1336
  * `{a,b}` alternates, `[...]` is a character class.
1284
1337
  */
1285
1338
  export declare function globToRegExp(glob: string, separator?: string): RegExp;
1286
- /** Matcher over key globs: `*` stays inside a segment, `**` spans segments. With literal keys (`separator: false`) `*` spans the whole key. */
1287
- export declare function keyMatcher(globs: readonly string[], separator?: KeySeparator): (key: KeyString) => boolean;
1339
+ /**
1340
+ * Matcher over key patterns. In a glob `*` stays inside a segment and `**` spans segments; with literal keys
1341
+ * (`separator: false`) `*` spans the whole key. A regular expression is tested against the whole key as is.
1342
+ */
1343
+ export declare function keyMatcher(patterns: readonly KeyPattern[], separator?: KeySeparator): (key: KeyString) => boolean;
1288
1344
  /** Matcher over posix file paths. */
1289
1345
  export declare function pathMatcher(globs: readonly string[]): (path: string) => boolean;
1290
1346
  //#endregion
@@ -1627,6 +1683,20 @@ interface UnusedDeletion {
1627
1683
  /** Sorted by key. */
1628
1684
  readonly kept: readonly KeptUnusedKey[];
1629
1685
  }
1686
+ /** Which fixes `planFixes` applies. */
1687
+ interface FixQuery {
1688
+ /** Also apply `unsafe` fixes (`x18n check --fix=unsafe`). */
1689
+ readonly unsafe?: boolean | undefined;
1690
+ }
1691
+ /** The fixes of many diagnostics, as one plan: review it (dry run), then pass `plan` to `apply`. */
1692
+ interface FixPlan {
1693
+ /** Deletes first, then the other ops, each planned once. */
1694
+ readonly plan: Plan;
1695
+ /** The diagnostics whose fix is in the plan. */
1696
+ readonly fixed: readonly Diagnostic[];
1697
+ /** Diagnostics left out because their only bulk fix is `unsafe` and the query does not allow it. */
1698
+ readonly skipped: readonly Diagnostic[];
1699
+ }
1630
1700
  interface ProjectExplanation {
1631
1701
  readonly name: string;
1632
1702
  readonly root: string;
@@ -1772,6 +1842,8 @@ interface Project {
1772
1842
  readonly plan: (ops: readonly EditOp[]) => Plan;
1773
1843
  /** Plans the deletion of the unused keys (`analyze().unused`), minus `ignoreKeys` and keys a scoped fully dynamic usage may reach. */
1774
1844
  readonly planUnused: (query?: UnusedQuery) => UnusedDeletion;
1845
+ /** Plans the first fix of each diagnostic that has a `safe` fix, or also an `unsafe` one with `unsafe: true` (`x18n check --fix`). */
1846
+ readonly planFixes: (diagnostics: readonly Diagnostic[], query?: FixQuery) => FixPlan;
1775
1847
  readonly apply: (plan: Plan, options?: {
1776
1848
  readonly answers?: Answers | undefined;
1777
1849
  }) => Promise<ApplyResult>;
@@ -1908,10 +1980,14 @@ interface LineIndex {
1908
1980
  readonly line: (line: number) => string;
1909
1981
  readonly lineCount: number;
1910
1982
  }
1911
- /** Line index of a text, cached for the few texts used at a time (reporters, LSP ranges). */
1983
+ /**
1984
+ * Line index of a text, cached for the few texts used at a time (reporters, LSP ranges).
1985
+ * The least recently used text goes first, so a large resource hit by many diagnostics stays
1986
+ * cached while ranges in many small source files pass through.
1987
+ */
1912
1988
  export declare function createLineIndex(text: string): LineIndex;
1913
1989
  export declare function rangeContains(range: TextRange, offset: number): boolean;
1914
1990
  /** Fast non-cryptographic string hash (cyrb53), as hex. */
1915
1991
  export declare function hashText(text: string): string;
1916
1992
  //#endregion
1917
- export type { Analysis, Answers, ApplyResult, ArgSource, BaselineSummary, Builtins, CacheStore, CallSpec, CapabilityConflict, Check, CheckContext, CheckIds, CheckLevel, CheckSetting, ConfigContext, ConfigDraft, ConfigOrigin, CreateProjectOptions, Decision, DecisionOption, DetectContext, Diagnostic, DiagnosticInput, Disposable, EditOp, Envelope, ErrorCode, ErrorInfo, ExtractItem, ExtractOptions, ExtractTemplate, Extractor, FileChange, FileEvent, FileScan, FinderContext, Fix, Format, FormatContext, FormatEntry, GlobOptions, GlossaryEntry, HardString, HistoryConfigs, HistoryContext, HistoryOptions, HistoryProvider, HistorySettings, Host, JsonObject, JsonPrimitive, JsonValue, KeptUnusedKey, KeyContext, KeyFile, KeyId, KeyInfo, KeyQuery, KeySeparator, KeyString, KeyTarget, LazyFormat, LazyScanner, LibraryOptions, LineIndex, LinePosition, LocaleCode, LocaleCoverage, LocaleFinder, LocaleInfo, Location, Logger, ManifestReader, MemoryHost, Message, MessageChange, MessageHistory, MessageLeaf, MessageOp, MessageState, MessageTree, NamespaceFromPath, NamespaceOptions, NodeRange, OverlayHost, ParamsSource, ParseResult, Placeholder, PlaceholderComparer, PlaceholderProblem, PlaceholderSyntax, Plan, Plugin, PluginConfigs, PluginFile, PluginLoader, PluginOption, PluginRegistry, PluginSpecifier, PluralForm, Preset, PresetDetection, PresetEntry, PresetExplanation, PresetFactory, PresetLoader, PresetOption, Project, ProjectChange, ProjectExplanation, PromptContext, PromptData, PromptDraft, PromptIssue, PromptItem, PromptOptions, RegexScannerSpec, RelatedValue, ReportInput, ReportSummary, Reporter, ReporterContext, ReporterLoader, ResolveConfigOptions, ResolvedConfig, Resource, ResourceBlock, ResourceCandidate, ResourceId, ResourceNode, ResourceSpec, ResourceTransform, ResourceTransformContext, ReviewAuthor, ReviewComment, ReviewData, ReviewFile, ReviewFilter, ReviewOptions, ReviewState, ReviewStore, ReviewThread, ReviewType, ScanContext, ScanResult, Scanner, ScannerEntry, SchemaIssue, Scope, SearchMatch, SearchOptions, SerializableConfig, Setup, Severity, SourceFile, TextEdit, TextRange, ThreadComment, TreeLeaf, UnusedDeletion, UnusedKeyDeletion, UnusedQuery, Usage, UsageCandidate, UsageContext, UsageKind, UsageMatch, UsageOptions, UsageQuery, UserConfig, WireDiagnostic, WireRange, WriteOptions, X18nError };
1993
+ export type { Analysis, Answers, ApplyResult, ArgSource, BaselineSummary, Builtins, CacheStore, CallSpec, CapabilityConflict, Check, CheckContext, CheckIds, CheckLevel, CheckSetting, ConfigContext, ConfigDraft, ConfigOrigin, CreateProjectOptions, Decision, DecisionOption, DetectContext, Diagnostic, DiagnosticInput, Disposable, EditOp, Envelope, ErrorCode, ErrorInfo, ExtractItem, ExtractOptions, ExtractTemplate, Extractor, FileChange, FileEvent, FileScan, FileWrite, FinderContext, Fix, FixLevel, FixPlan, FixQuery, FixSummary, Format, FormatContext, FormatEntry, GlobOptions, GlossaryEntry, HardString, HistoryConfigs, HistoryContext, HistoryOptions, HistoryProvider, HistorySettings, Host, JsonObject, JsonPrimitive, JsonValue, KeptUnusedKey, KeyContext, KeyFile, KeyId, KeyInfo, KeyPattern, KeyQuery, KeySeparator, KeyString, KeyTarget, LazyFormat, LazyScanner, LibraryOptions, LineIndex, LinePosition, LocaleCode, LocaleCoverage, LocaleFinder, LocaleInfo, Location, Logger, ManifestReader, MemoryHost, Message, MessageChange, MessageHistory, MessageLeaf, MessageOp, MessageState, MessageTree, NamespaceFromPath, NamespaceOptions, NodeRange, OverlayHost, ParamsSource, ParseResult, Placeholder, PlaceholderComparer, PlaceholderProblem, PlaceholderSyntax, Plan, Plugin, PluginConfigs, PluginFile, PluginLoader, PluginOption, PluginRegistry, PluginSpecifier, PluralForm, Preset, PresetDetection, PresetEntry, PresetExplanation, PresetFactory, PresetLoader, PresetOption, Project, ProjectChange, ProjectExplanation, PromptContext, PromptData, PromptDraft, PromptIssue, PromptItem, PromptOptions, RegexScannerSpec, RelatedValue, ReportInput, ReportSummary, Reporter, ReporterContext, ReporterLoader, ResolveConfigOptions, ResolvedConfig, Resource, ResourceBlock, ResourceCandidate, ResourceId, ResourceNode, ResourceSpec, ResourceTransform, ResourceTransformContext, ReviewAuthor, ReviewComment, ReviewData, ReviewEdit, ReviewEditContext, ReviewFile, ReviewFilter, ReviewOptions, ReviewState, ReviewStore, ReviewThread, ReviewType, ScanContext, ScanResult, Scanner, ScannerEntry, SchemaIssue, Scope, SearchMatch, SearchOptions, SerializableConfig, Setup, Severity, SourceFile, TextEdit, TextRange, ThreadComment, TreeLeaf, UnusedDeletion, UnusedKeyDeletion, UnusedQuery, Usage, UsageCandidate, UsageContext, UsageKind, UsageMatch, UsageOptions, UsageQuery, UserConfig, WireDiagnostic, WireRange, WriteOptions, X18nError };
package/dist/index.mjs CHANGED
@@ -29,14 +29,19 @@ function applyTextEdits(text, edits) {
29
29
  return out;
30
30
  }
31
31
  const lineIndexes = /* @__PURE__ */ new Map();
32
- /** Line index of a text, cached for the few texts used at a time (reporters, LSP ranges). */
32
+ /**
33
+ * Line index of a text, cached for the few texts used at a time (reporters, LSP ranges).
34
+ * The least recently used text goes first, so a large resource hit by many diagnostics stays
35
+ * cached while ranges in many small source files pass through.
36
+ */
33
37
  function createLineIndex(text) {
34
38
  let index = lineIndexes.get(text);
35
- if (!index) {
39
+ if (index) lineIndexes.delete(text);
40
+ else {
36
41
  if (lineIndexes.size >= 16) lineIndexes.delete(lineIndexes.keys().next().value);
37
42
  index = buildLineIndex(text);
38
- lineIndexes.set(text, index);
39
43
  }
44
+ lineIndexes.set(text, index);
40
45
  return index;
41
46
  }
42
47
  function buildLineIndex(text) {
@@ -379,10 +384,13 @@ function globToRegExp(glob, separator = "/") {
379
384
  }
380
385
  return new RegExp(`^${out}$`);
381
386
  }
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 = ".") {
384
- if (globs.length === 0) return () => false;
385
- const regexes = globs.map((glob) => globToRegExp(glob, separator === false ? "\0" : separator));
387
+ /**
388
+ * Matcher over key patterns. In a glob `*` stays inside a segment and `**` spans segments; with literal keys
389
+ * (`separator: false`) `*` spans the whole key. A regular expression is tested against the whole key as is.
390
+ */
391
+ function keyMatcher(patterns, separator = ".") {
392
+ if (patterns.length === 0) return () => false;
393
+ const regexes = patterns.map((pattern) => pattern instanceof RegExp ? new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, "")) : globToRegExp(pattern, separator === false ? "\0" : separator));
386
394
  return (key) => regexes.some((re) => re.test(key));
387
395
  }
388
396
  /** Matcher over posix file paths. */
@@ -621,11 +629,15 @@ function deleteIn(tree, path) {
621
629
  delete parents[i - 1][path[i - 1]];
622
630
  }
623
631
  }
632
+ /** Applies ops to the tree itself. Parent objects left empty are removed. */
633
+ function applyMessageOpsInPlace(tree, ops) {
634
+ for (const op of ops) if (op.type === "set") setIn(tree, op.path, op.value);
635
+ else deleteIn(tree, op.path);
636
+ }
624
637
  /** Applies ops to a copy of the tree. Parent objects left empty are removed. */
625
638
  function applyMessageOps(tree, ops) {
626
639
  const out = cloneTree(tree);
627
- for (const op of ops) if (op.type === "set") setIn(out, op.path, op.value);
628
- else deleteIn(out, op.path);
640
+ applyMessageOpsInPlace(out, ops);
629
641
  return out;
630
642
  }
631
643
  /** Sorts keys at every level. */
@@ -1112,6 +1124,9 @@ function renderExplanation(e, configFile) {
1112
1124
  return lines.join("\n");
1113
1125
  }
1114
1126
  //#endregion
1127
+ //#region package.json
1128
+ var version = "0.1.10";
1129
+ //#endregion
1115
1130
  //#region src/project/history.ts
1116
1131
  const localChange = { updatedAt: Number.POSITIVE_INFINITY };
1117
1132
  /** A target message is stale when it last changed before its source message. */
@@ -1165,6 +1180,90 @@ function createHistory(input) {
1165
1180
  };
1166
1181
  }
1167
1182
  //#endregion
1183
+ //#region src/project/unused.ts
1184
+ /**
1185
+ * True when a fully dynamic usage (`t(key)`) may reach `key`: the key lies in the usage's scope namespace and under its
1186
+ * scope key prefix. Undefined for a usage without a scope, which may reach any key.
1187
+ */
1188
+ function scopeMatcher(usage, namespaced) {
1189
+ const namespace = namespaced ? usage.scopeNamespace : void 0;
1190
+ const prefix = usage.scopePrefix;
1191
+ if (namespace === void 0 && !prefix) return void 0;
1192
+ return (key) => {
1193
+ const id = splitKey(key, namespaced);
1194
+ if (namespace !== void 0 && id.namespace !== namespace) return false;
1195
+ if (!prefix) return true;
1196
+ const path = id.path.join(".");
1197
+ return path === prefix || path.startsWith(`${prefix}.`);
1198
+ };
1199
+ }
1200
+ function dynamicReach(usages, namespaced) {
1201
+ const scoped = [];
1202
+ const unscoped = [];
1203
+ for (const usage of usages) {
1204
+ if (usage.dynamic !== "unknown") continue;
1205
+ const reaches = scopeMatcher(usage, namespaced);
1206
+ if (reaches) scoped.push({
1207
+ usage,
1208
+ reaches
1209
+ });
1210
+ else unscoped.push(usage);
1211
+ }
1212
+ return {
1213
+ reaching: (key) => scoped.filter((s) => s.reaches(key)).map((s) => s.usage),
1214
+ unscoped
1215
+ };
1216
+ }
1217
+ /** The plan warning for deleting unused keys while fully dynamic usages without a scope may reach any key. */
1218
+ function unscopedWarning(unscoped) {
1219
+ const files = [...new Set(unscoped.map((usage) => usage.path))].sort();
1220
+ const shown = files.length > 5 ? `${files.slice(0, 5).join(", ")} and ${files.length - 5} more files` : files.join(", ");
1221
+ return `${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.`;
1222
+ }
1223
+ function planUnusedDeletion(options, query = {}) {
1224
+ const ignored = keyMatcher(options.ignoreKeys);
1225
+ const match = query.glob ? keyMatcher([query.glob]) : void 0;
1226
+ 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();
1227
+ const reach = dynamicReach(options.usages, options.namespaced);
1228
+ const keys = [];
1229
+ const kept = [];
1230
+ for (const key of candidates) {
1231
+ const usages = reach.reaching(key);
1232
+ if (usages.length > 0) {
1233
+ kept.push({
1234
+ key,
1235
+ usages
1236
+ });
1237
+ continue;
1238
+ }
1239
+ keys.push({
1240
+ key,
1241
+ messages: options.locations(key).filter((node) => node.leaf).map((node) => ({
1242
+ locale: node.locale,
1243
+ path: node.path
1244
+ }))
1245
+ });
1246
+ }
1247
+ const plan = options.plan(keys.map(({ key }) => ({
1248
+ type: "delete",
1249
+ key,
1250
+ locales: "all"
1251
+ })));
1252
+ if (reach.unscoped.length === 0 || keys.length === 0) return {
1253
+ plan,
1254
+ keys,
1255
+ kept
1256
+ };
1257
+ return {
1258
+ plan: {
1259
+ ...plan,
1260
+ warnings: [...plan.warnings, unscopedWarning(reach.unscoped)]
1261
+ },
1262
+ keys,
1263
+ kept
1264
+ };
1265
+ }
1266
+ //#endregion
1168
1267
  //#region src/project/usages.ts
1169
1268
  function innermost(scopes, range) {
1170
1269
  let best;
@@ -1503,6 +1602,7 @@ function createAnalyzer(options) {
1503
1602
  return prefixes.some((prefix) => key.startsWith(prefix));
1504
1603
  };
1505
1604
  const unused = hasScanners ? allKeys.filter((key) => !isUsed(key)) : [];
1605
+ const reach = dynamicReach(state.usages, options.namespaced());
1506
1606
  const undefinedUsages = state.usages.filter((usage) => usage.dynamic === false && usage.key !== void 0 && !isDefined(usage.key));
1507
1607
  const placeholders = (value) => {
1508
1608
  let found = placeholderCache.get(value);
@@ -1530,6 +1630,7 @@ function createAnalyzer(options) {
1530
1630
  usages: () => state.usages,
1531
1631
  coverage: () => coverage,
1532
1632
  unused: () => unused,
1633
+ dynamicUsages: reach.reaching,
1533
1634
  undefinedUsages: () => undefinedUsages,
1534
1635
  placeholders,
1535
1636
  placeholderSyntaxes: registry.placeholders,
@@ -1565,6 +1666,55 @@ function createAnalyzer(options) {
1565
1666
  return { analyze };
1566
1667
  }
1567
1668
  //#endregion
1669
+ //#region src/project/fixes.ts
1670
+ /** The first fix of a diagnostic that the query allows: `safe`, or also `unsafe` with `unsafe: true`. */
1671
+ function pick(diagnostic, unsafe) {
1672
+ return diagnostic.fixes.find((fix) => fix.level === "safe" || unsafe && fix.level === "unsafe");
1673
+ }
1674
+ /** Identifies ops that do the same: a second `set` of a key in a locale is dropped, so the first value wins. */
1675
+ function opId(op) {
1676
+ return op.type === "set" ? JSON.stringify([
1677
+ op.type,
1678
+ op.key,
1679
+ op.locale
1680
+ ]) : JSON.stringify(op);
1681
+ }
1682
+ /**
1683
+ * Plans the fixes of many diagnostics as one plan. Deletes go first, so a deleted message makes room for a key that a
1684
+ * later `set` with `replace: false` adds under it. Identical ops are planned once.
1685
+ */
1686
+ function planFixes(diagnostics, options, query = {}) {
1687
+ const unsafe = query.unsafe === true;
1688
+ const fixed = [];
1689
+ const skipped = [];
1690
+ const deletes = /* @__PURE__ */ new Map();
1691
+ const others = /* @__PURE__ */ new Map();
1692
+ for (const diagnostic of diagnostics) {
1693
+ const fix = pick(diagnostic, unsafe);
1694
+ if (!fix) {
1695
+ if (diagnostic.fixes.some((f) => f.level === "unsafe")) skipped.push(diagnostic);
1696
+ continue;
1697
+ }
1698
+ fixed.push(diagnostic);
1699
+ for (const op of fix.ops) {
1700
+ const ops = op.type === "delete" ? deletes : others;
1701
+ const id = opId(op);
1702
+ if (!ops.has(id)) ops.set(id, op);
1703
+ }
1704
+ }
1705
+ const plan = options.plan([...deletes.values(), ...others.values()]);
1706
+ const deletesEverywhere = [...deletes.values()].some((op) => op.type === "delete" && (op.locales === void 0 || op.locales === "all"));
1707
+ const { unscoped } = dynamicReach(options.usages, options.namespaced);
1708
+ return {
1709
+ plan: deletesEverywhere && unscoped.length > 0 ? {
1710
+ ...plan,
1711
+ warnings: [...plan.warnings, unscopedWarning(unscoped)]
1712
+ } : plan,
1713
+ fixed,
1714
+ skipped
1715
+ };
1716
+ }
1717
+ //#endregion
1568
1718
  //#region src/project/reviews.ts
1569
1719
  const noReviews = {
1570
1720
  threads: {},
@@ -1736,6 +1886,10 @@ function nodeAt(tree, path) {
1736
1886
  }
1737
1887
  return node;
1738
1888
  }
1889
+ function leafAt(tree, path) {
1890
+ const node = nodeAt(tree, path);
1891
+ return isMessageLeaf(node) ? node : void 0;
1892
+ }
1739
1893
  /** True for flat files (`{ "a.b": "x" }`), false for nested ones, undefined when it cannot tell. */
1740
1894
  function isFlatTree(tree, separator) {
1741
1895
  const entries = Object.entries(tree);
@@ -1795,6 +1949,7 @@ var PlanBuilder = class {
1795
1949
  config;
1796
1950
  /** Source values set by this plan, for the source records of targets set after them. */
1797
1951
  plannedSource = /* @__PURE__ */ new Map();
1952
+ reviewEdits = [];
1798
1953
  draft;
1799
1954
  constructor(options, state, answers) {
1800
1955
  this.options = options;
@@ -1822,7 +1977,7 @@ var PlanBuilder = class {
1822
1977
  if (!entry) {
1823
1978
  entry = {
1824
1979
  target,
1825
- tree: target.state?.rawTree ?? {},
1980
+ tree: cloneTree(target.state?.rawTree ?? {}),
1826
1981
  ops: [],
1827
1982
  keyRenames: [],
1828
1983
  formatOnly: false
@@ -1866,7 +2021,8 @@ var PlanBuilder = class {
1866
2021
  const stored = this.options.toStored(entry.target, relative);
1867
2022
  const separator = this.separatorOf(entry.target);
1868
2023
  if (separator === false) return stored;
1869
- return stored.length === relative.length && (isFlatTree(entry.tree, separator) ?? this.config.keyStyle === "flat") ? [stored.join(separator)] : stored;
2024
+ if (stored.length === relative.length) entry.flat ??= isFlatTree(entry.tree, separator);
2025
+ return stored.length === relative.length && (entry.flat ?? this.config.keyStyle === "flat") ? [stored.join(separator)] : stored;
1870
2026
  }
1871
2027
  createTarget(key, locale) {
1872
2028
  const { resources, sourceLocale, namespaced } = this.state;
@@ -1979,30 +2135,63 @@ var PlanBuilder = class {
1979
2135
  /** ---- Message ops ---- */
1980
2136
  pushOp(entry, op) {
1981
2137
  entry.ops.push(op);
1982
- entry.tree = applyMessageOps(entry.tree, [op]);
2138
+ this.applyOps(entry, [op]);
2139
+ }
2140
+ /** Applies ops to the entry's tree in place; drops `flat` when a top-level object appears or goes, or a top-level key goes. */
2141
+ applyOps(entry, ops) {
2142
+ for (const op of ops) {
2143
+ const top = op.path[0];
2144
+ const before = entry.tree[top];
2145
+ applyMessageOpsInPlace(entry.tree, [op]);
2146
+ const after = entry.tree[top];
2147
+ if ((before !== void 0 && !isMessageLeaf(before)) !== (after !== void 0 && !isMessageLeaf(after)) || before !== void 0 && after === void 0) entry.flat = void 0;
2148
+ }
1983
2149
  }
1984
- setValue(key, locale, value) {
1985
- this.touched.add(key);
2150
+ /**
2151
+ * Returns the value the set replaced in `before`. Undefined when `replace` is false and the value would replace other keys
2152
+ * or a message on its path: the set is then skipped with a warning.
2153
+ */
2154
+ setValue(key, locale, value, replace = true) {
1986
2155
  const existing = this.options.message(key, locale);
1987
2156
  if (existing) {
1988
- this.pushOp(this.workFor(this.targetOf(this.writable(existing.resource))), {
2157
+ this.touched.add(key);
2158
+ const entry = this.workFor(this.targetOf(this.writable(existing.resource)));
2159
+ const before = leafAt(entry.tree, existing.storedPath);
2160
+ this.pushOp(entry, {
1989
2161
  type: "set",
1990
2162
  path: existing.storedPath,
1991
2163
  value
1992
2164
  });
1993
- return;
2165
+ return { before };
1994
2166
  }
1995
2167
  const target = this.pickTarget(key, locale);
1996
2168
  if (target.state) this.writable(target.state.resource.id);
1997
2169
  const entry = this.workFor(target);
1998
2170
  const stored = this.storedPathFor(entry, this.relativePath(key, target));
1999
2171
  const occupied = nodeAt(entry.tree, stored);
2000
- if (occupied !== void 0 && !isMessageLeaf(occupied)) this.warnings.push(`"${key}" is a parent of other keys in ${target.path}; they are replaced.`);
2172
+ const parent = occupied !== void 0 && !isMessageLeaf(occupied);
2173
+ if (!replace) {
2174
+ const leaf = stored.slice(0, -1).findIndex((_, i) => {
2175
+ const node = nodeAt(entry.tree, stored.slice(0, i + 1));
2176
+ return node !== void 0 && isMessageLeaf(node);
2177
+ });
2178
+ if (leaf !== -1) {
2179
+ const message = stored.slice(0, leaf + 1).join(keyJoiner(this.separatorOf(target)));
2180
+ this.warnings.push(`"${key}" is not added: "${message}" is a message in ${target.path}, and adding "${key}" would replace it.`);
2181
+ return;
2182
+ }
2183
+ if (parent) {
2184
+ this.warnings.push(`"${key}" is not added: it is a parent of other keys in ${target.path}, and adding it would replace them.`);
2185
+ return;
2186
+ }
2187
+ } else if (parent) this.warnings.push(`"${key}" is a parent of other keys in ${target.path}; they are replaced.`);
2188
+ this.touched.add(key);
2001
2189
  this.pushOp(entry, {
2002
2190
  type: "set",
2003
2191
  path: stored,
2004
2192
  value
2005
2193
  });
2194
+ return { before: isMessageLeaf(occupied) ? occupied : void 0 };
2006
2195
  }
2007
2196
  deleteMessage(m) {
2008
2197
  const s = this.writable(m.resource);
@@ -2046,7 +2235,7 @@ var PlanBuilder = class {
2046
2235
  text: last
2047
2236
  });
2048
2237
  const leaves = [...walkLeaves(node, stored)];
2049
- entry.tree = applyMessageOps(entry.tree, [...leaves.map((leaf) => ({
2238
+ this.applyOps(entry, [...leaves.map((leaf) => ({
2050
2239
  type: "delete",
2051
2240
  path: leaf.path
2052
2241
  })), ...leaves.map((leaf) => ({
@@ -2061,45 +2250,54 @@ var PlanBuilder = class {
2061
2250
  moveKey(from, to, renamed = /* @__PURE__ */ new Set()) {
2062
2251
  this.touched.add(from);
2063
2252
  this.touched.add(to);
2064
- for (const [locale, list] of this.options.messages(from) ?? []) {
2065
- const m = list[0];
2253
+ for (const list of this.options.messages(from)?.values() ?? []) for (const m of [...list].reverse()) {
2066
2254
  if (renamed.has(m.resource)) continue;
2067
- const s = this.writable(m.resource);
2068
- const relative = this.relativePath(to, s.resource);
2069
- const own = this.options.resolveKey(to, locale);
2070
- if (!relative || own && own.path !== s.resource.path) {
2071
- this.deleteMessage(m);
2072
- this.setValue(to, locale, m.value);
2073
- continue;
2074
- }
2075
- const entry = this.workFor(this.targetOf(s));
2076
- const separator = this.separatorOf(s.resource);
2077
- const flat = separator !== false && m.storedPath.length === 1 && m.storedPath[0].includes(separator);
2078
- const next = flat ? [relative.join(separator)] : this.options.toStored(s.resource, relative);
2079
- const sameParent = next.length === m.storedPath.length && next.slice(0, -1).every((segment, i) => segment === m.storedPath[i]);
2080
- const last = next.at(-1);
2081
- if (sameParent && m.range && (flat ? safeFlat : safeSegment).test(last)) {
2082
- entry.keyRenames.push({
2083
- range: m.range.key,
2084
- text: last
2085
- });
2086
- entry.tree = applyMessageOps(entry.tree, [{
2087
- type: "delete",
2088
- path: m.storedPath
2089
- }, {
2090
- type: "set",
2091
- path: next,
2092
- value: m.value
2093
- }]);
2094
- continue;
2255
+ const replaced = this.moveMessage(m, to);
2256
+ if (replaced !== void 0 && replaced !== m.value) {
2257
+ const path = this.stateById.get(m.resource)?.resource.path ?? m.resource;
2258
+ this.warnings.push(`Copies of "${from}" in ${m.locale} have different values; "${to}" keeps the one from ${path}.`);
2095
2259
  }
2260
+ }
2261
+ }
2262
+ moveMessage(m, to) {
2263
+ const s = this.writable(m.resource);
2264
+ const relative = this.relativePath(to, s.resource);
2265
+ const own = this.options.resolveKey(to, m.locale);
2266
+ if (!relative || own && own.path !== s.resource.path) {
2096
2267
  this.deleteMessage(m);
2097
- this.pushOp(entry, {
2268
+ return this.setValue(to, m.locale, m.value)?.before;
2269
+ }
2270
+ const entry = this.workFor(this.targetOf(s));
2271
+ const separator = this.separatorOf(s.resource);
2272
+ const flat = separator !== false && m.storedPath.length === 1 && m.storedPath[0].includes(separator);
2273
+ const next = flat ? [relative.join(separator)] : this.options.toStored(s.resource, relative);
2274
+ const sameParent = next.length === m.storedPath.length && next.slice(0, -1).every((segment, i) => segment === m.storedPath[i]);
2275
+ const last = next.at(-1);
2276
+ if (sameParent && m.range && (flat ? safeFlat : safeSegment).test(last)) {
2277
+ const replaced = leafAt(entry.tree, next);
2278
+ entry.keyRenames.push({
2279
+ range: m.range.key,
2280
+ text: last
2281
+ });
2282
+ this.applyOps(entry, [{
2283
+ type: "delete",
2284
+ path: m.storedPath
2285
+ }, {
2098
2286
  type: "set",
2099
- path: this.storedPathFor(entry, relative),
2287
+ path: next,
2100
2288
  value: m.value
2101
- });
2289
+ }]);
2290
+ return replaced;
2102
2291
  }
2292
+ this.deleteMessage(m);
2293
+ const path = this.storedPathFor(entry, relative);
2294
+ const replaced = leafAt(entry.tree, path);
2295
+ this.pushOp(entry, {
2296
+ type: "set",
2297
+ path,
2298
+ value: m.value
2299
+ });
2300
+ return replaced;
2103
2301
  }
2104
2302
  /** The raw text a usage needs to refer to `next`, given its scope. */
2105
2303
  rawFor(usage, next) {
@@ -2239,6 +2437,17 @@ var PlanBuilder = class {
2239
2437
  ...op.review === "suggestion" && { suggestion: op.suggestion },
2240
2438
  ...op.review === "approve" && current !== void 0 && { value: valueText(current) }
2241
2439
  });
2440
+ this.reviewEdited(draft, op.review, op.id);
2441
+ }
2442
+ reviewEdited(draft, type, id) {
2443
+ const { key, locale, comment } = findReview(draft.data, id);
2444
+ this.reviewEdits.push({
2445
+ type,
2446
+ key,
2447
+ locale,
2448
+ comment,
2449
+ comments: draft.data.threads[key][locale]
2450
+ });
2242
2451
  }
2243
2452
  applySuggestion(id) {
2244
2453
  const draft = this.requireReviews();
@@ -2249,6 +2458,7 @@ var PlanBuilder = class {
2249
2458
  this.setValue(key, locale, comment.suggestion);
2250
2459
  this.recordSource(key, locale, true);
2251
2460
  draft.resolve(id);
2461
+ this.reviewEdited(draft, "apply-suggestion", id);
2252
2462
  }
2253
2463
  resolveReview(id) {
2254
2464
  const draft = this.requireReviews();
@@ -2256,6 +2466,7 @@ var PlanBuilder = class {
2256
2466
  if (!found) throw errors.X18N5004({ id });
2257
2467
  this.touched.add(found.key);
2258
2468
  draft.resolve(id);
2469
+ this.reviewEdited(draft, "resolve-review", id);
2259
2470
  }
2260
2471
  markFresh(op) {
2261
2472
  this.requireReviews();
@@ -2265,11 +2476,12 @@ var PlanBuilder = class {
2265
2476
  for (const locale of locales) this.recordSource(op.key, locale, true);
2266
2477
  }
2267
2478
  set(op) {
2268
- this.setValue(op.key, op.locale, op.value);
2479
+ const replace = op.replace !== false;
2480
+ if (!this.setValue(op.key, op.locale, op.value, replace)) return;
2269
2481
  if (op.locale === this.state.sourceLocale) this.plannedSource.set(op.key, op.value);
2270
2482
  else if (op.value !== "") this.recordSource(op.key, op.locale);
2271
2483
  if (!this.config.write.keepFilled) return;
2272
- 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, "");
2484
+ 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, "", replace);
2273
2485
  }
2274
2486
  run(ops) {
2275
2487
  for (const op of ops) switch (op.type) {
@@ -2415,6 +2627,7 @@ var PlanBuilder = class {
2415
2627
  });
2416
2628
  texts.set(path, text);
2417
2629
  }
2630
+ this.reviewWrites(changes, texts);
2418
2631
  const review = this.reviewChange();
2419
2632
  if (review) {
2420
2633
  changes.push(review);
@@ -2425,6 +2638,41 @@ var PlanBuilder = class {
2425
2638
  texts
2426
2639
  };
2427
2640
  }
2641
+ /** Runs the `reviewEdit` hooks and puts their writes into the changes. */
2642
+ reviewWrites(changes, texts) {
2643
+ const hooks = this.options.registry.plugins.flatMap((plugin) => plugin.reviewEdit ? [plugin.reviewEdit] : []);
2644
+ if (hooks.length === 0 || this.reviewEdits.length === 0) return;
2645
+ const written = /* @__PURE__ */ new Map();
2646
+ const text = (path) => {
2647
+ if (written.has(path)) return written.get(path);
2648
+ const change = changes.find((c) => c.path === path);
2649
+ if (!change) return this.options.text(path);
2650
+ return change.type === "create" ? change.content : change.type === "edit" ? applyTextEdits(texts.get(path) ?? "", change.edits) : void 0;
2651
+ };
2652
+ const ctx = {
2653
+ message: this.options.message,
2654
+ resource: (id) => this.stateById.get(id)?.resource,
2655
+ text
2656
+ };
2657
+ for (const edit of this.reviewEdits) for (const hook of hooks) for (const write of hook(edit, ctx) ?? []) written.set(write.path, write.content);
2658
+ for (const [path, content] of written) {
2659
+ const index = changes.findIndex((c) => c.path === path);
2660
+ const before = index === -1 ? this.options.text(path) : texts.get(path);
2661
+ if (index === -1 && before === content) continue;
2662
+ const change = before === void 0 ? {
2663
+ type: "create",
2664
+ path,
2665
+ content
2666
+ } : {
2667
+ type: "edit",
2668
+ path,
2669
+ edits: diffText(before, content)
2670
+ };
2671
+ if (index === -1) changes.push(change);
2672
+ else changes[index] = change;
2673
+ texts.set(path, before);
2674
+ }
2675
+ }
2428
2676
  reviewChange() {
2429
2677
  const reviews = this.state.reviews;
2430
2678
  if (!reviews || !this.draft?.changed) return void 0;
@@ -2443,6 +2691,7 @@ var PlanBuilder = class {
2443
2691
  };
2444
2692
  function createPlanner(options) {
2445
2693
  const texts = /* @__PURE__ */ new Map();
2694
+ let textsRevision;
2446
2695
  /** Gives new reviews an id and a time, so planning again (after decisions) writes the same comments. */
2447
2696
  function withReviewIds(ops, data) {
2448
2697
  const used = /* @__PURE__ */ new Set();
@@ -2506,6 +2755,10 @@ function createPlanner(options) {
2506
2755
  return {
2507
2756
  plan(ops, answers = {}) {
2508
2757
  const result = planWith(ops, answers);
2758
+ if (result.plan.revision !== textsRevision) {
2759
+ texts.clear();
2760
+ textsRevision = result.plan.revision;
2761
+ }
2509
2762
  texts.set(result.plan.id, result.texts);
2510
2763
  return result.plan;
2511
2764
  },
@@ -2748,80 +3001,6 @@ function renderPrompt(data) {
2748
3001
  return out.join("\n");
2749
3002
  }
2750
3003
  //#endregion
2751
- //#region src/project/unused.ts
2752
- /**
2753
- * True when a fully dynamic usage (`t(key)`) may reach `key`: the key lies in the usage's scope namespace and under its
2754
- * scope key prefix. Undefined for a usage without a scope, which may reach any key.
2755
- */
2756
- function scopeMatcher(usage, namespaced) {
2757
- const namespace = namespaced ? usage.scopeNamespace : void 0;
2758
- const prefix = usage.scopePrefix;
2759
- if (namespace === void 0 && !prefix) return void 0;
2760
- return (key) => {
2761
- const id = splitKey(key, namespaced);
2762
- if (namespace !== void 0 && id.namespace !== namespace) return false;
2763
- if (!prefix) return true;
2764
- const path = id.path.join(".");
2765
- return path === prefix || path.startsWith(`${prefix}.`);
2766
- };
2767
- }
2768
- function planUnusedDeletion(options, query = {}) {
2769
- const ignored = keyMatcher(options.ignoreKeys);
2770
- const match = query.glob ? keyMatcher([query.glob]) : void 0;
2771
- const candidates = [...options.unused].filter((key) => !ignored(key) && (match === void 0 || match(key)) && (query.namespace === void 0 || splitKey(key, options.namespaced).namespace === query.namespace)).sort();
2772
- const scoped = [];
2773
- const unscoped = [];
2774
- for (const usage of options.usages) {
2775
- if (usage.dynamic !== "unknown") continue;
2776
- const reaches = scopeMatcher(usage, options.namespaced);
2777
- if (reaches) scoped.push({
2778
- usage,
2779
- reaches
2780
- });
2781
- else unscoped.push(usage);
2782
- }
2783
- const keys = [];
2784
- const kept = [];
2785
- for (const key of candidates) {
2786
- const usages = scoped.filter((s) => s.reaches(key)).map((s) => s.usage);
2787
- if (usages.length > 0) {
2788
- kept.push({
2789
- key,
2790
- usages
2791
- });
2792
- continue;
2793
- }
2794
- keys.push({
2795
- key,
2796
- messages: options.locations(key).filter((node) => node.leaf).map((node) => ({
2797
- locale: node.locale,
2798
- path: node.path
2799
- }))
2800
- });
2801
- }
2802
- const plan = options.plan(keys.map(({ key }) => ({
2803
- type: "delete",
2804
- key,
2805
- locales: "all"
2806
- })));
2807
- if (unscoped.length === 0 || keys.length === 0) return {
2808
- plan,
2809
- keys,
2810
- kept
2811
- };
2812
- const files = [...new Set(unscoped.map((usage) => usage.path))].sort();
2813
- const shown = files.length > 5 ? `${files.slice(0, 5).join(", ")} and ${files.length - 5} more files` : files.join(", ");
2814
- const warning = `${unscoped.length} usage${unscoped.length === 1 ? " has" : "s have"} a fully dynamic key (in ${shown}) that may reach any key; list the keys they use in \`usage.keysInUse\` to keep them.`;
2815
- return {
2816
- plan: {
2817
- ...plan,
2818
- warnings: [...plan.warnings, warning]
2819
- },
2820
- keys,
2821
- kept
2822
- };
2823
- }
2824
- //#endregion
2825
3004
  //#region src/project/project.ts
2826
3005
  const ioConcurrency = 64;
2827
3006
  async function forEachLimit(items, limit, fn) {
@@ -2861,8 +3040,10 @@ function createProject(options) {
2861
3040
  const { basesOf, formOf } = derivedKeys(config.usage.derivedKeys);
2862
3041
  const scanSignature = hashText(JSON.stringify({
2863
3042
  usage: config.usage,
3043
+ extract: config.extract,
2864
3044
  namespace: config.namespace,
2865
3045
  scanners: registry.scanners.map((s) => s.id),
3046
+ version,
2866
3047
  v: 1
2867
3048
  }));
2868
3049
  const localeFilter = (code) => (!config.locales || config.locales.includes(code)) && !config.ignoreLocales.includes(code);
@@ -3440,8 +3621,14 @@ function createProject(options) {
3440
3621
  basesOf,
3441
3622
  formOf,
3442
3623
  localeInfo,
3443
- text: (path) => fileTexts.get(path) ?? files.get(path)?.text
3624
+ text: (path) => fileTexts.get(path) ?? files.get(path)?.text,
3625
+ namespaced: () => namespaced
3444
3626
  });
3627
+ let lastAnalysis;
3628
+ function analyze() {
3629
+ if (lastAnalysis?.revision !== revision) lastAnalysis = analyzer.analyze();
3630
+ return lastAnalysis;
3631
+ }
3445
3632
  function keys(query = {}) {
3446
3633
  const { messages, keyPaths } = idx();
3447
3634
  const match = query.glob ? keyMatcher([query.glob], config.keySeparator) : void 0;
@@ -3456,7 +3643,7 @@ function createProject(options) {
3456
3643
  });
3457
3644
  }
3458
3645
  if (query.unused) {
3459
- const unused = new Set(analyzer.analyze().unused);
3646
+ const unused = new Set(analyze().unused);
3460
3647
  list = list.filter((key) => unused.has(key));
3461
3648
  }
3462
3649
  return list.sort();
@@ -3484,6 +3671,24 @@ function createProject(options) {
3484
3671
  }
3485
3672
  return found.sort((a, b) => b.score - a.score || a.value.length - b.value.length || a.key.localeCompare(b.key));
3486
3673
  }
3674
+ let diagnosticIndex;
3675
+ function diagnosticsOf(key) {
3676
+ const analysis = analyze();
3677
+ if (diagnosticIndex?.analysis !== analysis) {
3678
+ const byKey = /* @__PURE__ */ new Map();
3679
+ for (const d of analysis.diagnostics) {
3680
+ if (d.key === void 0) continue;
3681
+ const list = byKey.get(d.key);
3682
+ if (list) list.push(d);
3683
+ else byKey.set(d.key, [d]);
3684
+ }
3685
+ diagnosticIndex = {
3686
+ analysis,
3687
+ byKey
3688
+ };
3689
+ }
3690
+ return [...diagnosticIndex.byKey.get(key) ?? []];
3691
+ }
3487
3692
  function get(key) {
3488
3693
  const { messages, keyPaths } = idx();
3489
3694
  const byLocale = messages.get(key);
@@ -3498,7 +3703,7 @@ function createProject(options) {
3498
3703
  messages: list,
3499
3704
  missing: locales.filter((locale) => !byLocale.has(locale) && needs(key, locale)),
3500
3705
  usages: usageMatches({ key }),
3501
- diagnostics: analyzer.analyze().diagnostics.filter((d) => d.key === key),
3706
+ diagnostics: diagnosticsOf(key),
3502
3707
  reviews: reviews({ key })
3503
3708
  };
3504
3709
  }
@@ -3720,7 +3925,7 @@ function createProject(options) {
3720
3925
  ...h,
3721
3926
  path: p
3722
3927
  }))),
3723
- analyze: () => analyzer.analyze(),
3928
+ analyze,
3724
3929
  async loadHistory(historyOptions = {}) {
3725
3930
  await ready;
3726
3931
  await history.load(historyOptions);
@@ -3743,13 +3948,18 @@ function createProject(options) {
3743
3948
  owns: (path) => fileTexts.has(path) || files.has(path) || usageEligible(path),
3744
3949
  plan: (ops) => planner.plan(ops),
3745
3950
  planUnused: (query) => planUnusedDeletion({
3746
- unused: analyzer.analyze().unused,
3951
+ unused: analyze().unused,
3747
3952
  usages: idx().usages,
3748
3953
  namespaced,
3749
3954
  ignoreKeys: config.ignoreKeys,
3750
3955
  locations: (key) => project.locations(key),
3751
3956
  plan: (ops) => planner.plan(ops)
3752
3957
  }, query),
3958
+ planFixes: (diagnostics, query) => planFixes(diagnostics, {
3959
+ usages: idx().usages,
3960
+ namespaced,
3961
+ plan: (ops) => planner.plan(ops)
3962
+ }, query),
3753
3963
  apply: (plan, applyOptions) => planner.apply(plan, applyOptions?.answers ?? {}),
3754
3964
  update,
3755
3965
  watch() {
@@ -3832,7 +4042,12 @@ function defaultDraft() {
3832
4042
  };
3833
4043
  }
3834
4044
  function serializablePart(config) {
3835
- const { presets, plugins, pluginConfigs, history, ...rest } = config;
4045
+ const { presets, plugins, pluginConfigs, history, ignoreKeys: patterns, ...fields } = config;
4046
+ const ignoreKeys = patterns?.filter((pattern) => !(pattern instanceof RegExp));
4047
+ const rest = ignoreKeys === void 0 ? fields : {
4048
+ ...fields,
4049
+ ignoreKeys
4050
+ };
3836
4051
  if (typeof history !== "object") return {
3837
4052
  ...rest,
3838
4053
  history
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@x18n/core",
3
3
  "type": "module",
4
- "version": "0.1.8",
4
+ "version": "0.1.10",
5
5
  "description": "Runtime-neutral engine of x18n: domain model, plugin API and project analysis",
6
6
  "author": "Anthony Fu <anthonyfu117@hotmail.com>",
7
7
  "license": "MIT",