@lokascript/i18n 2.3.0 → 2.4.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 (71) hide show
  1. package/dist/browser.cjs +626 -473
  2. package/dist/browser.cjs.map +1 -1
  3. package/dist/browser.d.cts +25 -3
  4. package/dist/browser.d.ts +25 -3
  5. package/dist/browser.js +623 -474
  6. package/dist/browser.js.map +1 -1
  7. package/dist/dictionaries/index.cjs +489 -472
  8. package/dist/dictionaries/index.cjs.map +1 -1
  9. package/dist/dictionaries/index.d.cts +5 -3
  10. package/dist/dictionaries/index.d.ts +5 -3
  11. package/dist/dictionaries/index.js +489 -473
  12. package/dist/dictionaries/index.js.map +1 -1
  13. package/dist/index.cjs +617 -473
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +37 -37
  16. package/dist/index.d.ts +37 -37
  17. package/dist/index.js +617 -473
  18. package/dist/index.js.map +1 -1
  19. package/dist/lokascript-i18n.min.js +1 -1
  20. package/dist/lokascript-i18n.min.js.map +1 -1
  21. package/dist/lokascript-i18n.mjs +730 -478
  22. package/dist/lokascript-i18n.mjs.map +1 -1
  23. package/dist/plugins/vite.cjs +488 -472
  24. package/dist/plugins/vite.cjs.map +1 -1
  25. package/dist/plugins/vite.js +488 -472
  26. package/dist/plugins/vite.js.map +1 -1
  27. package/dist/plugins/webpack.cjs +488 -472
  28. package/dist/plugins/webpack.cjs.map +1 -1
  29. package/dist/plugins/webpack.js +488 -472
  30. package/dist/plugins/webpack.js.map +1 -1
  31. package/dist/{transformer-B0zy-kti.d.ts → transformer-BLv389qz.d.ts} +12 -47
  32. package/dist/{transformer-CzY_vHU0.d.cts → transformer-PFsYELrc.d.cts} +12 -47
  33. package/dist/{types-BYtpqGq3.d.cts → types-BcALAO6h.d.cts} +1 -1
  34. package/dist/{types-BYtpqGq3.d.ts → types-BcALAO6h.d.ts} +1 -1
  35. package/package.json +5 -5
  36. package/src/browser.ts +3 -0
  37. package/src/constants.ts +9 -0
  38. package/src/dictionaries/ar.ts +22 -43
  39. package/src/dictionaries/bn.ts +17 -0
  40. package/src/dictionaries/de.ts +19 -40
  41. package/src/dictionaries/en.ts +26 -0
  42. package/src/dictionaries/es.ts +19 -40
  43. package/src/dictionaries/fr.ts +19 -40
  44. package/src/dictionaries/he.ts +2 -0
  45. package/src/dictionaries/hi.ts +18 -48
  46. package/src/dictionaries/id.ts +22 -43
  47. package/src/dictionaries/index.ts +5 -1
  48. package/src/dictionaries/it.ts +18 -39
  49. package/src/dictionaries/ja.ts +18 -39
  50. package/src/dictionaries/ko.ts +22 -43
  51. package/src/dictionaries/ms.ts +16 -50
  52. package/src/dictionaries/pl.ts +18 -45
  53. package/src/dictionaries/pt.ts +23 -48
  54. package/src/dictionaries/qu.ts +22 -43
  55. package/src/dictionaries/ru.ts +18 -48
  56. package/src/dictionaries/sw.ts +22 -43
  57. package/src/dictionaries/th.ts +17 -0
  58. package/src/dictionaries/tl.ts +16 -50
  59. package/src/dictionaries/tr.ts +24 -45
  60. package/src/dictionaries/uk.ts +18 -48
  61. package/src/dictionaries/vi.ts +17 -0
  62. package/src/dictionaries/zh.ts +22 -43
  63. package/src/grammar/grammar.test.ts +143 -24
  64. package/src/grammar/index.ts +1 -0
  65. package/src/grammar/profiles/index.ts +31 -0
  66. package/src/grammar/transformer.ts +161 -0
  67. package/src/parser/he.ts +29 -0
  68. package/src/parser/index.ts +1 -0
  69. package/src/schema-alignment.test.ts +154 -0
  70. package/src/test-utils/complete-languages.ts +40 -0
  71. package/src/translation-validation.test.ts +1 -26
@@ -62,6 +62,101 @@ function getCommandKeywordsForLocale(locale: string): Set<string> {
62
62
  * Example: "wait 2s toggle .highlight"
63
63
  * Returns: ["wait 2s", "toggle .highlight"]
64
64
  */
65
+ /**
66
+ * A reactive-block structure pulled apart by `extractBlockStructure`.
67
+ * Block-syntactic tokens (head, optional condition expression, optional
68
+ * connector, optional `end` tail) live outside the inner body so the
69
+ * body can be transformed by the regular pipeline (SOV reordering,
70
+ * possessives, etc.) without having block delimiters dragged into role
71
+ * values or moved by the canonical-order reorder.
72
+ */
73
+ interface BlockStructure {
74
+ headKeyword: string;
75
+ /** Condition expression for `when X changes Y` / `unless X Y`. */
76
+ prefixExpr?: string;
77
+ /** `changes` for `when`; undefined for `live`/`unless`. */
78
+ connector?: string;
79
+ body: string;
80
+ /** `end` if the source had one; undefined otherwise. */
81
+ tailKeyword?: string;
82
+ }
83
+
84
+ /**
85
+ * Detect a reactive block (`live ... end`, `when X changes Y [end]`,
86
+ * `unless X Y [end]`) and decompose it. Returns `null` when the input
87
+ * is not a reactive block, when there's content after the matched
88
+ * `end`, or when the heuristic can't locate a body — all of which fall
89
+ * through to the standard `parseStatement` path.
90
+ */
91
+ function extractBlockStructure(input: string, sourceLocale: string): BlockStructure | null {
92
+ const tokens = input.split(/\s+/);
93
+ const head = tokens[0]?.toLowerCase();
94
+ if (!head || !BLOCK_HEAD_KEYWORDS.has(head)) return null;
95
+
96
+ // Depth-aware match for the closing `end` so nested blocks
97
+ // (`live when X changes Y end end`) slice correctly.
98
+ let depth = 1;
99
+ let endIdx = -1;
100
+ for (let i = 1; i < tokens.length; i++) {
101
+ const t = tokens[i].toLowerCase();
102
+ if (BLOCK_HEAD_KEYWORDS.has(t)) depth++;
103
+ else if (t === 'end') {
104
+ depth--;
105
+ if (depth === 0) {
106
+ endIdx = i;
107
+ break;
108
+ }
109
+ }
110
+ }
111
+
112
+ // If there's trailing content after the matched `end`, bail out and
113
+ // let the existing splitter handle it. (`splitOnThen` normally
114
+ // separates trailing code before we get here.)
115
+ if (endIdx !== -1 && endIdx !== tokens.length - 1) return null;
116
+
117
+ const inner = endIdx !== -1 ? tokens.slice(1, endIdx) : tokens.slice(1);
118
+ const base: BlockStructure = { headKeyword: tokens[0], body: '' };
119
+ if (endIdx !== -1) base.tailKeyword = tokens[endIdx];
120
+
121
+ if (head === 'live') {
122
+ return { ...base, body: inner.join(' ') };
123
+ }
124
+
125
+ if (head === 'when') {
126
+ // Reactive: `when <expr> changes <body>`. Without `changes`, fall
127
+ // through to the standard event-wait path (parseConditional).
128
+ const idx = inner.findIndex(t => t.toLowerCase() === 'changes');
129
+ if (idx >= 0) {
130
+ return {
131
+ ...base,
132
+ prefixExpr: inner.slice(0, idx).join(' '),
133
+ connector: inner[idx],
134
+ body: inner.slice(idx + 1).join(' '),
135
+ };
136
+ }
137
+ return null;
138
+ }
139
+
140
+ // `unless <cond> <body>`: condition runs up to the first command
141
+ // keyword in `inner`. Heuristic — works because hyperscript bodies
142
+ // always start with a command verb, and `unless` conditions rarely
143
+ // contain bare command keywords as values.
144
+ const commands = getCommandKeywordsForLocale(sourceLocale);
145
+ let bodyStart = -1;
146
+ for (let i = 0; i < inner.length; i++) {
147
+ if (commands.has(inner[i].toLowerCase())) {
148
+ bodyStart = i;
149
+ break;
150
+ }
151
+ }
152
+ if (bodyStart <= 0) return null;
153
+ return {
154
+ ...base,
155
+ prefixExpr: inner.slice(0, bodyStart).join(' '),
156
+ body: inner.slice(bodyStart).join(' '),
157
+ };
158
+ }
159
+
65
160
  function splitCompoundStatement(input: string, sourceLocale: string): string[] {
66
161
  // First, split on newlines (preserving non-empty lines)
67
162
  const lines = input
@@ -265,6 +360,19 @@ const BOUNDARY_MODIFIERS = new Set([
265
360
  'over',
266
361
  ]);
267
362
 
363
+ /**
364
+ * Block-introducing keywords whose body should not be split at command
365
+ * boundaries by `splitOnCommandBoundaries`. Inputs starting with one of
366
+ * these are also routed around `parseStatement` entirely by
367
+ * `extractBlockStructure` + `transformBlock` so block-syntactic tokens
368
+ * never reach `parseCommand`/`parseConditional` (where they'd be
369
+ * misinterpreted as command verbs or swept into role values).
370
+ *
371
+ * `if` deliberately stays out: `if X then Y end` already works via the
372
+ * `splitOnThen` + `parseConditional` path.
373
+ */
374
+ const BLOCK_HEAD_KEYWORDS = new Set(['live', 'when', 'unless']);
375
+
268
376
  function splitOnCommandBoundaries(input: string, sourceLocale: string): string[] {
269
377
  const commandKeywords = getCommandKeywordsForLocale(sourceLocale);
270
378
  const tokens = input.split(/\s+/);
@@ -282,10 +390,23 @@ function splitOnCommandBoundaries(input: string, sourceLocale: string): string[]
282
390
  // So we need to track whether we've seen the first command yet
283
391
  let seenFirstCommand = !isEventHandler; // If not event handler, we're already past the "first command" phase
284
392
 
393
+ // Track block-scope depth (live/when/bind/if/unless/for/while/...). While
394
+ // inside a block, do not split on command boundaries — the body belongs
395
+ // to the block head and must transform as one unit. See comments on
396
+ // BLOCK_HEAD_KEYWORDS for the failure mode this prevents.
397
+ let blockDepth = 0;
398
+
285
399
  for (let i = 0; i < tokens.length; i++) {
286
400
  const token = tokens[i];
287
401
  const lowerToken = token.toLowerCase();
288
402
 
403
+ // Update block-scope depth before any split decision.
404
+ if (BLOCK_HEAD_KEYWORDS.has(lowerToken)) {
405
+ blockDepth++;
406
+ } else if (lowerToken === 'end' && blockDepth > 0) {
407
+ blockDepth--;
408
+ }
409
+
289
410
  // If this is a command keyword and we already have tokens in current part
290
411
  if (commandKeywords.has(lowerToken) && currentPart.length > 0) {
291
412
  // Check if the previous token looks like it could end a command
@@ -301,6 +422,13 @@ function splitOnCommandBoundaries(input: string, sourceLocale: string): string[]
301
422
  continue;
302
423
  }
303
424
 
425
+ // Don't split inside a block (live/when/bind/unless body, etc.).
426
+ // The block head and its body must transform as one statement.
427
+ if (blockDepth > 0) {
428
+ currentPart.push(token);
429
+ continue;
430
+ }
431
+
304
432
  if (!BOUNDARY_MODIFIERS.has(prevLower) && !commandKeywords.has(prevLower)) {
305
433
  // This looks like a command boundary - save current part and start new one
306
434
  parts.push(currentPart.join(' '));
@@ -1165,6 +1293,15 @@ export class GrammarTransformer {
1165
1293
  * Transform a single hyperscript statement (no compound "then" chains).
1166
1294
  */
1167
1295
  private transformSingle(input: string): string {
1296
+ // 0. Reactive block? Route around parseStatement entirely so
1297
+ // block-syntactic tokens (live/when/unless/end) aren't treated
1298
+ // as command verbs or swept into role values, and so SOV/VSO
1299
+ // reorder applies only inside the body.
1300
+ const block = extractBlockStructure(input, this.sourceProfile.code);
1301
+ if (block) {
1302
+ return this.transformBlock(block);
1303
+ }
1304
+
1168
1305
  // 1. Parse into semantic roles
1169
1306
  const parsed = parseStatement(input, this.sourceProfile.code);
1170
1307
  if (!parsed) {
@@ -1202,6 +1339,30 @@ export class GrammarTransformer {
1202
1339
  return joinTokens(reordered.map(e => e.translated || e.value));
1203
1340
  }
1204
1341
 
1342
+ /**
1343
+ * Translate a reactive block by translating the head/tail/connector
1344
+ * via the dictionary, recursively transforming the body through the
1345
+ * regular pipeline, and rejoining in source-language position order.
1346
+ * Block-syntactic tokens are never reordered: they're delimiters, not
1347
+ * arguments, and authors expect them at start/end positions
1348
+ * regardless of target word order.
1349
+ */
1350
+ private transformBlock(block: BlockStructure): string {
1351
+ const src = this.sourceProfile.code;
1352
+ const dst = this.targetProfile.code;
1353
+
1354
+ const head = translateWord(block.headKeyword, src, dst);
1355
+ const tail = block.tailKeyword ? translateWord(block.tailKeyword, src, dst) : '';
1356
+ const connector = block.connector ? translateWord(block.connector, src, dst) : '';
1357
+ const prefix = block.prefixExpr ? translateMultiWordValue(block.prefixExpr, src, dst) : '';
1358
+
1359
+ // Recurse through `transform()` (not `transformSingle`) so the body
1360
+ // gets `then`-splitting and nested-block handling for free.
1361
+ const body = this.transform(block.body);
1362
+
1363
+ return [head, prefix, connector, body, tail].filter(s => s.length > 0).join(' ');
1364
+ }
1365
+
1205
1366
  /**
1206
1367
  * Find the best matching rule for this statement
1207
1368
  */
@@ -0,0 +1,29 @@
1
+ // packages/i18n/src/parser/he.ts
2
+
3
+ import { he } from '../dictionaries/he';
4
+ import { createKeywordProvider } from './create-provider';
5
+ import type { KeywordProvider } from './types';
6
+
7
+ /**
8
+ * Hebrew keyword provider for the hyperscript parser.
9
+ *
10
+ * Enables parsing hyperscript written in Hebrew (RTL, SVO):
11
+ * - `ב לחיצה מתג .active` → parses as `on click toggle .active`
12
+ *
13
+ * English keywords are also accepted (mixed mode).
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * import { heKeywords } from '@lokascript/i18n/parser/he';
18
+ * import { Parser } from '@hyperfixi/core';
19
+ *
20
+ * const parser = new Parser({ keywords: heKeywords });
21
+ * parser.parse('ב לחיצה מתג .active');
22
+ * ```
23
+ */
24
+ export const heKeywords: KeywordProvider = createKeywordProvider(he, 'he', {
25
+ allowEnglishFallback: true,
26
+ });
27
+
28
+ // Re-export for convenience
29
+ export { he as heDictionary } from '../dictionaries/he';
@@ -55,6 +55,7 @@ export { bnKeywords, bnDictionary } from './bn';
55
55
  export { thKeywords, thDictionary } from './th';
56
56
  export { msKeywords, msDictionary } from './ms';
57
57
  export { tlKeywords, tlDictionary } from './tl';
58
+ export { heKeywords, heDictionary } from './he';
58
59
 
59
60
  // Locale management
60
61
  export { LocaleManager, detectBrowserLocale } from './locale-manager';
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Schema ↔ i18n Alignment Tests
3
+ *
4
+ * Verifies that every CommandSchema exported by @lokascript/semantic has a
5
+ * matching English dictionary entry in en.ts's `commands` category, and that
6
+ * every COMPLETE language dictionary covers the same set of command keys as
7
+ * English.
8
+ *
9
+ * Catches drift going forward: adding a new schema without updating en.ts,
10
+ * or adding commands to en.ts without propagating via the semantic profile
11
+ * generator.
12
+ */
13
+
14
+ import { describe, it, expect } from 'vitest';
15
+ import { commandSchemas } from '@lokascript/semantic';
16
+ import { dictionaries } from './dictionaries';
17
+ import type { Dictionary } from './types';
18
+ import { DICTIONARY_CATEGORIES } from './types';
19
+ import { COMPLETE_LANGUAGES } from './test-utils/complete-languages';
20
+
21
+ /**
22
+ * Dictionaries store each English keyword once in whichever category it
23
+ * primarily belongs to — `focus`/`blur` end up in `events`, `empty` in
24
+ * `expressions`. This helper looks up a keyword across all categories.
25
+ */
26
+ function findInDictionary(dict: Dictionary, keyword: string): string | undefined {
27
+ for (const cat of DICTIONARY_CATEGORIES) {
28
+ const v = dict[cat]?.[keyword];
29
+ if (typeof v === 'string' && v.length > 0) return v;
30
+ }
31
+ return undefined;
32
+ }
33
+
34
+ // Actions that don't need a user-facing keyword translation at command
35
+ // position. They're either AST meta-nodes (`compound`) or definition-time
36
+ // feature keywords (`init`, `behavior`) handled elsewhere in the parser.
37
+ const NON_USER_FACING_ACTIONS = new Set(['compound', 'init', 'behavior']);
38
+
39
+ // Event handler 'on' is a top-level feature, not a command the user writes at
40
+ // command position in the same way. It's handled specially by the parser.
41
+ // Still, it has an i18n entry under `commands` in en.ts.
42
+
43
+ describe('Schema ↔ i18n alignment', () => {
44
+ const schemaActions = Object.keys(commandSchemas).filter(
45
+ action => !NON_USER_FACING_ACTIONS.has(action)
46
+ );
47
+
48
+ describe('every command schema has an English dictionary entry', () => {
49
+ const en = dictionaries.en as Dictionary;
50
+ schemaActions.forEach(action => {
51
+ it(`en dictionary contains '${action}'`, () => {
52
+ const value = findInDictionary(en, action);
53
+ expect(
54
+ value,
55
+ `CommandSchema '${action}' is exported from @lokascript/semantic but has no en.ts dictionary entry in any category. Add it to packages/i18n/src/dictionaries/en.ts and run \`npm run generate:language-assets\`.`
56
+ ).toBeDefined();
57
+ });
58
+ });
59
+ });
60
+
61
+ describe('Phase 1 commands (v0.9.90) are present in all complete languages', () => {
62
+ const PHASE_1_COMMANDS = [
63
+ 'focus',
64
+ 'blur',
65
+ 'empty',
66
+ 'open',
67
+ 'close',
68
+ 'select',
69
+ 'clear',
70
+ 'reset',
71
+ 'breakpoint',
72
+ ] as const;
73
+
74
+ COMPLETE_LANGUAGES.forEach(code => {
75
+ const dict = dictionaries[code] as Dictionary | undefined;
76
+ if (!dict) return;
77
+
78
+ it(`${code}: has all 9 Phase 1 commands (any category)`, () => {
79
+ const missing: string[] = [];
80
+ for (const cmd of PHASE_1_COMMANDS) {
81
+ if (!findInDictionary(dict, cmd)) {
82
+ missing.push(cmd);
83
+ }
84
+ }
85
+ expect(
86
+ missing,
87
+ `Missing Phase 1 commands in ${code}: ${missing.join(', ')}. ` +
88
+ `Fix by adding to packages/semantic/src/generators/profiles/${code}.ts ` +
89
+ `and running \`npm run generate:language-assets\`.`
90
+ ).toEqual([]);
91
+ });
92
+ });
93
+ });
94
+
95
+ describe('Phase 2 comparators (v0.9.90) are present in all complete languages', () => {
96
+ // Expression operators wired into the core Pratt parser. Unlike Phase 1
97
+ // commands, these live in the hand-written `expressions` category (or
98
+ // `modifiers` for `between`) rather than deriving from semantic profiles.
99
+ const PHASE_2_COMPARATORS = ['starts with', 'ends with', 'between', 'ignoring case'] as const;
100
+
101
+ COMPLETE_LANGUAGES.forEach(code => {
102
+ const dict = dictionaries[code] as Dictionary | undefined;
103
+ if (!dict) return;
104
+
105
+ it(`${code}: has all Phase 2 comparators (any category)`, () => {
106
+ const missing: string[] = [];
107
+ for (const op of PHASE_2_COMPARATORS) {
108
+ if (!findInDictionary(dict, op)) {
109
+ missing.push(op);
110
+ }
111
+ }
112
+ expect(
113
+ missing,
114
+ `Missing Phase 2 comparators in ${code}: ${missing.join(', ')}. ` +
115
+ `Add to packages/i18n/src/dictionaries/${code}.ts's \`expressions\` ` +
116
+ `category (or \`modifiers\` for \`between\`).`
117
+ ).toEqual([]);
118
+ });
119
+ });
120
+ });
121
+
122
+ describe('Phase 3 collection ops (v0.9.90) are present in all complete languages', () => {
123
+ // Infix operators on arrays and strings — parsed as custom Pratt AST nodes
124
+ // by the core parser. `where` predates v0.9.90 and lives in `logical`; the
125
+ // four `<verb> by/to` forms live alongside `starts with` in `expressions`.
126
+ const PHASE_3_COLLECTIONS = [
127
+ 'where',
128
+ 'sorted by',
129
+ 'mapped to',
130
+ 'split by',
131
+ 'joined by',
132
+ ] as const;
133
+
134
+ COMPLETE_LANGUAGES.forEach(code => {
135
+ const dict = dictionaries[code] as Dictionary | undefined;
136
+ if (!dict) return;
137
+
138
+ it(`${code}: has all Phase 3 collection ops (any category)`, () => {
139
+ const missing: string[] = [];
140
+ for (const op of PHASE_3_COLLECTIONS) {
141
+ if (!findInDictionary(dict, op)) {
142
+ missing.push(op);
143
+ }
144
+ }
145
+ expect(
146
+ missing,
147
+ `Missing Phase 3 collection ops in ${code}: ${missing.join(', ')}. ` +
148
+ `Add to packages/i18n/src/dictionaries/${code}.ts's \`expressions\` ` +
149
+ `category (or \`logical\` for \`where\`).`
150
+ ).toEqual([]);
151
+ });
152
+ });
153
+ });
154
+ });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Languages with complete (non-placeholder) dictionaries.
3
+ *
4
+ * Shared by `translation-validation.test.ts` and `schema-alignment.test.ts`
5
+ * so a new dictionary only needs to be added in one place to enter the test
6
+ * matrix.
7
+ *
8
+ * This is intentionally distinct from `@lokascript/semantic`'s
9
+ * `SUPPORTED_LANGUAGES`: a language can be loadable by the semantic parser
10
+ * (e.g. `tr` / Turkish) yet not be "complete" by i18n's definition if its
11
+ * dictionary is missing newer Phase command/comparator entries. When a
12
+ * dictionary catches up to en.ts, add its code here to enable the full
13
+ * test sweep.
14
+ */
15
+ export const COMPLETE_LANGUAGES = [
16
+ 'en',
17
+ 'es',
18
+ 'ja',
19
+ 'ko',
20
+ 'ar',
21
+ 'id',
22
+ 'pt',
23
+ 'it',
24
+ 'vi',
25
+ 'qu',
26
+ 'sw',
27
+ 'pl',
28
+ 'ru',
29
+ 'zh',
30
+ 'hi',
31
+ 'bn',
32
+ 'de',
33
+ 'th',
34
+ 'fr',
35
+ 'uk',
36
+ 'tl',
37
+ 'ms',
38
+ ] as const;
39
+
40
+ export type CompleteLanguage = (typeof COMPLETE_LANGUAGES)[number];
@@ -8,36 +8,11 @@
8
8
  import { describe, it, expect } from 'vitest';
9
9
  import { dictionaries } from './dictionaries';
10
10
  import type { Dictionary } from './types';
11
+ import { COMPLETE_LANGUAGES } from './test-utils/complete-languages';
11
12
 
12
13
  // Focus on when/where which were the main TODO items we fixed
13
14
  const REQUIRED_LOGICAL_KEYWORDS = ['when', 'where', 'and', 'or', 'not'] as const;
14
15
 
15
- // Languages that are fully implemented (not placeholder stubs)
16
- const COMPLETE_LANGUAGES = [
17
- 'en',
18
- 'es',
19
- 'ja',
20
- 'ko',
21
- 'ar',
22
- 'id',
23
- 'pt',
24
- 'it',
25
- 'vi',
26
- 'qu',
27
- 'sw',
28
- 'pl',
29
- 'ru',
30
- 'zh',
31
- 'hi',
32
- 'bn',
33
- 'de',
34
- 'th',
35
- 'fr',
36
- 'uk',
37
- 'tl',
38
- 'ms',
39
- ] as const;
40
-
41
16
  // Languages with placeholder translations (none currently)
42
17
  const INCOMPLETE_LANGUAGES = [] as const;
43
18