@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.
Files changed (96) hide show
  1. package/README.md +286 -0
  2. package/dist/browser.cjs +7669 -0
  3. package/dist/browser.cjs.map +1 -0
  4. package/dist/browser.d.cts +50 -0
  5. package/dist/browser.d.ts +50 -0
  6. package/dist/browser.js +7592 -0
  7. package/dist/browser.js.map +1 -0
  8. package/dist/hyperfixi-i18n.min.js +2 -0
  9. package/dist/hyperfixi-i18n.min.js.map +1 -0
  10. package/dist/hyperfixi-i18n.mjs +8558 -0
  11. package/dist/hyperfixi-i18n.mjs.map +1 -0
  12. package/dist/index.cjs +14205 -0
  13. package/dist/index.cjs.map +1 -0
  14. package/dist/index.d.cts +947 -0
  15. package/dist/index.d.ts +947 -0
  16. package/dist/index.js +14095 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/transformer-Ckask-yw.d.cts +1041 -0
  19. package/dist/transformer-Ckask-yw.d.ts +1041 -0
  20. package/package.json +84 -0
  21. package/src/browser.ts +122 -0
  22. package/src/compatibility/browser-tests/grammar-demo.spec.ts +169 -0
  23. package/src/constants.ts +366 -0
  24. package/src/dictionaries/ar.ts +233 -0
  25. package/src/dictionaries/bn.ts +156 -0
  26. package/src/dictionaries/de.ts +233 -0
  27. package/src/dictionaries/derive.ts +515 -0
  28. package/src/dictionaries/en.ts +237 -0
  29. package/src/dictionaries/es.ts +233 -0
  30. package/src/dictionaries/fr.ts +233 -0
  31. package/src/dictionaries/hi.ts +270 -0
  32. package/src/dictionaries/id.ts +233 -0
  33. package/src/dictionaries/index.ts +238 -0
  34. package/src/dictionaries/it.ts +233 -0
  35. package/src/dictionaries/ja.ts +233 -0
  36. package/src/dictionaries/ko.ts +233 -0
  37. package/src/dictionaries/ms.ts +276 -0
  38. package/src/dictionaries/pl.ts +239 -0
  39. package/src/dictionaries/pt.ts +237 -0
  40. package/src/dictionaries/qu.ts +233 -0
  41. package/src/dictionaries/ru.ts +270 -0
  42. package/src/dictionaries/sw.ts +233 -0
  43. package/src/dictionaries/th.ts +156 -0
  44. package/src/dictionaries/tl.ts +276 -0
  45. package/src/dictionaries/tr.ts +233 -0
  46. package/src/dictionaries/uk.ts +270 -0
  47. package/src/dictionaries/vi.ts +210 -0
  48. package/src/dictionaries/zh.ts +233 -0
  49. package/src/enhanced-i18n.test.ts +454 -0
  50. package/src/enhanced-i18n.ts +713 -0
  51. package/src/examples/new-languages.ts +326 -0
  52. package/src/formatting.test.ts +213 -0
  53. package/src/formatting.ts +416 -0
  54. package/src/grammar/direct-mappings.ts +353 -0
  55. package/src/grammar/grammar.test.ts +1053 -0
  56. package/src/grammar/index.ts +59 -0
  57. package/src/grammar/profiles/index.ts +860 -0
  58. package/src/grammar/transformer.ts +1318 -0
  59. package/src/grammar/types.ts +630 -0
  60. package/src/index.ts +202 -0
  61. package/src/new-languages.test.ts +389 -0
  62. package/src/parser/analyze-conflicts.test.ts +229 -0
  63. package/src/parser/ar.ts +40 -0
  64. package/src/parser/create-provider.ts +309 -0
  65. package/src/parser/de.ts +36 -0
  66. package/src/parser/es.ts +31 -0
  67. package/src/parser/fr.ts +31 -0
  68. package/src/parser/id.ts +34 -0
  69. package/src/parser/index.ts +50 -0
  70. package/src/parser/ja.ts +36 -0
  71. package/src/parser/ko.ts +37 -0
  72. package/src/parser/locale-manager.test.ts +198 -0
  73. package/src/parser/locale-manager.ts +197 -0
  74. package/src/parser/parser-integration.test.ts +439 -0
  75. package/src/parser/pt.ts +37 -0
  76. package/src/parser/qu.ts +37 -0
  77. package/src/parser/sw.ts +37 -0
  78. package/src/parser/tr.ts +38 -0
  79. package/src/parser/types.ts +113 -0
  80. package/src/parser/zh.ts +38 -0
  81. package/src/plugins/vite.ts +224 -0
  82. package/src/plugins/webpack.ts +124 -0
  83. package/src/pluralization.test.ts +197 -0
  84. package/src/pluralization.ts +393 -0
  85. package/src/runtime.ts +441 -0
  86. package/src/ssr-integration.ts +225 -0
  87. package/src/test-setup.ts +195 -0
  88. package/src/translation-validation.test.ts +171 -0
  89. package/src/translator.test.ts +252 -0
  90. package/src/translator.ts +297 -0
  91. package/src/types.ts +209 -0
  92. package/src/utils/locale.ts +190 -0
  93. package/src/utils/tokenizer-adapter.ts +469 -0
  94. package/src/utils/tokenizer.ts +19 -0
  95. package/src/validators/index.ts +174 -0
  96. 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
+ }
@@ -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
+ });