@x18n/builtins 0.0.2 → 0.0.3
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 +19 -0
- package/dist/checks.mjs +329 -0
- package/dist/extractors.d.mts +51 -0
- package/dist/extractors.mjs +269 -0
- package/dist/finders-CqTGh4OJ.mjs +197 -0
- package/dist/finders.d.mts +31 -0
- package/dist/finders.mjs +3 -0
- package/dist/formats-BAogSrvw.mjs +1331 -0
- package/dist/formats.d.mts +34 -0
- package/dist/formats.mjs +1 -0
- package/dist/index.d.mts +5 -0
- package/dist/index.mjs +38 -0
- package/dist/manifests.d.mts +10 -0
- package/dist/manifests.mjs +49 -0
- package/dist/placeholders.d.mts +27 -0
- package/dist/placeholders.mjs +131 -0
- package/dist/presets-DNnE_2MQ.mjs +1124 -0
- package/dist/presets.d.mts +58 -0
- package/dist/presets.mjs +1 -0
- package/dist/regex-B9TBGgXC.mjs +97 -0
- package/dist/reporters.d.mts +14 -0
- package/dist/reporters.mjs +203 -0
- package/dist/scanners-B27IEH4_.mjs +119 -0
- package/dist/scanners.d.mts +49 -0
- package/dist/scanners.mjs +4 -0
- package/dist/sfc-blocks-BwJwCUka.mjs +161 -0
- package/dist/vue-5GFR5hft.mjs +727 -0
- package/dist/yaml-plugin.d.mts +8 -0
- package/dist/yaml-plugin.mjs +68 -0
- package/package.json +2 -2
|
@@ -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
|
package/dist/checks.mjs
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { shouldExtract } 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
|
+
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
|
+
const placeholderMismatch = {
|
|
163
|
+
id: "placeholder-mismatch",
|
|
164
|
+
defaultSeverity: "error",
|
|
165
|
+
description: "Target value placeholders differ from the source value.",
|
|
166
|
+
*run(ctx) {
|
|
167
|
+
const compare = createPlaceholderComparer(ctx.placeholderSyntaxes, ctx.placeholders);
|
|
168
|
+
for (const key of ctx.keys()) {
|
|
169
|
+
const source = ctx.message(key, ctx.sourceLocale);
|
|
170
|
+
if (typeof source?.value !== "string") continue;
|
|
171
|
+
for (const locale of ctx.locales) {
|
|
172
|
+
if (locale === ctx.sourceLocale) continue;
|
|
173
|
+
const target = ctx.message(key, locale);
|
|
174
|
+
if (typeof target?.value !== "string" || target.value === "") continue;
|
|
175
|
+
const { missing, extra } = compare(source.value, target.value);
|
|
176
|
+
if (missing.length || extra.length) {
|
|
177
|
+
const parts = [missing.length ? `missing ${list(missing)}` : "", extra.length ? `unexpected ${list(extra)}` : ""].filter(Boolean);
|
|
178
|
+
yield {
|
|
179
|
+
key,
|
|
180
|
+
locale,
|
|
181
|
+
message: `"${key}" in ${locale} has different placeholders than ${ctx.sourceLocale}: ${parts.join("; ")}.`
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
const invalidSyntax = {
|
|
189
|
+
id: "invalid-syntax",
|
|
190
|
+
defaultSeverity: "error",
|
|
191
|
+
description: "Message syntax errors (ICU, linked messages, nesting).",
|
|
192
|
+
*run(ctx) {
|
|
193
|
+
const keys = new Set(ctx.keys());
|
|
194
|
+
const exists = (key) => keys.has(key) || ctx.keys().some((k) => k.startsWith(`${key}.`));
|
|
195
|
+
for (const message of ctx.messages()) {
|
|
196
|
+
if (typeof message.value !== "string") continue;
|
|
197
|
+
const errors = [...ctx.syntaxErrors(message.value)];
|
|
198
|
+
for (const placeholder of ctx.placeholders(message.value)) if (placeholder.syntax === "linked" && !exists(placeholder.name)) errors.push(`linked message "${placeholder.name}" does not exist`);
|
|
199
|
+
for (const error of errors) yield {
|
|
200
|
+
key: message.key,
|
|
201
|
+
locale: message.locale,
|
|
202
|
+
message: `"${message.key}" in ${message.locale}: ${error}.`
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
const dynamicKey = {
|
|
208
|
+
id: "dynamic-key",
|
|
209
|
+
defaultSeverity: "off",
|
|
210
|
+
description: "A fully dynamic key that no check can follow.",
|
|
211
|
+
*run(ctx) {
|
|
212
|
+
for (const usage of ctx.usages()) if (usage.dynamic === "unknown") yield {
|
|
213
|
+
location: {
|
|
214
|
+
path: usage.path,
|
|
215
|
+
range: usage.range
|
|
216
|
+
},
|
|
217
|
+
message: `Dynamic key "${usage.raw}" cannot be checked.`
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
const identicalToSource = {
|
|
222
|
+
id: "identical-to-source",
|
|
223
|
+
defaultSeverity: "off",
|
|
224
|
+
description: "A target value that equals the source value.",
|
|
225
|
+
*run(ctx) {
|
|
226
|
+
for (const message of ctx.messages()) {
|
|
227
|
+
if (message.locale === ctx.sourceLocale || typeof message.value !== "string" || message.value === "") continue;
|
|
228
|
+
if (ctx.message(message.key, ctx.sourceLocale)?.value === message.value) yield {
|
|
229
|
+
key: message.key,
|
|
230
|
+
locale: message.locale,
|
|
231
|
+
message: `"${message.key}" in ${message.locale} is the same as in ${ctx.sourceLocale}.`
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
const caseRules = {
|
|
237
|
+
camel: /^[a-z][a-zA-Z0-9]*$/,
|
|
238
|
+
snake: /^[a-z][a-z0-9_]*$/,
|
|
239
|
+
kebab: /^[a-z][a-z0-9-]*$/,
|
|
240
|
+
pascal: /^[A-Z][a-zA-Z0-9]*$/
|
|
241
|
+
};
|
|
242
|
+
const keyStyle = {
|
|
243
|
+
id: "key-style",
|
|
244
|
+
defaultSeverity: "off",
|
|
245
|
+
description: "A key that does not match a naming rule (options: case, pattern).",
|
|
246
|
+
*run(ctx) {
|
|
247
|
+
const rule = typeof ctx.options.case === "string" ? caseRules[ctx.options.case] : void 0;
|
|
248
|
+
const pattern = typeof ctx.options.pattern === "string" ? new RegExp(ctx.options.pattern) : void 0;
|
|
249
|
+
for (const key of ctx.keys()) {
|
|
250
|
+
const path = key.slice(key.indexOf(":") + 1);
|
|
251
|
+
if (rule && !path.split(".").every((segment) => rule.test(segment))) yield {
|
|
252
|
+
key,
|
|
253
|
+
message: `"${key}" is not ${String(ctx.options.case)} case.`
|
|
254
|
+
};
|
|
255
|
+
else if (pattern && !pattern.test(key)) yield {
|
|
256
|
+
key,
|
|
257
|
+
message: `"${key}" does not match ${pattern.source}.`
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
const hardString = {
|
|
263
|
+
id: "hard-string",
|
|
264
|
+
defaultSeverity: "info",
|
|
265
|
+
description: "A hard-coded string that should probably be a message. On with `extract.autoDetect`.",
|
|
266
|
+
*run(ctx) {
|
|
267
|
+
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 {
|
|
269
|
+
location: {
|
|
270
|
+
path: hard.path,
|
|
271
|
+
range: hard.range
|
|
272
|
+
},
|
|
273
|
+
message: `Hard-coded string "${hard.text}".`
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
/** A term as a whole word, so "Home" does not match "Homepage". */
|
|
278
|
+
function wordPattern(term) {
|
|
279
|
+
return new RegExp(`(?<![\\p{L}\\p{N}])${term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\p{L}\\p{N}])`, "iu");
|
|
280
|
+
}
|
|
281
|
+
function* glossaryDiagnostics(ctx, entry) {
|
|
282
|
+
const term = wordPattern(entry.term);
|
|
283
|
+
for (const key of ctx.keys()) {
|
|
284
|
+
const source = ctx.message(key, ctx.sourceLocale)?.value;
|
|
285
|
+
if (typeof source !== "string" || !term.test(source)) continue;
|
|
286
|
+
for (const locale of ctx.locales) {
|
|
287
|
+
const value = ctx.message(key, locale)?.value;
|
|
288
|
+
const expected = entry.doNotTranslate ? entry.term : entry.translations?.[locale];
|
|
289
|
+
if (locale === ctx.sourceLocale || typeof value !== "string" || value === "" || !expected) continue;
|
|
290
|
+
if (!wordPattern(expected).test(value)) yield {
|
|
291
|
+
key,
|
|
292
|
+
locale,
|
|
293
|
+
message: `"${key}" in ${locale} should use "${expected}" for "${entry.term}".`
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const glossary = {
|
|
299
|
+
id: "glossary",
|
|
300
|
+
defaultSeverity: "off",
|
|
301
|
+
description: "A glossary term translated differently from the glossary.",
|
|
302
|
+
*run(ctx) {
|
|
303
|
+
for (const entry of ctx.config.glossary) yield* glossaryDiagnostics(ctx, entry);
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
const allChecks = [
|
|
307
|
+
missingKey,
|
|
308
|
+
invalidSyntax,
|
|
309
|
+
placeholderMismatch,
|
|
310
|
+
keyConflict,
|
|
311
|
+
missingTranslation,
|
|
312
|
+
emptyTranslation,
|
|
313
|
+
unusedKey,
|
|
314
|
+
localeCodeConflict,
|
|
315
|
+
dynamicKey,
|
|
316
|
+
identicalToSource,
|
|
317
|
+
keyStyle,
|
|
318
|
+
hardString,
|
|
319
|
+
glossary
|
|
320
|
+
];
|
|
321
|
+
/** Every built-in check with its `recommended` severity. */
|
|
322
|
+
function recommended() {
|
|
323
|
+
return {
|
|
324
|
+
name: "x18n:checks",
|
|
325
|
+
checks: allChecks
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
//#endregion
|
|
329
|
+
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
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { X18nError, createLineIndex, pathMatcher } from "@x18n/core";
|
|
2
|
+
//#region src/extractors/index.ts
|
|
3
|
+
const textKinds = /* @__PURE__ */ new Set([
|
|
4
|
+
"jsx-text",
|
|
5
|
+
"vue-template-text",
|
|
6
|
+
"svelte-text",
|
|
7
|
+
"html-text"
|
|
8
|
+
]);
|
|
9
|
+
/** Built-in rules, applied in order. A `must-*` wins at once; otherwise the last `should-*` decides. */
|
|
10
|
+
const rules = [
|
|
11
|
+
{
|
|
12
|
+
id: "letters",
|
|
13
|
+
test: (text) => /\p{L}/u.test(text) ? "none" : "must-exclude"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
id: "digits",
|
|
17
|
+
test: (text) => /^[\d\s.,:%+-]+$/.test(text) ? "must-exclude" : "none"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: "url",
|
|
21
|
+
test: (text) => /^(?:[a-z][\w+.-]*:\/\/|mailto:|#|\/\/)/i.test(text.trim()) ? "must-exclude" : "none"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: "path",
|
|
25
|
+
test: (text) => /^(?:\.{0,2}\/|~\/|@\/)[\w./@-]*$/.test(text.trim()) || /^[\w-]+\.[a-z]{2,4}$/i.test(text.trim()) ? "must-exclude" : "none"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "translate-call",
|
|
29
|
+
test: (text) => /\$?t\(['"`]/.test(text) || text.includes("{{") ? "must-exclude" : "none"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: "markup",
|
|
33
|
+
test: (text) => /^<[^>]+>$/.test(text.trim()) ? "must-exclude" : "none"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "non-ascii",
|
|
37
|
+
test: (text) => /\P{ASCII}/u.test(text) && /\p{L}/u.test(text) ? "must-include" : "none"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "sentence",
|
|
41
|
+
test: (text) => /\p{L}\s+\p{L}/u.test(text) ? "should-include" : "none"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: "text-node",
|
|
45
|
+
test: (text, kind) => textKinds.has(kind) || kind.endsWith("attribute") ? "should-include" : "none"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "single-word",
|
|
49
|
+
test: (text, kind) => !textKinds.has(kind) && !kind.endsWith("attribute") && !/\s/.test(text.trim()) ? "should-exclude" : "none"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "attribute-token",
|
|
53
|
+
test: (text, kind) => kind.endsWith("attribute") && /^[a-z][\w-]*$/.test(text.trim()) ? "must-exclude" : "none"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "identifier",
|
|
57
|
+
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
|
+
}
|
|
59
|
+
];
|
|
60
|
+
/** Folds rule scores the i18n-ally way (`shouldExtract`). */
|
|
61
|
+
function shouldExtract(text, kind, list = rules) {
|
|
62
|
+
let result = "none";
|
|
63
|
+
for (const rule of list) {
|
|
64
|
+
const score = rule.test(text, kind);
|
|
65
|
+
if (score === "must-include") return true;
|
|
66
|
+
if (score === "must-exclude") return false;
|
|
67
|
+
if (score !== "none") result = score;
|
|
68
|
+
}
|
|
69
|
+
return result === "should-include";
|
|
70
|
+
}
|
|
71
|
+
/** Words of a text for slugs: letters and digits only. */
|
|
72
|
+
function words(text) {
|
|
73
|
+
return text.normalize("NFKD").replace(/[\u0300-\u036F]/g, "").split(/[^\p{L}\d]+/u).filter(Boolean);
|
|
74
|
+
}
|
|
75
|
+
/** "Forgot password?" becomes `forgotPassword` (camel), `forgot_password`, `forgot-password` or `forgot.password`. */
|
|
76
|
+
function slugify(text, keyCase = "camel", maxLength = 40) {
|
|
77
|
+
const parts = [];
|
|
78
|
+
let length = 0;
|
|
79
|
+
for (const word of words(text).map((w) => w.toLowerCase())) {
|
|
80
|
+
if (length + word.length > maxLength && parts.length > 0) break;
|
|
81
|
+
parts.push(word);
|
|
82
|
+
length += word.length + 1;
|
|
83
|
+
}
|
|
84
|
+
if (parts.length === 0) return "text";
|
|
85
|
+
switch (keyCase) {
|
|
86
|
+
case "snake": return parts.join("_");
|
|
87
|
+
case "kebab": return parts.join("-");
|
|
88
|
+
case "dot": return parts.join(".");
|
|
89
|
+
default: return parts.map((word, i) => i === 0 ? word : word[0].toUpperCase() + word.slice(1)).join("");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** Fallback templates per kind, used after config and preset templates. */
|
|
93
|
+
const defaultTemplates = [
|
|
94
|
+
{
|
|
95
|
+
template: "{t('{key}'{args})}",
|
|
96
|
+
kinds: ["jsx-text"]
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
template: "{attr}={t('{key}'{args})}",
|
|
100
|
+
kinds: ["jsx-attribute"]
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
template: "{{ $t('{key}'{args}) }}",
|
|
104
|
+
kinds: ["vue-template-text", "html-text"]
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
template: ":{attr}=\"$t('{key}'{args})\"",
|
|
108
|
+
kinds: ["vue-attribute"]
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
template: "{$t('{key}'{args})}",
|
|
112
|
+
kinds: ["svelte-text"]
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
template: "{attr}={$t('{key}'{args})}",
|
|
116
|
+
kinds: ["svelte-attribute"]
|
|
117
|
+
},
|
|
118
|
+
{ template: "t('{key}'{args})" }
|
|
119
|
+
];
|
|
120
|
+
function prefixFor(template, path) {
|
|
121
|
+
if (!template) return "";
|
|
122
|
+
const base = path.slice(path.lastIndexOf("/") + 1).replace(/\.[^.]+$/, "");
|
|
123
|
+
const dir = path.includes("/") ? path.slice(0, path.lastIndexOf("/")).split("/").at(-1) : "";
|
|
124
|
+
return template.replace("{fileName}", slugify(base)).replace("{dirName}", slugify(dir)).replace("{component}", base.replace(/(?:^|[-_])(\w)/g, (_, c) => c.toUpperCase()));
|
|
125
|
+
}
|
|
126
|
+
function randomKey() {
|
|
127
|
+
return Math.random().toString(36).slice(2, 10);
|
|
128
|
+
}
|
|
129
|
+
var KeyAllocator = class {
|
|
130
|
+
project;
|
|
131
|
+
byValue = /* @__PURE__ */ new Map();
|
|
132
|
+
taken = /* @__PURE__ */ new Map();
|
|
133
|
+
constructor(project) {
|
|
134
|
+
this.project = project;
|
|
135
|
+
const options = project.config.extract;
|
|
136
|
+
for (const key of project.keys()) {
|
|
137
|
+
const value = project.message(key, project.sourceLocale)?.value;
|
|
138
|
+
if (typeof value === "string") {
|
|
139
|
+
this.taken.set(key, value);
|
|
140
|
+
if (options.reuseExisting !== false && !this.byValue.has(value)) this.byValue.set(value, key);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
allocate(value, path, slugText) {
|
|
145
|
+
const options = this.project.config.extract;
|
|
146
|
+
const reused = this.byValue.get(value);
|
|
147
|
+
if (reused) return {
|
|
148
|
+
key: reused,
|
|
149
|
+
reused: this.taken.has(reused)
|
|
150
|
+
};
|
|
151
|
+
const strategy = options.keygen ?? "slug";
|
|
152
|
+
const prefix = prefixFor(options.keyPrefix, path);
|
|
153
|
+
let base;
|
|
154
|
+
if (strategy === "source") base = value;
|
|
155
|
+
else if (strategy === "random") base = randomKey();
|
|
156
|
+
else if (strategy === "empty") base = "";
|
|
157
|
+
else base = slugify(slugText, options.keyCase ?? "camel", options.keyMaxLength ?? 40);
|
|
158
|
+
const raw = prefix ? `${prefix}.${base}` : base;
|
|
159
|
+
let key = this.project.parseKey(raw);
|
|
160
|
+
for (let i = 2; this.taken.has(key) && this.taken.get(key) !== value; i++) key = this.project.parseKey(`${raw}${i}`);
|
|
161
|
+
this.taken.set(key, value);
|
|
162
|
+
this.byValue.set(value, key);
|
|
163
|
+
return {
|
|
164
|
+
key,
|
|
165
|
+
reused: false
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
function isIgnored(project, hard, text) {
|
|
170
|
+
const options = project.config.extract;
|
|
171
|
+
const matches = (rule) => {
|
|
172
|
+
const regex = /^\/(.+)\/([a-z]*)$/.exec(rule);
|
|
173
|
+
return regex ? new RegExp(regex[1], regex[2]).test(hard.text) : rule === hard.text;
|
|
174
|
+
};
|
|
175
|
+
if (options.ignore?.some(matches)) return true;
|
|
176
|
+
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;
|
|
178
|
+
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");
|
|
181
|
+
}
|
|
182
|
+
/** Placeholder shape of the project's first placeholder syntax, `{name}` by default. */
|
|
183
|
+
function placeholderShape(project) {
|
|
184
|
+
const example = project.placeholderSyntaxes.find((s) => s.example?.includes("name"))?.example ?? "{name}";
|
|
185
|
+
return (name) => example.replace("name", name);
|
|
186
|
+
}
|
|
187
|
+
function templatesFor(project, kind, path) {
|
|
188
|
+
return [...project.extractTemplates, ...defaultTemplates].filter((t) => (!t.kinds || t.kinds.includes(kind)) && (!t.files || pathMatcher(t.files)(path))).map((t) => t.template);
|
|
189
|
+
}
|
|
190
|
+
function rawKeyFor(project, key) {
|
|
191
|
+
const ns = project.config.namespace.default;
|
|
192
|
+
return project.namespaced && ns && key.startsWith(`${ns}:`) ? key.slice(ns.length + 1) : key;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Escapes a key for the quotes around `{key}` in a template, since `keygen: 'source'` keys hold any text:
|
|
196
|
+
* backslashes inside a JS string, entities inside a double-quoted attribute (`:title="$t('{key}')"`).
|
|
197
|
+
*/
|
|
198
|
+
function quoteKey(before, key) {
|
|
199
|
+
const quote = before.at(-1);
|
|
200
|
+
const inAttribute = /="[^"]*$/.test(before);
|
|
201
|
+
let out = key;
|
|
202
|
+
if (quote === "'" || quote === "`" || quote === "\"" && !inAttribute) out = out.replace(quote === "`" ? /[\\`]|\$\{/g : new RegExp(`[\\\\${quote}]`, "g"), (c) => `\\${c}`);
|
|
203
|
+
return inAttribute ? out.replace(/&/g, "&").replace(/"/g, """) : out;
|
|
204
|
+
}
|
|
205
|
+
function fillTemplate(template, values) {
|
|
206
|
+
return template.replace(/\{(key|attr|args)\}/g, (_, name, at) => name === "key" ? quoteKey(template.slice(0, at), values.key) : values[name]);
|
|
207
|
+
}
|
|
208
|
+
/** Hard strings worth extracting, with proposed keys, values and replacements. */
|
|
209
|
+
function findCandidates(project, options = {}) {
|
|
210
|
+
const inFiles = options.files?.length ? pathMatcher(options.files) : void 0;
|
|
211
|
+
const keys = new KeyAllocator(project);
|
|
212
|
+
const shape = placeholderShape(project);
|
|
213
|
+
const out = [];
|
|
214
|
+
for (const hard of project.hardStrings()) {
|
|
215
|
+
if (inFiles && !inFiles(hard.path)) continue;
|
|
216
|
+
const text = project.text(hard.path);
|
|
217
|
+
if (text === void 0 || !shouldExtract(hard.text, hard.kind) || isIgnored(project, hard, text)) continue;
|
|
218
|
+
const value = hard.kind === "js-template" ? hard.text.replace(/\{(\d+)\}/g, (_, i) => shape(hard.args?.[Number(i)]?.name ?? `arg${i}`)) : hard.text;
|
|
219
|
+
const { key, reused } = keys.allocate(value, hard.path, hard.text.replace(/\{\d+\}/g, " "));
|
|
220
|
+
const templates = templatesFor(project, hard.kind, hard.path);
|
|
221
|
+
const chosen = templates[0];
|
|
222
|
+
const args = hard.args?.length ? `, { ${hard.args.map((a) => a.name === a.expression ? a.name : `${a.name}: ${a.expression}`).join(", ")} }` : "";
|
|
223
|
+
const template = project.extractTemplates.find((t) => t.template === chosen);
|
|
224
|
+
out.push({
|
|
225
|
+
path: hard.path,
|
|
226
|
+
line: createLineIndex(text).position(hard.range.start).line + 1,
|
|
227
|
+
kind: hard.kind,
|
|
228
|
+
text: hard.text,
|
|
229
|
+
original: text.slice(hard.range.start, hard.range.end),
|
|
230
|
+
range: hard.range,
|
|
231
|
+
attribute: hard.attribute,
|
|
232
|
+
key,
|
|
233
|
+
reused,
|
|
234
|
+
value,
|
|
235
|
+
replacement: fillTemplate(chosen, {
|
|
236
|
+
key: rawKeyFor(project, key),
|
|
237
|
+
attr: hard.attribute ?? "",
|
|
238
|
+
args
|
|
239
|
+
}),
|
|
240
|
+
imports: template?.imports ?? [],
|
|
241
|
+
alternatives: templates.slice(1)
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
function toPlanFile(project, candidates) {
|
|
247
|
+
return {
|
|
248
|
+
x18n: 1,
|
|
249
|
+
project: project.name,
|
|
250
|
+
items: candidates
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/** Turns plan items (maybe edited by a human or agent) into edit items, after checking the files did not change. */
|
|
254
|
+
function extractItems(project, items) {
|
|
255
|
+
return items.map((item) => {
|
|
256
|
+
const text = project.text(item.path);
|
|
257
|
+
if (text === void 0 || text.slice(item.range.start, item.range.end) !== item.original) throw new X18nError("X18N4004", `${item.path} changed since the plan was made`);
|
|
258
|
+
return {
|
|
259
|
+
path: item.path,
|
|
260
|
+
range: item.range,
|
|
261
|
+
key: project.parseKey(item.key),
|
|
262
|
+
value: item.value,
|
|
263
|
+
replacement: item.replacement,
|
|
264
|
+
imports: item.imports
|
|
265
|
+
};
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
//#endregion
|
|
269
|
+
export { defaultTemplates, extractItems, findCandidates, rules, shouldExtract, slugify, toPlanFile };
|