@x18n/core 0.0.12 → 0.0.14

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
@@ -196,6 +196,8 @@ interface LocaleInfo {
196
196
  readonly displayName: string | undefined;
197
197
  readonly flag: string | undefined;
198
198
  readonly pluralCategories: readonly Intl.LDMLPluralRule[];
199
+ /** Categories of ordinal numbers (1st, 2nd), which differ from the cardinal ones. */
200
+ readonly ordinalCategories: readonly Intl.LDMLPluralRule[];
199
201
  }
200
202
  /** Embedded resource inside a file, for example a Vue `<i18n>` block. */
201
203
  interface ResourceBlock {
@@ -237,6 +239,8 @@ interface Usage {
237
239
  readonly path: string;
238
240
  /** Range of the key text only (inside the quotes): the range rename rewrites. */
239
241
  readonly range: TextRange;
242
+ /** Range of the whole call that holds the key (`t('a', { n })`), when the scanner knows it. */
243
+ readonly expression: TextRange | undefined;
240
244
  readonly scanner: string;
241
245
  readonly kind: UsageKind;
242
246
  /** `t(`pages.${id}.title`)` gives 'prefix' with key `pages`. */
@@ -430,10 +434,14 @@ interface LocaleFinder {
430
434
  readonly id: string;
431
435
  readonly override?: boolean | undefined;
432
436
  readonly find: (ctx: FinderContext) => Promise<readonly ResourceCandidate[]>;
433
- /** Where a new resource for (locale, namespace) should go. Used by edits that create files. */
437
+ /**
438
+ * Where a new resource for (locale, namespace) should go. Used by edits that create files.
439
+ * `model` is the existing resource the new one would otherwise mirror: the key's source file when it has one.
440
+ */
434
441
  readonly resolveNew?: ((target: {
435
442
  readonly locale: LocaleCode;
436
443
  readonly namespace: string | undefined;
444
+ readonly model: Resource | undefined;
437
445
  }, ctx: FinderContext) => string | undefined) | undefined;
438
446
  }
439
447
  interface FormatContext {
@@ -455,6 +463,28 @@ type MessageOp = {
455
463
  readonly type: "delete";
456
464
  readonly path: readonly string[];
457
465
  };
466
+ /** A built-in format known by its extensions only: the module behind `load` is imported when a file of that format is read. */
467
+ interface FormatEntry {
468
+ /** File extensions without the dot. */
469
+ readonly extensions: readonly string[];
470
+ /** The module's default export creates a plugin that holds the format. */
471
+ readonly load: PluginLoader;
472
+ }
473
+ /** A format entry under its id, as plugins list it. */
474
+ interface LazyFormat extends FormatEntry {
475
+ readonly id: string;
476
+ }
477
+ /** A built-in scanner known by its extensions only: the module behind `load` is imported when a file it scans is first scanned. */
478
+ interface ScannerEntry {
479
+ /** File extensions without dot. `*` matches every file. */
480
+ readonly extensions: readonly string[];
481
+ /** The module's default export creates a plugin that holds the scanner. */
482
+ readonly load: PluginLoader;
483
+ }
484
+ /** A scanner entry under its id, as plugins list it. */
485
+ interface LazyScanner extends ScannerEntry {
486
+ readonly id: string;
487
+ }
458
488
  interface Format {
459
489
  readonly id: string;
460
490
  /** File extensions without the dot. */
@@ -479,6 +509,8 @@ interface ScanContext {
479
509
  interface UsageCandidate {
480
510
  readonly raw: string;
481
511
  readonly range: TextRange;
512
+ /** Range of the whole call that holds the key, when the scanner knows it. */
513
+ readonly expression?: TextRange | undefined;
482
514
  readonly kind: UsageKind;
483
515
  readonly dynamic: false | "prefix" | "unknown";
484
516
  readonly namespace?: string | undefined;
@@ -490,6 +522,8 @@ interface Scope {
490
522
  readonly range: TextRange;
491
523
  readonly namespace?: string | undefined;
492
524
  readonly keyPrefix?: string | undefined;
525
+ /** The key prefix applies only to keys it defines; others resolve without it, as page messages that extend global ones. */
526
+ readonly optionalPrefix?: boolean | undefined;
493
527
  }
494
528
  interface HardString {
495
529
  readonly text: string;
@@ -571,6 +605,13 @@ interface HistoryProvider {
571
605
  /** Calls `onChange` when the history changes outside resource files, for example on a commit. */
572
606
  readonly watch?: ((ctx: HistoryContext, onChange: () => void) => Disposable) | undefined;
573
607
  }
608
+ /** A key that is one plural form of a base key, such as `item_one` of `item`. */
609
+ interface PluralForm {
610
+ readonly type: Intl.PluralRuleType;
611
+ readonly category: Intl.LDMLPluralRule;
612
+ /** The sibling key of another category. */
613
+ readonly keyOf: (category: Intl.LDMLPluralRule) => KeyString;
614
+ }
574
615
  interface CheckContext {
575
616
  readonly config: ResolvedConfig;
576
617
  readonly sourceLocale: LocaleCode;
@@ -587,6 +628,8 @@ interface CheckContext {
587
628
  readonly message: (key: KeyString, locale: LocaleCode) => Message | undefined;
588
629
  /** Every resource message of a key in a locale, more than one when duplicated across files. */
589
630
  readonly duplicates: (key: KeyString, locale: LocaleCode) => readonly Message[];
631
+ /** The plural form a key is, from the `{plural}` and `{ordinal}` derived-key rules. */
632
+ readonly formOf: (key: KeyString) => PluralForm | undefined;
590
633
  readonly usages: () => Iterable<Usage>;
591
634
  readonly coverage: () => readonly LocaleCoverage[];
592
635
  readonly unused: () => readonly KeyString[];
@@ -688,6 +731,10 @@ interface Plugin {
688
731
  readonly manifests?: readonly ManifestReader[] | undefined;
689
732
  readonly localeFinders?: readonly LocaleFinder[] | undefined;
690
733
  readonly formats?: readonly Format[] | undefined;
734
+ /** Formats that load their module when a file of that format is first read. */
735
+ readonly lazyFormats?: readonly LazyFormat[] | undefined;
736
+ /** Scanners that load their module when a file they scan is first scanned. */
737
+ readonly lazyScanners?: readonly LazyScanner[] | undefined;
691
738
  readonly scanners?: readonly Scanner[] | undefined;
692
739
  readonly extractors?: readonly Extractor[] | undefined;
693
740
  readonly checks?: readonly Check[] | undefined;
@@ -712,6 +759,32 @@ interface Preset {
712
759
  }
713
760
  /** A preset factory as referenced by name from YAML and JSON configs. */
714
761
  type PresetFactory = (options?: JsonObject) => Preset;
762
+ /** Loads a plugin on demand, usually `() => import('./plugin')`: the module's default export creates it. */
763
+ type PluginLoader = () => Promise<{
764
+ readonly default: () => Plugin;
765
+ }>;
766
+ /** Loads a reporter on demand, usually `() => import('./reporter')`: the module's default export is the reporter. */
767
+ type ReporterLoader = () => Promise<{
768
+ readonly default: Reporter;
769
+ }>;
770
+ /** Loads a preset on demand, usually `() => import('./preset')`: the module's default export is the factory. */
771
+ type PresetLoader = () => Promise<{
772
+ readonly default: PresetFactory;
773
+ }>;
774
+ /** When a built-in preset applies. Cheap, so it stays loaded while the preset module does not. */
775
+ interface PresetDetection {
776
+ /** Without `detect`, the preset applies only when listed in config. */
777
+ readonly detect?: ((ctx: DetectContext) => boolean | Promise<boolean>) | undefined;
778
+ /** When true and detected, presets that come after it are not applied. */
779
+ readonly exclusive?: boolean | undefined;
780
+ }
781
+ /**
782
+ * A built-in preset split in two: the detection is always loaded; the module behind `load`
783
+ * is imported only when it passes or the preset is listed in config.
784
+ */
785
+ interface PresetEntry extends PresetDetection {
786
+ readonly load: PresetLoader;
787
+ }
715
788
  //#endregion
716
789
  //#region src/types/config.d.ts
717
790
  /** Where a value comes from: an argument, or a property of an object argument. */
@@ -727,8 +800,8 @@ interface CallSpec {
727
800
  readonly kind?: "call" | "selector" | "accessor" | undefined;
728
801
  /** Which argument holds the key. Default 0. */
729
802
  readonly key?: number | ArgSource | undefined;
730
- /** Where the namespace comes from (call option, or factory argument when `factory` is true). */
731
- readonly namespace?: ArgSource | undefined;
803
+ /** Where the namespace comes from (call option, or factory argument when `factory` is true), or a fixed namespace such as `'shared'` for a hook that wraps `useTranslation('shared')`. */
804
+ readonly namespace?: ArgSource | string | undefined;
732
805
  /** Where a key prefix comes from (`keyPrefix` option, or next-intl `useTranslations('a.b')`). */
733
806
  readonly keyPrefix?: ArgSource | undefined;
734
807
  /** The callee is a factory that returns the translate function, for example `useI18n()` returning `{ t }`. */
@@ -785,12 +858,14 @@ interface ExtractOptions {
785
858
  readonly keyMaxLength?: number | undefined;
786
859
  /** Reuse a key when the same text exists as a source value. Default true. */
787
860
  readonly reuseExisting?: boolean | undefined;
788
- /** Strings or `/regex/` never extracted. */
861
+ /** Strings never extracted: an exact string such as `Elk`, or a `/regex/flags` string such as `/^v\d+/i` that matches the text. */
789
862
  readonly ignore?: readonly string[] | undefined;
790
- /** Ignored strings per file glob. */
863
+ /** Strings never extracted in the files of a glob, written as in `ignore`. */
791
864
  readonly ignoreByFile?: {
792
865
  readonly [glob: string]: readonly string[];
793
866
  } | undefined;
867
+ /** Attribute names whose values are text, on top of those whose last word is `title`, `label`, `text`, `placeholder`, `alt`, `content`, `description`, `message` and the like. Markup attributes outside this set are never extracted. */
868
+ readonly attributes?: readonly string[] | undefined;
794
869
  readonly ignoreAttributes?: readonly string[] | undefined;
795
870
  readonly ignoreCallees?: readonly string[] | undefined;
796
871
  readonly templates?: readonly (string | ExtractTemplate)[] | undefined;
@@ -804,7 +879,7 @@ interface UsageOptions {
804
879
  readonly ignoreCallees?: readonly string[] | undefined;
805
880
  /** Keys (globs) that count as used, for example keys built at runtime. */
806
881
  readonly keysInUse?: readonly string[] | undefined;
807
- /** Rules like `{key}_plural`: when `{key}` is used the derived keys count as used. */
882
+ /** Rules like `{key}_plural`: when `{key}` is used the derived keys count as used. Rules apply repeatedly, so with `{key}_*` the key `a_b_c` derives from both `a_b` and `a`. `*` matches one key segment. `{plural}` and `{ordinal}` match a plural or ordinal category (`one`, `few`, `other`): each locale then needs the categories of its own language, and categories the language lacks are optional. */
808
883
  readonly derivedKeys?: readonly string[] | undefined;
809
884
  }
810
885
  interface WriteOptions {
@@ -818,6 +893,12 @@ interface NamespaceOptions {
818
893
  readonly separator?: string | undefined;
819
894
  /** Namespace of keys written without one. */
820
895
  readonly default?: string | undefined;
896
+ /** Namespaces searched in order when a key written without a namespace is not in its own (i18next `fallbackNS`). */
897
+ readonly fallback?: readonly string[] | undefined;
898
+ /** Namespace of keys written without one, per glob of source files, before `default`: `{ 'packages/admin/**': 'admin' }`. */
899
+ readonly byFile?: {
900
+ readonly [glob: string]: string;
901
+ } | undefined;
821
902
  /** 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`. */
822
903
  readonly fromPath?: "dot" | "slash" | "camel" | undefined;
823
904
  }
@@ -909,6 +990,10 @@ interface ResolvedConfig {
909
990
  readonly namespace: {
910
991
  readonly separator: string;
911
992
  readonly default: string | undefined;
993
+ readonly fallback: readonly string[];
994
+ readonly byFile: {
995
+ readonly [glob: string]: string;
996
+ };
912
997
  readonly fromPath: NamespaceFromPath;
913
998
  };
914
999
  readonly usage: {
@@ -967,6 +1052,34 @@ export declare function keyMatcher(globs: readonly string[]): (key: KeyString) =
967
1052
  /** Matcher over posix file paths. */
968
1053
  export declare function pathMatcher(globs: readonly string[]): (path: string) => boolean;
969
1054
  //#endregion
1055
+ //#region src/library.d.ts
1056
+ interface LibraryOptions {
1057
+ /** The plugin name, prefixed by the caller: `x18n:vue-i18n`. */
1058
+ readonly name: string;
1059
+ readonly calls?: readonly CallSpec[];
1060
+ /** i18n-ally style `usageMatchRegex` patterns, for languages without an AST scanner. */
1061
+ readonly regex?: readonly RegexScannerSpec[];
1062
+ /** The library's placeholder syntaxes. */
1063
+ readonly placeholders?: readonly PlaceholderSyntax[];
1064
+ readonly derivedKeys?: readonly string[];
1065
+ readonly namespace?: {
1066
+ readonly separator?: string;
1067
+ readonly default?: string;
1068
+ readonly fromPath?: NamespaceFromPath;
1069
+ };
1070
+ /** Default resource patterns (i18n-ally `preferredLocalePaths` + `pathMatcher`). */
1071
+ readonly resources?: readonly ResourceSpec[];
1072
+ readonly keyStyle?: "nested" | "flat";
1073
+ readonly templates?: readonly ExtractTemplate[];
1074
+ readonly finders?: readonly LocaleFinder[];
1075
+ readonly normalizeKey?: Plugin["normalizeKey"];
1076
+ readonly transformResource?: ResourceTransform;
1077
+ readonly extra?: (draft: ConfigDraft, ctx: ConfigContext) => void | Promise<void>;
1078
+ readonly translatePrompt?: Plugin["translatePrompt"];
1079
+ }
1080
+ /** One plugin holding a library's call specs, regexes, namespace rules, defaults and extraction templates. */
1081
+ export declare function createLibraryPlugin(options: LibraryOptions): Plugin;
1082
+ //#endregion
970
1083
  //#region src/locale.d.ts
971
1084
  /** Lenient display parse of a locale code (`zh_CN`, `pt-BR`, `sr-Latn-RS`). Never changes the code. */
972
1085
  export declare function parseLocaleInfo(code: LocaleCode): LocaleInfo;
@@ -1019,9 +1132,11 @@ interface CapabilityConflict {
1019
1132
  /** The capabilities of the final plugin list, indexed for the project engine. */
1020
1133
  interface PluginRegistry {
1021
1134
  readonly plugins: readonly Plugin[];
1022
- readonly formats: readonly Format[];
1135
+ /** Every format, loaded or not; use `formatFor` and `loadFormatFor` to run one. */
1136
+ readonly formats: readonly (Format | LazyFormat)[];
1023
1137
  readonly finders: readonly LocaleFinder[];
1024
- readonly scanners: readonly Scanner[];
1138
+ /** Every scanner, loaded or not. */
1139
+ readonly scanners: readonly (Scanner | LazyScanner)[];
1025
1140
  readonly checks: ReadonlyMap<string, Check>;
1026
1141
  readonly reporters: ReadonlyMap<string, Reporter>;
1027
1142
  readonly placeholders: readonly PlaceholderSyntax[];
@@ -1029,10 +1144,18 @@ interface PluginRegistry {
1029
1144
  readonly extractors: readonly Extractor[];
1030
1145
  readonly histories: readonly HistoryProvider[];
1031
1146
  readonly conflicts: readonly CapabilityConflict[];
1032
- /** Format for a file path: config `formats` first, then the first format that claims the extension. */
1147
+ /** The loaded format for a file path: config `formats` first, then the first format that claims the extension. */
1033
1148
  readonly formatFor: (path: string, hint?: string) => Format | undefined;
1149
+ /** The id of the format `formatFor` would pick, loaded or not. */
1150
+ readonly formatIdFor: (path: string, hint?: string) => string | undefined;
1151
+ /** The loaded format with this id. */
1034
1152
  readonly formatById: (id: string) => Format | undefined;
1035
- readonly scannersFor: (path: string) => readonly Scanner[];
1153
+ /** Loads the format of a file path, once, and returns it. */
1154
+ readonly loadFormatFor: (path: string, hint?: string) => Promise<Format | undefined>;
1155
+ /** The scanners that handle a file path, loaded or not. */
1156
+ readonly scannersFor: (path: string) => readonly (Scanner | LazyScanner)[];
1157
+ /** Loads the scanners of a file path, once each, and returns them. */
1158
+ readonly loadScannersFor: (path: string) => Promise<readonly Scanner[]>;
1036
1159
  }
1037
1160
  export declare function extensionOf(path: string): string;
1038
1161
  export declare function createRegistry(input: readonly Plugin[], config: Pick<ResolvedConfig, "formats">): PluginRegistry;
@@ -1099,7 +1222,7 @@ export declare function renderPrompt(data: PromptData): string;
1099
1222
  interface Builtins {
1100
1223
  readonly plugins: readonly Plugin[];
1101
1224
  readonly presets: {
1102
- readonly [name: string]: PresetFactory;
1225
+ readonly [name: string]: PresetEntry;
1103
1226
  };
1104
1227
  }
1105
1228
  interface ResolveConfigOptions {
@@ -1456,4 +1579,4 @@ export declare function rangeContains(range: TextRange, offset: number): boolean
1456
1579
  /** Fast non-cryptographic string hash (cyrb53), as hex. */
1457
1580
  export declare function hashText(text: string): string;
1458
1581
  //#endregion
1459
- 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, GlobOptions, GlossaryEntry, HardString, HistoryConfigs, HistoryContext, HistoryOptions, HistoryProvider, HistorySettings, Host, JsonObject, JsonPrimitive, JsonValue, KeyContext, KeyId, KeyInfo, KeyQuery, KeyString, KeyTarget, 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, PluginOption, PluginRegistry, PluginSpecifier, Preset, PresetExplanation, PresetFactory, PresetOption, Project, ProjectChange, ProjectExplanation, PromptContext, PromptData, PromptDraft, PromptItem, PromptOptions, RegexScannerSpec, ReportInput, ReportSummary, Reporter, ReporterContext, ResolveConfigOptions, ResolvedConfig, Resource, ResourceBlock, ResourceCandidate, ResourceId, ResourceNode, ResourceSpec, ResourceTransform, ResourceTransformContext, ScanContext, ScanResult, Scanner, SchemaIssue, Scope, SerializableConfig, Setup, Severity, SourceFile, TextEdit, TextRange, TreeLeaf, Usage, UsageCandidate, UsageContext, UsageKind, UsageMatch, UsageOptions, UsageQuery, UserConfig, WireDiagnostic, WireRange, WriteOptions, X18nError };
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 };
package/dist/index.mjs CHANGED
@@ -138,7 +138,9 @@ function defineConfig(config) {
138
138
  }
139
139
  //#endregion
140
140
  //#region src/errors.ts
141
- const at = (p) => p.line === void 0 ? p.path : `${p.path}:${p.line}`;
141
+ function at(p) {
142
+ return p.line === void 0 ? p.path : `${p.path}:${p.line}`;
143
+ }
142
144
  /**
143
145
  * Every coded error. Groups: 0xxx config, 1xxx plugin, 2xxx discovery and parse,
144
146
  * 3xxx scanning, 4xxx edits, 5xxx reviews. `fix` stays static so the docs can show it.
@@ -331,6 +333,40 @@ function pathMatcher(globs) {
331
333
  return (path) => regexes.some((re) => re.test(path));
332
334
  }
333
335
  //#endregion
336
+ //#region src/library.ts
337
+ /** One plugin holding a library's call specs, regexes, namespace rules, defaults and extraction templates. */
338
+ function createLibraryPlugin(options) {
339
+ return {
340
+ name: options.name,
341
+ async config(draft, ctx) {
342
+ if (options.namespace) draft.namespace = {
343
+ ...draft.namespace,
344
+ separator: options.namespace.separator ?? draft.namespace.separator,
345
+ default: draft.namespace.default ?? options.namespace.default,
346
+ fromPath: options.namespace.fromPath ?? draft.namespace.fromPath
347
+ };
348
+ if (options.resources) draft.resources = [...draft.resources, ...options.resources.filter((spec) => !draft.resources.some((r) => r.pattern === spec.pattern))];
349
+ if (options.keyStyle && draft.keyStyle === "auto" && !ctx.user.keyStyle) draft.keyStyle = options.keyStyle;
350
+ draft.usage = {
351
+ ...draft.usage,
352
+ calls: [...draft.usage.calls, ...options.calls ?? []],
353
+ regex: [...draft.usage.regex, ...options.regex ?? []],
354
+ derivedKeys: [...draft.usage.derivedKeys, ...options.derivedKeys ?? []]
355
+ };
356
+ await options.extra?.(draft, ctx);
357
+ },
358
+ placeholders: options.placeholders,
359
+ localeFinders: options.finders,
360
+ normalizeKey: options.normalizeKey,
361
+ transformResource: options.transformResource,
362
+ translatePrompt: options.translatePrompt,
363
+ extractors: options.templates ? [{
364
+ id: options.name,
365
+ templates: options.templates
366
+ }] : void 0
367
+ };
368
+ }
369
+ //#endregion
334
370
  //#region src/locale.ts
335
371
  const defaultRegions = {
336
372
  ar: "SA",
@@ -418,8 +454,10 @@ function parseLocaleInfo(code) {
418
454
  region
419
455
  ].filter(Boolean).join("-");
420
456
  const displayName = language ? safe(() => new Intl.DisplayNames(["en"], { type: "language" }).of(tag)) : void 0;
421
- const found = language ? safe(() => new Intl.PluralRules(tag).resolvedOptions().pluralCategories) : void 0;
422
- const pluralCategories = found ? pluralOrder.filter((category) => found.includes(category)) : ["other"];
457
+ const categories = (type) => {
458
+ const found = language ? safe(() => new Intl.PluralRules(tag, { type }).resolvedOptions().pluralCategories) : void 0;
459
+ return found ? pluralOrder.filter((category) => found.includes(category)) : ["other"];
460
+ };
423
461
  const flagRegion = region ?? (language === "zh" && script === "Hant" ? "TW" : language ? defaultRegions[language] : void 0);
424
462
  return {
425
463
  code,
@@ -427,7 +465,8 @@ function parseLocaleInfo(code) {
427
465
  region,
428
466
  displayName,
429
467
  flag: flagRegion ? flagOf(flagRegion) : void 0,
430
- pluralCategories
468
+ pluralCategories: categories("cardinal"),
469
+ ordinalCategories: categories("ordinal")
431
470
  };
432
471
  }
433
472
  /** Codes that differ only in spelling (`zh_CN` and `zh-cn`), the same after a lenient normalization. */
@@ -582,8 +621,8 @@ function uniqueById(items, kind) {
582
621
  }
583
622
  function createRegistry(input, config) {
584
623
  const plugins = sortByEnforce(input);
585
- const formats = plugins.flatMap((plugin) => plugin.formats ?? []);
586
- const scanners = plugins.flatMap((plugin) => plugin.scanners ?? []);
624
+ const formats = plugins.flatMap((plugin) => [...plugin.formats ?? [], ...plugin.lazyFormats ?? []]);
625
+ const scanners = plugins.flatMap((plugin) => [...plugin.scanners ?? [], ...plugin.lazyScanners ?? []]);
587
626
  const finders = [...uniqueById(plugins.flatMap((plugin) => plugin.localeFinders ?? []), "Locale finder").values()];
588
627
  const checks = uniqueById(plugins.flatMap((plugin) => plugin.checks ?? []), "Check");
589
628
  const reporters = uniqueById(plugins.flatMap((plugin) => plugin.reporters ?? []), "Reporter");
@@ -599,8 +638,58 @@ function createRegistry(input, config) {
599
638
  });
600
639
  else byExtension.set(extension, format);
601
640
  }
602
- const formatById = (id) => formats.find((format) => format.id === id);
641
+ const isLazy = (format) => "load" in format;
642
+ const loaded = new Map(formats.filter((format) => !isLazy(format)).map((format) => [format.id, format]));
643
+ const loading = /* @__PURE__ */ new Map();
644
+ const formatById = (id) => loaded.get(id);
645
+ const formatIdFor = (path, hint) => {
646
+ if (hint) return hint;
647
+ const extension = extensionOf(path);
648
+ return config.formats[extension] ?? byExtension.get(extension)?.id;
649
+ };
650
+ const loadFormat = (id) => {
651
+ const ready = loaded.get(id);
652
+ if (ready) return Promise.resolve(ready);
653
+ const lazy = formats.find((format) => format.id === id);
654
+ if (!lazy || !isLazy(lazy)) return Promise.resolve(void 0);
655
+ let pending = loading.get(id);
656
+ if (!pending) {
657
+ pending = lazy.load().then((mod) => {
658
+ const format = mod.default().formats?.find((item) => item.id === id && !isLazy(item));
659
+ if (format) loaded.set(id, format);
660
+ return format;
661
+ });
662
+ loading.set(id, pending);
663
+ }
664
+ return pending;
665
+ };
603
666
  const scannerCache = /* @__PURE__ */ new Map();
667
+ const loadedScanners = new Map(scanners.filter((scanner) => !("load" in scanner)).map((scanner) => [scanner.id, scanner]));
668
+ const loadingScanners = /* @__PURE__ */ new Map();
669
+ const loadScanner = (entry) => {
670
+ if (!("load" in entry)) return Promise.resolve(entry);
671
+ const ready = loadedScanners.get(entry.id);
672
+ if (ready) return Promise.resolve(ready);
673
+ let pending = loadingScanners.get(entry.id);
674
+ if (!pending) {
675
+ pending = entry.load().then((mod) => {
676
+ const scanner = mod.default().scanners?.find((item) => item.id === entry.id);
677
+ if (scanner) loadedScanners.set(entry.id, scanner);
678
+ return scanner;
679
+ });
680
+ loadingScanners.set(entry.id, pending);
681
+ }
682
+ return pending;
683
+ };
684
+ const scannersFor = (path) => {
685
+ const extension = extensionOf(path);
686
+ let list = scannerCache.get(extension);
687
+ if (!list) {
688
+ list = extension ? scanners.filter((scanner) => scanner.extensions.includes(extension) || scanner.extensions.includes("*")) : [];
689
+ scannerCache.set(extension, list);
690
+ }
691
+ return list;
692
+ };
604
693
  return {
605
694
  plugins,
606
695
  formats,
@@ -614,20 +703,18 @@ function createRegistry(input, config) {
614
703
  histories: plugins.flatMap((plugin) => plugin.histories ?? []),
615
704
  conflicts,
616
705
  formatById,
706
+ formatIdFor,
617
707
  formatFor(path, hint) {
618
- if (hint) return formatById(hint);
619
- const extension = extensionOf(path);
620
- const mapped = config.formats[extension];
621
- return mapped ? formatById(mapped) : byExtension.get(extension);
708
+ const id = formatIdFor(path, hint);
709
+ return id === void 0 ? void 0 : formatById(id);
622
710
  },
623
- scannersFor(path) {
624
- const extension = extensionOf(path);
625
- let list = scannerCache.get(extension);
626
- if (!list) {
627
- list = extension ? scanners.filter((scanner) => scanner.extensions.includes(extension) || scanner.extensions.includes("*")) : [];
628
- scannerCache.set(extension, list);
629
- }
630
- return list;
711
+ async loadFormatFor(path, hint) {
712
+ const id = formatIdFor(path, hint);
713
+ return id === void 0 ? void 0 : loadFormat(id);
714
+ },
715
+ scannersFor,
716
+ async loadScannersFor(path) {
717
+ return (await Promise.all(scannersFor(path).map(loadScanner))).filter((scanner) => scanner !== void 0);
631
718
  }
632
719
  };
633
720
  }
@@ -662,7 +749,7 @@ const callSpec = v.strictObject({
662
749
  "accessor"
663
750
  ])),
664
751
  key: v.optional(v.union([v.number(), argSource])),
665
- namespace: v.optional(argSource),
752
+ namespace: v.optional(v.union([argSource, v.string()])),
666
753
  keyPrefix: v.optional(argSource),
667
754
  factory: v.optional(v.boolean()),
668
755
  returns: v.optional(strings),
@@ -706,6 +793,7 @@ const extractOptions = v.strictObject({
706
793
  reuseExisting: v.optional(v.boolean()),
707
794
  ignore: v.optional(strings),
708
795
  ignoreByFile: v.optional(v.record(v.string(), strings)),
796
+ attributes: v.optional(strings),
709
797
  ignoreAttributes: v.optional(strings),
710
798
  ignoreCallees: v.optional(strings),
711
799
  templates: v.optional(extractTemplates)
@@ -720,6 +808,8 @@ const checkSetting = v.union([level, v.tuple([level, jsonObject])]);
720
808
  const namespaceOptions = v.strictObject({
721
809
  separator: v.optional(v.string()),
722
810
  default: v.optional(v.string()),
811
+ fallback: v.optional(strings),
812
+ byFile: v.optional(v.record(v.string(), v.string())),
723
813
  fromPath: v.optional(v.picklist([
724
814
  "dot",
725
815
  "slash",
@@ -938,6 +1028,159 @@ function createHistory(input) {
938
1028
  };
939
1029
  }
940
1030
  //#endregion
1031
+ //#region src/project/usages.ts
1032
+ function innermost(scopes, range) {
1033
+ let best;
1034
+ for (const scope of scopes) if (scope.range.start <= range.start && scope.range.end >= range.end && (!best || scope.range.end - scope.range.start < best.range.end - best.range.start)) best = scope;
1035
+ return best;
1036
+ }
1037
+ function createUsageResolver(config, namespaced, normalizers) {
1038
+ const { separator, fallback } = config.namespace;
1039
+ const byFile = Object.entries(config.namespace.byFile).map(([glob, namespace]) => ({
1040
+ matches: pathMatcher([glob]),
1041
+ namespace
1042
+ }));
1043
+ return { resolve(candidate, scopes, path, scanner, has) {
1044
+ const scope = innermost(scopes, candidate.range);
1045
+ const ctx = { path };
1046
+ let text = candidate.raw;
1047
+ for (const normalize of normalizers) {
1048
+ const result = normalize(text, ctx);
1049
+ if (result !== void 0) {
1050
+ text = result;
1051
+ break;
1052
+ }
1053
+ }
1054
+ const scopeNamespace = candidate.namespace ?? scope?.namespace ?? byFile.find((rule) => rule.matches(path))?.namespace;
1055
+ let scopePrefix = candidate.keyPrefix ?? scope?.keyPrefix;
1056
+ let namespace;
1057
+ let explicit = false;
1058
+ if (namespaced) {
1059
+ const index = text.indexOf(separator);
1060
+ explicit = index > 0;
1061
+ if (explicit) {
1062
+ namespace = text.slice(0, index);
1063
+ text = text.slice(index + separator.length);
1064
+ } else namespace = scopeNamespace ?? config.namespace.default;
1065
+ }
1066
+ if (scopePrefix && !explicit) {
1067
+ const prefixed = text ? `${scopePrefix}.${text}` : scopePrefix;
1068
+ const lookup = candidate.dynamic === "prefix" ? prefixed.replace(/\.$/, "") : prefixed;
1069
+ if (candidate.keyPrefix || !scope?.optionalPrefix || has(joinKey(namespace, lookup.split(".")))) text = prefixed;
1070
+ else scopePrefix = void 0;
1071
+ }
1072
+ const keyPath = text === "" ? [] : text.split(".");
1073
+ let key;
1074
+ if (candidate.dynamic === "unknown") key = void 0;
1075
+ else key = joinKey(namespace, keyPath);
1076
+ if (key !== void 0 && namespaced && !explicit && candidate.dynamic === false && !has(key)) key = fallback.map((ns) => joinKey(ns, keyPath)).find(has) ?? key;
1077
+ return {
1078
+ key,
1079
+ raw: candidate.raw,
1080
+ path,
1081
+ range: candidate.range,
1082
+ expression: candidate.expression,
1083
+ scanner,
1084
+ kind: candidate.kind,
1085
+ dynamic: candidate.dynamic,
1086
+ locale: candidate.locale,
1087
+ scopeNamespace,
1088
+ scopePrefix
1089
+ };
1090
+ } };
1091
+ }
1092
+ /** True when a prefix usage may refer to `key`. The key of `t(`pages.${id}`)` is `pages.`, so only children match. */
1093
+ function prefixMatches(usage, key) {
1094
+ return usage.dynamic === "prefix" && usage.key !== void 0 && key.startsWith(usage.key);
1095
+ }
1096
+ /** Ranges covered by `x18n-disable-next-line` and `x18n-disable` ... `x18n-enable` comments. */
1097
+ function suppressedRanges(text) {
1098
+ if (!text.includes("x18n-disable")) return [];
1099
+ const ranges = [];
1100
+ for (const match of text.matchAll(/x18n-disable-next-line/g)) {
1101
+ const lineEnd = text.indexOf("\n", match.index);
1102
+ if (lineEnd === -1) continue;
1103
+ const nextEnd = text.indexOf("\n", lineEnd + 1);
1104
+ ranges.push({
1105
+ start: lineEnd + 1,
1106
+ end: nextEnd === -1 ? text.length : nextEnd
1107
+ });
1108
+ }
1109
+ for (const match of text.matchAll(/x18n-disable(?![-\w])/g)) {
1110
+ const end = text.indexOf("x18n-enable", match.index);
1111
+ ranges.push({
1112
+ start: match.index,
1113
+ end: end === -1 ? text.length : end
1114
+ });
1115
+ }
1116
+ return ranges;
1117
+ }
1118
+ /** The plural categories of a locale's language: Japanese has only `other`. */
1119
+ function categoriesOf(info, type) {
1120
+ return type === "ordinal" ? info.ordinalCategories : info.pluralCategories;
1121
+ }
1122
+ const tokens = /(\{key\}|\{plural\}|\{ordinal\}|\*)/;
1123
+ const categories = [
1124
+ "zero",
1125
+ "one",
1126
+ "two",
1127
+ "few",
1128
+ "many",
1129
+ "other"
1130
+ ];
1131
+ /**
1132
+ * Compiles derived-key rules (`{key}_plural`, `{key}_*`, `{key}_{plural}`). `*` is any key segment;
1133
+ * `{plural}` and `{ordinal}` are CLDR plural categories, which each locale needs its own set of.
1134
+ */
1135
+ function derivedKeys(rules) {
1136
+ const compiled = rules.map((rule) => {
1137
+ const parts = rule.split(tokens);
1138
+ const type = parts.includes("{ordinal}") ? "ordinal" : parts.includes("{plural}") ? "cardinal" : void 0;
1139
+ const source = parts.map((part) => {
1140
+ if (part === "{key}") return "(?<key>.+)";
1141
+ if (part === "{plural}" || part === "{ordinal}") return `(?<category>${categories.join("|")})`;
1142
+ if (part === "*") return "[^.:]+";
1143
+ return part.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
1144
+ }).join("");
1145
+ return {
1146
+ type,
1147
+ re: new RegExp(`^${source}$`, "d")
1148
+ };
1149
+ });
1150
+ const baseOf = (key) => {
1151
+ for (const { re } of compiled) {
1152
+ const base = re.exec(key)?.groups?.key;
1153
+ if (base) return base;
1154
+ }
1155
+ };
1156
+ return {
1157
+ basesOf(key) {
1158
+ const bases = [];
1159
+ for (let base = baseOf(key); base !== void 0; base = baseOf(base)) bases.push(base);
1160
+ return bases;
1161
+ },
1162
+ formOf(key) {
1163
+ let best;
1164
+ for (const { type, re } of compiled) {
1165
+ const match = type && re.exec(key);
1166
+ if (!type || !match) continue;
1167
+ const base = match.groups?.key;
1168
+ const category = categories.find((c) => c === match.groups?.category);
1169
+ const at = match.indices?.groups?.category;
1170
+ if (!base || !category || !at || best && best.base.length <= base.length) continue;
1171
+ const [start, end] = at;
1172
+ best = {
1173
+ base,
1174
+ type,
1175
+ category,
1176
+ keyOf: (other) => `${key.slice(0, start)}${other}${key.slice(end)}`
1177
+ };
1178
+ }
1179
+ return best;
1180
+ }
1181
+ };
1182
+ }
1183
+ //#endregion
941
1184
  //#region src/project/analysis.ts
942
1185
  function levelOf(setting) {
943
1186
  if (setting === void 0) return void 0;
@@ -950,9 +1193,24 @@ function levelOf(setting) {
950
1193
  options: setting[1]
951
1194
  };
952
1195
  }
953
- function computeCoverage(state, reference) {
1196
+ /**
1197
+ * The keys a locale needs: the reference keys, with plural forms swapped for the categories of the locale's
1198
+ * language. Forms the language lacks (`item_zero` in English) count only where they exist.
1199
+ */
1200
+ function expectedKeys(state, options, reference, locale) {
1201
+ const info = options.localeInfo(locale);
1202
+ const keys = /* @__PURE__ */ new Set();
1203
+ for (const key of reference) {
1204
+ const form = options.formOf(key);
1205
+ if (!form || state.messages.get(key)?.has(locale)) keys.add(key);
1206
+ form && categoriesOf(info, form.type).forEach((category) => keys.add(form.keyOf(category)));
1207
+ }
1208
+ return [...keys].sort();
1209
+ }
1210
+ function computeCoverage(state, options, sourceKeys) {
954
1211
  const { messages, sourceLocale } = state;
955
1212
  return state.locales.map((locale) => {
1213
+ const reference = expectedKeys(state, options, sourceKeys, locale);
956
1214
  const chain = fallbackChain(state.fallbacks, locale);
957
1215
  const inherited = (key) => chain.some((code) => {
958
1216
  const value = messages.get(key)?.get(code)?.[0]?.value;
@@ -1007,7 +1265,7 @@ function* runCheck(check, level, inputs, input) {
1007
1265
  const severity = config.strict && (item.severity ?? level) === "warning" ? "error" : item.severity ?? level;
1008
1266
  let location = item.location;
1009
1267
  if (!location && item.key !== void 0) {
1010
- const byLocale = state.messages.get(item.key);
1268
+ const byLocale = state.messages.get(item.key) ?? state.messages.get(input.ctx.formOf(item.key)?.keyOf("other") ?? item.key);
1011
1269
  const found = byLocale?.get(item.locale ?? state.sourceLocale)?.[0] ?? byLocale?.get(state.sourceLocale)?.[0];
1012
1270
  if (found) location = {
1013
1271
  path: resourcePath(found.resource),
@@ -1067,7 +1325,7 @@ function createAnalyzer(options) {
1067
1325
  const { sourceLocale, locales, messages } = state;
1068
1326
  const sourceKeys = [...messages.keys()].filter((key) => messages.get(key)?.has(sourceLocale)).sort();
1069
1327
  const allKeys = [...messages.keys()].sort();
1070
- const coverage = computeCoverage(state, sourceKeys.length > 0 ? sourceKeys : allKeys);
1328
+ const coverage = computeCoverage(state, options, sourceKeys.length > 0 ? sourceKeys : allKeys);
1071
1329
  const isDefined = (key) => messages.get(key)?.has(sourceLocale) === true || state.parents.has(key) || (state.derived.get(key)?.length ?? 0) > 0 || sourceKeys.length === 0 && messages.has(key);
1072
1330
  const hasScanners = state.files.size > 0;
1073
1331
  const referenceSyntaxes = registry.placeholders.filter((syntax) => syntax.reference);
@@ -1079,8 +1337,7 @@ function createAnalyzer(options) {
1079
1337
  const prefixes = [...new Set(state.prefixUsages.flatMap((usage) => usage.key === void 0 ? [] : [usage.key]))];
1080
1338
  const isUsed = (key) => {
1081
1339
  if (state.usagesByKey.has(key) || inUse(key) || referenced.has(key)) return true;
1082
- const base = options.baseOf(key);
1083
- if (base !== void 0 && (state.usagesByKey.has(base) || inUse(base))) return true;
1340
+ if (options.basesOf(key).some((base) => state.usagesByKey.has(base) || inUse(base))) return true;
1084
1341
  return prefixes.some((prefix) => key.startsWith(prefix));
1085
1342
  };
1086
1343
  const unused = hasScanners ? allKeys.filter((key) => !isUsed(key)) : [];
@@ -1107,6 +1364,7 @@ function createAnalyzer(options) {
1107
1364
  },
1108
1365
  message: (key, locale) => messages.get(key)?.get(locale)?.[0],
1109
1366
  duplicates: (key, locale) => messages.get(key)?.get(locale) ?? [],
1367
+ formOf: options.formOf,
1110
1368
  usages: () => state.usages,
1111
1369
  coverage: () => coverage,
1112
1370
  unused: () => unused,
@@ -1184,7 +1442,8 @@ function mirrorPath(path, from, to) {
1184
1442
  function importEdit(text, lines) {
1185
1443
  const missing = lines.filter((line) => !text.includes(line.trim()));
1186
1444
  if (missing.length === 0) return void 0;
1187
- const script = /<script\b[^>]*>\r?\n?/.exec(text);
1445
+ const open = /^\s*---\r?\n/.exec(text);
1446
+ const script = (open && /^---\s*$/m.test(text.slice(open[0].length)) ? open : void 0) ?? /<script\b[^>]*>\r?\n?/.exec(text);
1188
1447
  let at = 0;
1189
1448
  if (script) at = script.index + script[0].length;
1190
1449
  else {
@@ -1278,7 +1537,9 @@ var PlanBuilder = class {
1278
1537
  const sourceState = source && this.stateById.get(source.resource);
1279
1538
  const namespace = namespaced ? this.keyId(key).namespace ?? this.config.namespace.default : sourceState?.resource.namespace;
1280
1539
  const exists = (path) => resources.some((r) => r.resource.path === path);
1281
- const resolved = this.options.resolveNew(locale, namespace);
1540
+ const sibling = (r) => r.resource.locale === locale && r.resource.namespace !== void 0 && namespace !== void 0;
1541
+ const model = (sourceState && fits(sourceState) ? sourceState : void 0) ?? resources.find((s) => s.resource.locale === sourceLocale && fits(s)) ?? resources.find(fits) ?? resources.find(sibling);
1542
+ const resolved = this.options.resolveNew(locale, namespace, model?.resource);
1282
1543
  if (resolved && !exists(resolved)) {
1283
1544
  const format = this.options.registry.formatFor(resolved);
1284
1545
  if (format?.serialize) {
@@ -1294,8 +1555,6 @@ var PlanBuilder = class {
1294
1555
  return target;
1295
1556
  }
1296
1557
  }
1297
- const sibling = (r) => r.resource.locale === locale && r.resource.namespace !== void 0 && namespace !== void 0;
1298
- const model = (sourceState && fits(sourceState) ? sourceState : void 0) ?? resources.find((s) => s.resource.locale === sourceLocale && fits(s)) ?? resources.find(fits) ?? resources.find(sibling);
1299
1558
  const format = model && this.options.registry.formatById(model.resource.format);
1300
1559
  let path = model && mirrorPath(model.resource.path, model.resource.locale, locale);
1301
1560
  if (model && path && namespace !== void 0 && model.resource.namespace !== void 0 && model.resource.namespace !== namespace) path = mirrorPath(path, model.resource.namespace, namespace);
@@ -1527,7 +1786,7 @@ var PlanBuilder = class {
1527
1786
  const all = this.options.allKeys();
1528
1787
  const isLeaf = this.options.messages(op.from) !== void 0;
1529
1788
  const under = all.filter((key) => isKeyUnder(key, op.from));
1530
- const derived = all.filter((key) => this.options.baseOf(key) === op.from);
1789
+ const derived = all.filter((key) => this.options.basesOf(key).includes(op.from));
1531
1790
  let keys;
1532
1791
  if (op.type === "rename-prefix") keys = under;
1533
1792
  else if (isLeaf) keys = [op.from, ...derived];
@@ -1919,95 +2178,6 @@ function renderPrompt(data) {
1919
2178
  return out.join("\n");
1920
2179
  }
1921
2180
  //#endregion
1922
- //#region src/project/usages.ts
1923
- function innermost(scopes, range) {
1924
- let best;
1925
- for (const scope of scopes) if (scope.range.start <= range.start && scope.range.end >= range.end && (!best || scope.range.end - scope.range.start < best.range.end - best.range.start)) best = scope;
1926
- return best;
1927
- }
1928
- function createUsageResolver(config, namespaced, normalizers) {
1929
- const separator = config.namespace.separator;
1930
- return { resolve(candidate, scopes, path, scanner) {
1931
- const scope = innermost(scopes, candidate.range);
1932
- const ctx = { path };
1933
- let text = candidate.raw;
1934
- for (const normalize of normalizers) {
1935
- const result = normalize(text, ctx);
1936
- if (result !== void 0) {
1937
- text = result;
1938
- break;
1939
- }
1940
- }
1941
- const scopeNamespace = candidate.namespace ?? scope?.namespace;
1942
- const scopePrefix = candidate.keyPrefix ?? scope?.keyPrefix;
1943
- let namespace;
1944
- let explicit = false;
1945
- if (namespaced) {
1946
- const index = text.indexOf(separator);
1947
- explicit = index > 0;
1948
- if (explicit) {
1949
- namespace = text.slice(0, index);
1950
- text = text.slice(index + separator.length);
1951
- } else namespace = scopeNamespace ?? config.namespace.default;
1952
- }
1953
- if (scopePrefix && !explicit) text = text ? `${scopePrefix}.${text}` : scopePrefix;
1954
- let key;
1955
- if (candidate.dynamic === "unknown") key = void 0;
1956
- else key = joinKey(namespace, text === "" ? [] : text.split("."));
1957
- return {
1958
- key,
1959
- raw: candidate.raw,
1960
- path,
1961
- range: candidate.range,
1962
- scanner,
1963
- kind: candidate.kind,
1964
- dynamic: candidate.dynamic,
1965
- locale: candidate.locale,
1966
- scopeNamespace,
1967
- scopePrefix
1968
- };
1969
- } };
1970
- }
1971
- /** True when a prefix usage may refer to `key`. The key of `t(`pages.${id}`)` is `pages.`, so only children match. */
1972
- function prefixMatches(usage, key) {
1973
- return usage.dynamic === "prefix" && usage.key !== void 0 && key.startsWith(usage.key);
1974
- }
1975
- /** Ranges covered by `x18n-disable-next-line` and `x18n-disable` ... `x18n-enable` comments. */
1976
- function suppressedRanges(text) {
1977
- if (!text.includes("x18n-disable")) return [];
1978
- const ranges = [];
1979
- for (const match of text.matchAll(/x18n-disable-next-line/g)) {
1980
- const lineEnd = text.indexOf("\n", match.index);
1981
- if (lineEnd === -1) continue;
1982
- const nextEnd = text.indexOf("\n", lineEnd + 1);
1983
- ranges.push({
1984
- start: lineEnd + 1,
1985
- end: nextEnd === -1 ? text.length : nextEnd
1986
- });
1987
- }
1988
- for (const match of text.matchAll(/x18n-disable(?![-\w])/g)) {
1989
- const end = text.indexOf("x18n-enable", match.index);
1990
- ranges.push({
1991
- start: match.index,
1992
- end: end === -1 ? text.length : end
1993
- });
1994
- }
1995
- return ranges;
1996
- }
1997
- /** Compiles derived-key rules (`{key}_plural`, `{key}_*`) into a function from a key to its base key. */
1998
- function derivedBase(rules) {
1999
- const regexes = rules.map((rule) => {
2000
- const source = rule.split("{key}").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^.:]+")).join("(.+)");
2001
- return new RegExp(`^${source}$`);
2002
- });
2003
- return (key) => {
2004
- for (const re of regexes) {
2005
- const match = re.exec(key);
2006
- if (match?.[1]) return match[1];
2007
- }
2008
- };
2009
- }
2010
- //#endregion
2011
2181
  //#region src/project/project.ts
2012
2182
  const scanConcurrency = 64;
2013
2183
  function createProject(options) {
@@ -2033,7 +2203,7 @@ function createProject(options) {
2033
2203
  const normalizers = registry.plugins.flatMap((plugin) => plugin.normalizeKey ? [plugin.normalizeKey] : []);
2034
2204
  const transforms = registry.plugins.flatMap((plugin) => plugin.transformResource ? [plugin.transformResource] : []);
2035
2205
  const localeInfoHooks = registry.plugins.flatMap((plugin) => plugin.localeInfo ? [plugin.localeInfo] : []);
2036
- const baseOf = derivedBase(config.usage.derivedKeys);
2206
+ const { basesOf, formOf } = derivedKeys(config.usage.derivedKeys);
2037
2207
  const scanSignature = hashText(JSON.stringify({
2038
2208
  usage: config.usage,
2039
2209
  namespace: config.namespace,
@@ -2061,6 +2231,11 @@ function createProject(options) {
2061
2231
  }
2062
2232
  return info;
2063
2233
  }
2234
+ /** Whether `locale` needs `key`: not a plural form of a category its language lacks. */
2235
+ function needs(key, locale) {
2236
+ const form = formOf(key);
2237
+ return !form || categoriesOf(localeInfo(locale), form.type).includes(form.category);
2238
+ }
2064
2239
  async function discover() {
2065
2240
  const ctx = {
2066
2241
  host,
@@ -2188,6 +2363,22 @@ function createProject(options) {
2188
2363
  if (state.error) return void 0;
2189
2364
  return new Map([...walkLeaves(state.tree)].map((leaf) => [keyFor(state.resource, leaf.path), leaf.value]));
2190
2365
  }
2366
+ /** A path for a new resource, from the locale finders. */
2367
+ function newResourcePath(locale, namespace, model) {
2368
+ const ctx = {
2369
+ host,
2370
+ config,
2371
+ extensions: [...new Set(registry.formats.flatMap((format) => format.extensions))]
2372
+ };
2373
+ for (const finder of registry.finders) {
2374
+ const path = finder.resolveNew?.({
2375
+ locale,
2376
+ namespace,
2377
+ model
2378
+ }, ctx);
2379
+ if (path) return path;
2380
+ }
2381
+ }
2191
2382
  async function loadResources(candidates, reread) {
2192
2383
  const next = /* @__PURE__ */ new Map();
2193
2384
  const paths = [...new Set(candidates.map((c) => c.path))];
@@ -2199,6 +2390,7 @@ function createProject(options) {
2199
2390
  }
2200
2391
  }));
2201
2392
  namespaced = config.namespace.separator !== "." && candidates.some((c) => c.namespace !== void 0);
2393
+ await Promise.all(candidates.map((candidate) => registry.loadFormatFor(candidate.path, candidate.format)));
2202
2394
  for (const candidate of candidates) {
2203
2395
  const text = fileTexts.get(candidate.path);
2204
2396
  if (text === void 0) continue;
@@ -2219,6 +2411,11 @@ function createProject(options) {
2219
2411
  resolver = createUsageResolver(config, namespaced, normalizers);
2220
2412
  resolved = /* @__PURE__ */ new WeakMap();
2221
2413
  resourceIndex = void 0;
2414
+ const namespaces = /* @__PURE__ */ new Set([void 0, ...[...resourceStates.values()].map((state) => state.resource.namespace)]);
2415
+ await Promise.all([...namespaces].map((namespace) => {
2416
+ const path = newResourcePath(sourceLocale, namespace, void 0);
2417
+ return path ? registry.loadFormatFor(path) : void 0;
2418
+ }));
2222
2419
  }
2223
2420
  async function scanOne(file) {
2224
2421
  const usages = [];
@@ -2226,7 +2423,7 @@ function createProject(options) {
2226
2423
  const keySlots = [];
2227
2424
  const hardStrings = [];
2228
2425
  const calls = config.usage.calls.filter((spec) => ![spec.callee].flat().some((name) => config.usage.ignoreCallees.includes(name)));
2229
- for (const scanner of registry.scannersFor(file.path)) {
2426
+ for (const scanner of await registry.loadScannersFor(file.path)) {
2230
2427
  const result = await scanner.scan(file, {
2231
2428
  config,
2232
2429
  calls
@@ -2379,13 +2576,10 @@ function createProject(options) {
2379
2576
  }
2380
2577
  for (const { namespace, path } of keyPaths.values()) for (let i = 1; i < path.length; i++) parents.add(joinKey(namespace, path.slice(0, i)));
2381
2578
  const derived = /* @__PURE__ */ new Map();
2382
- if (config.usage.derivedKeys.length > 0) for (const key of messages.keys()) {
2383
- const base = baseOf(key);
2384
- if (base) {
2385
- const list = derived.get(base) ?? [];
2386
- derived.set(base, list);
2387
- list.push(key);
2388
- }
2579
+ if (config.usage.derivedKeys.length > 0) for (const key of messages.keys()) for (const base of basesOf(key)) {
2580
+ const list = derived.get(base) ?? [];
2581
+ derived.set(base, list);
2582
+ list.push(key);
2389
2583
  }
2390
2584
  return {
2391
2585
  messages,
@@ -2396,12 +2590,13 @@ function createProject(options) {
2396
2590
  };
2397
2591
  }
2398
2592
  function buildIndex() {
2399
- resourceIndex ??= buildResourceIndex();
2593
+ const { messages, parents, derived } = resourceIndex ??= buildResourceIndex();
2594
+ const has = (key) => messages.has(key) || parents.has(key) || derived.has(key);
2400
2595
  const usages = [];
2401
2596
  for (const [path, state] of [...files].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
2402
2597
  let list = resolved.get(state);
2403
2598
  if (!list) {
2404
- list = state.result.usages.map((candidate) => resolver.resolve(candidate, state.result.scopes ?? [], path, "scanner"));
2599
+ list = state.result.usages.map((candidate) => resolver.resolve(candidate, state.result.scopes ?? [], path, "scanner", has));
2405
2600
  resolved.set(state, list);
2406
2601
  }
2407
2602
  usages.push(...list);
@@ -2457,8 +2652,7 @@ function createProject(options) {
2457
2652
  const inPath = (usage) => query.path === void 0 || usage.path === query.path;
2458
2653
  if (query.key !== void 0) {
2459
2654
  const key = query.key;
2460
- const base = baseOf(key);
2461
- return [...[...usagesByKey.get(key) ?? [], ...base ? usagesByKey.get(base) ?? [] : []].filter(inPath).map((usage) => ({
2655
+ return [...[key, ...basesOf(key)].flatMap((k) => usagesByKey.get(k) ?? []).filter(inPath).map((usage) => ({
2462
2656
  usage,
2463
2657
  possible: false
2464
2658
  })), ...prefixUsages.filter((usage) => inPath(usage) && prefixMatches(usage, key)).map((usage) => ({
@@ -2508,7 +2702,8 @@ function createProject(options) {
2508
2702
  files,
2509
2703
  history: history.get
2510
2704
  }),
2511
- baseOf,
2705
+ basesOf,
2706
+ formOf,
2512
2707
  localeInfo,
2513
2708
  text: (path) => fileTexts.get(path) ?? files.get(path)?.text
2514
2709
  });
@@ -2522,7 +2717,7 @@ function createProject(options) {
2522
2717
  const locale = query.missing;
2523
2718
  list = list.filter((key) => {
2524
2719
  const value = message(key, locale)?.value;
2525
- return message(key, sourceLocale) !== void 0 && (value === void 0 || value === "");
2720
+ return message(key, sourceLocale) !== void 0 && (value === void 0 || value === "") && needs(key, locale);
2526
2721
  });
2527
2722
  }
2528
2723
  if (query.unused) {
@@ -2542,7 +2737,7 @@ function createProject(options) {
2542
2737
  namespace: id.namespace,
2543
2738
  path: id.path,
2544
2739
  messages: list,
2545
- missing: locales.filter((locale) => !byLocale.has(locale)),
2740
+ missing: locales.filter((locale) => !byLocale.has(locale) && needs(key, locale)),
2546
2741
  usages: usageMatches({ key }),
2547
2742
  diagnostics: analyzer.analyze().diagnostics.filter((d) => d.key === key)
2548
2743
  };
@@ -2634,8 +2829,9 @@ function createProject(options) {
2634
2829
  const before = idx();
2635
2830
  const paths = new Set(events.map((event) => event.path));
2636
2831
  const resourcePaths = new Set([...resourceStates.values()].map((state) => state.resource.path));
2637
- const maybeResource = (path) => !resourcePaths.has(path) && registry.formatFor(path) !== void 0 && !usageEligible(path);
2638
- const structural = events.some((event) => event.type !== "change" || maybeResource(event.path));
2832
+ const maybeResource = (path) => !resourcePaths.has(path) && registry.formatIdFor(path) !== void 0 && !usageEligible(path);
2833
+ const blockPaths = new Set([...resourceStates.values()].filter((state) => state.resource.block).map((state) => state.resource.path));
2834
+ const structural = events.some((event) => event.type !== "change" || maybeResource(event.path) || blockPaths.has(event.path));
2639
2835
  if (structural || [...paths].some((path) => resourcePaths.has(path))) await loadResources(structural ? await discover() : [...resourceStates.values()].map((state) => ({
2640
2836
  ...state.resource,
2641
2837
  format: state.resource.format === "unknown" ? void 0 : state.resource.format
@@ -2674,22 +2870,9 @@ function createProject(options) {
2674
2870
  parents: () => idx().parents,
2675
2871
  usageMatches,
2676
2872
  text: (path) => fileTexts.get(path) ?? files.get(path)?.text,
2677
- baseOf,
2873
+ basesOf,
2678
2874
  toStored: (target, path) => storedPathOf(target)(path),
2679
- resolveNew(locale, namespace) {
2680
- const ctx = {
2681
- host,
2682
- config,
2683
- extensions: [...new Set(registry.formats.flatMap((format) => format.extensions))]
2684
- };
2685
- for (const finder of registry.finders) {
2686
- const path = finder.resolveNew?.({
2687
- locale,
2688
- namespace
2689
- }, ctx);
2690
- if (path) return path;
2691
- }
2692
- },
2875
+ resolveNew: newResourcePath,
2693
2876
  update
2694
2877
  });
2695
2878
  const ready = (async () => {
@@ -2748,7 +2931,7 @@ function createProject(options) {
2748
2931
  lookup(key, locale) {
2749
2932
  const exact = message(key, locale);
2750
2933
  if (exact) return exact;
2751
- const derived = [...idx().derived.get(key) ?? []].sort();
2934
+ const derived = (idx().derived.get(key) ?? []).filter((k) => message(k, locale) !== void 0).sort((a, b) => a.length - b.length || a.localeCompare(b));
2752
2935
  const other = derived.find((k) => /[._-]other$/.test(k)) ?? derived[0];
2753
2936
  return other === void 0 ? void 0 : message(other, locale);
2754
2937
  },
@@ -2820,6 +3003,8 @@ function defaultDraft() {
2820
3003
  namespace: {
2821
3004
  separator: ".",
2822
3005
  default: void 0,
3006
+ fallback: [],
3007
+ byFile: {},
2823
3008
  fromPath: "dot"
2824
3009
  },
2825
3010
  usage: {
@@ -2903,6 +3088,11 @@ function applyLayer(draft, layer, origin, origins) {
2903
3088
  draft.namespace = {
2904
3089
  separator: layer.namespace.separator ?? draft.namespace.separator,
2905
3090
  default: layer.namespace.default ?? draft.namespace.default,
3091
+ fallback: layer.namespace.fallback ?? draft.namespace.fallback,
3092
+ byFile: {
3093
+ ...draft.namespace.byFile,
3094
+ ...layer.namespace.byFile
3095
+ },
2906
3096
  fromPath: layer.namespace.fromPath ?? draft.namespace.fromPath
2907
3097
  };
2908
3098
  mark("namespace");
@@ -3007,8 +3197,8 @@ function createLoader(options) {
3007
3197
  if (option === false || option === void 0) return void 0;
3008
3198
  if (typeof option === "object" && !Array.isArray(option)) return option;
3009
3199
  const [specifier, pluginOptions] = typeof option === "string" ? [option, void 0] : [option[0], option[1]];
3010
- const factory = options.builtins.presets[specifier];
3011
- if (factory) return factory(pluginOptions);
3200
+ const entry = options.builtins.presets[specifier];
3201
+ if (entry) return (await entry.load()).default(pluginOptions);
3012
3202
  if (!options.loadPlugin) throw errors.X18N1003({ preset: specifier });
3013
3203
  return options.loadPlugin(specifier, pluginOptions);
3014
3204
  };
@@ -3044,7 +3234,17 @@ async function expandPresets(presets, userPlugins, listed, detectCtx) {
3044
3234
  name: preset.name,
3045
3235
  reason: "after-exclusive"
3046
3236
  });
3047
- else stopped = await flatten(preset, listed);
3237
+ else if ("entry" in preset) {
3238
+ const applies = listed || (preset.entry.detect ? await preset.entry.detect(detectCtx) : false);
3239
+ explanations.push({
3240
+ name: preset.name,
3241
+ reason: applies ? listed ? "listed" : "detected" : "not-detected"
3242
+ });
3243
+ if (!applies) continue;
3244
+ const loaded = (await preset.entry.load()).default();
3245
+ for (const child of loaded.plugins) await flatten(child, false);
3246
+ stopped = preset.entry.exclusive === true || loaded.exclusive === true;
3247
+ } else stopped = await flatten(preset, listed);
3048
3248
  for (const plugin of userPlugins) await flatten(plugin, true);
3049
3249
  return {
3050
3250
  plugins,
@@ -3067,7 +3267,10 @@ async function resolveConfig(options) {
3067
3267
  const userPlugins = await loadAll(load, [...options.parent?.plugins ?? [], ...user.plugins ?? []]);
3068
3268
  const presetOption = user.presets ?? options.parent?.presets ?? "auto";
3069
3269
  const auto = presetOption === "auto";
3070
- const presets = auto ? Object.values(builtins.presets).map((factory) => factory()) : (await loadAll(load, presetOption)).map((item) => isPreset(item) ? item : {
3270
+ const presets = auto ? Object.entries(builtins.presets).map(([name, entry]) => ({
3271
+ name,
3272
+ entry
3273
+ })) : (await loadAll(load, presetOption)).map((item) => isPreset(item) ? item : {
3071
3274
  name: item.name,
3072
3275
  plugins: [item]
3073
3276
  });
@@ -3108,4 +3311,4 @@ async function resolveConfig(options) {
3108
3311
  };
3109
3312
  }
3110
3313
  //#endregion
3111
- export { ConfigSchema, PluginFileSchema, applyMessageOps, applyTextEdits, buildTree, contractVersion, createLineIndex, createMemoryHost, createOverlayHost, createPlaceholderComparer, createProject, createRegistry, defineConfig, definePlugin, definePreset, diffText, envelope, errors, extensionOf, globToRegExp, hashText, isJsonObject, isKeyUnder, isMessageLeaf, isMessageTree, isPreset, isX18nError, joinKey, keyMatcher, localeSpellingKey, parseLocaleInfo, parseMessageBatch, pathId, pathMatcher, placeholderProblems, rangeContains, renderPrompt, replaceKeyPrefix, resolveConfig, sortTree, splitKey, toErrorInfo, toWireDiagnostic, toWireRange, validate, walkLeaves };
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 };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@x18n/core",
3
3
  "type": "module",
4
- "version": "0.0.12",
4
+ "version": "0.0.14",
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",
@@ -145,6 +145,21 @@
145
145
  "default": {
146
146
  "type": "string"
147
147
  },
148
+ "fallback": {
149
+ "type": "array",
150
+ "items": {
151
+ "type": "string"
152
+ }
153
+ },
154
+ "byFile": {
155
+ "type": "object",
156
+ "propertyNames": {
157
+ "type": "string"
158
+ },
159
+ "additionalProperties": {
160
+ "type": "string"
161
+ }
162
+ },
148
163
  "fromPath": {
149
164
  "enum": [
150
165
  "dot",
@@ -221,19 +236,26 @@
221
236
  ]
222
237
  },
223
238
  "namespace": {
224
- "type": "object",
225
- "properties": {
226
- "arg": {
227
- "type": "number"
239
+ "anyOf": [
240
+ {
241
+ "type": "object",
242
+ "properties": {
243
+ "arg": {
244
+ "type": "number"
245
+ },
246
+ "property": {
247
+ "type": "string"
248
+ }
249
+ },
250
+ "required": [
251
+ "arg"
252
+ ],
253
+ "additionalProperties": false
228
254
  },
229
- "property": {
255
+ {
230
256
  "type": "string"
231
257
  }
232
- },
233
- "required": [
234
- "arg"
235
- ],
236
- "additionalProperties": false
258
+ ]
237
259
  },
238
260
  "keyPrefix": {
239
261
  "type": "object",
@@ -404,6 +426,12 @@
404
426
  }
405
427
  }
406
428
  },
429
+ "attributes": {
430
+ "type": "array",
431
+ "items": {
432
+ "type": "string"
433
+ }
434
+ },
407
435
  "ignoreAttributes": {
408
436
  "type": "array",
409
437
  "items": {
@@ -90,6 +90,21 @@
90
90
  "default": {
91
91
  "type": "string"
92
92
  },
93
+ "fallback": {
94
+ "type": "array",
95
+ "items": {
96
+ "type": "string"
97
+ }
98
+ },
99
+ "byFile": {
100
+ "type": "object",
101
+ "propertyNames": {
102
+ "type": "string"
103
+ },
104
+ "additionalProperties": {
105
+ "type": "string"
106
+ }
107
+ },
93
108
  "fromPath": {
94
109
  "enum": [
95
110
  "dot",
@@ -166,19 +181,26 @@
166
181
  ]
167
182
  },
168
183
  "namespace": {
169
- "type": "object",
170
- "properties": {
171
- "arg": {
172
- "type": "number"
184
+ "anyOf": [
185
+ {
186
+ "type": "object",
187
+ "properties": {
188
+ "arg": {
189
+ "type": "number"
190
+ },
191
+ "property": {
192
+ "type": "string"
193
+ }
194
+ },
195
+ "required": [
196
+ "arg"
197
+ ],
198
+ "additionalProperties": false
173
199
  },
174
- "property": {
200
+ {
175
201
  "type": "string"
176
202
  }
177
- },
178
- "required": [
179
- "arg"
180
- ],
181
- "additionalProperties": false
203
+ ]
182
204
  },
183
205
  "keyPrefix": {
184
206
  "type": "object",