@x18n/core 0.0.10 → 0.0.13

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`. */
@@ -479,6 +483,8 @@ interface ScanContext {
479
483
  interface UsageCandidate {
480
484
  readonly raw: string;
481
485
  readonly range: TextRange;
486
+ /** Range of the whole call that holds the key, when the scanner knows it. */
487
+ readonly expression?: TextRange | undefined;
482
488
  readonly kind: UsageKind;
483
489
  readonly dynamic: false | "prefix" | "unknown";
484
490
  readonly namespace?: string | undefined;
@@ -571,10 +577,19 @@ interface HistoryProvider {
571
577
  /** Calls `onChange` when the history changes outside resource files, for example on a commit. */
572
578
  readonly watch?: ((ctx: HistoryContext, onChange: () => void) => Disposable) | undefined;
573
579
  }
580
+ /** A key that is one plural form of a base key, such as `item_one` of `item`. */
581
+ interface PluralForm {
582
+ readonly type: Intl.PluralRuleType;
583
+ readonly category: Intl.LDMLPluralRule;
584
+ /** The sibling key of another category. */
585
+ readonly keyOf: (category: Intl.LDMLPluralRule) => KeyString;
586
+ }
574
587
  interface CheckContext {
575
588
  readonly config: ResolvedConfig;
576
589
  readonly sourceLocale: LocaleCode;
577
590
  readonly locales: readonly LocaleCode[];
591
+ /** The locales a locale falls back to, nearest first. Empty for a locale without a fallback. */
592
+ readonly fallbacks: (locale: LocaleCode) => readonly LocaleCode[];
578
593
  readonly resources: readonly Resource[];
579
594
  /** Options from `[severity, options]` in config. */
580
595
  readonly options: JsonObject;
@@ -585,6 +600,8 @@ interface CheckContext {
585
600
  readonly message: (key: KeyString, locale: LocaleCode) => Message | undefined;
586
601
  /** Every resource message of a key in a locale, more than one when duplicated across files. */
587
602
  readonly duplicates: (key: KeyString, locale: LocaleCode) => readonly Message[];
603
+ /** The plural form a key is, from the `{plural}` and `{ordinal}` derived-key rules. */
604
+ readonly formOf: (key: KeyString) => PluralForm | undefined;
588
605
  readonly usages: () => Iterable<Usage>;
589
606
  readonly coverage: () => readonly LocaleCoverage[];
590
607
  readonly unused: () => readonly KeyString[];
@@ -783,12 +800,14 @@ interface ExtractOptions {
783
800
  readonly keyMaxLength?: number | undefined;
784
801
  /** Reuse a key when the same text exists as a source value. Default true. */
785
802
  readonly reuseExisting?: boolean | undefined;
786
- /** Strings or `/regex/` never extracted. */
803
+ /** Strings never extracted: an exact string such as `Elk`, or a `/regex/flags` string such as `/^v\d+/i` that matches the text. */
787
804
  readonly ignore?: readonly string[] | undefined;
788
- /** Ignored strings per file glob. */
805
+ /** Strings never extracted in the files of a glob, written as in `ignore`. */
789
806
  readonly ignoreByFile?: {
790
807
  readonly [glob: string]: readonly string[];
791
808
  } | undefined;
809
+ /** 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. */
810
+ readonly attributes?: readonly string[] | undefined;
792
811
  readonly ignoreAttributes?: readonly string[] | undefined;
793
812
  readonly ignoreCallees?: readonly string[] | undefined;
794
813
  readonly templates?: readonly (string | ExtractTemplate)[] | undefined;
@@ -802,7 +821,7 @@ interface UsageOptions {
802
821
  readonly ignoreCallees?: readonly string[] | undefined;
803
822
  /** Keys (globs) that count as used, for example keys built at runtime. */
804
823
  readonly keysInUse?: readonly string[] | undefined;
805
- /** Rules like `{key}_plural`: when `{key}` is used the derived keys count as used. */
824
+ /** 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. */
806
825
  readonly derivedKeys?: readonly string[] | undefined;
807
826
  }
808
827
  interface WriteOptions {
@@ -832,6 +851,10 @@ interface SerializableConfig {
832
851
  /** Locale codes as written in paths. Filters the found locales. */
833
852
  readonly locales?: readonly string[] | undefined;
834
853
  readonly ignoreLocales?: readonly string[] | undefined;
854
+ /** Locale to the locale it falls back to, such as `{ 'en-GB': 'en' }`. A locale with a fallback holds only overrides: a key it lacks or leaves empty is not missing when the fallback chain has it. `'auto'` falls back to the nearest parent language that exists: `en-US` to `en`, `zh-Hant-TW` to `zh-Hant` or `zh`. */
855
+ readonly localeFallbacks?: "auto" | {
856
+ readonly [locale: string]: string;
857
+ } | undefined;
835
858
  /** `'auto'` detects built-in presets from the project's dependencies. */
836
859
  readonly presets?: "auto" | readonly PluginSpecifier[] | undefined;
837
860
  readonly plugins?: readonly PluginSpecifier[] | undefined;
@@ -892,6 +915,9 @@ interface ResolvedConfig {
892
915
  readonly sourceLocale: string;
893
916
  readonly locales: readonly string[] | undefined;
894
917
  readonly ignoreLocales: readonly string[];
918
+ readonly localeFallbacks: "auto" | {
919
+ readonly [locale: string]: string;
920
+ };
895
921
  readonly resources: readonly ResourceSpec[];
896
922
  readonly formats: {
897
923
  readonly [extension: string]: string;
@@ -1447,4 +1473,4 @@ export declare function rangeContains(range: TextRange, offset: number): boolean
1447
1473
  /** Fast non-cryptographic string hash (cyrb53), as hex. */
1448
1474
  export declare function hashText(text: string): string;
1449
1475
  //#endregion
1450
- 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 };
1476
+ 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, PluralForm, 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 };
package/dist/index.mjs CHANGED
@@ -363,6 +363,28 @@ const defaultRegions = {
363
363
  vi: "VN",
364
364
  zh: "CN"
365
365
  };
366
+ /** The fallback of each locale: from the config map, or for `'auto'` the nearest parent language among `locales`. The source locale has none. */
367
+ function resolveFallbacks(setting, locales, sourceLocale) {
368
+ if (setting !== "auto") return Object.fromEntries(Object.entries(setting).filter(([locale]) => locale !== sourceLocale));
369
+ const fallbacks = {};
370
+ for (const locale of locales) {
371
+ if (locale === sourceLocale) continue;
372
+ for (let cut = locale.search(/[-_][^-_]*$/); cut > 0; cut = locale.slice(0, cut).search(/[-_][^-_]*$/)) {
373
+ const parent = locales.find((code) => code === locale.slice(0, cut));
374
+ if (parent) {
375
+ fallbacks[locale] = parent;
376
+ break;
377
+ }
378
+ }
379
+ }
380
+ return fallbacks;
381
+ }
382
+ /** The locales a locale falls back to, nearest first. Stops at a cycle. */
383
+ function fallbackChain(fallbacks, locale) {
384
+ const chain = [];
385
+ for (let next = fallbacks[locale]; next !== void 0 && next !== locale && !chain.includes(next); next = fallbacks[next]) chain.push(next);
386
+ return chain;
387
+ }
366
388
  const pluralOrder = [
367
389
  "zero",
368
390
  "one",
@@ -396,8 +418,10 @@ function parseLocaleInfo(code) {
396
418
  region
397
419
  ].filter(Boolean).join("-");
398
420
  const displayName = language ? safe(() => new Intl.DisplayNames(["en"], { type: "language" }).of(tag)) : void 0;
399
- const found = language ? safe(() => new Intl.PluralRules(tag).resolvedOptions().pluralCategories) : void 0;
400
- const pluralCategories = found ? pluralOrder.filter((category) => found.includes(category)) : ["other"];
421
+ const categories = (type) => {
422
+ const found = language ? safe(() => new Intl.PluralRules(tag, { type }).resolvedOptions().pluralCategories) : void 0;
423
+ return found ? pluralOrder.filter((category) => found.includes(category)) : ["other"];
424
+ };
401
425
  const flagRegion = region ?? (language === "zh" && script === "Hant" ? "TW" : language ? defaultRegions[language] : void 0);
402
426
  return {
403
427
  code,
@@ -405,7 +429,8 @@ function parseLocaleInfo(code) {
405
429
  region,
406
430
  displayName,
407
431
  flag: flagRegion ? flagOf(flagRegion) : void 0,
408
- pluralCategories
432
+ pluralCategories: categories("cardinal"),
433
+ ordinalCategories: categories("ordinal")
409
434
  };
410
435
  }
411
436
  /** Codes that differ only in spelling (`zh_CN` and `zh-cn`), the same after a lenient normalization. */
@@ -684,6 +709,7 @@ const extractOptions = v.strictObject({
684
709
  reuseExisting: v.optional(v.boolean()),
685
710
  ignore: v.optional(strings),
686
711
  ignoreByFile: v.optional(v.record(v.string(), strings)),
712
+ attributes: v.optional(strings),
687
713
  ignoreAttributes: v.optional(strings),
688
714
  ignoreCallees: v.optional(strings),
689
715
  templates: v.optional(extractTemplates)
@@ -712,6 +738,7 @@ const ConfigSchema = v.strictObject({
712
738
  sourceLocale: v.optional(v.string()),
713
739
  locales: v.optional(strings),
714
740
  ignoreLocales: v.optional(strings),
741
+ localeFallbacks: v.optional(v.union([v.literal("auto"), v.record(v.string(), v.string())])),
715
742
  presets: v.optional(v.union([v.literal("auto"), v.array(pluginSpecifier)])),
716
743
  plugins: v.optional(v.array(pluginSpecifier)),
717
744
  resources: v.optional(v.array(resourceSpec)),
@@ -915,6 +942,148 @@ function createHistory(input) {
915
942
  };
916
943
  }
917
944
  //#endregion
945
+ //#region src/project/usages.ts
946
+ function innermost(scopes, range) {
947
+ let best;
948
+ 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;
949
+ return best;
950
+ }
951
+ function createUsageResolver(config, namespaced, normalizers) {
952
+ const separator = config.namespace.separator;
953
+ return { resolve(candidate, scopes, path, scanner) {
954
+ const scope = innermost(scopes, candidate.range);
955
+ const ctx = { path };
956
+ let text = candidate.raw;
957
+ for (const normalize of normalizers) {
958
+ const result = normalize(text, ctx);
959
+ if (result !== void 0) {
960
+ text = result;
961
+ break;
962
+ }
963
+ }
964
+ const scopeNamespace = candidate.namespace ?? scope?.namespace;
965
+ const scopePrefix = candidate.keyPrefix ?? scope?.keyPrefix;
966
+ let namespace;
967
+ let explicit = false;
968
+ if (namespaced) {
969
+ const index = text.indexOf(separator);
970
+ explicit = index > 0;
971
+ if (explicit) {
972
+ namespace = text.slice(0, index);
973
+ text = text.slice(index + separator.length);
974
+ } else namespace = scopeNamespace ?? config.namespace.default;
975
+ }
976
+ if (scopePrefix && !explicit) text = text ? `${scopePrefix}.${text}` : scopePrefix;
977
+ let key;
978
+ if (candidate.dynamic === "unknown") key = void 0;
979
+ else key = joinKey(namespace, text === "" ? [] : text.split("."));
980
+ return {
981
+ key,
982
+ raw: candidate.raw,
983
+ path,
984
+ range: candidate.range,
985
+ expression: candidate.expression,
986
+ scanner,
987
+ kind: candidate.kind,
988
+ dynamic: candidate.dynamic,
989
+ locale: candidate.locale,
990
+ scopeNamespace,
991
+ scopePrefix
992
+ };
993
+ } };
994
+ }
995
+ /** True when a prefix usage may refer to `key`. The key of `t(`pages.${id}`)` is `pages.`, so only children match. */
996
+ function prefixMatches(usage, key) {
997
+ return usage.dynamic === "prefix" && usage.key !== void 0 && key.startsWith(usage.key);
998
+ }
999
+ /** Ranges covered by `x18n-disable-next-line` and `x18n-disable` ... `x18n-enable` comments. */
1000
+ function suppressedRanges(text) {
1001
+ if (!text.includes("x18n-disable")) return [];
1002
+ const ranges = [];
1003
+ for (const match of text.matchAll(/x18n-disable-next-line/g)) {
1004
+ const lineEnd = text.indexOf("\n", match.index);
1005
+ if (lineEnd === -1) continue;
1006
+ const nextEnd = text.indexOf("\n", lineEnd + 1);
1007
+ ranges.push({
1008
+ start: lineEnd + 1,
1009
+ end: nextEnd === -1 ? text.length : nextEnd
1010
+ });
1011
+ }
1012
+ for (const match of text.matchAll(/x18n-disable(?![-\w])/g)) {
1013
+ const end = text.indexOf("x18n-enable", match.index);
1014
+ ranges.push({
1015
+ start: match.index,
1016
+ end: end === -1 ? text.length : end
1017
+ });
1018
+ }
1019
+ return ranges;
1020
+ }
1021
+ /** The plural categories of a locale's language: Japanese has only `other`. */
1022
+ function categoriesOf(info, type) {
1023
+ return type === "ordinal" ? info.ordinalCategories : info.pluralCategories;
1024
+ }
1025
+ const tokens = /(\{key\}|\{plural\}|\{ordinal\}|\*)/;
1026
+ const categories = [
1027
+ "zero",
1028
+ "one",
1029
+ "two",
1030
+ "few",
1031
+ "many",
1032
+ "other"
1033
+ ];
1034
+ /**
1035
+ * Compiles derived-key rules (`{key}_plural`, `{key}_*`, `{key}_{plural}`). `*` is any key segment;
1036
+ * `{plural}` and `{ordinal}` are CLDR plural categories, which each locale needs its own set of.
1037
+ */
1038
+ function derivedKeys(rules) {
1039
+ const compiled = rules.map((rule) => {
1040
+ const parts = rule.split(tokens);
1041
+ const type = parts.includes("{ordinal}") ? "ordinal" : parts.includes("{plural}") ? "cardinal" : void 0;
1042
+ const source = parts.map((part) => {
1043
+ if (part === "{key}") return "(?<key>.+)";
1044
+ if (part === "{plural}" || part === "{ordinal}") return `(?<category>${categories.join("|")})`;
1045
+ if (part === "*") return "[^.:]+";
1046
+ return part.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
1047
+ }).join("");
1048
+ return {
1049
+ type,
1050
+ re: new RegExp(`^${source}$`, "d")
1051
+ };
1052
+ });
1053
+ const baseOf = (key) => {
1054
+ for (const { re } of compiled) {
1055
+ const base = re.exec(key)?.groups?.key;
1056
+ if (base) return base;
1057
+ }
1058
+ };
1059
+ return {
1060
+ basesOf(key) {
1061
+ const bases = [];
1062
+ for (let base = baseOf(key); base !== void 0; base = baseOf(base)) bases.push(base);
1063
+ return bases;
1064
+ },
1065
+ formOf(key) {
1066
+ let best;
1067
+ for (const { type, re } of compiled) {
1068
+ const match = type && re.exec(key);
1069
+ if (!type || !match) continue;
1070
+ const base = match.groups?.key;
1071
+ const category = categories.find((c) => c === match.groups?.category);
1072
+ const at = match.indices?.groups?.category;
1073
+ if (!base || !category || !at || best && best.base.length <= base.length) continue;
1074
+ const [start, end] = at;
1075
+ best = {
1076
+ base,
1077
+ type,
1078
+ category,
1079
+ keyOf: (other) => `${key.slice(0, start)}${other}${key.slice(end)}`
1080
+ };
1081
+ }
1082
+ return best;
1083
+ }
1084
+ };
1085
+ }
1086
+ //#endregion
918
1087
  //#region src/project/analysis.ts
919
1088
  function levelOf(setting) {
920
1089
  if (setting === void 0) return void 0;
@@ -927,17 +1096,38 @@ function levelOf(setting) {
927
1096
  options: setting[1]
928
1097
  };
929
1098
  }
930
- function computeCoverage(state, reference) {
1099
+ /**
1100
+ * The keys a locale needs: the reference keys, with plural forms swapped for the categories of the locale's
1101
+ * language. Forms the language lacks (`item_zero` in English) count only where they exist.
1102
+ */
1103
+ function expectedKeys(state, options, reference, locale) {
1104
+ const info = options.localeInfo(locale);
1105
+ const keys = /* @__PURE__ */ new Set();
1106
+ for (const key of reference) {
1107
+ const form = options.formOf(key);
1108
+ if (!form || state.messages.get(key)?.has(locale)) keys.add(key);
1109
+ form && categoriesOf(info, form.type).forEach((category) => keys.add(form.keyOf(category)));
1110
+ }
1111
+ return [...keys].sort();
1112
+ }
1113
+ function computeCoverage(state, options, sourceKeys) {
931
1114
  const { messages, sourceLocale } = state;
932
1115
  return state.locales.map((locale) => {
1116
+ const reference = expectedKeys(state, options, sourceKeys, locale);
1117
+ const chain = fallbackChain(state.fallbacks, locale);
1118
+ const inherited = (key) => chain.some((code) => {
1119
+ const value = messages.get(key)?.get(code)?.[0]?.value;
1120
+ return value !== void 0 && value !== "";
1121
+ });
933
1122
  const missing = [];
934
1123
  const empty = [];
935
1124
  const outdated = [];
936
1125
  for (const key of reference) {
937
1126
  const value = messages.get(key)?.get(locale)?.[0]?.value;
938
- if (value === void 0) missing.push(key);
939
- else if (value === "") empty.push(key);
940
- else if (locale !== sourceLocale && isOutdated(state.history(key, sourceLocale), state.history(key, locale))) outdated.push(key);
1127
+ if (value === void 0 || value === "") {
1128
+ if (inherited(key)) continue;
1129
+ (value === void 0 ? missing : empty).push(key);
1130
+ } else if (locale !== sourceLocale && isOutdated(state.history(key, sourceLocale), state.history(key, locale))) outdated.push(key);
941
1131
  }
942
1132
  return {
943
1133
  locale,
@@ -978,7 +1168,7 @@ function* runCheck(check, level, inputs, input) {
978
1168
  const severity = config.strict && (item.severity ?? level) === "warning" ? "error" : item.severity ?? level;
979
1169
  let location = item.location;
980
1170
  if (!location && item.key !== void 0) {
981
- const byLocale = state.messages.get(item.key);
1171
+ const byLocale = state.messages.get(item.key) ?? state.messages.get(input.ctx.formOf(item.key)?.keyOf("other") ?? item.key);
982
1172
  const found = byLocale?.get(item.locale ?? state.sourceLocale)?.[0] ?? byLocale?.get(state.sourceLocale)?.[0];
983
1173
  if (found) location = {
984
1174
  path: resourcePath(found.resource),
@@ -1038,7 +1228,7 @@ function createAnalyzer(options) {
1038
1228
  const { sourceLocale, locales, messages } = state;
1039
1229
  const sourceKeys = [...messages.keys()].filter((key) => messages.get(key)?.has(sourceLocale)).sort();
1040
1230
  const allKeys = [...messages.keys()].sort();
1041
- const coverage = computeCoverage(state, sourceKeys.length > 0 ? sourceKeys : allKeys);
1231
+ const coverage = computeCoverage(state, options, sourceKeys.length > 0 ? sourceKeys : allKeys);
1042
1232
  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);
1043
1233
  const hasScanners = state.files.size > 0;
1044
1234
  const referenceSyntaxes = registry.placeholders.filter((syntax) => syntax.reference);
@@ -1050,8 +1240,7 @@ function createAnalyzer(options) {
1050
1240
  const prefixes = [...new Set(state.prefixUsages.flatMap((usage) => usage.key === void 0 ? [] : [usage.key]))];
1051
1241
  const isUsed = (key) => {
1052
1242
  if (state.usagesByKey.has(key) || inUse(key) || referenced.has(key)) return true;
1053
- const base = options.baseOf(key);
1054
- if (base !== void 0 && (state.usagesByKey.has(base) || inUse(base))) return true;
1243
+ if (options.basesOf(key).some((base) => state.usagesByKey.has(base) || inUse(base))) return true;
1055
1244
  return prefixes.some((prefix) => key.startsWith(prefix));
1056
1245
  };
1057
1246
  const unused = hasScanners ? allKeys.filter((key) => !isUsed(key)) : [];
@@ -1069,6 +1258,7 @@ function createAnalyzer(options) {
1069
1258
  config,
1070
1259
  sourceLocale,
1071
1260
  locales,
1261
+ fallbacks: (locale) => fallbackChain(state.fallbacks, locale),
1072
1262
  resources: state.resources.map((s) => s.resource),
1073
1263
  hasScanners,
1074
1264
  keys: () => allKeys,
@@ -1077,6 +1267,7 @@ function createAnalyzer(options) {
1077
1267
  },
1078
1268
  message: (key, locale) => messages.get(key)?.get(locale)?.[0],
1079
1269
  duplicates: (key, locale) => messages.get(key)?.get(locale) ?? [],
1270
+ formOf: options.formOf,
1080
1271
  usages: () => state.usages,
1081
1272
  coverage: () => coverage,
1082
1273
  unused: () => unused,
@@ -1497,7 +1688,7 @@ var PlanBuilder = class {
1497
1688
  const all = this.options.allKeys();
1498
1689
  const isLeaf = this.options.messages(op.from) !== void 0;
1499
1690
  const under = all.filter((key) => isKeyUnder(key, op.from));
1500
- const derived = all.filter((key) => this.options.baseOf(key) === op.from);
1691
+ const derived = all.filter((key) => this.options.basesOf(key).includes(op.from));
1501
1692
  let keys;
1502
1693
  if (op.type === "rename-prefix") keys = under;
1503
1694
  else if (isLeaf) keys = [op.from, ...derived];
@@ -1543,7 +1734,7 @@ var PlanBuilder = class {
1543
1734
  case "set":
1544
1735
  this.setValue(op.key, op.locale, op.value);
1545
1736
  if (this.config.write.keepFilled) {
1546
- for (const locale of this.state.locales) if (locale !== op.locale && !this.options.message(op.key, locale)) this.setValue(op.key, locale, "");
1737
+ for (const locale of this.state.locales) if (locale !== op.locale && !this.state.fallbacks[locale] && !this.options.message(op.key, locale)) this.setValue(op.key, locale, "");
1547
1738
  }
1548
1739
  break;
1549
1740
  case "delete":
@@ -1889,95 +2080,6 @@ function renderPrompt(data) {
1889
2080
  return out.join("\n");
1890
2081
  }
1891
2082
  //#endregion
1892
- //#region src/project/usages.ts
1893
- function innermost(scopes, range) {
1894
- let best;
1895
- 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;
1896
- return best;
1897
- }
1898
- function createUsageResolver(config, namespaced, normalizers) {
1899
- const separator = config.namespace.separator;
1900
- return { resolve(candidate, scopes, path, scanner) {
1901
- const scope = innermost(scopes, candidate.range);
1902
- const ctx = { path };
1903
- let text = candidate.raw;
1904
- for (const normalize of normalizers) {
1905
- const result = normalize(text, ctx);
1906
- if (result !== void 0) {
1907
- text = result;
1908
- break;
1909
- }
1910
- }
1911
- const scopeNamespace = candidate.namespace ?? scope?.namespace;
1912
- const scopePrefix = candidate.keyPrefix ?? scope?.keyPrefix;
1913
- let namespace;
1914
- let explicit = false;
1915
- if (namespaced) {
1916
- const index = text.indexOf(separator);
1917
- explicit = index > 0;
1918
- if (explicit) {
1919
- namespace = text.slice(0, index);
1920
- text = text.slice(index + separator.length);
1921
- } else namespace = scopeNamespace ?? config.namespace.default;
1922
- }
1923
- if (scopePrefix && !explicit) text = text ? `${scopePrefix}.${text}` : scopePrefix;
1924
- let key;
1925
- if (candidate.dynamic === "unknown") key = void 0;
1926
- else key = joinKey(namespace, text === "" ? [] : text.split("."));
1927
- return {
1928
- key,
1929
- raw: candidate.raw,
1930
- path,
1931
- range: candidate.range,
1932
- scanner,
1933
- kind: candidate.kind,
1934
- dynamic: candidate.dynamic,
1935
- locale: candidate.locale,
1936
- scopeNamespace,
1937
- scopePrefix
1938
- };
1939
- } };
1940
- }
1941
- /** True when a prefix usage may refer to `key`. The key of `t(`pages.${id}`)` is `pages.`, so only children match. */
1942
- function prefixMatches(usage, key) {
1943
- return usage.dynamic === "prefix" && usage.key !== void 0 && key.startsWith(usage.key);
1944
- }
1945
- /** Ranges covered by `x18n-disable-next-line` and `x18n-disable` ... `x18n-enable` comments. */
1946
- function suppressedRanges(text) {
1947
- if (!text.includes("x18n-disable")) return [];
1948
- const ranges = [];
1949
- for (const match of text.matchAll(/x18n-disable-next-line/g)) {
1950
- const lineEnd = text.indexOf("\n", match.index);
1951
- if (lineEnd === -1) continue;
1952
- const nextEnd = text.indexOf("\n", lineEnd + 1);
1953
- ranges.push({
1954
- start: lineEnd + 1,
1955
- end: nextEnd === -1 ? text.length : nextEnd
1956
- });
1957
- }
1958
- for (const match of text.matchAll(/x18n-disable(?![-\w])/g)) {
1959
- const end = text.indexOf("x18n-enable", match.index);
1960
- ranges.push({
1961
- start: match.index,
1962
- end: end === -1 ? text.length : end
1963
- });
1964
- }
1965
- return ranges;
1966
- }
1967
- /** Compiles derived-key rules (`{key}_plural`, `{key}_*`) into a function from a key to its base key. */
1968
- function derivedBase(rules) {
1969
- const regexes = rules.map((rule) => {
1970
- const source = rule.split("{key}").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^.:]+")).join("(.+)");
1971
- return new RegExp(`^${source}$`);
1972
- });
1973
- return (key) => {
1974
- for (const re of regexes) {
1975
- const match = re.exec(key);
1976
- if (match?.[1]) return match[1];
1977
- }
1978
- };
1979
- }
1980
- //#endregion
1981
2083
  //#region src/project/project.ts
1982
2084
  const scanConcurrency = 64;
1983
2085
  function createProject(options) {
@@ -1993,6 +2095,7 @@ function createProject(options) {
1993
2095
  let revision = 0;
1994
2096
  let namespaced = false;
1995
2097
  let locales = [];
2098
+ let fallbacks = {};
1996
2099
  let sourceLocale = config.sourceLocale;
1997
2100
  let resolver;
1998
2101
  let watcher;
@@ -2002,7 +2105,7 @@ function createProject(options) {
2002
2105
  const normalizers = registry.plugins.flatMap((plugin) => plugin.normalizeKey ? [plugin.normalizeKey] : []);
2003
2106
  const transforms = registry.plugins.flatMap((plugin) => plugin.transformResource ? [plugin.transformResource] : []);
2004
2107
  const localeInfoHooks = registry.plugins.flatMap((plugin) => plugin.localeInfo ? [plugin.localeInfo] : []);
2005
- const baseOf = derivedBase(config.usage.derivedKeys);
2108
+ const { basesOf, formOf } = derivedKeys(config.usage.derivedKeys);
2006
2109
  const scanSignature = hashText(JSON.stringify({
2007
2110
  usage: config.usage,
2008
2111
  namespace: config.namespace,
@@ -2030,6 +2133,11 @@ function createProject(options) {
2030
2133
  }
2031
2134
  return info;
2032
2135
  }
2136
+ /** Whether `locale` needs `key`: not a plural form of a category its language lacks. */
2137
+ function needs(key, locale) {
2138
+ const form = formOf(key);
2139
+ return !form || categoriesOf(localeInfo(locale), form.type).includes(form.category);
2140
+ }
2033
2141
  async function discover() {
2034
2142
  const ctx = {
2035
2143
  host,
@@ -2184,6 +2292,7 @@ function createProject(options) {
2184
2292
  if (!found.includes(sourceLocale) && config.origins.sourceLocale === void 0 && found.length > 0) sourceLocale = found.find((code) => code.toLowerCase().startsWith("en")) ?? [...found].sort()[0];
2185
2293
  locales = [sourceLocale, ...found.filter((code) => code !== sourceLocale).sort()];
2186
2294
  if (!found.includes(sourceLocale) && found.length === 0) locales = [sourceLocale];
2295
+ fallbacks = resolveFallbacks(config.localeFallbacks, locales, sourceLocale);
2187
2296
  resolver = createUsageResolver(config, namespaced, normalizers);
2188
2297
  resolved = /* @__PURE__ */ new WeakMap();
2189
2298
  resourceIndex = void 0;
@@ -2347,13 +2456,10 @@ function createProject(options) {
2347
2456
  }
2348
2457
  for (const { namespace, path } of keyPaths.values()) for (let i = 1; i < path.length; i++) parents.add(joinKey(namespace, path.slice(0, i)));
2349
2458
  const derived = /* @__PURE__ */ new Map();
2350
- if (config.usage.derivedKeys.length > 0) for (const key of messages.keys()) {
2351
- const base = baseOf(key);
2352
- if (base) {
2353
- const list = derived.get(base) ?? [];
2354
- derived.set(base, list);
2355
- list.push(key);
2356
- }
2459
+ if (config.usage.derivedKeys.length > 0) for (const key of messages.keys()) for (const base of basesOf(key)) {
2460
+ const list = derived.get(base) ?? [];
2461
+ derived.set(base, list);
2462
+ list.push(key);
2357
2463
  }
2358
2464
  return {
2359
2465
  messages,
@@ -2425,8 +2531,7 @@ function createProject(options) {
2425
2531
  const inPath = (usage) => query.path === void 0 || usage.path === query.path;
2426
2532
  if (query.key !== void 0) {
2427
2533
  const key = query.key;
2428
- const base = baseOf(key);
2429
- return [...[...usagesByKey.get(key) ?? [], ...base ? usagesByKey.get(base) ?? [] : []].filter(inPath).map((usage) => ({
2534
+ return [...[key, ...basesOf(key)].flatMap((k) => usagesByKey.get(k) ?? []).filter(inPath).map((usage) => ({
2430
2535
  usage,
2431
2536
  possible: false
2432
2537
  })), ...prefixUsages.filter((usage) => inPath(usage) && prefixMatches(usage, key)).map((usage) => ({
@@ -2456,6 +2561,7 @@ function createProject(options) {
2456
2561
  revision,
2457
2562
  sourceLocale,
2458
2563
  locales,
2564
+ fallbacks,
2459
2565
  resources: [...resourceStates.values()],
2460
2566
  resourceErrors: [...resourceStates.values()].flatMap((s) => s.error ? [{
2461
2567
  error: s.error,
@@ -2475,7 +2581,8 @@ function createProject(options) {
2475
2581
  files,
2476
2582
  history: history.get
2477
2583
  }),
2478
- baseOf,
2584
+ basesOf,
2585
+ formOf,
2479
2586
  localeInfo,
2480
2587
  text: (path) => fileTexts.get(path) ?? files.get(path)?.text
2481
2588
  });
@@ -2489,7 +2596,7 @@ function createProject(options) {
2489
2596
  const locale = query.missing;
2490
2597
  list = list.filter((key) => {
2491
2598
  const value = message(key, locale)?.value;
2492
- return message(key, sourceLocale) !== void 0 && (value === void 0 || value === "");
2599
+ return message(key, sourceLocale) !== void 0 && (value === void 0 || value === "") && needs(key, locale);
2493
2600
  });
2494
2601
  }
2495
2602
  if (query.unused) {
@@ -2509,7 +2616,7 @@ function createProject(options) {
2509
2616
  namespace: id.namespace,
2510
2617
  path: id.path,
2511
2618
  messages: list,
2512
- missing: locales.filter((locale) => !byLocale.has(locale)),
2619
+ missing: locales.filter((locale) => !byLocale.has(locale) && needs(key, locale)),
2513
2620
  usages: usageMatches({ key }),
2514
2621
  diagnostics: analyzer.analyze().diagnostics.filter((d) => d.key === key)
2515
2622
  };
@@ -2630,6 +2737,7 @@ function createProject(options) {
2630
2737
  revision,
2631
2738
  sourceLocale,
2632
2739
  locales,
2740
+ fallbacks,
2633
2741
  namespaced,
2634
2742
  resources: [...resourceStates.values()],
2635
2743
  fileTexts
@@ -2640,7 +2748,7 @@ function createProject(options) {
2640
2748
  parents: () => idx().parents,
2641
2749
  usageMatches,
2642
2750
  text: (path) => fileTexts.get(path) ?? files.get(path)?.text,
2643
- baseOf,
2751
+ basesOf,
2644
2752
  toStored: (target, path) => storedPathOf(target)(path),
2645
2753
  resolveNew(locale, namespace) {
2646
2754
  const ctx = {
@@ -2714,7 +2822,7 @@ function createProject(options) {
2714
2822
  lookup(key, locale) {
2715
2823
  const exact = message(key, locale);
2716
2824
  if (exact) return exact;
2717
- const derived = [...idx().derived.get(key) ?? []].sort();
2825
+ const derived = (idx().derived.get(key) ?? []).filter((k) => message(k, locale) !== void 0).sort((a, b) => a.length - b.length || a.localeCompare(b));
2718
2826
  const other = derived.find((k) => /[._-]other$/.test(k)) ?? derived[0];
2719
2827
  return other === void 0 ? void 0 : message(other, locale);
2720
2828
  },
@@ -2779,6 +2887,7 @@ function defaultDraft() {
2779
2887
  sourceLocale: "en",
2780
2888
  locales: void 0,
2781
2889
  ignoreLocales: [],
2890
+ localeFallbacks: {},
2782
2891
  resources: [],
2783
2892
  formats: {},
2784
2893
  keyStyle: "auto",
@@ -2822,6 +2931,12 @@ function serializablePart(config) {
2822
2931
  history: options
2823
2932
  };
2824
2933
  }
2934
+ function mergeFallbacks(base, layer) {
2935
+ return base === "auto" || layer === "auto" ? layer : {
2936
+ ...base,
2937
+ ...layer
2938
+ };
2939
+ }
2825
2940
  /** Applies a user config layer: scalars replace, lists of rules append. */
2826
2941
  function applyLayer(draft, layer, origin, origins) {
2827
2942
  const mark = (key) => {
@@ -2839,6 +2954,10 @@ function applyLayer(draft, layer, origin, origins) {
2839
2954
  draft.ignoreLocales = [...draft.ignoreLocales, ...layer.ignoreLocales];
2840
2955
  mark("ignoreLocales");
2841
2956
  }
2957
+ if (layer.localeFallbacks !== void 0) {
2958
+ draft.localeFallbacks = mergeFallbacks(draft.localeFallbacks, layer.localeFallbacks);
2959
+ mark("localeFallbacks");
2960
+ }
2842
2961
  if (layer.resources !== void 0) {
2843
2962
  draft.resources = layer.resources;
2844
2963
  mark("resources");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@x18n/core",
3
3
  "type": "module",
4
- "version": "0.0.10",
4
+ "version": "0.0.13",
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",
@@ -25,6 +25,22 @@
25
25
  "type": "string"
26
26
  }
27
27
  },
28
+ "localeFallbacks": {
29
+ "anyOf": [
30
+ {
31
+ "const": "auto"
32
+ },
33
+ {
34
+ "type": "object",
35
+ "propertyNames": {
36
+ "type": "string"
37
+ },
38
+ "additionalProperties": {
39
+ "type": "string"
40
+ }
41
+ }
42
+ ]
43
+ },
28
44
  "presets": {
29
45
  "anyOf": [
30
46
  {
@@ -388,6 +404,12 @@
388
404
  }
389
405
  }
390
406
  },
407
+ "attributes": {
408
+ "type": "array",
409
+ "items": {
410
+ "type": "string"
411
+ }
412
+ },
391
413
  "ignoreAttributes": {
392
414
  "type": "array",
393
415
  "items": {