@x18n/builtins 0.0.2 → 0.0.5

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.
@@ -0,0 +1,20 @@
1
+ import { Check, Plugin } from "@x18n/core";
2
+ //#region src/checks/index.d.ts
3
+ export declare const missingKey: Check;
4
+ export declare const missingTranslation: Check;
5
+ export declare const staleTranslation: Check;
6
+ export declare const emptyTranslation: Check;
7
+ export declare const keyConflict: Check;
8
+ export declare const localeCodeConflict: Check;
9
+ export declare const unusedKey: Check;
10
+ export declare const placeholderMismatch: Check;
11
+ export declare const invalidSyntax: Check;
12
+ export declare const dynamicKey: Check;
13
+ export declare const identicalToSource: Check;
14
+ export declare const keyStyle: Check;
15
+ export declare const hardString: Check;
16
+ export declare const glossary: Check;
17
+ export declare const allChecks: readonly Check[];
18
+ /** Every built-in check with its `recommended` severity. */
19
+ export declare function recommended(): Plugin;
20
+ //#endregion
@@ -0,0 +1,352 @@
1
+ import { extractableHardStrings } from "./extractors.mjs";
2
+ import { createPlaceholderComparer, localeSpellingKey } from "@x18n/core";
3
+ //#region src/checks/index.ts
4
+ function list(items) {
5
+ return items.join(", ");
6
+ }
7
+ const missingKey = {
8
+ id: "missing-key",
9
+ defaultSeverity: "error",
10
+ description: "A usage whose key is not defined in the source locale.",
11
+ *run(ctx) {
12
+ for (const usage of ctx.undefinedUsages()) yield {
13
+ key: usage.key,
14
+ locale: ctx.sourceLocale,
15
+ location: {
16
+ path: usage.path,
17
+ range: usage.range
18
+ },
19
+ message: `"${usage.key}" is not defined in ${ctx.sourceLocale}.`,
20
+ fixes: [{
21
+ title: `Add "${usage.key}" to ${ctx.sourceLocale}`,
22
+ ops: [{
23
+ type: "set",
24
+ key: usage.key,
25
+ locale: ctx.sourceLocale,
26
+ value: ""
27
+ }]
28
+ }]
29
+ };
30
+ }
31
+ };
32
+ const missingTranslation = {
33
+ id: "missing-translation",
34
+ defaultSeverity: "warning",
35
+ description: "A source-locale key that target locales lack. One diagnostic per key, at the source entry.",
36
+ *run(ctx) {
37
+ const missing = /* @__PURE__ */ new Map();
38
+ for (const coverage of ctx.coverage()) {
39
+ if (coverage.locale === ctx.sourceLocale) continue;
40
+ for (const key of coverage.missing) {
41
+ const locales = missing.get(key) ?? [];
42
+ missing.set(key, locales);
43
+ locales.push(coverage.locale);
44
+ }
45
+ }
46
+ for (const [key, locales] of missing) {
47
+ const source = ctx.message(key, ctx.sourceLocale);
48
+ yield {
49
+ key,
50
+ locale: ctx.sourceLocale,
51
+ message: `"${key}" is missing in ${list(locales)}.`,
52
+ fixes: [{
53
+ title: `Add empty values in ${list(locales)}`,
54
+ ops: locales.map((locale) => ({
55
+ type: "set",
56
+ key,
57
+ locale,
58
+ value: ""
59
+ }))
60
+ }, ...source ? [{
61
+ title: "Copy the source value",
62
+ ops: locales.map((locale) => ({
63
+ type: "set",
64
+ key,
65
+ locale,
66
+ value: source.value
67
+ }))
68
+ }] : []]
69
+ };
70
+ }
71
+ }
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
+ };
90
+ const emptyTranslation = {
91
+ id: "empty-translation",
92
+ defaultSeverity: "warning",
93
+ description: "A message with an empty string value.",
94
+ *run(ctx) {
95
+ for (const message of ctx.messages()) if (message.value === "") yield {
96
+ key: message.key,
97
+ locale: message.locale,
98
+ message: `"${message.key}" is empty in ${message.locale}.`
99
+ };
100
+ }
101
+ };
102
+ const keyConflict = {
103
+ id: "key-conflict",
104
+ defaultSeverity: "error",
105
+ description: "A key that is both a leaf and a parent, or is duplicated across files of one locale.",
106
+ *run(ctx) {
107
+ const keys = new Set(ctx.keys());
108
+ for (const key of keys) {
109
+ const segments = key.split(".");
110
+ for (let i = 1; i < segments.length; i++) {
111
+ const parent = segments.slice(0, i).join(".");
112
+ if (keys.has(parent)) yield {
113
+ key: parent,
114
+ message: `"${parent}" is a message and also the parent of "${key}".`
115
+ };
116
+ }
117
+ for (const locale of ctx.locales) {
118
+ const copies = ctx.duplicates(key, locale);
119
+ if (copies.length > 1) {
120
+ const files = [...new Set(copies.map((m) => ctx.resources.find((r) => r.id === m.resource)?.path ?? m.resource))];
121
+ yield {
122
+ key,
123
+ locale,
124
+ message: `"${key}" is defined ${copies.length} times in ${locale} (${list(files)}).`
125
+ };
126
+ }
127
+ }
128
+ }
129
+ }
130
+ };
131
+ const localeCodeConflict = {
132
+ id: "locale-code-conflict",
133
+ defaultSeverity: "warning",
134
+ description: "Locale codes that differ only in spelling, such as zh_CN and zh-CN.",
135
+ *run(ctx) {
136
+ const groups = /* @__PURE__ */ new Map();
137
+ for (const locale of ctx.locales) {
138
+ const id = localeSpellingKey(locale);
139
+ const group = groups.get(id) ?? [];
140
+ groups.set(id, group);
141
+ group.push(locale);
142
+ }
143
+ for (const group of groups.values()) if (group.length > 1) {
144
+ const resource = ctx.resources.find((r) => r.locale === group[1]);
145
+ yield {
146
+ locale: group[1],
147
+ location: resource ? {
148
+ path: resource.path,
149
+ range: {
150
+ start: 0,
151
+ end: 0
152
+ }
153
+ } : void 0,
154
+ message: `Locale codes ${list(group)} differ only in spelling; they are separate locales.`
155
+ };
156
+ }
157
+ }
158
+ };
159
+ const unusedKey = {
160
+ id: "unused-key",
161
+ defaultSeverity: "warning",
162
+ description: "A key with no usage, after keysInUse, derived keys and dynamic prefixes.",
163
+ *run(ctx) {
164
+ if (!ctx.hasScanners) return;
165
+ for (const key of ctx.unused()) yield {
166
+ key,
167
+ message: `"${key}" is not used.`,
168
+ fixes: [{
169
+ title: `Delete "${key}" in every locale`,
170
+ ops: [{
171
+ type: "delete",
172
+ key,
173
+ locales: "all"
174
+ }]
175
+ }]
176
+ };
177
+ }
178
+ };
179
+ const placeholderMismatch = {
180
+ id: "placeholder-mismatch",
181
+ defaultSeverity: "error",
182
+ description: "Target value placeholders differ from the source value.",
183
+ *run(ctx) {
184
+ const compare = createPlaceholderComparer(ctx.placeholderSyntaxes, ctx.placeholders);
185
+ for (const key of ctx.keys()) {
186
+ const source = ctx.message(key, ctx.sourceLocale);
187
+ if (typeof source?.value !== "string") continue;
188
+ for (const locale of ctx.locales) {
189
+ if (locale === ctx.sourceLocale) continue;
190
+ const target = ctx.message(key, locale);
191
+ if (typeof target?.value !== "string" || target.value === "") continue;
192
+ const { missing, extra } = compare(source.value, target.value);
193
+ if (missing.length || extra.length) {
194
+ const parts = [missing.length ? `missing ${list(missing)}` : "", extra.length ? `unexpected ${list(extra)}` : ""].filter(Boolean);
195
+ yield {
196
+ key,
197
+ locale,
198
+ message: `"${key}" in ${locale} has different placeholders than ${ctx.sourceLocale}: ${parts.join("; ")}.`
199
+ };
200
+ }
201
+ }
202
+ }
203
+ }
204
+ };
205
+ const invalidSyntax = {
206
+ id: "invalid-syntax",
207
+ defaultSeverity: "error",
208
+ description: "Message syntax errors (ICU, linked messages, nesting).",
209
+ *run(ctx) {
210
+ const keys = new Set(ctx.keys());
211
+ const exists = (key) => keys.has(key) || ctx.keys().some((k) => k.startsWith(`${key}.`));
212
+ for (const message of ctx.messages()) {
213
+ if (typeof message.value !== "string") continue;
214
+ const errors = [...ctx.syntaxErrors(message.value)];
215
+ for (const placeholder of ctx.placeholders(message.value)) if (placeholder.syntax === "linked" && !exists(placeholder.name)) errors.push(`linked message "${placeholder.name}" does not exist`);
216
+ for (const error of errors) yield {
217
+ key: message.key,
218
+ locale: message.locale,
219
+ message: `"${message.key}" in ${message.locale}: ${error}.`
220
+ };
221
+ }
222
+ }
223
+ };
224
+ const dynamicKey = {
225
+ id: "dynamic-key",
226
+ defaultSeverity: "off",
227
+ description: "A fully dynamic key that no check can follow.",
228
+ *run(ctx) {
229
+ for (const usage of ctx.usages()) if (usage.dynamic === "unknown") yield {
230
+ location: {
231
+ path: usage.path,
232
+ range: usage.range
233
+ },
234
+ message: `Dynamic key "${usage.raw}" cannot be checked.`
235
+ };
236
+ }
237
+ };
238
+ const identicalToSource = {
239
+ id: "identical-to-source",
240
+ defaultSeverity: "off",
241
+ description: "A target value that equals the source value.",
242
+ *run(ctx) {
243
+ for (const message of ctx.messages()) {
244
+ if (message.locale === ctx.sourceLocale || typeof message.value !== "string" || message.value === "") continue;
245
+ if (ctx.message(message.key, ctx.sourceLocale)?.value === message.value) yield {
246
+ key: message.key,
247
+ locale: message.locale,
248
+ message: `"${message.key}" in ${message.locale} is the same as in ${ctx.sourceLocale}.`
249
+ };
250
+ }
251
+ }
252
+ };
253
+ const caseRules = {
254
+ camel: /^[a-z][a-zA-Z0-9]*$/,
255
+ snake: /^[a-z][a-z0-9_]*$/,
256
+ kebab: /^[a-z][a-z0-9-]*$/,
257
+ pascal: /^[A-Z][a-zA-Z0-9]*$/
258
+ };
259
+ const keyStyle = {
260
+ id: "key-style",
261
+ defaultSeverity: "off",
262
+ description: "A key that does not match a naming rule (options: case, pattern).",
263
+ *run(ctx) {
264
+ const rule = typeof ctx.options.case === "string" ? caseRules[ctx.options.case] : void 0;
265
+ const pattern = typeof ctx.options.pattern === "string" ? new RegExp(ctx.options.pattern) : void 0;
266
+ for (const key of ctx.keys()) {
267
+ const path = key.slice(key.indexOf(":") + 1);
268
+ if (rule && !path.split(".").every((segment) => rule.test(segment))) yield {
269
+ key,
270
+ message: `"${key}" is not ${String(ctx.options.case)} case.`
271
+ };
272
+ else if (pattern && !pattern.test(key)) yield {
273
+ key,
274
+ message: `"${key}" does not match ${pattern.source}.`
275
+ };
276
+ }
277
+ }
278
+ };
279
+ const hardString = {
280
+ id: "hard-string",
281
+ defaultSeverity: "info",
282
+ description: "A hard-coded string that should probably be a message. On with `extract.autoDetect`.",
283
+ *run(ctx) {
284
+ if (!ctx.config.extract.autoDetect && ctx.config.checks["hard-string"] === void 0) return;
285
+ for (const hard of extractableHardStrings(ctx.config.extract, ctx.hardStrings(), ctx.text)) yield {
286
+ location: {
287
+ path: hard.path,
288
+ range: hard.range
289
+ },
290
+ message: `Hard-coded string "${hard.text}".`
291
+ };
292
+ }
293
+ };
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. */
298
+ function wordPattern(term) {
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");
302
+ }
303
+ function* glossaryDiagnostics(ctx, entry) {
304
+ const term = wordPattern(entry.term);
305
+ for (const key of ctx.keys()) {
306
+ const source = ctx.message(key, ctx.sourceLocale)?.value;
307
+ if (typeof source !== "string" || !term.test(source)) continue;
308
+ for (const locale of ctx.locales) {
309
+ const value = ctx.message(key, locale)?.value;
310
+ const expected = entry.doNotTranslate ? entry.term : entry.translations?.[locale];
311
+ if (locale === ctx.sourceLocale || typeof value !== "string" || value === "" || !expected) continue;
312
+ if (!wordPattern(expected).test(value)) yield {
313
+ key,
314
+ locale,
315
+ message: `"${key}" in ${locale} should use "${expected}" for "${entry.term}".`
316
+ };
317
+ }
318
+ }
319
+ }
320
+ const glossary = {
321
+ id: "glossary",
322
+ defaultSeverity: "off",
323
+ description: "A glossary term translated differently from the glossary.",
324
+ *run(ctx) {
325
+ for (const entry of ctx.config.glossary) yield* glossaryDiagnostics(ctx, entry);
326
+ }
327
+ };
328
+ const allChecks = [
329
+ missingKey,
330
+ invalidSyntax,
331
+ placeholderMismatch,
332
+ keyConflict,
333
+ missingTranslation,
334
+ staleTranslation,
335
+ emptyTranslation,
336
+ unusedKey,
337
+ localeCodeConflict,
338
+ dynamicKey,
339
+ identicalToSource,
340
+ keyStyle,
341
+ hardString,
342
+ glossary
343
+ ];
344
+ /** Every built-in check with its `recommended` severity. */
345
+ function recommended() {
346
+ return {
347
+ name: "x18n:checks",
348
+ checks: allChecks
349
+ };
350
+ }
351
+ //#endregion
352
+ export { allChecks, dynamicKey, emptyTranslation, glossary, hardString, identicalToSource, invalidSyntax, keyConflict, keyStyle, localeCodeConflict, missingKey, missingTranslation, placeholderMismatch, recommended, staleTranslation, unusedKey };
@@ -0,0 +1,57 @@
1
+ import { ExtractItem, ExtractOptions, ExtractTemplate, HardString, KeyString, Project, TextRange } from "@x18n/core";
2
+ //#region src/extractors/index.d.ts
3
+ export type Score = "must-include" | "should-include" | "none" | "should-exclude" | "must-exclude";
4
+ export interface ExtractionRule {
5
+ readonly id: string;
6
+ readonly test: (text: string, kind: string) => Score;
7
+ }
8
+ /** Built-in rules, applied in order. A `must-*` wins at once; otherwise the last `should-*` decides. */
9
+ export declare const rules: readonly ExtractionRule[];
10
+ /** Folds rule scores the i18n-ally way (`shouldExtract`). */
11
+ export declare function shouldExtract(text: string, kind: string, list?: readonly ExtractionRule[]): boolean;
12
+ export type KeyCase = "camel" | "snake" | "kebab" | "dot";
13
+ /** "Forgot password?" becomes `forgotPassword` (camel), `forgot_password`, `forgot-password` or `forgot.password`. */
14
+ export declare function slugify(text: string, keyCase?: KeyCase, maxLength?: number): string;
15
+ /** Fallback templates per kind, used after config and preset templates. */
16
+ export declare const defaultTemplates: readonly ExtractTemplate[];
17
+ export interface ExtractCandidate {
18
+ readonly path: string;
19
+ /** 1-based line, for display. */
20
+ readonly line: number;
21
+ readonly kind: string;
22
+ readonly text: string;
23
+ /** The text in the file at `range`, used to detect stale plans. */
24
+ readonly original: string;
25
+ readonly range: TextRange;
26
+ readonly attribute: string | undefined;
27
+ readonly key: KeyString;
28
+ /** True when an existing key with the same source value is reused. */
29
+ readonly reused: boolean;
30
+ /** The message to store: template arguments become placeholders. */
31
+ readonly value: string;
32
+ readonly replacement: string;
33
+ readonly imports: readonly string[];
34
+ /** Other templates that also apply, for surfaces that let users choose. */
35
+ readonly alternatives: readonly string[];
36
+ }
37
+ export interface FindOptions {
38
+ /** Only these files (globs). */
39
+ readonly files?: readonly string[] | undefined;
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
+ }>;
47
+ /** Hard strings worth extracting, with proposed keys, values and replacements. */
48
+ export declare function findCandidates(project: Project, options?: FindOptions): ExtractCandidate[];
49
+ export interface ExtractPlanFile {
50
+ readonly x18n: 1;
51
+ readonly project: string;
52
+ readonly items: readonly ExtractCandidate[];
53
+ }
54
+ export declare function toPlanFile(project: Project, candidates: readonly ExtractCandidate[]): ExtractPlanFile;
55
+ /** Turns plan items (maybe edited by a human or agent) into edit items, after checking the files did not change. */
56
+ export declare function extractItems(project: Project, items: readonly Pick<ExtractCandidate, "path" | "range" | "original" | "key" | "value" | "replacement" | "imports">[]): ExtractItem[];
57
+ //#endregion