@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.
@@ -0,0 +1,247 @@
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: "identifier",
53
+ 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"
54
+ }
55
+ ];
56
+ /** Folds rule scores the i18n-ally way (`shouldExtract`). */
57
+ function shouldExtract(text, kind, list = rules) {
58
+ let result = "none";
59
+ for (const rule of list) {
60
+ const score = rule.test(text, kind);
61
+ if (score === "must-include") return true;
62
+ if (score === "must-exclude") return false;
63
+ if (score !== "none") result = score;
64
+ }
65
+ return result === "should-include";
66
+ }
67
+ /** Words of a text for slugs: letters and digits only. */
68
+ function words(text) {
69
+ return text.normalize("NFKD").replace(/[\u0300-\u036F]/g, "").split(/[^\p{L}\d]+/u).filter(Boolean);
70
+ }
71
+ /** "Forgot password?" becomes `forgotPassword` (camel), `forgot_password`, `forgot-password` or `forgot.password`. */
72
+ function slugify(text, keyCase = "camel", maxLength = 40) {
73
+ const parts = [];
74
+ let length = 0;
75
+ for (const word of words(text).map((w) => w.toLowerCase())) {
76
+ if (length + word.length > maxLength && parts.length > 0) break;
77
+ parts.push(word);
78
+ length += word.length + 1;
79
+ }
80
+ if (parts.length === 0) return "text";
81
+ switch (keyCase) {
82
+ case "snake": return parts.join("_");
83
+ case "kebab": return parts.join("-");
84
+ case "dot": return parts.join(".");
85
+ default: return parts.map((word, i) => i === 0 ? word : word[0].toUpperCase() + word.slice(1)).join("");
86
+ }
87
+ }
88
+ /** Fallback templates per kind, used after config and preset templates. */
89
+ const defaultTemplates = [
90
+ {
91
+ template: "{t('{key}'{args})}",
92
+ kinds: ["jsx-text"]
93
+ },
94
+ {
95
+ template: "{attr}={t('{key}'{args})}",
96
+ kinds: ["jsx-attribute"]
97
+ },
98
+ {
99
+ template: "{{ $t('{key}'{args}) }}",
100
+ kinds: ["vue-template-text", "html-text"]
101
+ },
102
+ {
103
+ template: ":{attr}=\"$t('{key}'{args})\"",
104
+ kinds: ["vue-attribute"]
105
+ },
106
+ {
107
+ template: "{$t('{key}'{args})}",
108
+ kinds: ["svelte-text"]
109
+ },
110
+ {
111
+ template: "{attr}={$t('{key}'{args})}",
112
+ kinds: ["svelte-attribute"]
113
+ },
114
+ { template: "t('{key}'{args})" }
115
+ ];
116
+ function prefixFor(template, path) {
117
+ if (!template) return "";
118
+ const base = path.slice(path.lastIndexOf("/") + 1).replace(/\.[^.]+$/, "");
119
+ const dir = path.includes("/") ? path.slice(0, path.lastIndexOf("/")).split("/").at(-1) : "";
120
+ return template.replace("{fileName}", slugify(base)).replace("{dirName}", slugify(dir)).replace("{component}", base.replace(/(?:^|[-_])(\w)/g, (_, c) => c.toUpperCase()));
121
+ }
122
+ function randomKey() {
123
+ return Math.random().toString(36).slice(2, 10);
124
+ }
125
+ var KeyAllocator = class {
126
+ project;
127
+ byValue = /* @__PURE__ */ new Map();
128
+ taken = /* @__PURE__ */ new Map();
129
+ constructor(project) {
130
+ this.project = project;
131
+ const options = project.config.extract;
132
+ for (const key of project.keys()) {
133
+ const value = project.message(key, project.sourceLocale)?.value;
134
+ if (typeof value === "string") {
135
+ this.taken.set(key, value);
136
+ if (options.reuseExisting !== false && !this.byValue.has(value)) this.byValue.set(value, key);
137
+ }
138
+ }
139
+ }
140
+ allocate(value, path, slugText) {
141
+ const options = this.project.config.extract;
142
+ const reused = this.byValue.get(value);
143
+ if (reused) return {
144
+ key: reused,
145
+ reused: this.taken.has(reused)
146
+ };
147
+ const strategy = options.keygen ?? "slug";
148
+ const prefix = prefixFor(options.keyPrefix, path);
149
+ let base;
150
+ if (strategy === "source") base = value;
151
+ else if (strategy === "random") base = randomKey();
152
+ else if (strategy === "empty") base = "";
153
+ else base = slugify(slugText, options.keyCase ?? "camel", options.keyMaxLength ?? 40);
154
+ const raw = prefix ? `${prefix}.${base}` : base;
155
+ let key = this.project.parseKey(raw);
156
+ for (let i = 2; this.taken.has(key) && this.taken.get(key) !== value; i++) key = this.project.parseKey(`${raw}${i}`);
157
+ this.taken.set(key, value);
158
+ this.byValue.set(value, key);
159
+ return {
160
+ key,
161
+ reused: false
162
+ };
163
+ }
164
+ };
165
+ function isIgnored(project, hard, text) {
166
+ const options = project.config.extract;
167
+ const matches = (rule) => {
168
+ const regex = /^\/(.+)\/([a-z]*)$/.exec(rule);
169
+ return regex ? new RegExp(regex[1], regex[2]).test(hard.text) : rule === hard.text;
170
+ };
171
+ if (options.ignore?.some(matches)) return true;
172
+ for (const [glob, list] of Object.entries(options.ignoreByFile ?? {})) if (pathMatcher([glob])(hard.path) && list.some(matches)) return true;
173
+ if (hard.attribute && options.ignoreAttributes?.includes(hard.attribute)) return true;
174
+ const index = createLineIndex(text);
175
+ const line = index.position(hard.range.start).line;
176
+ return index.line(line).includes("x18n-ignore") || line > 0 && index.line(line - 1).includes("x18n-ignore");
177
+ }
178
+ /** Placeholder shape of the project's first placeholder syntax, `{name}` by default. */
179
+ function placeholderShape(project) {
180
+ const example = project.placeholderSyntaxes.find((s) => s.example?.includes("name"))?.example ?? "{name}";
181
+ return (name) => example.replace("name", name);
182
+ }
183
+ function templatesFor(project, kind, path) {
184
+ return [...project.extractTemplates, ...defaultTemplates].filter((t) => (!t.kinds || t.kinds.includes(kind)) && (!t.files || pathMatcher(t.files)(path))).map((t) => t.template);
185
+ }
186
+ function rawKeyFor(project, key) {
187
+ const ns = project.config.namespace.default;
188
+ return project.namespaced && ns && key.startsWith(`${ns}:`) ? key.slice(ns.length + 1) : key;
189
+ }
190
+ /** Hard strings worth extracting, with proposed keys, values and replacements. */
191
+ function findCandidates(project, options = {}) {
192
+ const inFiles = options.files?.length ? pathMatcher(options.files) : void 0;
193
+ const keys = new KeyAllocator(project);
194
+ const shape = placeholderShape(project);
195
+ const out = [];
196
+ for (const hard of project.hardStrings()) {
197
+ if (inFiles && !inFiles(hard.path)) continue;
198
+ const text = project.text(hard.path);
199
+ if (text === void 0 || !shouldExtract(hard.text, hard.kind) || isIgnored(project, hard, text)) continue;
200
+ const value = hard.kind === "js-template" ? hard.text.replace(/\{(\d+)\}/g, (_, i) => shape(hard.args?.[Number(i)]?.name ?? `arg${i}`)) : hard.text;
201
+ const { key, reused } = keys.allocate(value, hard.path, hard.text.replace(/\{\d+\}/g, " "));
202
+ const templates = templatesFor(project, hard.kind, hard.path);
203
+ const chosen = templates[0];
204
+ const args = hard.args?.length ? `, { ${hard.args.map((a) => a.name === a.expression ? a.name : `${a.name}: ${a.expression}`).join(", ")} }` : "";
205
+ const template = project.extractTemplates.find((t) => t.template === chosen);
206
+ out.push({
207
+ path: hard.path,
208
+ line: createLineIndex(text).position(hard.range.start).line + 1,
209
+ kind: hard.kind,
210
+ text: hard.text,
211
+ original: text.slice(hard.range.start, hard.range.end),
212
+ range: hard.range,
213
+ attribute: hard.attribute,
214
+ key,
215
+ reused,
216
+ value,
217
+ replacement: chosen.replace("{key}", rawKeyFor(project, key)).replace("{attr}", hard.attribute ?? "").replace("{args}", args),
218
+ imports: template?.imports ?? [],
219
+ alternatives: templates.slice(1)
220
+ });
221
+ }
222
+ return out;
223
+ }
224
+ function toPlanFile(project, candidates) {
225
+ return {
226
+ x18n: 1,
227
+ project: project.name,
228
+ items: candidates
229
+ };
230
+ }
231
+ /** Turns plan items (maybe edited by a human or agent) into edit items, after checking the files did not change. */
232
+ function extractItems(project, items) {
233
+ return items.map((item) => {
234
+ const text = project.text(item.path);
235
+ 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`);
236
+ return {
237
+ path: item.path,
238
+ range: item.range,
239
+ key: project.parseKey(item.key),
240
+ value: item.value,
241
+ replacement: item.replacement,
242
+ imports: item.imports
243
+ };
244
+ });
245
+ }
246
+ //#endregion
247
+ export { defaultTemplates, extractItems, findCandidates, rules, shouldExtract, slugify, toPlanFile };
@@ -0,0 +1,188 @@
1
+ //#region src/finders/pattern.ts
2
+ const localeLike = /^[a-z]{2,3}(?:[-_][a-z\d]{2,8})*$/i;
3
+ function isLocaleCode(text) {
4
+ if (!localeLike.test(text)) return false;
5
+ try {
6
+ return Intl.getCanonicalLocales(text.replace(/_/g, "-")).length === 1;
7
+ } catch {
8
+ return false;
9
+ }
10
+ }
11
+ function escapeRegExp(text) {
12
+ return text.replace(/[.+^$()|[\]\\]/g, "\\$&");
13
+ }
14
+ /**
15
+ * Compiles a resource pattern. Tokens: `{locale}`, `{locale?}`, `{namespace}`,
16
+ * `{namespaces}` (several segments, joined with `.`), `{ext}`; plus `*` and `**`.
17
+ */
18
+ function compilePattern(pattern, extensions) {
19
+ const normalized = pattern.replace(/^\.\//, "");
20
+ const ext = `(?<ext>${extensions.map(escapeRegExp).join("|")})`;
21
+ let source = "";
22
+ let glob = "";
23
+ const tokens = /\{[^}]*\}|\*\*\/|\*\*|\*|\?/g;
24
+ let last = 0;
25
+ for (const match of normalized.matchAll(tokens)) {
26
+ const literal = normalized.slice(last, match.index);
27
+ source += escapeRegExp(literal);
28
+ glob += literal;
29
+ last = match.index + match[0].length;
30
+ switch (match[0]) {
31
+ case "{locale}":
32
+ source += "(?<locale>[\\w-]+)";
33
+ glob += "*";
34
+ break;
35
+ case "{locale?}":
36
+ source += "(?<locale>[\\w-]+)?";
37
+ glob += "*";
38
+ break;
39
+ case "{namespace}":
40
+ source += "(?<namespace>[^/]+)";
41
+ glob += "*";
42
+ break;
43
+ case "{namespaces}":
44
+ source += "(?<namespace>.+)";
45
+ glob += "**";
46
+ break;
47
+ case "{ext}":
48
+ source += ext;
49
+ glob += extensions.length === 1 ? extensions[0] : `{${extensions.join(",")}}`;
50
+ break;
51
+ case "**/":
52
+ source += "(?:.*/)?";
53
+ glob += "**/";
54
+ break;
55
+ case "**":
56
+ source += ".*";
57
+ glob += "**";
58
+ break;
59
+ case "*":
60
+ source += "[^/]*";
61
+ glob += "*";
62
+ break;
63
+ case "?":
64
+ source += "[^/]";
65
+ glob += "?";
66
+ break;
67
+ default: {
68
+ const options = match[0].slice(1, -1).split(",");
69
+ source += `(?:${options.map(escapeRegExp).join("|")})`;
70
+ glob += match[0];
71
+ }
72
+ }
73
+ }
74
+ source += escapeRegExp(normalized.slice(last));
75
+ glob += normalized.slice(last);
76
+ const regex = new RegExp(`^${source}$`);
77
+ return {
78
+ glob,
79
+ match(path) {
80
+ const found = regex.exec(path);
81
+ if (!found) return void 0;
82
+ const groups = found.groups ?? {};
83
+ const extension = groups.ext ?? path.slice(path.lastIndexOf(".") + 1);
84
+ return {
85
+ locale: groups.locale,
86
+ namespace: groups.namespace?.replace(/\//g, "."),
87
+ ext: extension
88
+ };
89
+ }
90
+ };
91
+ }
92
+ const localeDirNames = [
93
+ "locales",
94
+ "locale",
95
+ "i18n",
96
+ "lang",
97
+ "langs",
98
+ "language",
99
+ "languages",
100
+ "translations",
101
+ "messages",
102
+ "_locales",
103
+ "l10n"
104
+ ];
105
+ /** Guesses resource patterns from directories named like `locales`, as i18n-ally did without `localesPaths`. */
106
+ async function guessResources(ctx) {
107
+ const exts = ctx.extensions.length === 1 ? ctx.extensions[0] : `{${ctx.extensions.join(",")}}`;
108
+ const files = await ctx.host.glob([`**/{${localeDirNames.join(",")}}/**/*.${exts}`], {
109
+ ignore: [
110
+ "**/node_modules/**",
111
+ "**/dist/**",
112
+ "**/.git/**",
113
+ "**/.nuxt/**",
114
+ "**/.output/**",
115
+ "**/build/**"
116
+ ],
117
+ gitignore: true
118
+ });
119
+ const dirs = /* @__PURE__ */ new Map();
120
+ for (const file of files) {
121
+ const segments = file.split("/");
122
+ const index = segments.findLastIndex((segment) => localeDirNames.includes(segment));
123
+ const dir = segments.slice(0, index + 1).join("/");
124
+ const list = dirs.get(dir) ?? [];
125
+ dirs.set(dir, list);
126
+ list.push(segments.slice(index + 1).join("/"));
127
+ }
128
+ const specs = [];
129
+ for (const [dir, rest] of dirs) {
130
+ const stem = (file) => file.slice(0, file.lastIndexOf("."));
131
+ const fileLayout = rest.filter((file) => !file.includes("/") && isLocaleCode(stem(file))).length;
132
+ const dirLayout = rest.filter((file) => file.includes("/") && isLocaleCode(file.split("/")[0])).length;
133
+ if (fileLayout === 0 && dirLayout === 0) continue;
134
+ specs.push({ pattern: fileLayout >= dirLayout ? `${dir}/{locale}.{ext}` : `${dir}/{locale}/{namespaces}.{ext}` });
135
+ }
136
+ return specs;
137
+ }
138
+ async function findBySpecs(ctx, specs, guessed) {
139
+ const out = [];
140
+ for (const spec of specs) {
141
+ const compiled = compilePattern(spec.pattern, ctx.extensions);
142
+ const fixed = spec.locale ?? (spec.pattern.includes("{locale") ? void 0 : ctx.config.sourceLocale);
143
+ const paths = await ctx.host.glob([compiled.glob], {
144
+ ignore: ["**/node_modules/**"],
145
+ gitignore: false
146
+ });
147
+ for (const path of paths) {
148
+ const matched = compiled.match(path);
149
+ if (!matched) continue;
150
+ const locale = fixed ?? matched.locale ?? (spec.pattern.includes("{locale?}") ? ctx.config.sourceLocale : void 0);
151
+ if (!locale || guessed && !isLocaleCode(locale)) continue;
152
+ out.push({
153
+ path,
154
+ locale,
155
+ namespace: spec.namespace ?? matched.namespace,
156
+ format: spec.format,
157
+ readonly: spec.execute === true
158
+ });
159
+ }
160
+ }
161
+ return out;
162
+ }
163
+ const patternFinder = {
164
+ id: "pattern",
165
+ resolveNew(target, ctx) {
166
+ for (const spec of ctx.config.resources) {
167
+ if (/\{namespaces?\}/.test(spec.pattern) !== (target.namespace !== void 0) || spec.execute || !spec.pattern.includes("{locale")) continue;
168
+ const path = spec.pattern.replace(/^\.\//, "").replace(/\{locale\??\}/g, target.locale).replace("{namespaces}", (target.namespace ?? "").replace(/\./g, "/")).replace("{namespace}", target.namespace ?? "").replace("{ext}", spec.format === "yaml" ? "yml" : spec.format ?? "json");
169
+ if (!/[*?{}[\]]/.test(path)) return path;
170
+ }
171
+ },
172
+ async find(ctx) {
173
+ const configured = ctx.config.resources;
174
+ const found = configured.length > 0 ? await findBySpecs(ctx, configured, false) : [];
175
+ const fromUser = ctx.config.origins.resources === "user" || ctx.config.origins.resources === "parent";
176
+ if (found.length > 0 || fromUser) return found;
177
+ return findBySpecs(ctx, await guessResources(ctx), true);
178
+ }
179
+ };
180
+ /** The `pattern` locale finder: files that match `resources[].pattern`, or guessed locale directories. */
181
+ function pattern() {
182
+ return {
183
+ name: "x18n:finder-pattern",
184
+ localeFinders: [patternFinder]
185
+ };
186
+ }
187
+ //#endregion
188
+ export { patternFinder as i, guessResources as n, pattern as r, compilePattern as t };
@@ -0,0 +1,29 @@
1
+ import { FinderContext, LocaleFinder, Plugin, ResourceSpec } from "@x18n/core";
2
+ //#region src/finders/pattern.d.ts
3
+ interface CompiledPattern {
4
+ /** Glob for the host. */
5
+ readonly glob: string;
6
+ readonly match: (path: string) => {
7
+ locale: string | undefined;
8
+ namespace: string | undefined;
9
+ ext: string;
10
+ } | undefined;
11
+ }
12
+ /**
13
+ * Compiles a resource pattern. Tokens: `{locale}`, `{locale?}`, `{namespace}`,
14
+ * `{namespaces}` (several segments, joined with `.`), `{ext}`; plus `*` and `**`.
15
+ */
16
+ export declare function compilePattern(pattern: string, extensions: readonly string[]): CompiledPattern;
17
+ /** Guesses resource patterns from directories named like `locales`, as i18n-ally did without `localesPaths`. */
18
+ export declare function guessResources(ctx: FinderContext): Promise<ResourceSpec[]>;
19
+ export declare const patternFinder: LocaleFinder;
20
+ /** The `pattern` locale finder: files that match `resources[].pattern`, or guessed locale directories. */
21
+ export declare function pattern(): Plugin;
22
+ //#endregion
23
+ //#region src/finders/sfc-blocks.d.ts
24
+ /** Vue `<i18n>` blocks: one resource per block and locale (`locale="en"`, or every top-level locale key). */
25
+ export declare function sfcBlocks(): Plugin;
26
+ /** fluent-vue `<fluent locale="en">` blocks in Vue SFCs, read with the `ftl` format. */
27
+ export declare function sfcFluentBlocks(): Plugin;
28
+ //#endregion
29
+ export type { CompiledPattern };
@@ -0,0 +1,3 @@
1
+ import { i as patternFinder, n as guessResources, r as pattern, t as compilePattern } from "./finders-Cf-IEaDm.mjs";
2
+ import { n as sfcFluentBlocks, t as sfcBlocks } from "./sfc-blocks-CQhgEeT6.mjs";
3
+ export { compilePattern, guessResources, pattern, patternFinder, sfcBlocks, sfcFluentBlocks };