@x18n/builtins 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/checks.d.mts CHANGED
@@ -4,6 +4,7 @@ export declare const missingKey: Check;
4
4
  export declare const missingTranslation: Check;
5
5
  export declare const staleTranslation: Check;
6
6
  export declare const emptyTranslation: Check;
7
+ export declare const redundantTranslation: Check;
7
8
  export declare const keyConflict: Check;
8
9
  export declare const localeCodeConflict: Check;
9
10
  export declare const unusedKey: Check;
package/dist/checks.mjs CHANGED
@@ -32,7 +32,7 @@ const missingKey = {
32
32
  const missingTranslation = {
33
33
  id: "missing-translation",
34
34
  defaultSeverity: "warning",
35
- description: "A source-locale key that target locales lack. One diagnostic per key, at the source entry.",
35
+ description: "A source-locale key that target locales lack, or a plural form their language needs. One diagnostic per key, at the source entry.",
36
36
  *run(ctx) {
37
37
  const missing = /* @__PURE__ */ new Map();
38
38
  for (const coverage of ctx.coverage()) {
@@ -44,7 +44,8 @@ const missingTranslation = {
44
44
  }
45
45
  }
46
46
  for (const [key, locales] of missing) {
47
- const source = ctx.message(key, ctx.sourceLocale);
47
+ const other = ctx.formOf(key)?.keyOf("other");
48
+ const source = ctx.message(key, ctx.sourceLocale) ?? (other === void 0 ? void 0 : ctx.message(other, ctx.sourceLocale));
48
49
  yield {
49
50
  key,
50
51
  locale: ctx.sourceLocale,
@@ -92,13 +93,41 @@ const emptyTranslation = {
92
93
  defaultSeverity: "warning",
93
94
  description: "A message with an empty string value.",
94
95
  *run(ctx) {
95
- for (const message of ctx.messages()) if (message.value === "") yield {
96
+ for (const message of ctx.messages()) if (message.value === "" && ctx.fallbacks(message.locale).length === 0) yield {
96
97
  key: message.key,
97
98
  locale: message.locale,
98
99
  message: `"${message.key}" is empty in ${message.locale}.`
99
100
  };
100
101
  }
101
102
  };
103
+ const redundantTranslation = {
104
+ id: "redundant-translation",
105
+ defaultSeverity: "warning",
106
+ description: "A message in a locale with a fallback that equals the message it falls back to, so it can be removed.",
107
+ *run(ctx) {
108
+ for (const message of ctx.messages()) {
109
+ if (message.value === "") continue;
110
+ for (const code of ctx.fallbacks(message.locale)) {
111
+ const inherited = ctx.message(message.key, code);
112
+ if (!inherited || inherited.value === "") continue;
113
+ if (JSON.stringify(inherited.value) === JSON.stringify(message.value)) yield {
114
+ key: message.key,
115
+ locale: message.locale,
116
+ message: `"${message.key}" in ${message.locale} is the same as in ${code}, which it falls back to.`,
117
+ fixes: [{
118
+ title: `Remove "${message.key}" from ${message.locale}`,
119
+ ops: [{
120
+ type: "delete",
121
+ key: message.key,
122
+ locales: [message.locale]
123
+ }]
124
+ }]
125
+ };
126
+ break;
127
+ }
128
+ }
129
+ }
130
+ };
102
131
  const keyConflict = {
103
132
  id: "key-conflict",
104
133
  defaultSeverity: "error",
@@ -333,6 +362,7 @@ const allChecks = [
333
362
  missingTranslation,
334
363
  staleTranslation,
335
364
  emptyTranslation,
365
+ redundantTranslation,
336
366
  unusedKey,
337
367
  localeCodeConflict,
338
368
  dynamicKey,
@@ -349,4 +379,4 @@ function recommended() {
349
379
  };
350
380
  }
351
381
  //#endregion
352
- export { allChecks, dynamicKey, emptyTranslation, glossary, hardString, identicalToSource, invalidSyntax, keyConflict, keyStyle, localeCodeConflict, missingKey, missingTranslation, placeholderMismatch, recommended, staleTranslation, unusedKey };
382
+ export { allChecks, dynamicKey, emptyTranslation, glossary, hardString, identicalToSource, invalidSyntax, keyConflict, keyStyle, localeCodeConflict, missingKey, missingTranslation, placeholderMismatch, recommended, redundantTranslation, staleTranslation, unusedKey };
@@ -3,12 +3,12 @@ import { ExtractItem, ExtractOptions, ExtractTemplate, HardString, KeyString, Pr
3
3
  export type Score = "must-include" | "should-include" | "none" | "should-exclude" | "must-exclude";
4
4
  export interface ExtractionRule {
5
5
  readonly id: string;
6
- readonly test: (text: string, kind: string) => Score;
6
+ readonly test: (text: string, kind: string, attribute: string | undefined) => Score;
7
7
  }
8
8
  /** Built-in rules, applied in order. A `must-*` wins at once; otherwise the last `should-*` decides. */
9
9
  export declare const rules: readonly ExtractionRule[];
10
10
  /** Folds rule scores the i18n-ally way (`shouldExtract`). */
11
- export declare function shouldExtract(text: string, kind: string, list?: readonly ExtractionRule[]): boolean;
11
+ export declare function shouldExtract(text: string, kind: string, list?: readonly ExtractionRule[], attribute?: string): boolean;
12
12
  export type KeyCase = "camel" | "snake" | "kebab" | "dot";
13
13
  /** "Forgot password?" becomes `forgotPassword` (camel), `forgot_password`, `forgot-password` or `forgot.password`. */
14
14
  export declare function slugify(text: string, keyCase?: KeyCase, maxLength?: number): string;
@@ -43,6 +43,14 @@ const rules = [
43
43
  return tokens.every((t) => /^!?[a-z][\w:/[\].#%!-]*$/.test(t)) && tokens.filter((t) => /[-:!/[\d]/.test(t)).length * 2 > tokens.length ? "must-exclude" : "none";
44
44
  }
45
45
  },
46
+ {
47
+ id: "attributify",
48
+ test: (text, kind, attribute) => kind.endsWith("attribute") && (attribute === "text" || attribute?.includes(":") === true) && text.trim().split(/\s+/).every((t) => /^!?-?[a-z\d$[(][\w:/[\]().,#%!$+~-]*$/.test(t)) ? "must-exclude" : "none"
49
+ },
50
+ {
51
+ id: "css-value",
52
+ test: (text) => /^-?[a-z][\w-]*\(.*\)$/s.test(text.trim()) || /\bvar\(--/.test(text) ? "must-exclude" : "none"
53
+ },
46
54
  {
47
55
  id: "non-ascii",
48
56
  test: (text) => /\P{ASCII}/u.test(text) && /\p{L}/u.test(text) ? "must-include" : "none"
@@ -80,10 +88,10 @@ function isExtractableFile(path) {
80
88
  return !nonUiFiles.some((pattern) => pattern.test(path));
81
89
  }
82
90
  /** Folds rule scores the i18n-ally way (`shouldExtract`). */
83
- function shouldExtract(text, kind, list = rules) {
91
+ function shouldExtract(text, kind, list = rules, attribute) {
84
92
  let result = "none";
85
93
  for (const rule of list) {
86
- const score = rule.test(text, kind);
94
+ const score = rule.test(text, kind, attribute);
87
95
  if (score === "must-include") return true;
88
96
  if (score === "must-exclude") return false;
89
97
  if (score !== "none") result = score;
@@ -216,7 +224,7 @@ function commentIgnores(text) {
216
224
  function* extractableHardStrings(options, hardStrings, textOf) {
217
225
  const ignores = /* @__PURE__ */ new Map();
218
226
  for (const hard of hardStrings) {
219
- if (!isExtractableFile(hard.path) || !shouldExtract(hard.text, hard.kind) || ignoredByConfig(options, hard)) continue;
227
+ if (!isExtractableFile(hard.path) || !shouldExtract(hard.text, hard.kind, rules, hard.attribute) || ignoredByConfig(options, hard)) continue;
220
228
  let ignored = ignores.get(hard.path);
221
229
  if (!ignored) {
222
230
  const text = textOf(hard.path);
package/dist/finders.mjs CHANGED
@@ -1,3 +1,3 @@
1
1
  import { a as patternFinder, i as pattern, n as guessResources, r as namespaceFromPath, t as compilePattern } from "./finders-CqTGh4OJ.mjs";
2
- import { n as sfcFluentBlocks, t as sfcBlocks } from "./sfc-blocks-BIPqKcvd.mjs";
2
+ import { n as sfcFluentBlocks, t as sfcBlocks } from "./sfc-blocks-BqQ8m9rX.mjs";
3
3
  export { compilePattern, guessResources, namespaceFromPath, pattern, patternFinder, sfcBlocks, sfcFluentBlocks };
package/dist/index.mjs CHANGED
@@ -1,12 +1,12 @@
1
1
  import { recommended } from "./checks.mjs";
2
2
  import { i as pattern } from "./finders-CqTGh4OJ.mjs";
3
3
  import { a as json5, c as ini, i as php, l as ftl, n as properties, o as json, r as po, s as jsonc, t as yaml, u as ecmascript } from "./formats-CFkM5co0.mjs";
4
- import { i as js, n as vue } from "./vue-5GFR5hft.mjs";
4
+ import { a as js, n as vue } from "./vue-CF4pe-Yr.mjs";
5
5
  import { manifests } from "./manifests.mjs";
6
- import { n as regex } from "./regex-B9TBGgXC.mjs";
7
- import { t as presets } from "./presets-DJrGl3HR.mjs";
6
+ import { n as regex } from "./regex-AYVhuSb6.mjs";
7
+ import { t as presets } from "./presets-Co5SlnKr.mjs";
8
8
  import { reporters } from "./reporters.mjs";
9
- import { t as svelte } from "./scanners-B27IEH4_.mjs";
9
+ import { t as svelte } from "./scanners-DhnnVp7s.mjs";
10
10
  //#region src/index.ts
11
11
  /** Plugins that are always on, and the presets that `'auto'` detects. */
12
12
  function createBuiltins() {
@@ -1,7 +1,7 @@
1
1
  import { defaultTemplates } from "./extractors.mjs";
2
- import { n as sfcFluentBlocks, t as sfcBlocks } from "./sfc-blocks-BIPqKcvd.mjs";
2
+ import { n as sfcFluentBlocks, t as sfcBlocks } from "./sfc-blocks-BqQ8m9rX.mjs";
3
3
  import { braces, colon, dollars, doubleBraces, fluent, icu, linked, nesting, percentBraces, placeholders, printf } from "./placeholders.mjs";
4
- import { r as regexScopes } from "./regex-B9TBGgXC.mjs";
4
+ import { r as regexScopes } from "./regex-AYVhuSb6.mjs";
5
5
  import { isMessageLeaf } from "@x18n/core";
6
6
  //#region src/presets/shared.ts
7
7
  /** One plugin holding a library's call specs, regexes, namespace rules, defaults and extraction templates. */
@@ -295,7 +295,11 @@ const i18nextPlugin = library({
295
295
  types: ["TFunction"]
296
296
  }
297
297
  ],
298
- derivedKeys: ["{key}_*"],
298
+ derivedKeys: [
299
+ "{key}_ordinal_{ordinal}",
300
+ "{key}_{plural}",
301
+ "{key}_*"
302
+ ],
299
303
  translatePrompt: (draft, ctx) => {
300
304
  draft.notes.push(`i18next plural keys end in _${ctx.localeInfo.pluralCategories.join(", _")} for ${ctx.targetLocale}; write one key per category.`);
301
305
  },
@@ -350,6 +354,10 @@ function reactI18next() {
350
354
  namespace: {
351
355
  arg: 0,
352
356
  property: "ns"
357
+ },
358
+ keyPrefix: {
359
+ arg: 0,
360
+ property: "keyPrefix"
353
361
  }
354
362
  }
355
363
  ],
@@ -363,7 +371,7 @@ function reactI18next() {
363
371
  imports: ["import i18next from 'i18next'"]
364
372
  }]
365
373
  }),
366
- regexScopes("\\bwithTranslation\\(\\s*\\[?\\s*['\"`]([^'\"`]+)['\"`]", "react-i18next-hoc", {
374
+ regexScopes("\\bwithTranslation\\(\\s*(?:\\[?\\s*['\"`](?<namespace>[^'\"`]+)['\"`])?(?:[^)]*?\\bkeyPrefix\\s*:\\s*['\"`](?<keyPrefix>[^'\"`]+)['\"`])?", "react-i18next-hoc", {
367
375
  wholeFile: true,
368
376
  extensions: scriptExtensions
369
377
  })
@@ -764,15 +772,10 @@ function nextTranslate() {
764
772
  }
765
773
  /** Shopify's i18next format: plural and ordinal forms as nested keys (`{key}.one`). */
766
774
  function i18nextShopify() {
767
- const forms = [
768
- "plural",
769
- "zero",
770
- "one",
771
- "two",
772
- "few",
773
- "many",
774
- "other",
775
- ...Array.from({ length: 10 }, (_, i) => String(i))
775
+ const derivedKeys = [
776
+ "{key}.ordinal.{ordinal}",
777
+ "{key}.{plural}",
778
+ ...["plural", ...Array.from({ length: 10 }, (_, i) => String(i))].map((form) => `{key}.${form}`)
776
779
  ];
777
780
  return {
778
781
  name: "i18next-shopify",
@@ -781,7 +784,7 @@ function i18nextShopify() {
781
784
  ...reactI18next().plugins,
782
785
  library({
783
786
  name: "i18next-shopify",
784
- derivedKeys: forms.map((form) => `{key}.${form}`),
787
+ derivedKeys,
785
788
  keyStyle: "nested"
786
789
  }),
787
790
  placeholders(braces)
package/dist/presets.mjs CHANGED
@@ -1 +1 @@
1
- export { n as chromeExtension, r as emberIntl, i as fluentVue, S as flutter, C as general, a as globalize, o as i18nJs, s as i18nTag, w as i18next, c as i18nextShopify, g as jekyll, _ as laravel, l as lingui, u as nextInternational, T as nextIntl, d as nextTranslate, M as ngxTranslate, E as nuxtI18n, f as paraglide, v as phpGettext, y as phpJoomla, p as polyglot, t as presets, b as railsLazyKey, D as reactI18next, O as reactIntl, x as rubyRails, m as solidPrimitivesI18n, k as svelteI18n, N as transloco, h as ui5, A as vscodeExtension, j as vueI18n } from "./presets-DJrGl3HR.mjs";
1
+ export { n as chromeExtension, r as emberIntl, i as fluentVue, S as flutter, C as general, a as globalize, o as i18nJs, s as i18nTag, w as i18next, c as i18nextShopify, g as jekyll, _ as laravel, l as lingui, u as nextInternational, T as nextIntl, d as nextTranslate, M as ngxTranslate, E as nuxtI18n, f as paraglide, v as phpGettext, y as phpJoomla, p as polyglot, t as presets, b as railsLazyKey, D as reactI18next, O as reactIntl, x as rubyRails, m as solidPrimitivesI18n, k as svelteI18n, N as transloco, h as ui5, A as vscodeExtension, j as vueI18n } from "./presets-Co5SlnKr.mjs";
@@ -63,8 +63,9 @@ function regex(specs = [], id = "regex") {
63
63
  };
64
64
  }
65
65
  /**
66
- * Regions after each match of `pattern` (group 1 = namespace, or key prefix with `as: 'keyPrefix'`),
67
- * up to the next match: i18n-ally `scopeRangeRegex`. With `wholeFile`, the first match scopes the whole file.
66
+ * Regions after each match of `pattern` (group 1 = namespace, or key prefix with `as: 'keyPrefix'`; or the
67
+ * named groups `namespace` and `keyPrefix`), up to the next match: i18n-ally `scopeRangeRegex`.
68
+ * With `wholeFile`, the first match scopes the whole file. Matches that capture nothing are skipped.
68
69
  */
69
70
  function regexScopes(pattern, id = "scopes", options = {}) {
70
71
  const re = new RegExp(pattern, "g");
@@ -74,19 +75,25 @@ function regexScopes(pattern, id = "scopes", options = {}) {
74
75
  id,
75
76
  extensions: options.extensions ?? ["*"],
76
77
  scan(file) {
77
- const matches = [...file.text.matchAll(re)];
78
- if (options.wholeFile) matches.splice(1);
78
+ const found = [...file.text.matchAll(re)].map((match) => ({
79
+ match,
80
+ values: match.groups ? {
81
+ namespace: match.groups.namespace,
82
+ keyPrefix: match.groups.keyPrefix
83
+ } : options.as === "keyPrefix" ? { keyPrefix: match[1] } : { namespace: match[1] }
84
+ })).filter(({ values }) => values.namespace !== void 0 || values.keyPrefix !== void 0);
85
+ if (options.wholeFile) found.splice(1);
79
86
  return {
80
87
  usages: [],
81
- scopes: matches.map((match, i) => ({
88
+ scopes: found.map(({ match, values }, i) => ({
82
89
  range: options.wholeFile ? {
83
90
  start: 0,
84
91
  end: file.text.length
85
92
  } : {
86
93
  start: match.index + match[0].length,
87
- end: matches[i + 1]?.index ?? file.text.length
94
+ end: found[i + 1]?.match.index ?? file.text.length
88
95
  },
89
- ...options.as === "keyPrefix" ? { keyPrefix: match[1] } : { namespace: match[1] }
96
+ ...values
90
97
  }))
91
98
  };
92
99
  }
@@ -1,4 +1,4 @@
1
- import { a as loadParser, o as scanSource } from "./vue-5GFR5hft.mjs";
1
+ import { i as isTextAttribute, o as loadParser, s as scanSource } from "./vue-CF4pe-Yr.mjs";
2
2
  //#region src/scanners/svelte.ts
3
3
  let compiler;
4
4
  function isSvelteNode(value) {
@@ -14,33 +14,18 @@ const expressionTypes = /* @__PURE__ */ new Set([
14
14
  "AwaitBlock",
15
15
  "KeyBlock"
16
16
  ]);
17
- const ignoredAttributes = /* @__PURE__ */ new Set([
18
- "class",
19
- "style",
20
- "id",
21
- "name",
22
- "type",
23
- "href",
24
- "src",
25
- "for",
26
- "role",
27
- "lang",
28
- "rel",
29
- "target"
30
- ]);
31
- function textHardString(node, data, attribute, out) {
17
+ function textHardString(node, data, attribute, textAttributes, out) {
32
18
  const text = data.trim();
33
- const name = typeof attribute?.name === "string" ? attribute.name : void 0;
34
- if (!text || !/\p{L}/u.test(text) || name !== void 0 && ignoredAttributes.has(name)) return;
19
+ if (!text || !/\p{L}/u.test(text)) return;
35
20
  if (attribute) {
36
- out.push({
21
+ if (typeof attribute.name === "string" && isTextAttribute(attribute.name, textAttributes)) out.push({
37
22
  text,
38
23
  range: {
39
24
  start: attribute.start,
40
25
  end: attribute.end
41
26
  },
42
27
  kind: "svelte-attribute",
43
- ...name ? { attribute: name } : {}
28
+ attribute: attribute.name
44
29
  });
45
30
  return;
46
31
  }
@@ -101,7 +86,7 @@ function svelte() {
101
86
  return;
102
87
  }
103
88
  if (expressionTypes.has(node.type) && "expression" in node && isSvelteNode(node.expression)) scan(file.text.slice(node.expression.start, node.expression.end), node.expression.start, true);
104
- if (node.type === "Text" && "data" in node && typeof node.data === "string") textHardString(node, node.data, parentType === "Attribute" ? attribute : void 0, hardStrings);
89
+ if (node.type === "Text" && "data" in node && typeof node.data === "string") textHardString(node, node.data, parentType === "Attribute" ? attribute : void 0, ctx.config.extract.attributes, hardStrings);
105
90
  const owner = node.type === "Attribute" ? node : attribute;
106
91
  for (const [key, child] of Object.entries(node)) if (key !== "expression" && typeof child === "object") visit(child, node.type, owner);
107
92
  };
@@ -17,6 +17,8 @@ interface ScanSourceOptions {
17
17
  readonly expression?: boolean;
18
18
  /** Callees whose string arguments are never hard strings (`extract.ignoreCallees`). */
19
19
  readonly ignoreCallees?: readonly string[] | undefined;
20
+ /** Attributes whose values are text, on top of the built-in ones (`extract.attributes`). */
21
+ readonly textAttributes?: readonly string[] | undefined;
20
22
  }
21
23
  export declare function scanSource(parse: ParseSync, text: string, options: ScanSourceOptions): ScanResult;
22
24
  /** The AST scanner for JS and TS, driven by call specs (`usage.calls`). */
@@ -29,8 +31,9 @@ export declare function scanWithRegex(text: string, regexes: readonly RegExp[]):
29
31
  /** The `regex` scanner over `usage.regex` specs, plus extra specs given here (YAML plugins). */
30
32
  export declare function regex(specs?: readonly RegexScannerSpec[], id?: string): Plugin;
31
33
  /**
32
- * Regions after each match of `pattern` (group 1 = namespace, or key prefix with `as: 'keyPrefix'`),
33
- * up to the next match: i18n-ally `scopeRangeRegex`. With `wholeFile`, the first match scopes the whole file.
34
+ * Regions after each match of `pattern` (group 1 = namespace, or key prefix with `as: 'keyPrefix'`; or the
35
+ * named groups `namespace` and `keyPrefix`), up to the next match: i18n-ally `scopeRangeRegex`.
36
+ * With `wholeFile`, the first match scopes the whole file. Matches that capture nothing are skipped.
34
37
  */
35
38
  export declare function regexScopes(pattern: string, id?: string, options?: {
36
39
  readonly as?: "namespace" | "keyPrefix";
package/dist/scanners.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { a as loadParser, i as js, n as vue, o as scanSource, r as calleeName } from "./vue-5GFR5hft.mjs";
2
- import { i as scanWithRegex, n as regex, r as regexScopes, t as compileUsagePattern } from "./regex-B9TBGgXC.mjs";
3
- import { t as svelte } from "./scanners-B27IEH4_.mjs";
1
+ import { a as js, n as vue, o as loadParser, r as calleeName, s as scanSource } from "./vue-CF4pe-Yr.mjs";
2
+ import { i as scanWithRegex, n as regex, r as regexScopes, t as compileUsagePattern } from "./regex-AYVhuSb6.mjs";
3
+ import { t as svelte } from "./scanners-DhnnVp7s.mjs";
4
4
  export { calleeName, compileUsagePattern, js, loadParser, regex, regexScopes, scanSource, scanWithRegex, svelte, vue };
@@ -1,5 +1,5 @@
1
1
  import { a as json5, o as json, t as yaml } from "./formats-CFkM5co0.mjs";
2
- import { t as loadVueCompiler } from "./vue-5GFR5hft.mjs";
2
+ import { t as loadVueCompiler } from "./vue-CF4pe-Yr.mjs";
3
3
  import { isMessageLeaf, pathId } from "@x18n/core";
4
4
  //#region src/finders/sfc-blocks.ts
5
5
  const formatOfLang = {
@@ -1,30 +1,30 @@
1
1
  //#region src/scanners/js.ts
2
- const hardStringIgnoredAttributes = /* @__PURE__ */ new Set([
3
- "class",
4
- "className",
5
- "style",
6
- "key",
7
- "ref",
8
- "id",
9
- "href",
10
- "src",
11
- "type",
12
- "name",
13
- "for",
14
- "htmlFor",
15
- "role",
16
- "slot",
17
- "as",
18
- "to",
19
- "target",
20
- "rel",
21
- "lang",
22
- "xmlns",
23
- "viewBox",
24
- "d",
25
- "fill",
26
- "stroke"
2
+ const textAttributeWords = /* @__PURE__ */ new Set([
3
+ "title",
4
+ "subtitle",
5
+ "label",
6
+ "text",
7
+ "placeholder",
8
+ "alt",
9
+ "content",
10
+ "description",
11
+ "caption",
12
+ "message",
13
+ "tooltip",
14
+ "hint",
15
+ "summary",
16
+ "heading",
17
+ "headline"
27
18
  ]);
19
+ /**
20
+ * Markup attributes whose values users read: those whose last word is a text word (`title`, `aria-label`,
21
+ * `confirmText`, `empty-message`), plus `extract.attributes`. Every other attribute holds code: classes,
22
+ * URLs, ids, SVG geometry, attributify utilities.
23
+ */
24
+ function isTextAttribute(name, extra = []) {
25
+ const last = name.replace(/([a-z\d])([A-Z])/g, "$1-$2").toLowerCase().split(/[-_:.]/).at(-1);
26
+ return textAttributeWords.has(last) || extra.includes(name);
27
+ }
28
28
  function isNode(value) {
29
29
  return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string" && "start" in value;
30
30
  }
@@ -73,6 +73,7 @@ var FileScan = class {
73
73
  offset;
74
74
  collectHardStrings;
75
75
  ignoredCallees;
76
+ textAttributes;
76
77
  usages = [];
77
78
  keySlots = [];
78
79
  hardStrings = [];
@@ -83,11 +84,14 @@ var FileScan = class {
83
84
  factories = /* @__PURE__ */ new Map();
84
85
  typed = /* @__PURE__ */ new Map();
85
86
  consumed = /* @__PURE__ */ new Set();
86
- constructor(text, offset, calls, collectHardStrings, ignoredCallees = /* @__PURE__ */ new Set()) {
87
+ /** Render-prop functions of factory components (`<Translation>{t => ...}</Translation>`), by start offset. */
88
+ renderProps = /* @__PURE__ */ new Map();
89
+ constructor(text, offset, calls, collectHardStrings, ignoredCallees = /* @__PURE__ */ new Set(), textAttributes = []) {
87
90
  this.text = text;
88
91
  this.offset = offset;
89
92
  this.collectHardStrings = collectHardStrings;
90
93
  this.ignoredCallees = ignoredCallees;
94
+ this.textAttributes = textAttributes;
91
95
  for (const spec of calls) for (const name of [spec.callee].flat()) if (spec.factory) {
92
96
  const list = this.factories.get(name) ?? [];
93
97
  this.factories.set(name, list);
@@ -160,18 +164,21 @@ var FileScan = class {
160
164
  return object.properties.flatMap((p) => p.type === "Property" ? [p.value] : []);
161
165
  }
162
166
  /** ---- Usages ---- */
163
- addKey(value, binding, namespace) {
167
+ /** `expression` is the call holding `value`, kept only when the key is the call's own literal. */
168
+ addKey(value, binding, namespace, expression) {
164
169
  if ("props" in value) return;
165
170
  const node = unwrap(value);
166
171
  const base = {
167
172
  namespace: namespace ?? binding.namespace,
168
173
  keyPrefix: binding.keyPrefix
169
174
  };
175
+ const call = expression ? { expression: this.range(expression) } : {};
170
176
  if (isStringLiteral(node)) {
171
177
  this.consumed.add(node.start);
172
178
  this.keySlots.push(this.range(innerRange(node)));
173
179
  this.usages.push({
174
180
  ...base,
181
+ ...call,
175
182
  raw: node.value,
176
183
  range: this.range(innerRange(node)),
177
184
  kind: "call",
@@ -193,6 +200,7 @@ var FileScan = class {
193
200
  });
194
201
  this.usages.push({
195
202
  ...base,
203
+ ...call,
196
204
  raw,
197
205
  range,
198
206
  kind: "call",
@@ -256,30 +264,42 @@ var FileScan = class {
256
264
  const object = this.constants.get(node.object.name);
257
265
  return object && object.type === "ObjectExpression" ? objectProperty(object, node.property.name) : void 0;
258
266
  }
259
- handleCall(name, args) {
267
+ handleCall(name, args, expression) {
260
268
  const bindings = this.lookupAll(name).filter((binding) => !binding.spec.factory);
261
269
  for (const binding of bindings) {
262
270
  const namespace = binding.spec.namespace ? this.stringArg(this.argValues(args, binding.spec.namespace, 1)[0]) : void 0;
263
271
  for (const value of this.argValues(args, binding.spec.key, 0)) {
264
272
  if (bindings.length > 1 && !("props" in value) && unwrap(value).type === "ObjectExpression") continue;
265
- this.addKey(value, binding, namespace);
273
+ this.addKey(value, binding, namespace, expression);
266
274
  }
267
275
  }
268
276
  return bindings.length > 0;
269
277
  }
270
278
  call(node) {
271
279
  const name = calleeName(node.callee);
272
- if (name !== void 0) this.handleCall(name, node.arguments);
280
+ if (name !== void 0) this.handleCall(name, node.arguments, {
281
+ start: node.start,
282
+ end: node.end
283
+ });
273
284
  }
274
- jsx(node) {
275
- const name = jsxName(node.name);
276
- if (!name || !this.lookup(name)) return;
285
+ jsxProps(node) {
277
286
  const props = /* @__PURE__ */ new Map();
278
287
  for (const attribute of node.attributes) if (attribute.type === "JSXAttribute" && attribute.value && attribute.name.type === "JSXIdentifier") {
279
288
  props.set(attribute.name.name, attribute.value);
280
289
  if (isStringLiteral(attribute.value)) this.consumed.add(attribute.value.start);
281
290
  }
282
- this.handleCall(name, [{ props }]);
291
+ return { props };
292
+ }
293
+ jsx(node) {
294
+ const name = jsxName(node.name);
295
+ if (name && this.lookup(name)) this.handleCall(name, [this.jsxProps(node)]);
296
+ }
297
+ jsxElement(node) {
298
+ const name = jsxName(node.openingElement.name);
299
+ const specs = name ? this.factories.get(name) : void 0;
300
+ if (!specs) return;
301
+ const binding = this.pickFactory([this.jsxProps(node.openingElement)], specs);
302
+ for (const child of node.children) if (child.type === "JSXExpressionContainer" && (child.expression.type === "ArrowFunctionExpression" || child.expression.type === "FunctionExpression")) this.renderProps.set(child.expression.start, binding);
283
303
  }
284
304
  /** ---- Bindings ---- */
285
305
  declarator(id, init) {
@@ -289,7 +309,7 @@ var FileScan = class {
289
309
  if (value.type === "CallExpression") {
290
310
  const factory = calleeName(value.callee);
291
311
  const specs = factory ? this.factories.get(factory) : void 0;
292
- if (specs) this.bindFactory(id, value, specs);
312
+ if (specs) this.bindFactory(id, value.arguments, specs);
293
313
  return;
294
314
  }
295
315
  if (id.type === "Identifier" && (value.type === "Identifier" || value.type === "MemberExpression")) {
@@ -299,10 +319,10 @@ var FileScan = class {
299
319
  }
300
320
  }
301
321
  /** The first factory spec whose namespace and prefix sources all resolve, else the first spec. */
302
- pickFactory(call, specs) {
322
+ pickFactory(args, specs) {
303
323
  const resolved = specs.map((spec) => {
304
- const namespace = spec.namespace ? this.stringArg(this.argValues(call.arguments, spec.namespace, 0)[0]) : void 0;
305
- const keyPrefix = spec.keyPrefix ? this.stringArg(this.argValues(call.arguments, spec.keyPrefix, 0)[0]) : void 0;
324
+ const namespace = spec.namespace ? this.stringArg(this.argValues(args, spec.namespace, 0)[0]) : void 0;
325
+ const keyPrefix = spec.keyPrefix ? this.stringArg(this.argValues(args, spec.keyPrefix, 0)[0]) : void 0;
306
326
  const complete = (!spec.namespace || namespace !== void 0) && (!spec.keyPrefix || keyPrefix !== void 0);
307
327
  return {
308
328
  binding: {
@@ -318,8 +338,8 @@ var FileScan = class {
318
338
  });
319
339
  return (resolved.find((r) => r.complete) ?? resolved[0]).binding;
320
340
  }
321
- bindFactory(id, call, specs) {
322
- const binding = this.pickFactory(call, specs);
341
+ bindFactory(id, args, specs) {
342
+ const binding = this.pickFactory(args, specs);
323
343
  const returns = (binding.spec.returns ?? ["t"]).filter((name) => !name.includes("."));
324
344
  if (id.type === "Identifier") {
325
345
  this.bind(id.name, binding);
@@ -383,6 +403,9 @@ var FileScan = class {
383
403
  case "CallExpression":
384
404
  this.call(node);
385
405
  break;
406
+ case "JSXElement":
407
+ this.jsxElement(node);
408
+ break;
386
409
  case "JSXOpeningElement":
387
410
  this.jsx(node);
388
411
  break;
@@ -391,9 +414,12 @@ var FileScan = class {
391
414
  break;
392
415
  case "FunctionDeclaration":
393
416
  case "FunctionExpression":
394
- case "ArrowFunctionExpression":
417
+ case "ArrowFunctionExpression": {
395
418
  this.params(node.params);
419
+ const binding = this.renderProps.get(node.start);
420
+ if (binding && node.params[0]?.type === "Identifier") this.bind(node.params[0].name, binding);
396
421
  break;
422
+ }
397
423
  case "ImportSpecifier":
398
424
  this.importSpecifier(node);
399
425
  break;
@@ -413,7 +439,7 @@ var FileScan = class {
413
439
  if (this.consumed.has(node.start) || !parent) return;
414
440
  if (parent.type === "JSXAttribute") {
415
441
  const name = parent.name.type === "JSXIdentifier" ? parent.name.name : "";
416
- if (isStringLiteral(node) && !hardStringIgnoredAttributes.has(name) && !name.startsWith("data-") && !name.startsWith("aria-")) this.hard(node.value, {
442
+ if (isStringLiteral(node) && isTextAttribute(name, this.textAttributes)) this.hard(node.value, {
417
443
  start: parent.start,
418
444
  end: parent.end
419
445
  }, "jsx-attribute", name);
@@ -505,7 +531,7 @@ function scanSource(parse, text, options) {
505
531
  sourceType: "module",
506
532
  range: false
507
533
  });
508
- const scan = new FileScan(source, (options.offset ?? 0) + shift, options.calls, options.hardStrings !== false, new Set(options.ignoreCallees));
534
+ const scan = new FileScan(source, (options.offset ?? 0) + shift, options.calls, options.hardStrings !== false, new Set(options.ignoreCallees), options.textAttributes);
509
535
  scan.walk(result.program, void 0, "");
510
536
  scan.markers(result.program, result.comments);
511
537
  return scan.result();
@@ -531,7 +557,8 @@ function js() {
531
557
  calls: ctx.calls,
532
558
  filename: file.path,
533
559
  hardStrings: true,
534
- ignoreCallees: ctx.config.extract.ignoreCallees
560
+ ignoreCallees: ctx.config.extract.ignoreCallees,
561
+ textAttributes: ctx.config.extract.attributes
535
562
  });
536
563
  }
537
564
  }]
@@ -553,25 +580,6 @@ const ATTRIBUTE = 6;
553
580
  function isKeyAttribute(tag, name) {
554
581
  return name === "keypath" || name === "path" && (tag === "i18n" || tag === "i18n-d");
555
582
  }
556
- const ignoredAttributes = /* @__PURE__ */ new Set([
557
- "class",
558
- "style",
559
- "key",
560
- "ref",
561
- "id",
562
- "name",
563
- "type",
564
- "href",
565
- "src",
566
- "to",
567
- "for",
568
- "role",
569
- "lang",
570
- "target",
571
- "rel",
572
- "is",
573
- "slot"
574
- ]);
575
583
  function merge(into, result) {
576
584
  into.usages.push(...result.usages);
577
585
  into.keySlots.push(...result.keySlots ?? []);
@@ -580,14 +588,16 @@ function merge(into, result) {
580
588
  var TemplateScan = class {
581
589
  parse;
582
590
  calls;
591
+ textAttributes;
583
592
  out = {
584
593
  usages: [],
585
594
  keySlots: [],
586
595
  hardStrings: []
587
596
  };
588
- constructor(parse, calls) {
597
+ constructor(parse, calls, textAttributes) {
589
598
  this.parse = parse;
590
599
  this.calls = calls;
600
+ this.textAttributes = textAttributes;
591
601
  }
592
602
  expression(content, offset) {
593
603
  if (!content.trim()) return;
@@ -605,12 +615,16 @@ var TemplateScan = class {
605
615
  keyExpression(content, offset) {
606
616
  const callee = "__x18nKey(";
607
617
  try {
608
- merge(this.out, scanSource(this.parse, `${callee}${content})`, {
618
+ const result = scanSource(this.parse, `${callee}${content})`, {
609
619
  calls: [{ callee: "__x18nKey" }],
610
620
  offset: offset - 10,
611
621
  filename: "template.ts",
612
622
  hardStrings: false
613
- }));
623
+ });
624
+ merge(this.out, {
625
+ ...result,
626
+ usages: result.usages.map(({ expression: _, ...usage }) => usage)
627
+ });
614
628
  } catch {}
615
629
  }
616
630
  /** `v-t="'key'"` and `v-t="{ path: 'key' }"`. */
@@ -649,7 +663,7 @@ var TemplateScan = class {
649
663
  dynamic: false
650
664
  });
651
665
  this.out.keySlots.push(range);
652
- } else if (!ignoredAttributes.has(prop.name) && !prop.name.startsWith("data-") && /\p{L}/u.test(value.content)) this.out.hardStrings.push({
666
+ } else if (isTextAttribute(prop.name, this.textAttributes) && /\p{L}/u.test(value.content)) this.out.hardStrings.push({
653
667
  text: value.content,
654
668
  range: {
655
669
  start: prop.loc.start.offset,
@@ -706,13 +720,14 @@ function vue() {
706
720
  sourceMap: false,
707
721
  ignoreEmpty: false
708
722
  });
709
- const template = new TemplateScan(parseJs, ctx.calls);
723
+ const template = new TemplateScan(parseJs, ctx.calls, ctx.config.extract.attributes);
710
724
  for (const script of [descriptor.script, descriptor.scriptSetup]) {
711
725
  if (!script) continue;
712
726
  const lang = script.lang ?? "js";
713
727
  merge(template.out, scanSource(parseJs, script.content, {
714
728
  calls: ctx.calls,
715
729
  ignoreCallees: ctx.config.extract.ignoreCallees,
730
+ textAttributes: ctx.config.extract.attributes,
716
731
  offset: script.loc.start.offset,
717
732
  filename: `script.${lang === "tsx" || lang === "jsx" ? lang : lang === "ts" ? "ts" : "js"}`
718
733
  }));
@@ -724,4 +739,4 @@ function vue() {
724
739
  };
725
740
  }
726
741
  //#endregion
727
- export { loadParser as a, js as i, vue as n, scanSource as o, calleeName as r, loadVueCompiler as t };
742
+ export { js as a, isTextAttribute as i, vue as n, loadParser as o, calleeName as r, scanSource as s, loadVueCompiler as t };
@@ -1,5 +1,5 @@
1
1
  import { placeholderFromShape } from "./placeholders.mjs";
2
- import { r as regexScopes } from "./regex-B9TBGgXC.mjs";
2
+ import { r as regexScopes } from "./regex-AYVhuSb6.mjs";
3
3
  //#region src/yaml-plugin/index.ts
4
4
  /**
5
5
  * Compiles a validated YAML plugin into plugin objects. Each section maps to the
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@x18n/builtins",
3
3
  "type": "module",
4
- "version": "0.0.10",
4
+ "version": "0.0.13",
5
5
  "description": "Built-in x18n plugins and presets: formats, locale finders, scanners, checks and reporters",
6
6
  "author": "Anthony Fu <anthonyfu117@hotmail.com>",
7
7
  "license": "MIT",
@@ -33,7 +33,7 @@
33
33
  ],
34
34
  "dependencies": {
35
35
  "@vue/compiler-sfc": "^3.5.44",
36
- "@x18n/core": "0.0.10",
36
+ "@x18n/core": "0.0.13",
37
37
  "json5": "^2.2.3",
38
38
  "jsonc-parser": "^3.3.1",
39
39
  "oxc-parser": "^0.155.0",