@x18n/core 0.0.12 → 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,6 +577,13 @@ 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;
@@ -587,6 +600,8 @@ interface CheckContext {
587
600
  readonly message: (key: KeyString, locale: LocaleCode) => Message | undefined;
588
601
  /** Every resource message of a key in a locale, more than one when duplicated across files. */
589
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;
590
605
  readonly usages: () => Iterable<Usage>;
591
606
  readonly coverage: () => readonly LocaleCoverage[];
592
607
  readonly unused: () => readonly KeyString[];
@@ -785,12 +800,14 @@ interface ExtractOptions {
785
800
  readonly keyMaxLength?: number | undefined;
786
801
  /** Reuse a key when the same text exists as a source value. Default true. */
787
802
  readonly reuseExisting?: boolean | undefined;
788
- /** 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. */
789
804
  readonly ignore?: readonly string[] | undefined;
790
- /** Ignored strings per file glob. */
805
+ /** Strings never extracted in the files of a glob, written as in `ignore`. */
791
806
  readonly ignoreByFile?: {
792
807
  readonly [glob: string]: readonly string[];
793
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;
794
811
  readonly ignoreAttributes?: readonly string[] | undefined;
795
812
  readonly ignoreCallees?: readonly string[] | undefined;
796
813
  readonly templates?: readonly (string | ExtractTemplate)[] | undefined;
@@ -804,7 +821,7 @@ interface UsageOptions {
804
821
  readonly ignoreCallees?: readonly string[] | undefined;
805
822
  /** Keys (globs) that count as used, for example keys built at runtime. */
806
823
  readonly keysInUse?: readonly string[] | undefined;
807
- /** 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. */
808
825
  readonly derivedKeys?: readonly string[] | undefined;
809
826
  }
810
827
  interface WriteOptions {
@@ -1456,4 +1473,4 @@ export declare function rangeContains(range: TextRange, offset: number): boolean
1456
1473
  /** Fast non-cryptographic string hash (cyrb53), as hex. */
1457
1474
  export declare function hashText(text: string): string;
1458
1475
  //#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 };
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
@@ -418,8 +418,10 @@ function parseLocaleInfo(code) {
418
418
  region
419
419
  ].filter(Boolean).join("-");
420
420
  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"];
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
+ };
423
425
  const flagRegion = region ?? (language === "zh" && script === "Hant" ? "TW" : language ? defaultRegions[language] : void 0);
424
426
  return {
425
427
  code,
@@ -427,7 +429,8 @@ function parseLocaleInfo(code) {
427
429
  region,
428
430
  displayName,
429
431
  flag: flagRegion ? flagOf(flagRegion) : void 0,
430
- pluralCategories
432
+ pluralCategories: categories("cardinal"),
433
+ ordinalCategories: categories("ordinal")
431
434
  };
432
435
  }
433
436
  /** Codes that differ only in spelling (`zh_CN` and `zh-cn`), the same after a lenient normalization. */
@@ -706,6 +709,7 @@ const extractOptions = v.strictObject({
706
709
  reuseExisting: v.optional(v.boolean()),
707
710
  ignore: v.optional(strings),
708
711
  ignoreByFile: v.optional(v.record(v.string(), strings)),
712
+ attributes: v.optional(strings),
709
713
  ignoreAttributes: v.optional(strings),
710
714
  ignoreCallees: v.optional(strings),
711
715
  templates: v.optional(extractTemplates)
@@ -938,6 +942,148 @@ function createHistory(input) {
938
942
  };
939
943
  }
940
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
941
1087
  //#region src/project/analysis.ts
942
1088
  function levelOf(setting) {
943
1089
  if (setting === void 0) return void 0;
@@ -950,9 +1096,24 @@ function levelOf(setting) {
950
1096
  options: setting[1]
951
1097
  };
952
1098
  }
953
- 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) {
954
1114
  const { messages, sourceLocale } = state;
955
1115
  return state.locales.map((locale) => {
1116
+ const reference = expectedKeys(state, options, sourceKeys, locale);
956
1117
  const chain = fallbackChain(state.fallbacks, locale);
957
1118
  const inherited = (key) => chain.some((code) => {
958
1119
  const value = messages.get(key)?.get(code)?.[0]?.value;
@@ -1007,7 +1168,7 @@ function* runCheck(check, level, inputs, input) {
1007
1168
  const severity = config.strict && (item.severity ?? level) === "warning" ? "error" : item.severity ?? level;
1008
1169
  let location = item.location;
1009
1170
  if (!location && item.key !== void 0) {
1010
- 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);
1011
1172
  const found = byLocale?.get(item.locale ?? state.sourceLocale)?.[0] ?? byLocale?.get(state.sourceLocale)?.[0];
1012
1173
  if (found) location = {
1013
1174
  path: resourcePath(found.resource),
@@ -1067,7 +1228,7 @@ function createAnalyzer(options) {
1067
1228
  const { sourceLocale, locales, messages } = state;
1068
1229
  const sourceKeys = [...messages.keys()].filter((key) => messages.get(key)?.has(sourceLocale)).sort();
1069
1230
  const allKeys = [...messages.keys()].sort();
1070
- const coverage = computeCoverage(state, sourceKeys.length > 0 ? sourceKeys : allKeys);
1231
+ const coverage = computeCoverage(state, options, sourceKeys.length > 0 ? sourceKeys : allKeys);
1071
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);
1072
1233
  const hasScanners = state.files.size > 0;
1073
1234
  const referenceSyntaxes = registry.placeholders.filter((syntax) => syntax.reference);
@@ -1079,8 +1240,7 @@ function createAnalyzer(options) {
1079
1240
  const prefixes = [...new Set(state.prefixUsages.flatMap((usage) => usage.key === void 0 ? [] : [usage.key]))];
1080
1241
  const isUsed = (key) => {
1081
1242
  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;
1243
+ if (options.basesOf(key).some((base) => state.usagesByKey.has(base) || inUse(base))) return true;
1084
1244
  return prefixes.some((prefix) => key.startsWith(prefix));
1085
1245
  };
1086
1246
  const unused = hasScanners ? allKeys.filter((key) => !isUsed(key)) : [];
@@ -1107,6 +1267,7 @@ function createAnalyzer(options) {
1107
1267
  },
1108
1268
  message: (key, locale) => messages.get(key)?.get(locale)?.[0],
1109
1269
  duplicates: (key, locale) => messages.get(key)?.get(locale) ?? [],
1270
+ formOf: options.formOf,
1110
1271
  usages: () => state.usages,
1111
1272
  coverage: () => coverage,
1112
1273
  unused: () => unused,
@@ -1527,7 +1688,7 @@ var PlanBuilder = class {
1527
1688
  const all = this.options.allKeys();
1528
1689
  const isLeaf = this.options.messages(op.from) !== void 0;
1529
1690
  const under = all.filter((key) => isKeyUnder(key, op.from));
1530
- const derived = all.filter((key) => this.options.baseOf(key) === op.from);
1691
+ const derived = all.filter((key) => this.options.basesOf(key).includes(op.from));
1531
1692
  let keys;
1532
1693
  if (op.type === "rename-prefix") keys = under;
1533
1694
  else if (isLeaf) keys = [op.from, ...derived];
@@ -1919,95 +2080,6 @@ function renderPrompt(data) {
1919
2080
  return out.join("\n");
1920
2081
  }
1921
2082
  //#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
2083
  //#region src/project/project.ts
2012
2084
  const scanConcurrency = 64;
2013
2085
  function createProject(options) {
@@ -2033,7 +2105,7 @@ function createProject(options) {
2033
2105
  const normalizers = registry.plugins.flatMap((plugin) => plugin.normalizeKey ? [plugin.normalizeKey] : []);
2034
2106
  const transforms = registry.plugins.flatMap((plugin) => plugin.transformResource ? [plugin.transformResource] : []);
2035
2107
  const localeInfoHooks = registry.plugins.flatMap((plugin) => plugin.localeInfo ? [plugin.localeInfo] : []);
2036
- const baseOf = derivedBase(config.usage.derivedKeys);
2108
+ const { basesOf, formOf } = derivedKeys(config.usage.derivedKeys);
2037
2109
  const scanSignature = hashText(JSON.stringify({
2038
2110
  usage: config.usage,
2039
2111
  namespace: config.namespace,
@@ -2061,6 +2133,11 @@ function createProject(options) {
2061
2133
  }
2062
2134
  return info;
2063
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
+ }
2064
2141
  async function discover() {
2065
2142
  const ctx = {
2066
2143
  host,
@@ -2379,13 +2456,10 @@ function createProject(options) {
2379
2456
  }
2380
2457
  for (const { namespace, path } of keyPaths.values()) for (let i = 1; i < path.length; i++) parents.add(joinKey(namespace, path.slice(0, i)));
2381
2458
  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
- }
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);
2389
2463
  }
2390
2464
  return {
2391
2465
  messages,
@@ -2457,8 +2531,7 @@ function createProject(options) {
2457
2531
  const inPath = (usage) => query.path === void 0 || usage.path === query.path;
2458
2532
  if (query.key !== void 0) {
2459
2533
  const key = query.key;
2460
- const base = baseOf(key);
2461
- 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) => ({
2462
2535
  usage,
2463
2536
  possible: false
2464
2537
  })), ...prefixUsages.filter((usage) => inPath(usage) && prefixMatches(usage, key)).map((usage) => ({
@@ -2508,7 +2581,8 @@ function createProject(options) {
2508
2581
  files,
2509
2582
  history: history.get
2510
2583
  }),
2511
- baseOf,
2584
+ basesOf,
2585
+ formOf,
2512
2586
  localeInfo,
2513
2587
  text: (path) => fileTexts.get(path) ?? files.get(path)?.text
2514
2588
  });
@@ -2522,7 +2596,7 @@ function createProject(options) {
2522
2596
  const locale = query.missing;
2523
2597
  list = list.filter((key) => {
2524
2598
  const value = message(key, locale)?.value;
2525
- return message(key, sourceLocale) !== void 0 && (value === void 0 || value === "");
2599
+ return message(key, sourceLocale) !== void 0 && (value === void 0 || value === "") && needs(key, locale);
2526
2600
  });
2527
2601
  }
2528
2602
  if (query.unused) {
@@ -2542,7 +2616,7 @@ function createProject(options) {
2542
2616
  namespace: id.namespace,
2543
2617
  path: id.path,
2544
2618
  messages: list,
2545
- missing: locales.filter((locale) => !byLocale.has(locale)),
2619
+ missing: locales.filter((locale) => !byLocale.has(locale) && needs(key, locale)),
2546
2620
  usages: usageMatches({ key }),
2547
2621
  diagnostics: analyzer.analyze().diagnostics.filter((d) => d.key === key)
2548
2622
  };
@@ -2674,7 +2748,7 @@ function createProject(options) {
2674
2748
  parents: () => idx().parents,
2675
2749
  usageMatches,
2676
2750
  text: (path) => fileTexts.get(path) ?? files.get(path)?.text,
2677
- baseOf,
2751
+ basesOf,
2678
2752
  toStored: (target, path) => storedPathOf(target)(path),
2679
2753
  resolveNew(locale, namespace) {
2680
2754
  const ctx = {
@@ -2748,7 +2822,7 @@ function createProject(options) {
2748
2822
  lookup(key, locale) {
2749
2823
  const exact = message(key, locale);
2750
2824
  if (exact) return exact;
2751
- 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));
2752
2826
  const other = derived.find((k) => /[._-]other$/.test(k)) ?? derived[0];
2753
2827
  return other === void 0 ? void 0 : message(other, locale);
2754
2828
  },
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.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",
@@ -404,6 +404,12 @@
404
404
  }
405
405
  }
406
406
  },
407
+ "attributes": {
408
+ "type": "array",
409
+ "items": {
410
+ "type": "string"
411
+ }
412
+ },
407
413
  "ignoreAttributes": {
408
414
  "type": "array",
409
415
  "items": {