@lokascript/i18n 2.11.1 → 3.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 (42) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/browser.cjs +5 -1690
  3. package/dist/browser.cjs.map +1 -1
  4. package/dist/browser.d.cts +2 -2
  5. package/dist/browser.d.ts +2 -2
  6. package/dist/browser.js +6 -1685
  7. package/dist/browser.js.map +1 -1
  8. package/dist/dictionaries/index.cjs +5 -1
  9. package/dist/dictionaries/index.cjs.map +1 -1
  10. package/dist/dictionaries/index.js +5 -1
  11. package/dist/dictionaries/index.js.map +1 -1
  12. package/dist/{transformer-CsOeqayN.d.cts → index-BykxjYST.d.cts} +1 -232
  13. package/dist/{transformer-DWCTG1DQ.d.ts → index-DuIef8O7.d.ts} +1 -232
  14. package/dist/index.cjs +16 -1878
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +35 -35
  17. package/dist/index.d.ts +35 -35
  18. package/dist/index.js +17 -1873
  19. package/dist/index.js.map +1 -1
  20. package/dist/lokascript-i18n.min.js +1 -1
  21. package/dist/lokascript-i18n.min.js.map +1 -1
  22. package/dist/lokascript-i18n.mjs +49 -2680
  23. package/dist/lokascript-i18n.mjs.map +1 -1
  24. package/dist/plugins/vite.cjs +5 -1
  25. package/dist/plugins/vite.cjs.map +1 -1
  26. package/dist/plugins/vite.js +5 -1
  27. package/dist/plugins/vite.js.map +1 -1
  28. package/dist/plugins/webpack.cjs +5 -1
  29. package/dist/plugins/webpack.cjs.map +1 -1
  30. package/dist/plugins/webpack.js +5 -1
  31. package/dist/plugins/webpack.js.map +1 -1
  32. package/package.json +4 -4
  33. package/src/browser.ts +0 -7
  34. package/src/compatibility/browser-tests/grammar-demo.spec.ts +22 -8
  35. package/src/constants.ts +1 -0
  36. package/src/dictionaries/bn.ts +5 -1
  37. package/src/grammar/index.ts +15 -9
  38. package/src/grammar/profiles.test.ts +440 -0
  39. package/src/index.ts +0 -7
  40. package/src/lexicon-parity.test.ts +77 -0
  41. package/src/grammar/grammar.test.ts +0 -2751
  42. package/src/grammar/transformer.ts +0 -2737
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lokascript/i18n",
3
- "version": "2.11.1",
4
- "description": "Grammar transformation and keyword translation for hyperscript",
3
+ "version": "3.0.0",
4
+ "description": "Keyword dictionaries, keyword providers and grammar profiles for hyperscript",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
7
7
  "module": "dist/index.js",
@@ -64,8 +64,8 @@
64
64
  },
65
65
  "homepage": "https://github.com/codetalcott/hyperfixi/tree/main/packages/i18n#readme",
66
66
  "dependencies": {
67
- "@hyperfixi/core": "^2.11.1",
68
- "@lokascript/semantic": "^2.11.1",
67
+ "@hyperfixi/core": "^3.0.0",
68
+ "@lokascript/semantic": "^3.0.0",
69
69
  "esbuild": "^0.28.0",
70
70
  "happy-dom": "^20.10.6",
71
71
  "vite": "^8.1.4"
package/src/browser.ts CHANGED
@@ -137,11 +137,4 @@ export {
137
137
  getDirectMapping,
138
138
  translateWordDirect,
139
139
  getSupportedDirectPairs,
140
- // Transformer
141
- GrammarTransformer,
142
- parseStatement,
143
- toLocale,
144
- toEnglish,
145
- translate,
146
- examples as grammarExamples,
147
140
  } from './grammar';
@@ -6,6 +6,11 @@
6
6
  */
7
7
  import { test, expect } from '@playwright/test';
8
8
 
9
+ // NOTE (2026-08-28): no `playwright.config` in this package points at this
10
+ // directory — core's config has `testDir: './src/compatibility/browser-tests'`,
11
+ // which is core's own tree — so CI does not run this file. It is kept correct
12
+ // rather than deleted, but do not read a green CI as evidence it passes.
13
+
9
14
  test.describe('Grammar Transformation Demo', () => {
10
15
  test.beforeEach(async ({ page }) => {
11
16
  await page.goto('http://127.0.0.1:3000/examples/multilingual/index.html');
@@ -13,17 +18,26 @@ test.describe('Grammar Transformation Demo', () => {
13
18
  await page.waitForFunction(() => window.LokaScriptI18n !== undefined, { timeout: 5000 });
14
19
  });
15
20
 
16
- test('should load LokaScriptI18n bundle with GrammarTransformer', async ({ page }) => {
21
+ test('the i18n bundle exposes vocabulary and profiles, not a translator', async ({ page }) => {
22
+ // Was `should load LokaScriptI18n bundle with GrammarTransformer`. That
23
+ // bundle no longer carries a translator: the grammar transformer was retired
24
+ // 2026-08-28 (4/24 canonical-valid on an en -> L -> en round trip against the
25
+ // real hyperscript.org parser, where semantic measures 24/24). The demo page
26
+ // translates through `hyperfixi.translate`, which IS semantic's.
17
27
  const hasI18n = await page.evaluate(() => !!window.LokaScriptI18n);
18
28
  expect(hasI18n).toBe(true);
19
29
 
20
- const hasTransformer = await page.evaluate(() => !!window.LokaScriptI18n.GrammarTransformer);
21
- expect(hasTransformer).toBe(true);
22
-
23
- const hasTranslate = await page.evaluate(
24
- () => typeof window.LokaScriptI18n.translate === 'function'
25
- );
26
- expect(hasTranslate).toBe(true);
30
+ const surface = await page.evaluate(() => ({
31
+ transformer: typeof (window.LokaScriptI18n as Record<string, unknown>).GrammarTransformer,
32
+ translate: typeof (window.LokaScriptI18n as Record<string, unknown>).translate,
33
+ getProfile: typeof (window.LokaScriptI18n as Record<string, unknown>).getProfile,
34
+ hyperfixiTranslate: typeof (window as unknown as { hyperfixi?: Record<string, unknown> })
35
+ .hyperfixi?.translate,
36
+ }));
37
+ expect(surface.transformer).toBe('undefined');
38
+ expect(surface.translate).toBe('undefined');
39
+ expect(surface.getProfile).toBe('function');
40
+ expect(surface.hyperfixiTranslate).toBe('function');
27
41
  });
28
42
 
29
43
  test('should have profile functions available', async ({ page }) => {
package/src/constants.ts CHANGED
@@ -61,6 +61,7 @@ export const COMMAND_PRIMARY_ROLES: Readonly<Record<string, SemanticRole>> = {
61
61
  if: 'condition',
62
62
  unless: 'condition',
63
63
  while: 'condition',
64
+ when: 'condition', // reactive `when <expr> changes` — the watched expression
64
65
  repeat: 'loopType',
65
66
  go: 'destination',
66
67
  scroll: 'destination',
@@ -181,7 +181,11 @@ export const bengaliDictionary: Dictionary = {
181
181
  random: 'এলোমেলো',
182
182
  length: 'দৈর্ঘ্য',
183
183
  index: 'সূচক',
184
- empty: 'খালি-করুন',
184
+ // The EXPRESSION `empty` is the state predicate (`if my value is empty`),
185
+ // not the command: `খালি-করুন` is the imperative "empty it!" and belongs to
186
+ // the `empty` COMMAND, which keeps it. Kept in step with the semantic
187
+ // lexicon by `lexicon-parity.test.ts`.
188
+ empty: 'খালি',
185
189
  'starts with': 'দিয়ে_শুরু',
186
190
  'ends with': 'দিয়ে_শেষ',
187
191
  'ignoring case': 'কেস_উপেক্ষা',
@@ -59,12 +59,18 @@ export {
59
59
  getSupportedDirectPairs,
60
60
  } from './direct-mappings';
61
61
 
62
- // Transformer
63
- export {
64
- GrammarTransformer,
65
- parseStatement,
66
- toLocale,
67
- toEnglish,
68
- translate,
69
- examples,
70
- } from './transformer';
62
+ // Transformer — RETIRED 2026-08-28.
63
+ //
64
+ // `transformer.ts` (2,747 lines) exported `GrammarTransformer`, `parseStatement`,
65
+ // `toLocale`, `toEnglish`, `translate` and `examples`. Every consumer has moved to
66
+ // @lokascript/semantic, which is what the 3,657-row corpus is written by and what
67
+ // every runtime surface in this repo already called: `@hyperscript-tools/i18n`
68
+ // (#999), the vite-plugin's generated bundle (#997), and the classic-i18n browser
69
+ // bundle, which dropped the four helpers rather than pay +173 KB gzipped to keep
70
+ // them (#998).
71
+ //
72
+ // The rest of this directory STAYS and is not part of that retirement: `profiles/`
73
+ // is imported by i18n's own `runtime.ts` and re-exported to the classic-i18n
74
+ // bundle; `types.ts` backs `constants.ts` and the role helpers above;
75
+ // `direct-mappings.ts` is part of the browser API (`types-browser/i18n-api.ts`
76
+ // declares it). This was never "delete the grammar directory".
@@ -0,0 +1,440 @@
1
+ /**
2
+ * Grammar PROFILES and role-transformation tests.
3
+ *
4
+ * What this file is: the surviving ~400 lines of the old 2,780-line
5
+ * `grammar.test.ts`, which tested `GrammarTransformer` and the five profile /
6
+ * role / dictionary describes below in one place. The transformer is retired
7
+ * (see grammar/index.ts); these are not.
8
+ *
9
+ * They cover the half of `grammar/` that STAYS and has its own consumers:
10
+ * `profiles/` (i18n's `runtime.ts`, and the classic-i18n browser bundle's
11
+ * `getProfile` / `getSupportedGrammarLocales` / `profiles` surface), the role
12
+ * helpers in `types.ts` (`reorderRoles` / `insertMarkers` / `joinTokens`, which
13
+ * `constants.ts` imports from), and the has/have dictionary entries.
14
+ */
15
+
16
+ import { describe, it, expect } from 'vitest';
17
+ import {
18
+ getProfile,
19
+ getSupportedLocales,
20
+ profiles,
21
+ englishProfile,
22
+ japaneseProfile,
23
+ chineseProfile,
24
+ arabicProfile,
25
+ } from './profiles';
26
+ import {
27
+ reorderRoles,
28
+ insertMarkers,
29
+ joinTokens,
30
+ UNIVERSAL_PATTERNS,
31
+ LANGUAGE_FAMILY_DEFAULTS,
32
+ type SemanticRole,
33
+ } from './types';
34
+
35
+ describe('Language Profiles', () => {
36
+ it('should have profiles for all supported locales', () => {
37
+ const locales = getSupportedLocales();
38
+ // Explicit expected list — when adding a profile, append it here so the
39
+ // count assertion stays meaningful without becoming a magic number.
40
+ const expectedLocales = [
41
+ 'en',
42
+ 'ja',
43
+ 'ko',
44
+ 'zh',
45
+ 'ar',
46
+ 'tr',
47
+ 'es',
48
+ 'de',
49
+ 'fr',
50
+ 'pt',
51
+ 'id',
52
+ 'ms',
53
+ 'qu',
54
+ 'sw',
55
+ 'bn',
56
+ 'it',
57
+ 'ru',
58
+ 'uk',
59
+ 'vi',
60
+ 'hi',
61
+ 'tl',
62
+ 'th',
63
+ 'pl',
64
+ 'he',
65
+ ];
66
+ for (const code of expectedLocales) {
67
+ expect(locales, `missing profile: ${code}`).toContain(code);
68
+ }
69
+ expect(locales.length).toBe(expectedLocales.length);
70
+ });
71
+
72
+ it('should return undefined for unknown locales', () => {
73
+ expect(getProfile('xx')).toBeUndefined();
74
+ expect(getProfile('xyz')).toBeUndefined();
75
+ });
76
+
77
+ describe('English Profile', () => {
78
+ it('should have SVO word order', () => {
79
+ expect(englishProfile.wordOrder).toBe('SVO');
80
+ });
81
+
82
+ it('should use prepositions', () => {
83
+ expect(englishProfile.adpositionType).toBe('preposition');
84
+ });
85
+
86
+ it('should have required markers', () => {
87
+ const onMarker = englishProfile.markers.find(m => m.form === 'on');
88
+ expect(onMarker).toBeDefined();
89
+ expect(onMarker?.role).toBe('event');
90
+ expect(onMarker?.required).toBe(true);
91
+ });
92
+ });
93
+
94
+ describe('Japanese Profile', () => {
95
+ it('should have SOV word order', () => {
96
+ expect(japaneseProfile.wordOrder).toBe('SOV');
97
+ });
98
+
99
+ it('should use postpositions', () => {
100
+ expect(japaneseProfile.adpositionType).toBe('postposition');
101
+ });
102
+
103
+ it('should have particle markers', () => {
104
+ const woMarker = japaneseProfile.markers.find(m => m.form === 'を');
105
+ expect(woMarker).toBeDefined();
106
+ expect(woMarker?.role).toBe('patient');
107
+ expect(woMarker?.position).toBe('postposition');
108
+ });
109
+
110
+ it('should place patient before action in canonical order', () => {
111
+ const patientIndex = japaneseProfile.canonicalOrder.indexOf('patient');
112
+ const actionIndex = japaneseProfile.canonicalOrder.indexOf('action');
113
+ expect(patientIndex).toBeLessThan(actionIndex);
114
+ });
115
+ });
116
+
117
+ describe('Arabic Profile', () => {
118
+ it('should have VSO word order', () => {
119
+ expect(arabicProfile.wordOrder).toBe('VSO');
120
+ });
121
+
122
+ it('should be RTL', () => {
123
+ expect(arabicProfile.direction).toBe('rtl');
124
+ });
125
+
126
+ it('should place action first in canonical order', () => {
127
+ expect(arabicProfile.canonicalOrder[0]).toBe('action');
128
+ });
129
+ });
130
+
131
+ describe('Chinese Profile', () => {
132
+ it('should have isolating morphology', () => {
133
+ expect(chineseProfile.morphology).toBe('isolating');
134
+ });
135
+
136
+ it('should have circumfix markers for events', () => {
137
+ const eventMarkers = chineseProfile.markers.filter(m => m.role === 'event');
138
+ const hasPreposition = eventMarkers.some(m => m.position === 'preposition');
139
+ const hasPostposition = eventMarkers.some(m => m.position === 'postposition');
140
+ expect(hasPreposition).toBe(true);
141
+ expect(hasPostposition).toBe(true);
142
+ });
143
+ });
144
+ });
145
+
146
+ // =============================================================================
147
+ // Statement Parsing Tests
148
+ // =============================================================================
149
+
150
+ describe('Role Transformation', () => {
151
+ describe('reorderRoles', () => {
152
+ it('should reorder roles according to target order', () => {
153
+ const roles = new Map<SemanticRole, ParsedElement>([
154
+ ['action', { role: 'action', value: 'increment' }],
155
+ ['patient', { role: 'patient', value: '#count' }],
156
+ ['event', { role: 'event', value: 'click' }],
157
+ ]);
158
+
159
+ // Japanese order: patient, event, action
160
+ const reordered = reorderRoles(roles, ['patient', 'event', 'action']);
161
+
162
+ expect(reordered[0].role).toBe('patient');
163
+ expect(reordered[1].role).toBe('event');
164
+ expect(reordered[2].role).toBe('action');
165
+ });
166
+
167
+ it('should skip roles not present in input', () => {
168
+ const roles = new Map<SemanticRole, ParsedElement>([
169
+ ['action', { role: 'action', value: 'toggle' }],
170
+ ['patient', { role: 'patient', value: '.active' }],
171
+ ]);
172
+
173
+ const reordered = reorderRoles(roles, ['patient', 'destination', 'action']);
174
+
175
+ expect(reordered.length).toBe(2);
176
+ expect(reordered[0].role).toBe('patient');
177
+ expect(reordered[1].role).toBe('action');
178
+ });
179
+ });
180
+
181
+ describe('insertMarkers', () => {
182
+ it('should insert preposition markers before elements', () => {
183
+ const elements: ParsedElement[] = [
184
+ { role: 'destination', value: '#output', translated: '#output' },
185
+ ];
186
+ const markers = [
187
+ {
188
+ form: 'to',
189
+ role: 'destination' as SemanticRole,
190
+ position: 'preposition' as const,
191
+ required: false,
192
+ },
193
+ ];
194
+
195
+ const result = insertMarkers(elements, markers, 'preposition');
196
+ expect(result).toEqual(['to', '#output']);
197
+ });
198
+
199
+ it('should insert postposition markers after elements', () => {
200
+ const elements: ParsedElement[] = [
201
+ { role: 'patient', value: '#count', translated: '#count' },
202
+ ];
203
+ const markers = [
204
+ {
205
+ form: 'を',
206
+ role: 'patient' as SemanticRole,
207
+ position: 'postposition' as const,
208
+ required: true,
209
+ },
210
+ ];
211
+
212
+ const result = insertMarkers(elements, markers, 'postposition');
213
+ expect(result).toEqual(['#count', 'を']);
214
+ });
215
+
216
+ it('should use translated values when available', () => {
217
+ const elements: ParsedElement[] = [
218
+ { role: 'action', value: 'increment', translated: '増加' },
219
+ ];
220
+
221
+ const result = insertMarkers(elements, [], 'none');
222
+ expect(result).toEqual(['増加']);
223
+ });
224
+ });
225
+
226
+ describe('joinTokens', () => {
227
+ it('should join regular tokens with spaces', () => {
228
+ const result = joinTokens(['hello', 'world']);
229
+ expect(result).toBe('hello world');
230
+ });
231
+
232
+ it('should handle empty array', () => {
233
+ const result = joinTokens([]);
234
+ expect(result).toBe('');
235
+ });
236
+
237
+ it('should handle single token', () => {
238
+ const result = joinTokens(['hello']);
239
+ expect(result).toBe('hello');
240
+ });
241
+
242
+ it('should attach suffix markers without space (Quechua -ta)', () => {
243
+ // #count + -ta → #countta
244
+ const result = joinTokens(['#count', '-ta']);
245
+ expect(result).toBe('#countta');
246
+ });
247
+
248
+ it('should attach prefix markers without space (Arabic بـ-)', () => {
249
+ // بـ- + الماوس → بـالماوس
250
+ const result = joinTokens(['بـ-', 'الماوس']);
251
+ expect(result).toBe('بـالماوس');
252
+ });
253
+
254
+ it('should handle multiple suffix markers (Turkish case suffixes)', () => {
255
+ // value + -i + another → valuei another
256
+ const result = joinTokens(['value', '-i', 'another']);
257
+ expect(result).toBe('valuei another');
258
+ });
259
+
260
+ it('should handle Japanese particles with normal spacing', () => {
261
+ // Japanese particles don't use hyphen notation, so they get spaces
262
+ const result = joinTokens(['#count', 'を', 'クリック', 'で', '増加']);
263
+ expect(result).toBe('#count を クリック で 増加');
264
+ });
265
+
266
+ it('should handle Quechua agglutinative chain', () => {
267
+ // #count + -ta + click + -pi + increment
268
+ const result = joinTokens(['#count', '-ta', 'click', '-pi', 'increment']);
269
+ expect(result).toBe('#countta clickpi increment');
270
+ });
271
+
272
+ it('should handle mixed prefix and regular tokens', () => {
273
+ const result = joinTokens(['كـ-', 'JSON', 'format']);
274
+ expect(result).toBe('كـJSON format');
275
+ });
276
+ });
277
+ });
278
+
279
+ // =============================================================================
280
+ // Grammar Transformer Tests
281
+ // =============================================================================
282
+
283
+ describe('Universal Patterns', () => {
284
+ it('should define event-increment pattern', () => {
285
+ const pattern = UNIVERSAL_PATTERNS.eventIncrement;
286
+ expect(pattern.name).toBe('event-increment');
287
+ expect(pattern.roles).toContain('event');
288
+ expect(pattern.roles).toContain('action');
289
+ expect(pattern.roles).toContain('patient');
290
+ });
291
+
292
+ it('should define put-into pattern', () => {
293
+ const pattern = UNIVERSAL_PATTERNS.putInto;
294
+ expect(pattern.name).toBe('put-into');
295
+ expect(pattern.roles).toContain('action');
296
+ expect(pattern.roles).toContain('patient');
297
+ expect(pattern.roles).toContain('destination');
298
+ });
299
+
300
+ it('should define wait-duration pattern', () => {
301
+ const pattern = UNIVERSAL_PATTERNS.waitDuration;
302
+ expect(pattern.roles).toContain('action');
303
+ expect(pattern.roles).toContain('quantity');
304
+ });
305
+ });
306
+
307
+ // =============================================================================
308
+ // Language Family Defaults Tests
309
+ // =============================================================================
310
+
311
+ describe('Language Family Defaults', () => {
312
+ it('should have Germanic defaults', () => {
313
+ const germanic = LANGUAGE_FAMILY_DEFAULTS.germanic;
314
+ expect(germanic.wordOrder).toBe('SVO');
315
+ expect(germanic.adpositionType).toBe('preposition');
316
+ });
317
+
318
+ it('should have Japonic defaults', () => {
319
+ const japonic = LANGUAGE_FAMILY_DEFAULTS.japonic;
320
+ expect(japonic.wordOrder).toBe('SOV');
321
+ expect(japonic.adpositionType).toBe('postposition');
322
+ });
323
+
324
+ it('should have Semitic defaults', () => {
325
+ const semitic = LANGUAGE_FAMILY_DEFAULTS.semitic;
326
+ expect(semitic.wordOrder).toBe('VSO');
327
+ expect(semitic.direction).toBe('rtl');
328
+ });
329
+
330
+ it('should have Sinitic defaults', () => {
331
+ const sinitic = LANGUAGE_FAMILY_DEFAULTS.sinitic;
332
+ expect(sinitic.morphology).toBe('isolating');
333
+ });
334
+ });
335
+
336
+ // =============================================================================
337
+ // Examples Tests
338
+ // =============================================================================
339
+
340
+ describe('Has/Have Operator Translations', () => {
341
+ describe('Dictionary Entries', () => {
342
+ // Import dictionaries to verify has/have entries exist
343
+ it('should have has/have in English dictionary', async () => {
344
+ const { en } = await import('../dictionaries/en');
345
+ expect(en.logical.has).toBe('has');
346
+ expect(en.logical.have).toBe('have');
347
+ });
348
+
349
+ it('should have has/have in Spanish dictionary', async () => {
350
+ const { es } = await import('../dictionaries/es');
351
+ expect(es.logical.has).toBe('tiene'); // third-person
352
+ expect(es.logical.have).toBe('tengo'); // first-person
353
+ });
354
+
355
+ it('should have has/have in Japanese dictionary', async () => {
356
+ const { ja } = await import('../dictionaries/ja');
357
+ expect(ja.logical.has).toBe('ある');
358
+ expect(ja.logical.have).toBe('ある');
359
+ });
360
+
361
+ it('should have has/have in German dictionary', async () => {
362
+ const { de } = await import('../dictionaries/de');
363
+ expect(de.logical.has).toBe('hat'); // third-person
364
+ expect(de.logical.have).toBe('habe'); // first-person
365
+ });
366
+
367
+ it('should have has/have in French dictionary', async () => {
368
+ const { fr } = await import('../dictionaries/fr');
369
+ expect(fr.logical.has).toBe('a'); // third-person
370
+ expect(fr.logical.have).toBe('ai'); // first-person
371
+ });
372
+
373
+ it('should have has/have in Korean dictionary', async () => {
374
+ const { ko } = await import('../dictionaries/ko');
375
+ expect(ko.logical.has).toBe('있다');
376
+ expect(ko.logical.have).toBe('있다');
377
+ });
378
+
379
+ it('should have has/have in Chinese dictionary', async () => {
380
+ const { zh } = await import('../dictionaries/zh');
381
+ expect(zh.logical.has).toBe('有');
382
+ expect(zh.logical.have).toBe('有');
383
+ });
384
+
385
+ it('should have has/have in Arabic dictionary', async () => {
386
+ const { ar } = await import('../dictionaries/ar');
387
+ expect(ar.logical.has).toBe('لديه'); // third-person
388
+ expect(ar.logical.have).toBe('لدي'); // first-person
389
+ });
390
+ });
391
+
392
+ describe('Conjugating Languages', () => {
393
+ // Languages that have different forms for has (3rd person) vs have (1st person)
394
+ const conjugatingLanguages = [
395
+ { code: 'es', has: 'tiene', have: 'tengo' },
396
+ { code: 'de', has: 'hat', have: 'habe' },
397
+ { code: 'fr', has: 'a', have: 'ai' },
398
+ { code: 'pt', has: 'tem', have: 'tenho' },
399
+ { code: 'it', has: 'ha', have: 'ho' },
400
+ { code: 'pl', has: 'ma', have: 'mam' },
401
+ ];
402
+
403
+ for (const lang of conjugatingLanguages) {
404
+ it(`should have different has/have forms in ${lang.code}`, async () => {
405
+ const dict = await import(`../dictionaries/${lang.code}`);
406
+ const dictionary = Object.values(dict)[0] as { logical: { has: string; have: string } };
407
+ expect(dictionary.logical.has).toBe(lang.has);
408
+ expect(dictionary.logical.have).toBe(lang.have);
409
+ });
410
+ }
411
+ });
412
+
413
+ describe('Non-Conjugating Languages', () => {
414
+ // Languages that use the same form for both has and have
415
+ const sameFormLanguages = [
416
+ { code: 'ja', form: 'ある' },
417
+ { code: 'ko', form: '있다' },
418
+ { code: 'zh', form: '有' },
419
+ { code: 'tr', form: 'var' },
420
+ { code: 'id', form: 'punya' },
421
+ { code: 'vi', form: 'có' },
422
+ { code: 'th', form: 'มี' },
423
+ { code: 'tl', form: 'may' },
424
+ { code: 'ms', form: 'ada' },
425
+ ];
426
+
427
+ for (const lang of sameFormLanguages) {
428
+ it(`should have same has/have form in ${lang.code}`, async () => {
429
+ const dict = await import(`../dictionaries/${lang.code}`);
430
+ const dictionary = Object.values(dict)[0] as { logical: { has: string; have: string } };
431
+ expect(dictionary.logical.has).toBe(lang.form);
432
+ expect(dictionary.logical.have).toBe(lang.form);
433
+ });
434
+ }
435
+ });
436
+ });
437
+
438
+ // =============================================================================
439
+ // Possessive Dot Notation Translation Tests
440
+ // =============================================================================
package/src/index.ts CHANGED
@@ -223,11 +223,4 @@ export {
223
223
  malayProfile,
224
224
  quechuaProfile,
225
225
  swahiliProfile,
226
- // Transformer
227
- GrammarTransformer,
228
- parseStatement,
229
- toLocale,
230
- toEnglish,
231
- translate,
232
- examples as grammarExamples,
233
226
  } from './grammar';
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Profile `lexicon` ↔ i18n Dictionary parity
3
+ *
4
+ * The semantic profile gained a `lexicon` block (2026-08-26) carrying the six
5
+ * Dictionary categories it never modelled — the vocabulary that appears INSIDE
6
+ * role values (`true`, `value`, `seconds`) rather than as a command verb or a
7
+ * role marker. The renderer needs it: without it, `set @disabled to true`
8
+ * renders into Spanish as `… a true` instead of `… a verdadero`, the Spanish
9
+ * parser cannot bind the value back, and the role is silently dropped.
10
+ *
11
+ * This test is the oracle for that move: every entry in the hand-authored
12
+ * dictionary must be reachable from the profile with the SAME rendering. It
13
+ * lives in @lokascript/i18n rather than @lokascript/semantic because semantic
14
+ * builds first and cannot import the dictionaries.
15
+ *
16
+ * Direction of authority: the dictionary is the incumbent, so it wins. A
17
+ * mismatch here means the profile drifted from a curated translation, not that
18
+ * the dictionary is stale.
19
+ *
20
+ * Nothing is deleted while this passes — the dictionaries remain the source for
21
+ * i18n's own consumers. It exists so a later derive-flip can be made safely.
22
+ */
23
+ import { describe, it, expect } from 'vitest';
24
+ import { dictionaries } from './dictionaries';
25
+ import { KNOWN_PROFILES, getLexicon } from '@lokascript/semantic';
26
+ import type { LanguageProfile } from '@lokascript/semantic';
27
+ // The lexicons are separate modules so a parse-only consumer can drop them; the
28
+ // package's main entry pulls all 24 in (via `languages/_all`), which is what the
29
+ // import above already gives us. A consumer taking only `core` + `languages/{code}`
30
+ // would get none — that is the point of the split.
31
+
32
+ const CATEGORIES = [
33
+ 'events',
34
+ 'logical',
35
+ 'temporal',
36
+ 'values',
37
+ 'attributes',
38
+ 'expressions',
39
+ ] as const;
40
+
41
+ const profiles = KNOWN_PROFILES as Readonly<Record<string, LanguageProfile>>;
42
+ const codes = Object.keys(dictionaries).filter(c => profiles[c]);
43
+
44
+ describe('profile.lexicon ↔ i18n dictionary parity', () => {
45
+ it('covers every language that has both a dictionary and a profile', () => {
46
+ expect(codes.length).toBe(24);
47
+ });
48
+
49
+ describe.each(codes)('%s', code => {
50
+ const dict = dictionaries[code] as unknown as Record<string, Record<string, string>>;
51
+ const lexicon = (getLexicon(code) ?? {}) as Record<string, Record<string, { primary: string }>>;
52
+
53
+ it('has registered render vocabulary', () => {
54
+ expect(getLexicon(code), `${code} has no registered lexicon`).toBeDefined();
55
+ });
56
+
57
+ for (const category of CATEGORIES) {
58
+ const entries = dict[category] ?? {};
59
+ const keys = Object.keys(entries);
60
+ if (keys.length === 0) continue;
61
+
62
+ it(`${category}: all ${keys.length} dictionary entries are present with the same rendering`, () => {
63
+ const missing: string[] = [];
64
+ const diverged: string[] = [];
65
+ for (const key of keys) {
66
+ const onProfile = lexicon[category]?.[key];
67
+ if (!onProfile) {
68
+ missing.push(key);
69
+ } else if (onProfile.primary !== entries[key]) {
70
+ diverged.push(`${key}: profile=${onProfile.primary} dictionary=${entries[key]}`);
71
+ }
72
+ }
73
+ expect({ missing, diverged }).toEqual({ missing: [], diverged: [] });
74
+ });
75
+ }
76
+ });
77
+ });