@lokascript/i18n 1.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/README.md +286 -0
- package/dist/browser.cjs +7669 -0
- package/dist/browser.cjs.map +1 -0
- package/dist/browser.d.cts +50 -0
- package/dist/browser.d.ts +50 -0
- package/dist/browser.js +7592 -0
- package/dist/browser.js.map +1 -0
- package/dist/hyperfixi-i18n.min.js +2 -0
- package/dist/hyperfixi-i18n.min.js.map +1 -0
- package/dist/hyperfixi-i18n.mjs +8558 -0
- package/dist/hyperfixi-i18n.mjs.map +1 -0
- package/dist/index.cjs +14205 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +947 -0
- package/dist/index.d.ts +947 -0
- package/dist/index.js +14095 -0
- package/dist/index.js.map +1 -0
- package/dist/transformer-Ckask-yw.d.cts +1041 -0
- package/dist/transformer-Ckask-yw.d.ts +1041 -0
- package/package.json +84 -0
- package/src/browser.ts +122 -0
- package/src/compatibility/browser-tests/grammar-demo.spec.ts +169 -0
- package/src/constants.ts +366 -0
- package/src/dictionaries/ar.ts +233 -0
- package/src/dictionaries/bn.ts +156 -0
- package/src/dictionaries/de.ts +233 -0
- package/src/dictionaries/derive.ts +515 -0
- package/src/dictionaries/en.ts +237 -0
- package/src/dictionaries/es.ts +233 -0
- package/src/dictionaries/fr.ts +233 -0
- package/src/dictionaries/hi.ts +270 -0
- package/src/dictionaries/id.ts +233 -0
- package/src/dictionaries/index.ts +238 -0
- package/src/dictionaries/it.ts +233 -0
- package/src/dictionaries/ja.ts +233 -0
- package/src/dictionaries/ko.ts +233 -0
- package/src/dictionaries/ms.ts +276 -0
- package/src/dictionaries/pl.ts +239 -0
- package/src/dictionaries/pt.ts +237 -0
- package/src/dictionaries/qu.ts +233 -0
- package/src/dictionaries/ru.ts +270 -0
- package/src/dictionaries/sw.ts +233 -0
- package/src/dictionaries/th.ts +156 -0
- package/src/dictionaries/tl.ts +276 -0
- package/src/dictionaries/tr.ts +233 -0
- package/src/dictionaries/uk.ts +270 -0
- package/src/dictionaries/vi.ts +210 -0
- package/src/dictionaries/zh.ts +233 -0
- package/src/enhanced-i18n.test.ts +454 -0
- package/src/enhanced-i18n.ts +713 -0
- package/src/examples/new-languages.ts +326 -0
- package/src/formatting.test.ts +213 -0
- package/src/formatting.ts +416 -0
- package/src/grammar/direct-mappings.ts +353 -0
- package/src/grammar/grammar.test.ts +1053 -0
- package/src/grammar/index.ts +59 -0
- package/src/grammar/profiles/index.ts +860 -0
- package/src/grammar/transformer.ts +1318 -0
- package/src/grammar/types.ts +630 -0
- package/src/index.ts +202 -0
- package/src/new-languages.test.ts +389 -0
- package/src/parser/analyze-conflicts.test.ts +229 -0
- package/src/parser/ar.ts +40 -0
- package/src/parser/create-provider.ts +309 -0
- package/src/parser/de.ts +36 -0
- package/src/parser/es.ts +31 -0
- package/src/parser/fr.ts +31 -0
- package/src/parser/id.ts +34 -0
- package/src/parser/index.ts +50 -0
- package/src/parser/ja.ts +36 -0
- package/src/parser/ko.ts +37 -0
- package/src/parser/locale-manager.test.ts +198 -0
- package/src/parser/locale-manager.ts +197 -0
- package/src/parser/parser-integration.test.ts +439 -0
- package/src/parser/pt.ts +37 -0
- package/src/parser/qu.ts +37 -0
- package/src/parser/sw.ts +37 -0
- package/src/parser/tr.ts +38 -0
- package/src/parser/types.ts +113 -0
- package/src/parser/zh.ts +38 -0
- package/src/plugins/vite.ts +224 -0
- package/src/plugins/webpack.ts +124 -0
- package/src/pluralization.test.ts +197 -0
- package/src/pluralization.ts +393 -0
- package/src/runtime.ts +441 -0
- package/src/ssr-integration.ts +225 -0
- package/src/test-setup.ts +195 -0
- package/src/translation-validation.test.ts +171 -0
- package/src/translator.test.ts +252 -0
- package/src/translator.ts +297 -0
- package/src/types.ts +209 -0
- package/src/utils/locale.ts +190 -0
- package/src/utils/tokenizer-adapter.ts +469 -0
- package/src/utils/tokenizer.ts +19 -0
- package/src/validators/index.ts +174 -0
- package/src/validators/schema.ts +129 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// packages/i18n/src/parser/types.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* KeywordProvider interface for locale-aware parsing.
|
|
5
|
+
*
|
|
6
|
+
* This interface allows the parser to resolve non-English keywords
|
|
7
|
+
* to their canonical English equivalents, enabling multilingual
|
|
8
|
+
* hyperscript syntax.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```typescript
|
|
12
|
+
* import { esKeywords } from '@lokascript/i18n/parser/es';
|
|
13
|
+
* const parser = new Parser({ keywords: esKeywords });
|
|
14
|
+
* parser.parse('en clic alternar .active'); // Works!
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export interface KeywordProvider {
|
|
18
|
+
/** The locale code for this provider (e.g., 'es', 'ja') */
|
|
19
|
+
readonly locale: string;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolve a token to its canonical (English) keyword.
|
|
23
|
+
* Returns undefined if the token is not a recognized keyword.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* esKeywords.resolve('alternar') // 'toggle'
|
|
27
|
+
* esKeywords.resolve('en') // 'on'
|
|
28
|
+
* esKeywords.resolve('unknown') // undefined
|
|
29
|
+
*/
|
|
30
|
+
resolve(token: string): string | undefined;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if the token is a command in this locale.
|
|
34
|
+
* Includes both locale keywords and English fallbacks.
|
|
35
|
+
*/
|
|
36
|
+
isCommand(token: string): boolean;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check if the token is a keyword (non-command) in this locale.
|
|
40
|
+
* Includes modifiers, logical operators, events, etc.
|
|
41
|
+
*/
|
|
42
|
+
isKeyword(token: string): boolean;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Check if the token is an event name in this locale.
|
|
46
|
+
*/
|
|
47
|
+
isEvent(token: string): boolean;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Check if the token is a modifier/preposition in this locale.
|
|
51
|
+
*/
|
|
52
|
+
isModifier(token: string): boolean;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check if the token is a logical operator in this locale.
|
|
56
|
+
*/
|
|
57
|
+
isLogical(token: string): boolean;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Check if the token is a value keyword (me, it, true, etc.) in this locale.
|
|
61
|
+
*/
|
|
62
|
+
isValue(token: string): boolean;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Check if the token is an expression keyword (first, last, closest, etc.) in this locale.
|
|
66
|
+
*/
|
|
67
|
+
isExpression(token: string): boolean;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get all command names in this locale (for completions).
|
|
71
|
+
*/
|
|
72
|
+
getCommands(): string[];
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get all keywords in this locale (for completions).
|
|
76
|
+
*/
|
|
77
|
+
getKeywords(): string[];
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Get the locale keyword for an English canonical keyword.
|
|
81
|
+
* Useful for error messages and IDE completions in native language.
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* esKeywords.toLocale('toggle') // 'alternar'
|
|
85
|
+
*/
|
|
86
|
+
toLocale(englishKeyword: string): string | undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Options for creating a keyword provider.
|
|
91
|
+
*/
|
|
92
|
+
export interface KeywordProviderOptions {
|
|
93
|
+
/**
|
|
94
|
+
* When true, English keywords are always accepted alongside locale keywords.
|
|
95
|
+
* This allows mixing: "en click alternar .active"
|
|
96
|
+
* @default true
|
|
97
|
+
*/
|
|
98
|
+
allowEnglishFallback?: boolean;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Categories to include. If not specified, all categories are included.
|
|
102
|
+
*/
|
|
103
|
+
categories?: Array<
|
|
104
|
+
| 'commands'
|
|
105
|
+
| 'modifiers'
|
|
106
|
+
| 'events'
|
|
107
|
+
| 'logical'
|
|
108
|
+
| 'temporal'
|
|
109
|
+
| 'values'
|
|
110
|
+
| 'attributes'
|
|
111
|
+
| 'expressions'
|
|
112
|
+
>;
|
|
113
|
+
}
|
package/src/parser/zh.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// packages/i18n/src/parser/zh.ts
|
|
2
|
+
|
|
3
|
+
import { zh } from '../dictionaries/zh';
|
|
4
|
+
import { createKeywordProvider } from './create-provider';
|
|
5
|
+
import type { KeywordProvider } from './types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Chinese (Simplified) keyword provider for the hyperscript parser.
|
|
9
|
+
*
|
|
10
|
+
* Enables parsing hyperscript written in Chinese:
|
|
11
|
+
* - `当 点击 切换 .active` → parses as `on click toggle .active`
|
|
12
|
+
* - `如果 真 那么 日志 "你好"` → parses as `if true then log "hello"`
|
|
13
|
+
*
|
|
14
|
+
* English keywords are also accepted (mixed mode), so:
|
|
15
|
+
* - `当 click 切换 .active` also works (Chinese `当` + English `click`)
|
|
16
|
+
*
|
|
17
|
+
* Chinese is a useful test case because:
|
|
18
|
+
* - SVO word order (similar to English)
|
|
19
|
+
* - Logographic script (Chinese characters)
|
|
20
|
+
* - Isolating morphology (minimal inflection)
|
|
21
|
+
* - Tests parser's Unicode handling with CJK characters
|
|
22
|
+
* - Large developer population
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```typescript
|
|
26
|
+
* import { zhKeywords } from '@lokascript/i18n/parser/zh';
|
|
27
|
+
* import { Parser } from '@lokascript/core';
|
|
28
|
+
*
|
|
29
|
+
* const parser = new Parser({ keywords: zhKeywords });
|
|
30
|
+
* parser.parse('当 点击 切换 .active');
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export const zhKeywords: KeywordProvider = createKeywordProvider(zh, 'zh', {
|
|
34
|
+
allowEnglishFallback: true,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Re-export for convenience
|
|
38
|
+
export { zh as zhDictionary } from '../dictionaries/zh';
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// packages/i18n/src/plugins/vite.ts
|
|
2
|
+
|
|
3
|
+
import type { Plugin } from 'vite';
|
|
4
|
+
import { HyperscriptTranslator } from '../translator';
|
|
5
|
+
|
|
6
|
+
// node-html-parser is an optional dependency
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
8
|
+
let parse: any;
|
|
9
|
+
try {
|
|
10
|
+
// Dynamic require for optional dependency
|
|
11
|
+
parse = require('node-html-parser').parse;
|
|
12
|
+
} catch {
|
|
13
|
+
parse = null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface HyperscriptI18nViteOptions {
|
|
17
|
+
sourceLocale?: string;
|
|
18
|
+
targetLocale?: string;
|
|
19
|
+
preserveOriginal?: boolean;
|
|
20
|
+
attributes?: string[];
|
|
21
|
+
include?: RegExp | string[];
|
|
22
|
+
exclude?: RegExp | string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function hyperscriptI18nVitePlugin(options: HyperscriptI18nViteOptions = {}): Plugin {
|
|
26
|
+
const {
|
|
27
|
+
sourceLocale = 'en',
|
|
28
|
+
targetLocale = 'en',
|
|
29
|
+
preserveOriginal = false,
|
|
30
|
+
attributes = ['_', 'script', 'data-script'],
|
|
31
|
+
include = /\.(html|vue|svelte)$/,
|
|
32
|
+
exclude = /node_modules/,
|
|
33
|
+
} = options;
|
|
34
|
+
|
|
35
|
+
const translator = new HyperscriptTranslator({ locale: sourceLocale });
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
name: 'vite-plugin-hyperscript-i18n',
|
|
39
|
+
|
|
40
|
+
transform(code: string, id: string) {
|
|
41
|
+
// Check if file should be processed
|
|
42
|
+
if (!shouldProcess(id, include, exclude)) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Skip if no translation needed
|
|
47
|
+
if (sourceLocale === targetLocale) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
// Process HTML files
|
|
53
|
+
if (id.endsWith('.html')) {
|
|
54
|
+
return transformHtml(code, translator, {
|
|
55
|
+
sourceLocale,
|
|
56
|
+
targetLocale,
|
|
57
|
+
preserveOriginal,
|
|
58
|
+
attributes,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Process Vue SFCs
|
|
63
|
+
if (id.endsWith('.vue')) {
|
|
64
|
+
return transformVue(code, translator, {
|
|
65
|
+
sourceLocale,
|
|
66
|
+
targetLocale,
|
|
67
|
+
preserveOriginal,
|
|
68
|
+
attributes,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Process Svelte components
|
|
73
|
+
if (id.endsWith('.svelte')) {
|
|
74
|
+
return transformSvelte(code, translator, {
|
|
75
|
+
sourceLocale,
|
|
76
|
+
targetLocale,
|
|
77
|
+
preserveOriginal,
|
|
78
|
+
attributes,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return null;
|
|
83
|
+
} catch (error: unknown) {
|
|
84
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
85
|
+
this.error(`Failed to process ${id}: ${message}`);
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
configureServer(server) {
|
|
90
|
+
// Add middleware for development
|
|
91
|
+
server.middlewares.use((_req, res, next) => {
|
|
92
|
+
// Add i18n headers
|
|
93
|
+
res.setHeader('X-Hyperscript-I18n-Source', sourceLocale);
|
|
94
|
+
res.setHeader('X-Hyperscript-I18n-Target', targetLocale);
|
|
95
|
+
next();
|
|
96
|
+
});
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function shouldProcess(
|
|
102
|
+
id: string,
|
|
103
|
+
include: RegExp | string[],
|
|
104
|
+
exclude: RegExp | string[]
|
|
105
|
+
): boolean {
|
|
106
|
+
const includePattern = Array.isArray(include) ? new RegExp(include.join('|')) : include;
|
|
107
|
+
|
|
108
|
+
const excludePattern = Array.isArray(exclude) ? new RegExp(exclude.join('|')) : exclude;
|
|
109
|
+
|
|
110
|
+
return includePattern.test(id) && !excludePattern.test(id);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function transformHtml(
|
|
114
|
+
html: string,
|
|
115
|
+
translator: HyperscriptTranslator,
|
|
116
|
+
options: {
|
|
117
|
+
sourceLocale: string;
|
|
118
|
+
targetLocale: string;
|
|
119
|
+
preserveOriginal: boolean;
|
|
120
|
+
attributes: string[];
|
|
121
|
+
}
|
|
122
|
+
): { code: string; map?: any } {
|
|
123
|
+
const root = parse(html);
|
|
124
|
+
let hasTransformations = false;
|
|
125
|
+
|
|
126
|
+
// Find all elements with hyperscript attributes
|
|
127
|
+
options.attributes.forEach(attr => {
|
|
128
|
+
const elements = root.querySelectorAll(`[${attr}]`);
|
|
129
|
+
|
|
130
|
+
elements.forEach((element: any) => {
|
|
131
|
+
const original = element.getAttribute(attr);
|
|
132
|
+
if (!original) return;
|
|
133
|
+
|
|
134
|
+
const translated = translator.translate(original, {
|
|
135
|
+
from: options.sourceLocale,
|
|
136
|
+
to: options.targetLocale,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
if (original !== translated) {
|
|
140
|
+
hasTransformations = true;
|
|
141
|
+
|
|
142
|
+
if (options.preserveOriginal) {
|
|
143
|
+
element.setAttribute(`${attr}-${options.sourceLocale}`, original);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
element.setAttribute(attr, translated);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
if (!hasTransformations) {
|
|
152
|
+
return { code: html };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
code: root.toString(),
|
|
157
|
+
map: null, // Source maps could be added here
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function transformVue(
|
|
162
|
+
code: string,
|
|
163
|
+
translator: HyperscriptTranslator,
|
|
164
|
+
options: any
|
|
165
|
+
): { code: string; map?: any } {
|
|
166
|
+
// Extract template section
|
|
167
|
+
const templateMatch = code.match(/<template[^>]*>([\s\S]*?)<\/template>/);
|
|
168
|
+
if (!templateMatch) {
|
|
169
|
+
return { code };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const template = templateMatch[1];
|
|
173
|
+
const transformed = transformHtml(template, translator, options);
|
|
174
|
+
|
|
175
|
+
if (transformed.code === template) {
|
|
176
|
+
return { code };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Replace template content
|
|
180
|
+
const newCode = code.replace(templateMatch[0], `<template>${transformed.code}</template>`);
|
|
181
|
+
|
|
182
|
+
return { code: newCode };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function transformSvelte(
|
|
186
|
+
code: string,
|
|
187
|
+
translator: HyperscriptTranslator,
|
|
188
|
+
options: any
|
|
189
|
+
): { code: string; map?: any } {
|
|
190
|
+
// Svelte components have HTML at the top level
|
|
191
|
+
const scriptMatch = code.match(/<script[^>]*>[\s\S]*?<\/script>/g);
|
|
192
|
+
const styleMatch = code.match(/<style[^>]*>[\s\S]*?<\/style>/g);
|
|
193
|
+
|
|
194
|
+
// Extract HTML portion
|
|
195
|
+
let html = code;
|
|
196
|
+
if (scriptMatch) {
|
|
197
|
+
scriptMatch.forEach(script => {
|
|
198
|
+
html = html.replace(script, '');
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
if (styleMatch) {
|
|
202
|
+
styleMatch.forEach(style => {
|
|
203
|
+
html = html.replace(style, '');
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const transformed = transformHtml(html, translator, options);
|
|
208
|
+
|
|
209
|
+
if (transformed.code === html) {
|
|
210
|
+
return { code };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Reconstruct Svelte component
|
|
214
|
+
let newCode = '';
|
|
215
|
+
if (scriptMatch) {
|
|
216
|
+
newCode += scriptMatch.join('\n') + '\n\n';
|
|
217
|
+
}
|
|
218
|
+
newCode += transformed.code;
|
|
219
|
+
if (styleMatch) {
|
|
220
|
+
newCode += '\n\n' + styleMatch.join('\n');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return { code: newCode };
|
|
224
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// packages/i18n/src/plugins/webpack.ts
|
|
2
|
+
|
|
3
|
+
import { HyperscriptTranslator } from '../translator';
|
|
4
|
+
|
|
5
|
+
// node-html-parser is an optional dependency
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
7
|
+
let parse: any;
|
|
8
|
+
try {
|
|
9
|
+
parse = require('node-html-parser').parse;
|
|
10
|
+
} catch {
|
|
11
|
+
parse = null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Webpack types - optional peer dependency
|
|
15
|
+
interface Compiler {
|
|
16
|
+
hooks: any;
|
|
17
|
+
webpack: any;
|
|
18
|
+
}
|
|
19
|
+
interface WebpackPluginInstance {
|
|
20
|
+
apply(compiler: Compiler): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface HyperscriptI18nWebpackOptions {
|
|
24
|
+
sourceLocale?: string;
|
|
25
|
+
targetLocale?: string;
|
|
26
|
+
preserveOriginal?: boolean;
|
|
27
|
+
attributes?: string[];
|
|
28
|
+
test?: RegExp;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class HyperscriptI18nWebpackPlugin implements WebpackPluginInstance {
|
|
32
|
+
private options: Required<HyperscriptI18nWebpackOptions>;
|
|
33
|
+
private translator: HyperscriptTranslator;
|
|
34
|
+
|
|
35
|
+
constructor(options: HyperscriptI18nWebpackOptions = {}) {
|
|
36
|
+
this.options = {
|
|
37
|
+
sourceLocale: options.sourceLocale || 'en',
|
|
38
|
+
targetLocale: options.targetLocale || 'en',
|
|
39
|
+
preserveOriginal: options.preserveOriginal ?? false,
|
|
40
|
+
attributes: options.attributes || ['_', 'script', 'data-script'],
|
|
41
|
+
test: options.test || /\.(html)$/,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
this.translator = new HyperscriptTranslator({
|
|
45
|
+
locale: this.options.sourceLocale,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
apply(compiler: Compiler): void {
|
|
50
|
+
const pluginName = 'HyperscriptI18nWebpackPlugin';
|
|
51
|
+
|
|
52
|
+
// Skip if no translation needed
|
|
53
|
+
if (this.options.sourceLocale === this.options.targetLocale) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
compiler.hooks.compilation.tap(pluginName, (compilation: any) => {
|
|
58
|
+
// Hook into HTML processing
|
|
59
|
+
compilation.hooks.processAssets.tapPromise(
|
|
60
|
+
{
|
|
61
|
+
name: pluginName,
|
|
62
|
+
stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_SIZE,
|
|
63
|
+
},
|
|
64
|
+
async (assets: any) => {
|
|
65
|
+
const promises = Object.keys(assets)
|
|
66
|
+
.filter(filename => this.options.test.test(filename))
|
|
67
|
+
.map(filename => this.processAsset(compilation, filename));
|
|
68
|
+
|
|
69
|
+
await Promise.all(promises);
|
|
70
|
+
}
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private async processAsset(compilation: any, filename: string): Promise<void> {
|
|
76
|
+
const asset = compilation.assets[filename];
|
|
77
|
+
const source = asset.source();
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const processed = this.processHtml(source);
|
|
81
|
+
|
|
82
|
+
if (processed !== source) {
|
|
83
|
+
compilation.assets[filename] = {
|
|
84
|
+
source: () => processed,
|
|
85
|
+
size: () => processed.length,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
} catch (error: unknown) {
|
|
89
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
90
|
+
compilation.errors.push(new Error(`Failed to process ${filename}: ${message}`));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private processHtml(html: string): string {
|
|
95
|
+
const root = parse(html);
|
|
96
|
+
let hasChanges = false;
|
|
97
|
+
|
|
98
|
+
this.options.attributes.forEach(attr => {
|
|
99
|
+
const elements = root.querySelectorAll(`[${attr}]`);
|
|
100
|
+
|
|
101
|
+
elements.forEach((element: any) => {
|
|
102
|
+
const original = element.getAttribute(attr);
|
|
103
|
+
if (!original) return;
|
|
104
|
+
|
|
105
|
+
const translated = this.translator.translate(original, {
|
|
106
|
+
from: this.options.sourceLocale,
|
|
107
|
+
to: this.options.targetLocale,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (original !== translated) {
|
|
111
|
+
hasChanges = true;
|
|
112
|
+
|
|
113
|
+
if (this.options.preserveOriginal) {
|
|
114
|
+
element.setAttribute(`${attr}-${this.options.sourceLocale}`, original);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
element.setAttribute(attr, translated);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return hasChanges ? root.toString() : html;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// packages/i18n/src/pluralization.test.ts
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import { pluralRules, getPlural, PluralAwareTranslator } from './pluralization';
|
|
5
|
+
|
|
6
|
+
describe('Pluralization', () => {
|
|
7
|
+
describe('plural rules', () => {
|
|
8
|
+
it('should handle English pluralization', () => {
|
|
9
|
+
expect(pluralRules.en(1)).toBe('one');
|
|
10
|
+
expect(pluralRules.en(0)).toBe('other');
|
|
11
|
+
expect(pluralRules.en(2)).toBe('other');
|
|
12
|
+
expect(pluralRules.en(5)).toBe('other');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('should handle French pluralization', () => {
|
|
16
|
+
expect(pluralRules.fr(0)).toBe('one');
|
|
17
|
+
expect(pluralRules.fr(1)).toBe('one');
|
|
18
|
+
expect(pluralRules.fr(1.5)).toBe('one');
|
|
19
|
+
expect(pluralRules.fr(2)).toBe('other');
|
|
20
|
+
expect(pluralRules.fr(5)).toBe('other');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should handle Russian pluralization (complex)', () => {
|
|
24
|
+
expect(pluralRules.ru(1)).toBe('one');
|
|
25
|
+
expect(pluralRules.ru(21)).toBe('one');
|
|
26
|
+
expect(pluralRules.ru(2)).toBe('few');
|
|
27
|
+
expect(pluralRules.ru(3)).toBe('few');
|
|
28
|
+
expect(pluralRules.ru(4)).toBe('few');
|
|
29
|
+
expect(pluralRules.ru(22)).toBe('few');
|
|
30
|
+
expect(pluralRules.ru(5)).toBe('many');
|
|
31
|
+
expect(pluralRules.ru(11)).toBe('many');
|
|
32
|
+
expect(pluralRules.ru(15)).toBe('many');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should handle Arabic pluralization (with zero)', () => {
|
|
36
|
+
expect(pluralRules.ar(0)).toBe('zero');
|
|
37
|
+
expect(pluralRules.ar(1)).toBe('one');
|
|
38
|
+
expect(pluralRules.ar(2)).toBe('two');
|
|
39
|
+
expect(pluralRules.ar(3)).toBe('few');
|
|
40
|
+
expect(pluralRules.ar(10)).toBe('few');
|
|
41
|
+
expect(pluralRules.ar(11)).toBe('many');
|
|
42
|
+
expect(pluralRules.ar(99)).toBe('many');
|
|
43
|
+
expect(pluralRules.ar(100)).toBe('other');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should handle Chinese (no pluralization)', () => {
|
|
47
|
+
expect(pluralRules.zh(0)).toBe('other');
|
|
48
|
+
expect(pluralRules.zh(1)).toBe('other');
|
|
49
|
+
expect(pluralRules.zh(2)).toBe('other');
|
|
50
|
+
expect(pluralRules.zh(100)).toBe('other');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('getPlural', () => {
|
|
55
|
+
it('should return correct plural form for English', () => {
|
|
56
|
+
const forms = {
|
|
57
|
+
one: 'second',
|
|
58
|
+
other: 'seconds',
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
expect(getPlural('en', 1, forms)).toBe('second');
|
|
62
|
+
expect(getPlural('en', 0, forms)).toBe('seconds');
|
|
63
|
+
expect(getPlural('en', 2, forms)).toBe('seconds');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should return correct plural form for Russian', () => {
|
|
67
|
+
const forms = {
|
|
68
|
+
one: 'секунда',
|
|
69
|
+
few: 'секунды',
|
|
70
|
+
many: 'секунд',
|
|
71
|
+
other: 'секунд',
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
expect(getPlural('ru', 1, forms)).toBe('секунда');
|
|
75
|
+
expect(getPlural('ru', 2, forms)).toBe('секунды');
|
|
76
|
+
expect(getPlural('ru', 5, forms)).toBe('секунд');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should fallback to other form', () => {
|
|
80
|
+
const forms = {
|
|
81
|
+
other: 'default',
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
expect(getPlural('en', 1, forms)).toBe('default');
|
|
85
|
+
expect(getPlural('ru', 1, forms)).toBe('default');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('PluralAwareTranslator', () => {
|
|
90
|
+
describe('translateTimeExpression', () => {
|
|
91
|
+
it('should translate English time expressions', () => {
|
|
92
|
+
expect(PluralAwareTranslator.translateTimeExpression(1, 'second', 'en')).toBe('1 second');
|
|
93
|
+
expect(PluralAwareTranslator.translateTimeExpression(2, 'second', 'en')).toBe('2 seconds');
|
|
94
|
+
expect(PluralAwareTranslator.translateTimeExpression(1, 'minute', 'en')).toBe('1 minute');
|
|
95
|
+
expect(PluralAwareTranslator.translateTimeExpression(5, 'minute', 'en')).toBe('5 minutes');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should translate Spanish time expressions', () => {
|
|
99
|
+
expect(PluralAwareTranslator.translateTimeExpression(1, 'second', 'es')).toBe('1 segundo');
|
|
100
|
+
expect(PluralAwareTranslator.translateTimeExpression(2, 'second', 'es')).toBe('2 segundos');
|
|
101
|
+
expect(PluralAwareTranslator.translateTimeExpression(1, 'hour', 'es')).toBe('1 hora');
|
|
102
|
+
expect(PluralAwareTranslator.translateTimeExpression(3, 'hour', 'es')).toBe('3 horas');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should translate Russian time expressions', () => {
|
|
106
|
+
expect(PluralAwareTranslator.translateTimeExpression(1, 'second', 'ru')).toBe('1 секунда');
|
|
107
|
+
expect(PluralAwareTranslator.translateTimeExpression(2, 'second', 'ru')).toBe('2 секунды');
|
|
108
|
+
expect(PluralAwareTranslator.translateTimeExpression(5, 'second', 'ru')).toBe('5 секунд');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should translate Arabic time expressions', () => {
|
|
112
|
+
expect(PluralAwareTranslator.translateTimeExpression(0, 'second', 'ar')).toBe('0 ثوانِ');
|
|
113
|
+
expect(PluralAwareTranslator.translateTimeExpression(1, 'second', 'ar')).toBe('1 ثانية');
|
|
114
|
+
expect(PluralAwareTranslator.translateTimeExpression(2, 'second', 'ar')).toBe('2 ثانيتان');
|
|
115
|
+
expect(PluralAwareTranslator.translateTimeExpression(3, 'second', 'ar')).toBe('3 ثوانِ');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should handle unsupported locales with fallback', () => {
|
|
119
|
+
expect(PluralAwareTranslator.translateTimeExpression(1, 'second', 'unknown')).toBe(
|
|
120
|
+
'1 second'
|
|
121
|
+
);
|
|
122
|
+
expect(PluralAwareTranslator.translateTimeExpression(2, 'second', 'unknown')).toBe(
|
|
123
|
+
'2 seconds'
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('translateHyperscriptTime', () => {
|
|
129
|
+
it('should translate time expressions in hyperscript', () => {
|
|
130
|
+
const input = 'wait 5 seconds then wait 1 minute';
|
|
131
|
+
const result = PluralAwareTranslator.translateHyperscriptTime(input, 'es');
|
|
132
|
+
|
|
133
|
+
expect(result).toBe('wait 5 segundos then wait 1 minuto');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should handle milliseconds', () => {
|
|
137
|
+
const input = 'wait 500 milliseconds';
|
|
138
|
+
const result = PluralAwareTranslator.translateHyperscriptTime(input, 'en');
|
|
139
|
+
|
|
140
|
+
expect(result).toBe('wait 500 milliseconds');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should handle abbreviated forms', () => {
|
|
144
|
+
const input = 'wait 100 ms';
|
|
145
|
+
const result = PluralAwareTranslator.translateHyperscriptTime(input, 'en');
|
|
146
|
+
|
|
147
|
+
expect(result).toBe('wait 100 milliseconds');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should preserve non-time content', () => {
|
|
151
|
+
const input = 'on click wait 2 seconds then show #result';
|
|
152
|
+
const result = PluralAwareTranslator.translateHyperscriptTime(input, 'es');
|
|
153
|
+
|
|
154
|
+
expect(result).toBe('on click wait 2 segundos then show #result');
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('getOrdinal', () => {
|
|
159
|
+
it('should generate English ordinals', () => {
|
|
160
|
+
expect(PluralAwareTranslator.getOrdinal(1, 'en')).toBe('1st');
|
|
161
|
+
expect(PluralAwareTranslator.getOrdinal(2, 'en')).toBe('2nd');
|
|
162
|
+
expect(PluralAwareTranslator.getOrdinal(3, 'en')).toBe('3rd');
|
|
163
|
+
expect(PluralAwareTranslator.getOrdinal(4, 'en')).toBe('4th');
|
|
164
|
+
expect(PluralAwareTranslator.getOrdinal(11, 'en')).toBe('11th');
|
|
165
|
+
expect(PluralAwareTranslator.getOrdinal(21, 'en')).toBe('21st');
|
|
166
|
+
expect(PluralAwareTranslator.getOrdinal(22, 'en')).toBe('22nd');
|
|
167
|
+
expect(PluralAwareTranslator.getOrdinal(23, 'en')).toBe('23rd');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should generate Spanish ordinals', () => {
|
|
171
|
+
expect(PluralAwareTranslator.getOrdinal(1, 'es')).toBe('1º');
|
|
172
|
+
expect(PluralAwareTranslator.getOrdinal(5, 'es')).toBe('5º');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should generate French ordinals', () => {
|
|
176
|
+
expect(PluralAwareTranslator.getOrdinal(1, 'fr')).toBe('1er');
|
|
177
|
+
expect(PluralAwareTranslator.getOrdinal(2, 'fr')).toBe('2e');
|
|
178
|
+
expect(PluralAwareTranslator.getOrdinal(5, 'fr')).toBe('5e');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should generate Chinese ordinals', () => {
|
|
182
|
+
expect(PluralAwareTranslator.getOrdinal(1, 'zh')).toBe('第1');
|
|
183
|
+
expect(PluralAwareTranslator.getOrdinal(5, 'zh')).toBe('第5');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should generate Japanese ordinals', () => {
|
|
187
|
+
expect(PluralAwareTranslator.getOrdinal(1, 'ja')).toBe('1番目');
|
|
188
|
+
expect(PluralAwareTranslator.getOrdinal(5, 'ja')).toBe('5番目');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should generate Korean ordinals', () => {
|
|
192
|
+
expect(PluralAwareTranslator.getOrdinal(1, 'ko')).toBe('1번째');
|
|
193
|
+
expect(PluralAwareTranslator.getOrdinal(5, 'ko')).toBe('5번째');
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
});
|