@x18n/core 0.0.14 → 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.d.mts CHANGED
@@ -139,6 +139,31 @@ declare const errorDefinitions: {
139
139
  }) => string;
140
140
  fix: string;
141
141
  };
142
+ X18N5001: {
143
+ why: (p: {
144
+ path: string;
145
+ reason: string;
146
+ }) => string;
147
+ fix: string;
148
+ };
149
+ X18N5003: {
150
+ why: (p: {
151
+ reason: string;
152
+ }) => string;
153
+ fix: string;
154
+ };
155
+ X18N5004: {
156
+ why: (p: {
157
+ id: string;
158
+ }) => string;
159
+ fix: string;
160
+ };
161
+ X18N5005: {
162
+ why: (p: {
163
+ id: string;
164
+ }) => string;
165
+ fix: string;
166
+ };
142
167
  };
143
168
  type ErrorCode = keyof typeof errorDefinitions;
144
169
  type X18nError = Diagnostic$1 & {
@@ -159,6 +184,70 @@ interface ErrorInfo {
159
184
  export declare function isX18nError(error: unknown): error is X18nError;
160
185
  export declare function toErrorInfo(error: unknown): ErrorInfo;
161
186
  //#endregion
187
+ //#region src/types/review.d.ts
188
+ type ReviewType = "comment" | "approve" | "request-change" | "suggestion";
189
+ interface ReviewAuthor {
190
+ readonly name?: string | undefined;
191
+ readonly email?: string | undefined;
192
+ }
193
+ /** One entry of a review thread, as stored. */
194
+ interface ReviewComment {
195
+ /** Short random id, unique within the review file. */
196
+ readonly id: string;
197
+ readonly type: ReviewType;
198
+ readonly author?: ReviewAuthor | undefined;
199
+ /** ISO 8601 time. */
200
+ readonly time: string;
201
+ readonly text?: string | undefined;
202
+ /** The proposed value of a `suggestion`. */
203
+ readonly suggestion?: string | undefined;
204
+ /** The value an `approve` approved; the approval is stale once the message has another value. */
205
+ readonly value?: string | undefined;
206
+ readonly resolved?: boolean | undefined;
207
+ }
208
+ /** Everything a review store holds. */
209
+ interface ReviewData {
210
+ /** Comments per key and locale, oldest first. */
211
+ readonly threads: {
212
+ readonly [key: KeyString]: {
213
+ readonly [locale: LocaleCode]: readonly ReviewComment[];
214
+ };
215
+ };
216
+ /** Hash of the source value when x18n last wrote each target message. */
217
+ readonly sources: {
218
+ readonly [key: KeyString]: {
219
+ readonly [locale: LocaleCode]: string;
220
+ };
221
+ };
222
+ }
223
+ /** Reads and writes the review file (`review.file`). Core does the I/O and puts writes into edit plans. */
224
+ interface ReviewStore {
225
+ readonly id: string;
226
+ /** Throws on invalid content; core reports it as a coded error. */
227
+ readonly parse: (text: string) => ReviewData;
228
+ readonly serialize: (data: ReviewData) => string;
229
+ }
230
+ /** Where a thread stands: the last open approval or change request decides, then open suggestions, then open comments. */
231
+ type ReviewState = "changes-requested" | "approved" | "stale-approval" | "suggested" | "commented";
232
+ interface ThreadComment extends ReviewComment {
233
+ /** An approval of a value the message no longer has. */
234
+ readonly stale: boolean;
235
+ }
236
+ /** The comments on one message. */
237
+ interface ReviewThread {
238
+ readonly key: KeyString;
239
+ readonly locale: LocaleCode;
240
+ readonly comments: readonly ThreadComment[];
241
+ /** Undefined when every comment is resolved. */
242
+ readonly state: ReviewState | undefined;
243
+ }
244
+ interface ReviewFilter {
245
+ readonly key?: KeyString | undefined;
246
+ readonly locale?: LocaleCode | undefined;
247
+ /** Only unresolved comments, and only threads that have some. */
248
+ readonly open?: boolean | undefined;
249
+ }
250
+ //#endregion
162
251
  //#region src/types/model.d.ts
163
252
  /** A locale code exactly as written in paths and files: `en`, `zh_CN`, `pt-BR`. Never normalized. */
164
253
  type LocaleCode = string;
@@ -277,6 +366,27 @@ type EditOp = {
277
366
  } | {
278
367
  readonly type: "format";
279
368
  readonly paths?: readonly string[];
369
+ } | {
370
+ readonly type: "review";
371
+ readonly key: KeyString;
372
+ readonly locale: LocaleCode;
373
+ readonly review: ReviewType;
374
+ readonly text?: string | undefined;
375
+ /** The proposed value, required for `suggestion`. */
376
+ readonly suggestion?: string | undefined;
377
+ readonly author?: ReviewAuthor | undefined;
378
+ readonly id?: string | undefined;
379
+ readonly time?: string | undefined;
380
+ } | {
381
+ readonly type: "resolve-review";
382
+ readonly id: string;
383
+ } | {
384
+ readonly type: "apply-suggestion";
385
+ readonly id: string;
386
+ } | {
387
+ readonly type: "mark-fresh";
388
+ readonly key: KeyString;
389
+ readonly locales?: readonly LocaleCode[] | undefined;
280
390
  };
281
391
  interface ExtractItem {
282
392
  readonly path: string;
@@ -393,6 +503,8 @@ interface Host {
393
503
  readonly watch?: ((patterns: readonly string[], onChange: (events: readonly FileEvent[]) => void) => Disposable) | undefined;
394
504
  readonly cache?: CacheStore | undefined;
395
505
  readonly logger?: Logger | undefined;
506
+ /** Who writes reviews. The node host reads git `user.name` and `user.email`. */
507
+ readonly identity?: (() => Promise<ReviewAuthor | undefined>) | undefined;
396
508
  }
397
509
  //#endregion
398
510
  //#region src/types/plugin.d.ts
@@ -646,6 +758,7 @@ interface CheckContext {
646
758
  }>;
647
759
  /** When a message last changed, from the history providers. Undefined until history is loaded. */
648
760
  readonly history: (key: KeyString, locale: LocaleCode) => MessageChange | undefined;
761
+ readonly reviews: (filter?: ReviewFilter) => readonly ReviewThread[];
649
762
  }
650
763
  interface Check {
651
764
  readonly id: string;
@@ -741,6 +854,8 @@ interface Plugin {
741
854
  readonly reporters?: readonly Reporter[] | undefined;
742
855
  readonly placeholders?: readonly PlaceholderSyntax[] | undefined;
743
856
  readonly histories?: readonly HistoryProvider[] | undefined;
857
+ /** Only one store is active: the last one, so a plugin's store replaces the built-in one. */
858
+ readonly reviewStores?: readonly ReviewStore[] | undefined;
744
859
  /** Turns raw key text into canonical form. The first plugin that returns a value wins. */
745
860
  readonly normalizeKey?: ((raw: string, ctx: KeyContext) => string | undefined) | undefined;
746
861
  readonly derivedKeys?: readonly string[] | undefined;
@@ -902,6 +1017,14 @@ interface NamespaceOptions {
902
1017
  /** How `{namespaces}` folders name a namespace: `dot` makes `pages/home` into `pages.home`, `slash` keeps `pages/home`, `camel` makes `todos-page` into `todosPage`. Default `dot`. */
903
1018
  readonly fromPath?: "dot" | "slash" | "camel" | undefined;
904
1019
  }
1020
+ interface ReviewOptions {
1021
+ /** Default true. */
1022
+ readonly enabled?: boolean | undefined;
1023
+ /** Review file, relative to the project root. Default `.x18n/reviews.yml`. */
1024
+ readonly file?: string | undefined;
1025
+ /** Author of new reviews, instead of the host identity (git `user.name` and `user.email`). */
1026
+ readonly author?: ReviewAuthor | undefined;
1027
+ }
905
1028
  type NamespaceFromPath = NonNullable<NamespaceOptions["fromPath"]>;
906
1029
  type PluginSpecifier = string | readonly [string, JsonObject];
907
1030
  /** The part of the config that YAML and JSON files can hold. Validated by `ConfigSchema`. */
@@ -943,6 +1066,8 @@ interface SerializableConfig {
943
1066
  readonly glossary?: readonly GlossaryEntry[] | undefined;
944
1067
  /** 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`). */
945
1068
  readonly history?: boolean | HistoryOptions | undefined;
1069
+ /** Review comments, approvals and suggested values on messages, kept in a file tracked in git. */
1070
+ readonly review?: ReviewOptions | undefined;
946
1071
  /** Monorepo: directories (globs) that are projects inheriting this config. */
947
1072
  readonly projects?: readonly string[] | undefined;
948
1073
  }
@@ -1018,6 +1143,11 @@ interface ResolvedConfig {
1018
1143
  readonly ignoreKeys: readonly string[];
1019
1144
  readonly glossary: readonly GlossaryEntry[];
1020
1145
  readonly history: false | HistorySettings;
1146
+ readonly review: {
1147
+ readonly enabled: boolean;
1148
+ readonly file: string;
1149
+ readonly author: ReviewAuthor | undefined;
1150
+ };
1021
1151
  readonly pluginConfigs: Partial<PluginConfigs>;
1022
1152
  /** Origin of each top-level value, for `explain()`. */
1023
1153
  readonly origins: {
@@ -1033,6 +1163,10 @@ export declare function definePlugin<O, const T extends Plugin>(factory: (option
1033
1163
  export declare function definePreset<const T extends Preset>(preset: T): T;
1034
1164
  export declare function defineConfig(config: UserConfig & { readonly [K in keyof PluginConfigs]?: PluginConfigs[K]; }): UserConfig;
1035
1165
  //#endregion
1166
+ //#region src/fuzzy.d.ts
1167
+ /** Subsequence match score of `query` in `text`, case-insensitive; higher is better, -1 when it does not match. Substring hits rank above every subsequence. */
1168
+ export declare function fuzzyScore(query: string, text: string): number;
1169
+ //#endregion
1036
1170
  //#region src/keys.d.ts
1037
1171
  /** Canonical string form of a key: `ns:a.b.c`, or `a.b.c` without namespace. */
1038
1172
  export declare function joinKey(namespace: string | undefined, path: readonly string[]): KeyString;
@@ -1143,6 +1277,7 @@ interface PluginRegistry {
1143
1277
  readonly manifests: readonly ManifestReader[];
1144
1278
  readonly extractors: readonly Extractor[];
1145
1279
  readonly histories: readonly HistoryProvider[];
1280
+ readonly reviewStores: readonly ReviewStore[];
1146
1281
  readonly conflicts: readonly CapabilityConflict[];
1147
1282
  /** The loaded format for a file path: config `formats` first, then the first format that claims the extension. */
1148
1283
  readonly formatFor: (path: string, hint?: string) => Format | undefined;
@@ -1265,6 +1400,18 @@ interface KeyQuery {
1265
1400
  /** Only keys without usages. */
1266
1401
  readonly unused?: boolean | undefined;
1267
1402
  }
1403
+ interface SearchOptions {
1404
+ /** Only values containing the query; by default a fuzzy (subsequence) match also counts. Both ignore case. */
1405
+ readonly exact?: boolean | undefined;
1406
+ }
1407
+ /** A key found by its value: the best-matching locale, source first on ties. */
1408
+ interface SearchMatch {
1409
+ readonly key: KeyString;
1410
+ readonly locale: LocaleCode;
1411
+ readonly value: string;
1412
+ /** Higher is better; substring hits rank above fuzzy ones. */
1413
+ readonly score: number;
1414
+ }
1268
1415
  interface UsageMatch {
1269
1416
  readonly usage: Usage;
1270
1417
  /** True for prefix usages that may refer to the key. */
@@ -1285,6 +1432,7 @@ interface KeyInfo {
1285
1432
  readonly missing: readonly LocaleCode[];
1286
1433
  readonly usages: readonly UsageMatch[];
1287
1434
  readonly diagnostics: readonly Diagnostic[];
1435
+ readonly reviews: readonly ReviewThread[];
1288
1436
  }
1289
1437
  /** The key, or the resource node, under a position. */
1290
1438
  type KeyTarget = {
@@ -1417,6 +1565,8 @@ interface Project {
1417
1565
  /** Extraction templates: config `extract.templates` first, then those of presets. */
1418
1566
  readonly extractTemplates: readonly ExtractTemplate[];
1419
1567
  readonly keys: (query?: KeyQuery) => KeyString[];
1568
+ /** Keys whose value in some locale matches the query, best first. */
1569
+ readonly search: (query: string, options?: SearchOptions) => SearchMatch[];
1420
1570
  readonly get: (key: KeyString) => KeyInfo | undefined;
1421
1571
  readonly message: (key: KeyString, locale: LocaleCode) => Message | undefined;
1422
1572
  /** The message a usage of `key` shows: the key's own, or one of its derived keys (`item` → `item_other`). */
@@ -1443,6 +1593,12 @@ interface Project {
1443
1593
  readonly history: (key: KeyString, locale: LocaleCode) => MessageChange | undefined;
1444
1594
  /** The value a message had at a time (ms), from the provider that knows the message. */
1445
1595
  readonly valueAt: (key: KeyString, locale: LocaleCode, at: number) => MessageLeaf | undefined;
1596
+ /** Review threads, in key order and then locale order. */
1597
+ readonly reviews: (filter?: ReviewFilter) => readonly ReviewThread[];
1598
+ /** The review file when reviews are on and a review store is registered. */
1599
+ readonly reviewFile: string | undefined;
1600
+ /** Author of new reviews: config `review.author`, else the host identity. */
1601
+ readonly reviewAuthor: () => Promise<ReviewAuthor | undefined>;
1446
1602
  /** Parts of a translate or review prompt for an agent or LLM; render with `renderPrompt`. */
1447
1603
  readonly prompt: (kind: "translate" | "review", locale: LocaleCode, options?: PromptOptions) => PromptData;
1448
1604
  readonly explain: () => ProjectExplanation;
@@ -1484,6 +1640,10 @@ type PlaceholderComparer = (source: string, target: string) => {
1484
1640
  export declare function createPlaceholderComparer(syntaxes: readonly PlaceholderSyntax[], find?: (value: string) => readonly Placeholder[]): PlaceholderComparer;
1485
1641
  export declare function placeholderProblems(project: Project, ops: readonly EditOp[]): PlaceholderProblem[];
1486
1642
  //#endregion
1643
+ //#region src/project/explain.d.ts
1644
+ /** `project.explain()` as the text `x18n doctor` prints. `root` and `configFile` are shown as given. */
1645
+ export declare function renderExplanation(e: ProjectExplanation, configFile?: string | undefined): string;
1646
+ //#endregion
1487
1647
  //#region src/project/project.d.ts
1488
1648
  interface CreateProjectOptions {
1489
1649
  readonly host: Host;
@@ -1542,6 +1702,15 @@ interface PluginFile {
1542
1702
  } | undefined;
1543
1703
  }
1544
1704
  export declare const PluginFileSchema: v.GenericSchema<PluginFile>;
1705
+ /** The review file (`.x18n/reviews.yml`), as the YAML review store reads it. */
1706
+ interface ReviewFile {
1707
+ readonly $schema?: string | undefined;
1708
+ /** File format version. */
1709
+ readonly x18n: 1;
1710
+ readonly threads?: ReviewData["threads"] | undefined;
1711
+ readonly sources?: ReviewData["sources"] | undefined;
1712
+ }
1713
+ export declare const ReviewFileSchema: v.GenericSchema<ReviewFile>;
1545
1714
  interface SchemaIssue {
1546
1715
  readonly path: string;
1547
1716
  readonly message: string;
@@ -1579,4 +1748,4 @@ export declare function rangeContains(range: TextRange, offset: number): boolean
1579
1748
  /** Fast non-cryptographic string hash (cyrb53), as hex. */
1580
1749
  export declare function hashText(text: string): string;
1581
1750
  //#endregion
1582
- export type { Analysis, Answers, ApplyResult, ArgSource, 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, KeyContext, KeyId, KeyInfo, KeyQuery, KeyString, KeyTarget, LazyFormat, LazyScanner, LibraryOptions, LineIndex, LinePosition, LocaleCode, LocaleCoverage, LocaleFinder, LocaleInfo, Location, Logger, ManifestReader, MemoryHost, Message, MessageChange, MessageHistory, MessageLeaf, MessageOp, MessageTree, NamespaceFromPath, NamespaceOptions, NodeRange, OverlayHost, 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, PromptItem, PromptOptions, RegexScannerSpec, ReportInput, ReportSummary, Reporter, ReporterContext, ReporterLoader, ResolveConfigOptions, ResolvedConfig, Resource, ResourceBlock, ResourceCandidate, ResourceId, ResourceNode, ResourceSpec, ResourceTransform, ResourceTransformContext, ScanContext, ScanResult, Scanner, ScannerEntry, SchemaIssue, Scope, SerializableConfig, Setup, Severity, SourceFile, TextEdit, TextRange, TreeLeaf, Usage, UsageCandidate, UsageContext, UsageKind, UsageMatch, UsageOptions, UsageQuery, UserConfig, WireDiagnostic, WireRange, WriteOptions, X18nError };
1751
+ export type { Analysis, Answers, ApplyResult, ArgSource, 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, KeyContext, KeyId, KeyInfo, KeyQuery, KeyString, KeyTarget, LazyFormat, LazyScanner, LibraryOptions, LineIndex, LinePosition, LocaleCode, LocaleCoverage, LocaleFinder, LocaleInfo, Location, Logger, ManifestReader, MemoryHost, Message, MessageChange, MessageHistory, MessageLeaf, MessageOp, MessageTree, NamespaceFromPath, NamespaceOptions, NodeRange, OverlayHost, 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, PromptItem, PromptOptions, RegexScannerSpec, 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, Usage, UsageCandidate, UsageContext, UsageKind, UsageMatch, UsageOptions, UsageQuery, UserConfig, WireDiagnostic, WireRange, WriteOptions, X18nError };
package/dist/index.mjs CHANGED
@@ -225,6 +225,22 @@ const errorDefinitions = {
225
225
  X18N4008: {
226
226
  why: (p) => `Invalid edit input: ${p.reason}`,
227
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`."
228
244
  }
229
245
  };
230
246
  /** Raise with `throw errors.X18N2002({ path, reason })`. Core has no reporters: surfaces print what they catch. */
@@ -251,6 +267,24 @@ function toErrorInfo(error) {
251
267
  };
252
268
  }
253
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
254
288
  //#region src/keys.ts
255
289
  /** Canonical string form of a key: `ns:a.b.c`, or `a.b.c` without namespace. */
256
290
  function joinKey(namespace, path) {
@@ -701,6 +735,7 @@ function createRegistry(input, config) {
701
735
  manifests: plugins.flatMap((plugin) => plugin.manifests ?? []),
702
736
  extractors: plugins.flatMap((plugin) => plugin.extractors ?? []),
703
737
  histories: plugins.flatMap((plugin) => plugin.histories ?? []),
738
+ reviewStores: plugins.flatMap((plugin) => plugin.reviewStores ?? []),
704
739
  conflicts,
705
740
  formatById,
706
741
  formatIdFor,
@@ -816,6 +851,10 @@ const namespaceOptions = v.strictObject({
816
851
  "camel"
817
852
  ]))
818
853
  });
854
+ const reviewAuthor = v.strictObject({
855
+ name: v.optional(v.string()),
856
+ email: v.optional(v.string())
857
+ });
819
858
  /** Validates YAML and JSON configs, and the serializable part of TS configs. */
820
859
  const ConfigSchema = v.strictObject({
821
860
  $schema: v.optional(v.string()),
@@ -855,6 +894,11 @@ const ConfigSchema = v.strictObject({
855
894
  ignoreKeys: v.optional(strings),
856
895
  glossary: v.optional(v.array(glossaryEntry)),
857
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
+ })),
858
902
  projects: v.optional(strings)
859
903
  });
860
904
  const PluginFileSchema = v.strictObject({
@@ -896,6 +940,27 @@ const PluginFileSchema = v.strictObject({
896
940
  }))),
897
941
  extract: v.optional(v.strictObject({ templates: v.optional(extractTemplates) }))
898
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
+ });
899
964
  /** Validates data against a schema and returns readable issues instead of throwing. */
900
965
  function validate(schema, data) {
901
966
  const result = v.safeParse(schema, data);
@@ -975,6 +1040,28 @@ function placeholderProblems(project, ops) {
975
1040
  });
976
1041
  }
977
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
978
1065
  //#region src/project/history.ts
979
1066
  const localChange = { updatedAt: Number.POSITIVE_INFINITY };
980
1067
  /** A target message is stale when it last changed before its source message. */
@@ -1224,7 +1311,7 @@ function computeCoverage(state, options, sourceKeys) {
1224
1311
  if (value === void 0 || value === "") {
1225
1312
  if (inherited(key)) continue;
1226
1313
  (value === void 0 ? missing : empty).push(key);
1227
- } 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);
1228
1315
  }
1229
1316
  return {
1230
1317
  locale,
@@ -1375,6 +1462,7 @@ function createAnalyzer(options) {
1375
1462
  localeInfo: options.localeInfo,
1376
1463
  text: options.text,
1377
1464
  history: state.history,
1465
+ reviews: state.reviews,
1378
1466
  *hardStrings() {
1379
1467
  for (const [path, file] of state.files) for (const hard of file.result.hardStrings ?? []) yield {
1380
1468
  ...hard,
@@ -1402,6 +1490,161 @@ function createAnalyzer(options) {
1402
1490
  return { analyze };
1403
1491
  }
1404
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
1405
1648
  //#region src/project/planner.ts
1406
1649
  const safeSegment = /^[\w-]+$/;
1407
1650
  const safeFlat = /^[\w.-]+$/;
@@ -1471,6 +1714,9 @@ var PlanBuilder = class {
1471
1714
  touched = /* @__PURE__ */ new Set();
1472
1715
  stateById;
1473
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;
1474
1720
  constructor(options, state, answers) {
1475
1721
  this.options = options;
1476
1722
  this.state = state;
@@ -1797,6 +2043,7 @@ var PlanBuilder = class {
1797
2043
  const renamed = keys.includes(op.from) ? /* @__PURE__ */ new Set() : this.renameNode(op.from, op.to);
1798
2044
  for (const [from, to] of pairs) {
1799
2045
  this.moveKey(from, to, renamed);
2046
+ this.reviewDraft()?.move(from, to);
1800
2047
  if (op.usages !== false) this.rewriteUsages(from, to);
1801
2048
  }
1802
2049
  if (op.usages === false) return;
@@ -1808,6 +2055,7 @@ var PlanBuilder = class {
1808
2055
  if (keys.length === 0) throw errors.X18N4006({ key: op.key });
1809
2056
  for (const key of keys) {
1810
2057
  this.touched.add(key);
2058
+ this.reviewDraft()?.remove(key, op.locales ?? "all");
1811
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));
1812
2060
  const used = this.options.usageMatches({ key }).filter((match) => !match.possible).length;
1813
2061
  if (used > 0) this.warnings.push(`"${key}" still has ${used} usage${used === 1 ? "" : "s"}.`);
@@ -1827,13 +2075,75 @@ var PlanBuilder = class {
1827
2075
  }
1828
2076
  }
1829
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
+ }
1830
2143
  run(ops) {
1831
2144
  for (const op of ops) switch (op.type) {
1832
2145
  case "set":
1833
- this.setValue(op.key, op.locale, op.value);
1834
- if (this.config.write.keepFilled) {
1835
- 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, "");
1836
- }
2146
+ this.set(op);
1837
2147
  break;
1838
2148
  case "delete":
1839
2149
  this.delete(op);
@@ -1845,7 +2155,19 @@ var PlanBuilder = class {
1845
2155
  case "extract":
1846
2156
  this.extract(op.items);
1847
2157
  break;
1848
- 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);
1849
2171
  }
1850
2172
  }
1851
2173
  /** ---- Changes ---- */
@@ -1954,16 +2276,52 @@ var PlanBuilder = class {
1954
2276
  });
1955
2277
  texts.set(path, text);
1956
2278
  }
2279
+ const review = this.reviewChange();
2280
+ if (review) {
2281
+ changes.push(review);
2282
+ texts.set(review.path, this.state.reviews?.text);
2283
+ }
1957
2284
  return {
1958
2285
  changes,
1959
2286
  texts
1960
2287
  };
1961
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
+ }
1962
2304
  };
1963
2305
  function createPlanner(options) {
1964
2306
  const texts = /* @__PURE__ */ new Map();
1965
- 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) {
1966
2323
  const state = options.state();
2324
+ const ops = withReviewIds(input, state.reviews?.data ?? noReviews);
1967
2325
  const builder = new PlanBuilder(options, state, answers);
1968
2326
  builder.run(ops);
1969
2327
  const result = builder.changes();
@@ -2063,7 +2421,8 @@ function buildPrompt(project, kind, to, options, extra) {
2063
2421
  const from = project.sourceLocale;
2064
2422
  const match = typeof options.keys === "string" ? keyMatcher([options.keys]) : void 0;
2065
2423
  const list = Array.isArray(options.keys) ? new Set(options.keys) : void 0;
2066
- 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);
2067
2426
  const candidates = project.keys({ namespace: options.namespace }).filter((key) => {
2068
2427
  if (match && !match(key)) return false;
2069
2428
  if (list && !list.has(key)) return false;
@@ -2118,7 +2477,11 @@ function buildPrompt(project, kind, to, options, extra) {
2118
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.`,
2119
2478
  `Then run \`x18n check --only placeholder-mismatch,invalid-syntax --locale ${to} --json\` and fix the reported keys.`,
2120
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\`.`
2121
- ] : [`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
+ ];
2122
2485
  return {
2123
2486
  kind,
2124
2487
  project: project.name,
@@ -2200,6 +2563,10 @@ function createProject(options) {
2200
2563
  let usageEligible = () => false;
2201
2564
  let resourceIndex;
2202
2565
  let resolved = /* @__PURE__ */ new WeakMap();
2566
+ const reviewFile = config.review.file;
2567
+ let reviewText;
2568
+ let reviewData = noReviews;
2569
+ let reviewError;
2203
2570
  const normalizers = registry.plugins.flatMap((plugin) => plugin.normalizeKey ? [plugin.normalizeKey] : []);
2204
2571
  const transforms = registry.plugins.flatMap((plugin) => plugin.transformResource ? [plugin.transformResource] : []);
2205
2572
  const localeInfoHooks = registry.plugins.flatMap((plugin) => plugin.localeInfo ? [plugin.localeInfo] : []);
@@ -2236,6 +2603,44 @@ function createProject(options) {
2236
2603
  const form = formOf(key);
2237
2604
  return !form || categoriesOf(localeInfo(locale), form.type).includes(form.category);
2238
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
+ }
2239
2644
  async function discover() {
2240
2645
  const ctx = {
2241
2646
  host,
@@ -2692,7 +3097,10 @@ function createProject(options) {
2692
3097
  error: s.error,
2693
3098
  path
2694
3099
  }] : []),
2695
- discoveryErrors,
3100
+ discoveryErrors: [...discoveryErrors, ...reviewError ? [{
3101
+ error: reviewError,
3102
+ path: reviewFile
3103
+ }] : []],
2696
3104
  messages: idx().messages,
2697
3105
  parents: idx().parents,
2698
3106
  usages: idx().usages,
@@ -2700,7 +3108,9 @@ function createProject(options) {
2700
3108
  prefixUsages: idx().prefixUsages,
2701
3109
  derived: idx().derived,
2702
3110
  files,
2703
- history: history.get
3111
+ history: history.get,
3112
+ fresh,
3113
+ reviews
2704
3114
  }),
2705
3115
  basesOf,
2706
3116
  formOf,
@@ -2726,6 +3136,29 @@ function createProject(options) {
2726
3136
  }
2727
3137
  return list.sort();
2728
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
+ }
2729
3162
  function get(key) {
2730
3163
  const { messages, keyPaths } = idx();
2731
3164
  const byLocale = messages.get(key);
@@ -2739,7 +3172,8 @@ function createProject(options) {
2739
3172
  messages: list,
2740
3173
  missing: locales.filter((locale) => !byLocale.has(locale) && needs(key, locale)),
2741
3174
  usages: usageMatches({ key }),
2742
- diagnostics: analyzer.analyze().diagnostics.filter((d) => d.key === key)
3175
+ diagnostics: analyzer.analyze().diagnostics.filter((d) => d.key === key),
3176
+ reviews: reviews({ key })
2743
3177
  };
2744
3178
  }
2745
3179
  function keyAt(path, offset) {
@@ -2793,6 +3227,10 @@ function createProject(options) {
2793
3227
  conflicts: registry.conflicts,
2794
3228
  errors: [
2795
3229
  ...discoveryErrors,
3230
+ ...reviewError ? [{
3231
+ error: reviewError,
3232
+ path: reviewFile
3233
+ }] : [],
2796
3234
  ...resources.flatMap((state) => state.error ? [{
2797
3235
  error: state.error,
2798
3236
  path: state.resource.path
@@ -2822,14 +3260,22 @@ function createProject(options) {
2822
3260
  collect(idx());
2823
3261
  return [...out];
2824
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
+ }
2825
3270
  let current = Promise.resolve();
2826
3271
  async function update(events) {
2827
3272
  const run = current.then(async () => {
2828
3273
  if (events.length === 0) return void 0;
2829
3274
  const before = idx();
2830
3275
  const paths = new Set(events.map((event) => event.path));
3276
+ const reviewKeys = paths.has(reviewFile) ? await reloadReviews() : [];
2831
3277
  const resourcePaths = new Set([...resourceStates.values()].map((state) => state.resource.path));
2832
- const maybeResource = (path) => !resourcePaths.has(path) && registry.formatIdFor(path) !== void 0 && !usageEligible(path);
3278
+ const maybeResource = (path) => path !== reviewFile && !resourcePaths.has(path) && registry.formatIdFor(path) !== void 0 && !usageEligible(path);
2833
3279
  const blockPaths = new Set([...resourceStates.values()].filter((state) => state.resource.block).map((state) => state.resource.path));
2834
3280
  const structural = events.some((event) => event.type !== "change" || maybeResource(event.path) || blockPaths.has(event.path));
2835
3281
  if (structural || [...paths].some((path) => resourcePaths.has(path))) await loadResources(structural ? await discover() : [...resourceStates.values()].map((state) => ({
@@ -2843,7 +3289,7 @@ function createProject(options) {
2843
3289
  const change = {
2844
3290
  revision,
2845
3291
  paths: [...paths],
2846
- keys: touchedKeys(paths, before)
3292
+ keys: [.../* @__PURE__ */ new Set([...touchedKeys(paths, before), ...reviewKeys])]
2847
3293
  };
2848
3294
  emit(change);
2849
3295
  return change;
@@ -2862,7 +3308,9 @@ function createProject(options) {
2862
3308
  fallbacks,
2863
3309
  namespaced,
2864
3310
  resources: [...resourceStates.values()],
2865
- fileTexts
3311
+ fileTexts,
3312
+ reviews: reviewSource(),
3313
+ reviewProblem: reviewProblem()
2866
3314
  }),
2867
3315
  message: (key, locale) => message(key, locale),
2868
3316
  messages: (key) => idx().messages.get(key),
@@ -2876,7 +3324,8 @@ function createProject(options) {
2876
3324
  update
2877
3325
  });
2878
3326
  const ready = (async () => {
2879
- await loadResources(await discover(), "all");
3327
+ const candidates = await discover();
3328
+ await Promise.all([loadResources(candidates, "all"), loadReviews()]);
2880
3329
  await scanAll();
2881
3330
  revision++;
2882
3331
  })();
@@ -2905,6 +3354,7 @@ function createProject(options) {
2905
3354
  placeholderSyntaxes: registry.placeholders,
2906
3355
  extractTemplates: [...(config.extract.templates ?? []).map((template) => typeof template === "string" ? { template } : template), ...registry.extractors.flatMap((extractor) => extractor.templates)],
2907
3356
  keys,
3357
+ search,
2908
3358
  get,
2909
3359
  message,
2910
3360
  parseKey(input) {
@@ -2947,13 +3397,18 @@ function createProject(options) {
2947
3397
  },
2948
3398
  history: history.get,
2949
3399
  valueAt: history.valueAt,
3400
+ reviews,
3401
+ reviewFile: reviewStore && reviewFile,
3402
+ async reviewAuthor() {
3403
+ return config.review.author ?? await host.identity?.();
3404
+ },
2950
3405
  prompt: (kind, locale, promptOptions = {}) => buildPrompt(project, kind, locale, promptOptions, {
2951
3406
  config,
2952
3407
  hooks: registry.plugins.flatMap((plugin) => plugin.translatePrompt ? [plugin.translatePrompt] : [])
2953
3408
  }),
2954
3409
  explain,
2955
3410
  localeInfo,
2956
- text: (path) => fileTexts.get(path) ?? files.get(path)?.text,
3411
+ text: (path) => fileTexts.get(path) ?? files.get(path)?.text ?? (path === reviewFile ? reviewText : void 0),
2957
3412
  owns: (path) => fileTexts.has(path) || files.has(path) || usageEligible(path),
2958
3413
  plan: (ops) => planner.plan(ops),
2959
3414
  apply: (plan, applyOptions) => planner.apply(plan, applyOptions?.answers ?? {}),
@@ -3027,6 +3482,11 @@ function defaultDraft() {
3027
3482
  ignoreKeys: [],
3028
3483
  glossary: [],
3029
3484
  history: { providers: [] },
3485
+ review: {
3486
+ enabled: true,
3487
+ file: ".x18n/reviews.yml",
3488
+ author: void 0
3489
+ },
3030
3490
  pluginConfigs: {}
3031
3491
  };
3032
3492
  }
@@ -3154,6 +3614,14 @@ function applyLayer(draft, layer, origin, origins) {
3154
3614
  };
3155
3615
  mark("history");
3156
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
+ }
3157
3625
  if (layer.pluginConfigs !== void 0) {
3158
3626
  draft.pluginConfigs = {
3159
3627
  ...draft.pluginConfigs,
@@ -3311,4 +3779,4 @@ async function resolveConfig(options) {
3311
3779
  };
3312
3780
  }
3313
3781
  //#endregion
3314
- export { ConfigSchema, PluginFileSchema, applyMessageOps, applyTextEdits, buildTree, contractVersion, createLibraryPlugin, 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 };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@x18n/core",
3
3
  "type": "module",
4
- "version": "0.0.14",
4
+ "version": "0.0.15",
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",
@@ -591,6 +591,32 @@
591
591
  }
592
592
  ]
593
593
  },
594
+ "review": {
595
+ "type": "object",
596
+ "properties": {
597
+ "enabled": {
598
+ "type": "boolean"
599
+ },
600
+ "file": {
601
+ "type": "string"
602
+ },
603
+ "author": {
604
+ "type": "object",
605
+ "properties": {
606
+ "name": {
607
+ "type": "string"
608
+ },
609
+ "email": {
610
+ "type": "string"
611
+ }
612
+ },
613
+ "required": [],
614
+ "additionalProperties": false
615
+ }
616
+ },
617
+ "required": [],
618
+ "additionalProperties": false
619
+ },
594
620
  "projects": {
595
621
  "type": "array",
596
622
  "items": {
@@ -0,0 +1,98 @@
1
+ {
2
+ "type": "object",
3
+ "properties": {
4
+ "$schema": {
5
+ "type": "string"
6
+ },
7
+ "x18n": {
8
+ "const": 1
9
+ },
10
+ "threads": {
11
+ "type": "object",
12
+ "propertyNames": {
13
+ "type": "string"
14
+ },
15
+ "additionalProperties": {
16
+ "type": "object",
17
+ "propertyNames": {
18
+ "type": "string"
19
+ },
20
+ "additionalProperties": {
21
+ "type": "array",
22
+ "items": {
23
+ "type": "object",
24
+ "properties": {
25
+ "id": {
26
+ "type": "string"
27
+ },
28
+ "type": {
29
+ "enum": [
30
+ "comment",
31
+ "approve",
32
+ "request-change",
33
+ "suggestion"
34
+ ],
35
+ "type": "string"
36
+ },
37
+ "author": {
38
+ "type": "object",
39
+ "properties": {
40
+ "name": {
41
+ "type": "string"
42
+ },
43
+ "email": {
44
+ "type": "string"
45
+ }
46
+ },
47
+ "required": [],
48
+ "additionalProperties": false
49
+ },
50
+ "time": {
51
+ "type": "string"
52
+ },
53
+ "text": {
54
+ "type": "string"
55
+ },
56
+ "suggestion": {
57
+ "type": "string"
58
+ },
59
+ "value": {
60
+ "type": "string"
61
+ },
62
+ "resolved": {
63
+ "type": "boolean"
64
+ }
65
+ },
66
+ "required": [
67
+ "id",
68
+ "type",
69
+ "time"
70
+ ],
71
+ "additionalProperties": false
72
+ }
73
+ }
74
+ }
75
+ },
76
+ "sources": {
77
+ "type": "object",
78
+ "propertyNames": {
79
+ "type": "string"
80
+ },
81
+ "additionalProperties": {
82
+ "type": "object",
83
+ "propertyNames": {
84
+ "type": "string"
85
+ },
86
+ "additionalProperties": {
87
+ "type": "string"
88
+ }
89
+ }
90
+ }
91
+ },
92
+ "required": [
93
+ "x18n"
94
+ ],
95
+ "additionalProperties": false,
96
+ "$schema": "http://json-schema.org/draft-07/schema#",
97
+ "title": "x18n reviews"
98
+ }