@x18n/builtins 0.0.0-stage → 0.0.0

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/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-PRESENT Anthony Fu <https://github.com/antfu>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,37 @@
1
+ # Third-party notices
2
+
3
+ x18n adapts code and ideas from [i18n-ally](https://github.com/lokalise/i18n-ally). Each adapted file starts with a line `Adapted from i18n-ally (MIT), <original path>`.
4
+
5
+ ## Adapted files
6
+
7
+ | x18n file | i18n-ally source |
8
+ | --- | --- |
9
+ | `packages/builtins/src/finders/pattern.ts` | `src/utils/PathMatcher.ts`, `src/core/loaders/LocaleLoader.ts` (`guessDirStructure`, `findLocaleDirs`) |
10
+ | `packages/builtins/src/scanners/regex.ts` | `src/utils/Regex.ts` |
11
+ | `packages/builtins/src/manifests/index.ts` | `src/packagesParsers/*` |
12
+ | `packages/builtins/src/presets/index.ts` | `src/frameworks/*` |
13
+ | `packages/builtins/src/extractors/index.ts` | `src/extraction/*`, `src/core/Extract.ts` |
14
+
15
+ ## i18n-ally license
16
+
17
+ MIT License
18
+
19
+ Copyright (c) 2020 Lokalise Inc., Anthony Fu, think2011
20
+
21
+ Permission is hereby granted, free of charge, to any person obtaining a copy
22
+ of this software and associated documentation files (the "Software"), to deal
23
+ in the Software without restriction, including without limitation the rights
24
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
25
+ copies of the Software, and to permit persons to whom the Software is
26
+ furnished to do so, subject to the following conditions:
27
+
28
+ The above copyright notice and this permission notice shall be included in all
29
+ copies or substantial portions of the Software.
30
+
31
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
32
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
33
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
34
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
35
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
36
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
37
+ SOFTWARE.
@@ -0,0 +1,19 @@
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 emptyTranslation: Check;
6
+ export declare const keyConflict: Check;
7
+ export declare const localeCodeConflict: Check;
8
+ export declare const unusedKey: Check;
9
+ export declare const placeholderMismatch: Check;
10
+ export declare const invalidSyntax: Check;
11
+ export declare const dynamicKey: Check;
12
+ export declare const identicalToSource: Check;
13
+ export declare const keyStyle: Check;
14
+ export declare const hardString: Check;
15
+ export declare const glossary: Check;
16
+ export declare const allChecks: readonly Check[];
17
+ /** Every built-in check with its `recommended` severity. */
18
+ export declare function recommended(): Plugin;
19
+ //#endregion
@@ -0,0 +1,330 @@
1
+ import { shouldExtract } from "./extractors.mjs";
2
+ import { 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
+ const emptyTranslation = {
74
+ id: "empty-translation",
75
+ defaultSeverity: "warning",
76
+ description: "A message with an empty string value.",
77
+ *run(ctx) {
78
+ for (const message of ctx.messages()) if (message.value === "") yield {
79
+ key: message.key,
80
+ locale: message.locale,
81
+ message: `"${message.key}" is empty in ${message.locale}.`
82
+ };
83
+ }
84
+ };
85
+ const keyConflict = {
86
+ id: "key-conflict",
87
+ defaultSeverity: "error",
88
+ description: "A key that is both a leaf and a parent, or is duplicated across files of one locale.",
89
+ *run(ctx) {
90
+ const keys = new Set(ctx.keys());
91
+ for (const key of keys) {
92
+ const segments = key.split(".");
93
+ for (let i = 1; i < segments.length; i++) {
94
+ const parent = segments.slice(0, i).join(".");
95
+ if (keys.has(parent)) yield {
96
+ key: parent,
97
+ message: `"${parent}" is a message and also the parent of "${key}".`
98
+ };
99
+ }
100
+ for (const locale of ctx.locales) {
101
+ const copies = ctx.duplicates(key, locale);
102
+ if (copies.length > 1) {
103
+ const files = [...new Set(copies.map((m) => ctx.resources.find((r) => r.id === m.resource)?.path ?? m.resource))];
104
+ yield {
105
+ key,
106
+ locale,
107
+ message: `"${key}" is defined ${copies.length} times in ${locale} (${list(files)}).`
108
+ };
109
+ }
110
+ }
111
+ }
112
+ }
113
+ };
114
+ const localeCodeConflict = {
115
+ id: "locale-code-conflict",
116
+ defaultSeverity: "warning",
117
+ description: "Locale codes that differ only in spelling, such as zh_CN and zh-CN.",
118
+ *run(ctx) {
119
+ const groups = /* @__PURE__ */ new Map();
120
+ for (const locale of ctx.locales) {
121
+ const id = localeSpellingKey(locale);
122
+ const group = groups.get(id) ?? [];
123
+ groups.set(id, group);
124
+ group.push(locale);
125
+ }
126
+ for (const group of groups.values()) if (group.length > 1) {
127
+ const resource = ctx.resources.find((r) => r.locale === group[1]);
128
+ yield {
129
+ locale: group[1],
130
+ location: resource ? {
131
+ path: resource.path,
132
+ range: {
133
+ start: 0,
134
+ end: 0
135
+ }
136
+ } : void 0,
137
+ message: `Locale codes ${list(group)} differ only in spelling; they are separate locales.`
138
+ };
139
+ }
140
+ }
141
+ };
142
+ const unusedKey = {
143
+ id: "unused-key",
144
+ defaultSeverity: "warning",
145
+ description: "A key with no usage, after keysInUse, derived keys and dynamic prefixes.",
146
+ *run(ctx) {
147
+ if (!ctx.hasScanners) return;
148
+ for (const key of ctx.unused()) yield {
149
+ key,
150
+ message: `"${key}" is not used.`,
151
+ fixes: [{
152
+ title: `Delete "${key}" in every locale`,
153
+ ops: [{
154
+ type: "delete",
155
+ key,
156
+ locales: "all"
157
+ }]
158
+ }]
159
+ };
160
+ }
161
+ };
162
+ function placeholderNames(ctx, value) {
163
+ return ctx.placeholders(value).map((p) => p.name).sort();
164
+ }
165
+ const placeholderMismatch = {
166
+ id: "placeholder-mismatch",
167
+ defaultSeverity: "error",
168
+ description: "Target value placeholders differ from the source value.",
169
+ *run(ctx) {
170
+ for (const key of ctx.keys()) {
171
+ const source = ctx.message(key, ctx.sourceLocale);
172
+ if (typeof source?.value !== "string") continue;
173
+ const expected = placeholderNames(ctx, source.value);
174
+ for (const locale of ctx.locales) {
175
+ if (locale === ctx.sourceLocale) continue;
176
+ const target = ctx.message(key, locale);
177
+ if (typeof target?.value !== "string" || target.value === "") continue;
178
+ const actual = placeholderNames(ctx, target.value);
179
+ const missing = expected.filter((name) => !actual.includes(name));
180
+ const extra = actual.filter((name) => !expected.includes(name));
181
+ if (missing.length || extra.length) {
182
+ const parts = [missing.length ? `missing ${list(missing)}` : "", extra.length ? `unexpected ${list(extra)}` : ""].filter(Boolean);
183
+ yield {
184
+ key,
185
+ locale,
186
+ message: `"${key}" in ${locale} has different placeholders than ${ctx.sourceLocale}: ${parts.join("; ")}.`
187
+ };
188
+ }
189
+ }
190
+ }
191
+ }
192
+ };
193
+ const invalidSyntax = {
194
+ id: "invalid-syntax",
195
+ defaultSeverity: "error",
196
+ description: "Message syntax errors (ICU, linked messages, nesting).",
197
+ *run(ctx) {
198
+ const keys = new Set(ctx.keys());
199
+ const exists = (key) => keys.has(key) || ctx.keys().some((k) => k.startsWith(`${key}.`));
200
+ for (const message of ctx.messages()) {
201
+ if (typeof message.value !== "string") continue;
202
+ const errors = [...ctx.syntaxErrors(message.value)];
203
+ for (const placeholder of ctx.placeholders(message.value)) if (placeholder.syntax === "linked" && !exists(placeholder.name)) errors.push(`linked message "${placeholder.name}" does not exist`);
204
+ for (const error of errors) yield {
205
+ key: message.key,
206
+ locale: message.locale,
207
+ message: `"${message.key}" in ${message.locale}: ${error}.`
208
+ };
209
+ }
210
+ }
211
+ };
212
+ const dynamicKey = {
213
+ id: "dynamic-key",
214
+ defaultSeverity: "off",
215
+ description: "A fully dynamic key that no check can follow.",
216
+ *run(ctx) {
217
+ for (const usage of ctx.usages()) if (usage.dynamic === "unknown") yield {
218
+ location: {
219
+ path: usage.path,
220
+ range: usage.range
221
+ },
222
+ message: `Dynamic key "${usage.raw}" cannot be checked.`
223
+ };
224
+ }
225
+ };
226
+ const identicalToSource = {
227
+ id: "identical-to-source",
228
+ defaultSeverity: "off",
229
+ description: "A target value that equals the source value.",
230
+ *run(ctx) {
231
+ for (const message of ctx.messages()) {
232
+ if (message.locale === ctx.sourceLocale || typeof message.value !== "string" || message.value === "") continue;
233
+ if (ctx.message(message.key, ctx.sourceLocale)?.value === message.value) yield {
234
+ key: message.key,
235
+ locale: message.locale,
236
+ message: `"${message.key}" in ${message.locale} is the same as in ${ctx.sourceLocale}.`
237
+ };
238
+ }
239
+ }
240
+ };
241
+ const caseRules = {
242
+ camel: /^[a-z][a-zA-Z0-9]*$/,
243
+ snake: /^[a-z][a-z0-9_]*$/,
244
+ kebab: /^[a-z][a-z0-9-]*$/,
245
+ pascal: /^[A-Z][a-zA-Z0-9]*$/
246
+ };
247
+ const keyStyle = {
248
+ id: "key-style",
249
+ defaultSeverity: "off",
250
+ description: "A key that does not match a naming rule (options: case, pattern).",
251
+ *run(ctx) {
252
+ const rule = typeof ctx.options.case === "string" ? caseRules[ctx.options.case] : void 0;
253
+ const pattern = typeof ctx.options.pattern === "string" ? new RegExp(ctx.options.pattern) : void 0;
254
+ for (const key of ctx.keys()) {
255
+ const path = key.slice(key.indexOf(":") + 1);
256
+ if (rule && !path.split(".").every((segment) => rule.test(segment))) yield {
257
+ key,
258
+ message: `"${key}" is not ${String(ctx.options.case)} case.`
259
+ };
260
+ else if (pattern && !pattern.test(key)) yield {
261
+ key,
262
+ message: `"${key}" does not match ${pattern.source}.`
263
+ };
264
+ }
265
+ }
266
+ };
267
+ const hardString = {
268
+ id: "hard-string",
269
+ defaultSeverity: "info",
270
+ description: "A hard-coded string that should probably be a message. On with `extract.autoDetect`.",
271
+ *run(ctx) {
272
+ if (!ctx.config.extract.autoDetect && ctx.config.checks["hard-string"] === void 0) return;
273
+ for (const hard of ctx.hardStrings()) if (shouldExtract(hard.text, hard.kind)) yield {
274
+ location: {
275
+ path: hard.path,
276
+ range: hard.range
277
+ },
278
+ message: `Hard-coded string "${hard.text}".`
279
+ };
280
+ }
281
+ };
282
+ function* glossaryDiagnostics(ctx, entry) {
283
+ const term = entry.term.toLowerCase();
284
+ for (const key of ctx.keys()) {
285
+ const source = ctx.message(key, ctx.sourceLocale)?.value;
286
+ if (typeof source !== "string" || !source.toLowerCase().includes(term)) continue;
287
+ for (const locale of ctx.locales) {
288
+ const value = ctx.message(key, locale)?.value;
289
+ const expected = entry.doNotTranslate ? entry.term : entry.translations?.[locale];
290
+ if (locale === ctx.sourceLocale || typeof value !== "string" || value === "" || !expected) continue;
291
+ if (!value.toLowerCase().includes(expected.toLowerCase())) yield {
292
+ key,
293
+ locale,
294
+ message: `"${key}" in ${locale} should use "${expected}" for "${entry.term}".`
295
+ };
296
+ }
297
+ }
298
+ }
299
+ const glossary = {
300
+ id: "glossary",
301
+ defaultSeverity: "off",
302
+ description: "A glossary term translated differently from the glossary.",
303
+ *run(ctx) {
304
+ for (const entry of ctx.config.glossary) yield* glossaryDiagnostics(ctx, entry);
305
+ }
306
+ };
307
+ const allChecks = [
308
+ missingKey,
309
+ invalidSyntax,
310
+ placeholderMismatch,
311
+ keyConflict,
312
+ missingTranslation,
313
+ emptyTranslation,
314
+ unusedKey,
315
+ localeCodeConflict,
316
+ dynamicKey,
317
+ identicalToSource,
318
+ keyStyle,
319
+ hardString,
320
+ glossary
321
+ ];
322
+ /** Every built-in check with its `recommended` severity. */
323
+ function recommended() {
324
+ return {
325
+ name: "x18n:checks",
326
+ checks: allChecks
327
+ };
328
+ }
329
+ //#endregion
330
+ export { allChecks, dynamicKey, emptyTranslation, glossary, hardString, identicalToSource, invalidSyntax, keyConflict, keyStyle, localeCodeConflict, missingKey, missingTranslation, placeholderMismatch, recommended, unusedKey };
@@ -0,0 +1,51 @@
1
+ import { ExtractItem, ExtractTemplate, 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, with proposed keys, values and replacements. */
42
+ export declare function findCandidates(project: Project, options?: FindOptions): ExtractCandidate[];
43
+ export interface ExtractPlanFile {
44
+ readonly x18n: 1;
45
+ readonly project: string;
46
+ readonly items: readonly ExtractCandidate[];
47
+ }
48
+ export declare function toPlanFile(project: Project, candidates: readonly ExtractCandidate[]): ExtractPlanFile;
49
+ /** Turns plan items (maybe edited by a human or agent) into edit items, after checking the files did not change. */
50
+ export declare function extractItems(project: Project, items: readonly Pick<ExtractCandidate, "path" | "range" | "original" | "key" | "value" | "replacement" | "imports">[]): ExtractItem[];
51
+ //#endregion