@x18n/builtins 0.0.3 → 0.0.6

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
@@ -2,6 +2,7 @@ import { Check, Plugin } from "@x18n/core";
2
2
  //#region src/checks/index.d.ts
3
3
  export declare const missingKey: Check;
4
4
  export declare const missingTranslation: Check;
5
+ export declare const staleTranslation: Check;
5
6
  export declare const emptyTranslation: Check;
6
7
  export declare const keyConflict: Check;
7
8
  export declare const localeCodeConflict: Check;
package/dist/checks.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { shouldExtract } from "./extractors.mjs";
1
+ import { extractableHardStrings } from "./extractors.mjs";
2
2
  import { createPlaceholderComparer, localeSpellingKey } from "@x18n/core";
3
3
  //#region src/checks/index.ts
4
4
  function list(items) {
@@ -70,6 +70,23 @@ const missingTranslation = {
70
70
  }
71
71
  }
72
72
  };
73
+ function changed(change) {
74
+ if (!Number.isFinite(change.updatedAt)) return "changed locally";
75
+ const revision = change.revision ? ` in ${change.revision.slice(0, 7)}` : "";
76
+ return `changed ${new Date(change.updatedAt).toISOString().slice(0, 10)}${revision}`;
77
+ }
78
+ const staleTranslation = {
79
+ id: "stale-translation",
80
+ defaultSeverity: "warning",
81
+ description: "A translation that last changed before its source message, by the history providers (git).",
82
+ *run(ctx) {
83
+ for (const coverage of ctx.coverage()) for (const key of coverage.outdated) yield {
84
+ key,
85
+ locale: coverage.locale,
86
+ message: `"${key}" in ${coverage.locale} is older than ${ctx.sourceLocale} (source ${changed(ctx.history(key, ctx.sourceLocale))}).`
87
+ };
88
+ }
89
+ };
73
90
  const emptyTranslation = {
74
91
  id: "empty-translation",
75
92
  defaultSeverity: "warning",
@@ -265,7 +282,7 @@ const hardString = {
265
282
  description: "A hard-coded string that should probably be a message. On with `extract.autoDetect`.",
266
283
  *run(ctx) {
267
284
  if (!ctx.config.extract.autoDetect && ctx.config.checks["hard-string"] === void 0) return;
268
- for (const hard of ctx.hardStrings()) if (shouldExtract(hard.text, hard.kind)) yield {
285
+ for (const hard of extractableHardStrings(ctx.config.extract, ctx.hardStrings(), ctx.text)) yield {
269
286
  location: {
270
287
  path: hard.path,
271
288
  range: hard.range
@@ -274,9 +291,14 @@ const hardString = {
274
291
  };
275
292
  }
276
293
  };
277
- /** A term as a whole word, so "Home" does not match "Homepage". */
294
+ const unspaced = "\\p{scx=Han}\\p{scx=Hiragana}\\p{scx=Katakana}\\p{scx=Hangul}\\p{scx=Thai}\\p{scx=Lao}\\p{scx=Khmer}\\p{scx=Myanmar}";
295
+ const startsUnspaced = new RegExp(`^[${unspaced}]`, "u");
296
+ const endsUnspaced = new RegExp(`[${unspaced}]$`, "u");
297
+ /** A term as a whole word, so "Home" does not match "Homepage"; a plain substring at edges in unspaced scripts. */
278
298
  function wordPattern(term) {
279
- return new RegExp(`(?<![\\p{L}\\p{N}])${term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\p{L}\\p{N}])`, "iu");
299
+ const before = startsUnspaced.test(term) ? "" : "(?<![\\p{L}\\p{N}])";
300
+ const after = endsUnspaced.test(term) ? "" : "(?![\\p{L}\\p{N}])";
301
+ return new RegExp(`${before}${term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}${after}`, "iu");
280
302
  }
281
303
  function* glossaryDiagnostics(ctx, entry) {
282
304
  const term = wordPattern(entry.term);
@@ -309,6 +331,7 @@ const allChecks = [
309
331
  placeholderMismatch,
310
332
  keyConflict,
311
333
  missingTranslation,
334
+ staleTranslation,
312
335
  emptyTranslation,
313
336
  unusedKey,
314
337
  localeCodeConflict,
@@ -326,4 +349,4 @@ function recommended() {
326
349
  };
327
350
  }
328
351
  //#endregion
329
- export { allChecks, dynamicKey, emptyTranslation, glossary, hardString, identicalToSource, invalidSyntax, keyConflict, keyStyle, localeCodeConflict, missingKey, missingTranslation, placeholderMismatch, recommended, unusedKey };
352
+ export { allChecks, dynamicKey, emptyTranslation, glossary, hardString, identicalToSource, invalidSyntax, keyConflict, keyStyle, localeCodeConflict, missingKey, missingTranslation, placeholderMismatch, recommended, staleTranslation, unusedKey };
@@ -1,4 +1,4 @@
1
- import { ExtractItem, ExtractTemplate, KeyString, Project, TextRange } from "@x18n/core";
1
+ import { ExtractItem, ExtractOptions, ExtractTemplate, HardString, KeyString, Project, TextRange } from "@x18n/core";
2
2
  //#region src/extractors/index.d.ts
3
3
  export type Score = "must-include" | "should-include" | "none" | "should-exclude" | "must-exclude";
4
4
  export interface ExtractionRule {
@@ -38,6 +38,12 @@ export interface FindOptions {
38
38
  /** Only these files (globs). */
39
39
  readonly files?: readonly string[] | undefined;
40
40
  }
41
+ /** Hard strings worth extracting: they pass the rules and no non-UI file, config ignore or control comment skips them. */
42
+ export declare function extractableHardStrings(options: ExtractOptions, hardStrings: Iterable<HardString & {
43
+ readonly path: string;
44
+ }>, textOf: (path: string) => string | undefined): Generator<HardString & {
45
+ readonly path: string;
46
+ }>;
41
47
  /** Hard strings worth extracting, with proposed keys, values and replacements. */
42
48
  export declare function findCandidates(project: Project, options?: FindOptions): ExtractCandidate[];
43
49
  export interface ExtractPlanFile {
@@ -30,7 +30,18 @@ const rules = [
30
30
  },
31
31
  {
32
32
  id: "markup",
33
- test: (text) => /^<[^>]+>$/.test(text.trim()) ? "must-exclude" : "none"
33
+ test: (text) => /^<.*>$/s.test(text.trim()) && !/\p{L}/u.test(text.replace(/<[^>]*>|\{\d+\}/g, "")) ? "must-exclude" : "none"
34
+ },
35
+ {
36
+ id: "svg-path",
37
+ test: (text) => /^\s*M\s*-?[\d.]/i.test(text) && /^[MLHVCSQTAZ\d\s.,-]+$/i.test(text) ? "must-exclude" : "none"
38
+ },
39
+ {
40
+ id: "class-list",
41
+ test: (text) => {
42
+ const tokens = text.trim().split(/\s+/);
43
+ return tokens.every((t) => /^!?[a-z][\w:/[\].#%!-]*$/.test(t)) && tokens.filter((t) => /[-:!/[\d]/.test(t)).length * 2 > tokens.length ? "must-exclude" : "none";
44
+ }
34
45
  },
35
46
  {
36
47
  id: "non-ascii",
@@ -57,6 +68,17 @@ const rules = [
57
68
  test: (text) => /^[a-z]+(?:[A-Z][a-z\d]*)+$/.test(text) || /^[A-Z\d]+(?:_[A-Z\d]+)+$/.test(text) || /^[a-z\d]+(?:[-_.:][a-z\d]+)+$/.test(text) ? "should-exclude" : "none"
58
69
  }
59
70
  ];
71
+ /** Files whose strings users never see, by the naming conventions of common languages and test runners. */
72
+ const nonUiFiles = [
73
+ /(?:^|\/)(?:tests?|specs?|e2e|cypress|fixtures?|mocks?|testdata|__(?:tests|mocks|snapshots|fixtures)__)\//i,
74
+ /[._-](?:tests?|specs?|e2e|cy|stories|story|config|conf)\.\w+$/i,
75
+ /(?:^|\/)(?:test_[^/]+|(?:tests?|specs?|conftest|config)\.\w+)$/i,
76
+ /[a-z\d](?:Tests?|Spec|IT)\.(?:java|kt|kts|scala|groovy|cs|fs|vb|php|swift|m|mm)$/,
77
+ /\.d\.[cm]?ts$/
78
+ ];
79
+ function isExtractableFile(path) {
80
+ return !nonUiFiles.some((pattern) => pattern.test(path));
81
+ }
60
82
  /** Folds rule scores the i18n-ally way (`shouldExtract`). */
61
83
  function shouldExtract(text, kind, list = rules) {
62
84
  let result = "none";
@@ -166,18 +188,43 @@ var KeyAllocator = class {
166
188
  };
167
189
  }
168
190
  };
169
- function isIgnored(project, hard, text) {
170
- const options = project.config.extract;
191
+ function ignoredByConfig(options, hard) {
171
192
  const matches = (rule) => {
172
193
  const regex = /^\/(.+)\/([a-z]*)$/.exec(rule);
173
194
  return regex ? new RegExp(regex[1], regex[2]).test(hard.text) : rule === hard.text;
174
195
  };
175
196
  if (options.ignore?.some(matches)) return true;
176
197
  for (const [glob, list] of Object.entries(options.ignoreByFile ?? {})) if (pathMatcher([glob])(hard.path) && list.some(matches)) return true;
177
- if (hard.attribute && options.ignoreAttributes?.includes(hard.attribute)) return true;
198
+ return hard.attribute !== void 0 && options.ignoreAttributes?.includes(hard.attribute) === true;
199
+ }
200
+ const ignoreLine = /x18n-ignore(?![-\w])/;
201
+ /**
202
+ * Offsets silenced by control comments: `x18n-ignore-file` anywhere, inside `x18n-ignore-start` ... `x18n-ignore-end`
203
+ * (to the end of the file when unclosed), or on the line of, or the line after, `x18n-ignore`.
204
+ */
205
+ function commentIgnores(text) {
206
+ if (text.includes("x18n-ignore-file")) return () => true;
207
+ const regions = [...text.matchAll(/x18n-ignore-start[\s\S]*?(?:x18n-ignore-end|$)/g)].map((m) => [m.index, m.index + m[0].length]);
178
208
  const index = createLineIndex(text);
179
- const line = index.position(hard.range.start).line;
180
- return index.line(line).includes("x18n-ignore") || line > 0 && index.line(line - 1).includes("x18n-ignore");
209
+ return (offset) => {
210
+ if (regions.some(([start, end]) => offset >= start && offset < end)) return true;
211
+ const line = index.position(offset).line;
212
+ return ignoreLine.test(index.line(line)) || line > 0 && ignoreLine.test(index.line(line - 1));
213
+ };
214
+ }
215
+ /** Hard strings worth extracting: they pass the rules and no non-UI file, config ignore or control comment skips them. */
216
+ function* extractableHardStrings(options, hardStrings, textOf) {
217
+ const ignores = /* @__PURE__ */ new Map();
218
+ for (const hard of hardStrings) {
219
+ if (!isExtractableFile(hard.path) || !shouldExtract(hard.text, hard.kind) || ignoredByConfig(options, hard)) continue;
220
+ let ignored = ignores.get(hard.path);
221
+ if (!ignored) {
222
+ const text = textOf(hard.path);
223
+ ignored = text === void 0 ? () => true : commentIgnores(text);
224
+ ignores.set(hard.path, ignored);
225
+ }
226
+ if (!ignored(hard.range.start)) yield hard;
227
+ }
181
228
  }
182
229
  /** Placeholder shape of the project's first placeholder syntax, `{name}` by default. */
183
230
  function placeholderShape(project) {
@@ -211,10 +258,10 @@ function findCandidates(project, options = {}) {
211
258
  const keys = new KeyAllocator(project);
212
259
  const shape = placeholderShape(project);
213
260
  const out = [];
214
- for (const hard of project.hardStrings()) {
215
- if (inFiles && !inFiles(hard.path)) continue;
261
+ const inScope = [...project.hardStrings()].filter((hard) => !inFiles || inFiles(hard.path));
262
+ for (const hard of extractableHardStrings(project.config.extract, inScope, (path) => project.text(path))) {
216
263
  const text = project.text(hard.path);
217
- if (text === void 0 || !shouldExtract(hard.text, hard.kind) || isIgnored(project, hard, text)) continue;
264
+ if (text === void 0) continue;
218
265
  const value = hard.kind === "js-template" ? hard.text.replace(/\{(\d+)\}/g, (_, i) => shape(hard.args?.[Number(i)]?.name ?? `arg${i}`)) : hard.text;
219
266
  const { key, reused } = keys.allocate(value, hard.path, hard.text.replace(/\{\d+\}/g, " "));
220
267
  const templates = templatesFor(project, hard.kind, hard.path);
@@ -266,4 +313,4 @@ function extractItems(project, items) {
266
313
  });
267
314
  }
268
315
  //#endregion
269
- export { defaultTemplates, extractItems, findCandidates, rules, shouldExtract, slugify, toPlanFile };
316
+ export { defaultTemplates, extractItems, extractableHardStrings, findCandidates, rules, shouldExtract, slugify, toPlanFile };
@@ -82,7 +82,8 @@ const jsonReporter = {
82
82
  total: c.total,
83
83
  translated: c.translated,
84
84
  missing: c.missing.length,
85
- empty: c.empty.length
85
+ empty: c.empty.length,
86
+ outdated: c.outdated.length
86
87
  }))
87
88
  })) };
88
89
  return `${JSON.stringify(envelope("check", data, diagnostics), null, 2)}\n`;
package/package.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "@x18n/builtins",
3
3
  "type": "module",
4
- "version": "0.0.3",
4
+ "version": "0.0.6",
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",
8
- "homepage": "https://github.com/antfu/x18n#readme",
8
+ "homepage": "https://github.com/x18n-dev/x18n#readme",
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "git+https://github.com/antfu/x18n.git",
11
+ "url": "git+https://github.com/x18n-dev/x18n.git",
12
12
  "directory": "packages/builtins"
13
13
  },
14
- "bugs": "https://github.com/antfu/x18n/issues",
14
+ "bugs": "https://github.com/x18n-dev/x18n/issues",
15
15
  "sideEffects": false,
16
16
  "exports": {
17
17
  ".": "./dist/index.mjs",
@@ -33,10 +33,10 @@
33
33
  ],
34
34
  "dependencies": {
35
35
  "@vue/compiler-sfc": "^3.5.44",
36
- "@x18n/core": "0.0.3",
36
+ "@x18n/core": "0.0.6",
37
37
  "json5": "^2.2.3",
38
38
  "jsonc-parser": "^3.3.1",
39
- "oxc-parser": "^0.154.0",
39
+ "oxc-parser": "^0.155.0",
40
40
  "svelte": "^5.57.2",
41
41
  "yaml": "^2.9.1"
42
42
  },