@lokascript/i18n 2.5.1 → 2.6.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 (73) hide show
  1. package/dist/browser.cjs +1274 -154
  2. package/dist/browser.cjs.map +1 -1
  3. package/dist/browser.d.cts +3 -3
  4. package/dist/browser.d.ts +3 -3
  5. package/dist/browser.js +1274 -155
  6. package/dist/browser.js.map +1 -1
  7. package/dist/dictionaries/index.cjs +364 -133
  8. package/dist/dictionaries/index.cjs.map +1 -1
  9. package/dist/dictionaries/index.d.cts +1 -1
  10. package/dist/dictionaries/index.d.ts +1 -1
  11. package/dist/dictionaries/index.js +364 -133
  12. package/dist/dictionaries/index.js.map +1 -1
  13. package/dist/index.cjs +8860 -7740
  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 +8860 -7741
  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 +1697 -146
  22. package/dist/lokascript-i18n.mjs.map +1 -1
  23. package/dist/plugins/vite.cjs +359 -132
  24. package/dist/plugins/vite.cjs.map +1 -1
  25. package/dist/plugins/vite.js +359 -132
  26. package/dist/plugins/vite.js.map +1 -1
  27. package/dist/plugins/webpack.cjs +359 -132
  28. package/dist/plugins/webpack.cjs.map +1 -1
  29. package/dist/plugins/webpack.js +359 -132
  30. package/dist/plugins/webpack.js.map +1 -1
  31. package/dist/{transformer-BLv389qz.d.ts → transformer-BA8YX9u1.d.ts} +120 -2
  32. package/dist/{transformer-PFsYELrc.d.cts → transformer-DM7iplCU.d.cts} +120 -2
  33. package/dist/{types-BcALAO6h.d.cts → types-BYtpqGq3.d.cts} +1 -1
  34. package/dist/{types-BcALAO6h.d.ts → types-BYtpqGq3.d.ts} +1 -1
  35. package/package.json +6 -6
  36. package/src/browser.ts +1 -0
  37. package/src/command-primary-roles.test.ts +59 -0
  38. package/src/constants.ts +51 -0
  39. package/src/dictionaries/ar.ts +8 -3
  40. package/src/dictionaries/bn.ts +3 -1
  41. package/src/dictionaries/de.ts +19 -4
  42. package/src/dictionaries/derive.ts +4 -0
  43. package/src/dictionaries/en.ts +9 -0
  44. package/src/dictionaries/es.ts +2 -2
  45. package/src/dictionaries/fr.ts +6 -2
  46. package/src/dictionaries/he.ts +7 -1
  47. package/src/dictionaries/hi.ts +17 -10
  48. package/src/dictionaries/id.ts +15 -8
  49. package/src/dictionaries/it.ts +6 -3
  50. package/src/dictionaries/ja.ts +10 -4
  51. package/src/dictionaries/ko.ts +9 -4
  52. package/src/dictionaries/ms.ts +2 -1
  53. package/src/dictionaries/pl.ts +11 -3
  54. package/src/dictionaries/pt.ts +10 -4
  55. package/src/dictionaries/qu.ts +46 -22
  56. package/src/dictionaries/ru.ts +14 -7
  57. package/src/dictionaries/sw.ts +37 -10
  58. package/src/dictionaries/th.ts +3 -1
  59. package/src/dictionaries/tl.ts +14 -7
  60. package/src/dictionaries/tr.ts +22 -9
  61. package/src/dictionaries/uk.ts +13 -7
  62. package/src/dictionaries/vi.ts +3 -3
  63. package/src/dictionaries/zh.ts +10 -2
  64. package/src/examples/new-languages.ts +1 -1
  65. package/src/grammar/grammar.test.ts +1242 -1
  66. package/src/grammar/index.ts +1 -0
  67. package/src/grammar/profiles/index.ts +358 -12
  68. package/src/grammar/transformer.ts +1053 -16
  69. package/src/grammar/types.ts +22 -1
  70. package/src/index.ts +1 -0
  71. package/src/new-languages.test.ts +7 -3
  72. package/src/parser/parser-integration.test.ts +5 -1
  73. package/src/positional-keyword-drift.test.ts +138 -0
@@ -22,6 +22,7 @@ import { findInDictionary, translateFromEnglish } from '../types';
22
22
  import {
23
23
  ENGLISH_MODIFIER_ROLES,
24
24
  ENGLISH_COMMANDS,
25
+ COMMAND_PRIMARY_ROLES,
25
26
  CONDITIONAL_KEYWORDS,
26
27
  THEN_KEYWORDS,
27
28
  } from '../constants';
@@ -49,6 +50,104 @@ function getCommandKeywordsForLocale(locale: string): Set<string> {
49
50
  return keywords;
50
51
  }
51
52
 
53
+ /**
54
+ * English copula forms. A command keyword IMMEDIATELY after one of these is a
55
+ * predicate adjective, not a command verb: in `if my value is empty add .error
56
+ * to me` the `empty` belongs to the condition (`empty` is also a hyperscript
57
+ * command, v0.9.90). Without this guard the condition/body scans cut the
58
+ * condition at `is` and displace the adjective into the body's argument zone,
59
+ * where it anchors a spurious `empty-{lang}-generated` parse AND steals a
60
+ * neighboring role (the empty ×8 bn/hi/tr family). Source-locale copulas are
61
+ * added via the dictionary in `isPredicateAdjectivePosition`.
62
+ */
63
+ const EN_COPULAS = ['is', 'are', 'was', 'were', 'am', 'be'];
64
+
65
+ function getCopulasForLocale(locale: string): Set<string> {
66
+ const copulas = new Set(EN_COPULAS);
67
+ if (locale !== 'en') {
68
+ for (const form of EN_COPULAS) {
69
+ copulas.add(translateWord(form, 'en', locale).toLowerCase());
70
+ }
71
+ }
72
+ return copulas;
73
+ }
74
+
75
+ /**
76
+ * True when tokens[i] sits right after a copula — a predicate-adjective
77
+ * position that must never be read as the start of a body command.
78
+ */
79
+ function isPredicateAdjectivePosition(tokens: string[], i: number, copulas: Set<string>): boolean {
80
+ const prev = tokens[i - 1]?.toLowerCase();
81
+ return !!prev && copulas.has(prev);
82
+ }
83
+
84
+ /**
85
+ * Source-locale surface forms of the `for` command keyword and the loop's
86
+ * `in` preposition, for the loop-head test below.
87
+ */
88
+ function getForLoopWordsForLocale(locale: string): { forWords: Set<string>; inWords: Set<string> } {
89
+ const forWords = new Set(['for']);
90
+ const inWords = new Set(['in']);
91
+ if (locale !== 'en') {
92
+ forWords.add(translateWord('for', 'en', locale).toLowerCase());
93
+ inWords.add(translateWord('in', 'en', locale).toLowerCase());
94
+ }
95
+ return { forWords, inWords };
96
+ }
97
+
98
+ /**
99
+ * True when a `for` at tokens[i] heads a real loop. Hyperscript's only
100
+ * statement-head `for` is `for <var> in <iterable>`, so a `for` with no `in`
101
+ * among the tokens before the next command keyword is a ROLE PHRASE — take's
102
+ * target (`take .active from .tab-button for me`) or a duration (`for 2s`) —
103
+ * and must never split the statement: the split shattered the phrase into a
104
+ * dangling `then for me` clause, which six SOV languages then parsed as a
105
+ * spurious `for` loop with patient "me" (the take-class ×6 family).
106
+ */
107
+ function isLoopHeadFor(
108
+ tokens: string[],
109
+ i: number,
110
+ inWords: Set<string>,
111
+ commandKeywords: Set<string>
112
+ ): boolean {
113
+ for (let j = i + 1; j < tokens.length; j++) {
114
+ const lt = tokens[j].toLowerCase();
115
+ if (inWords.has(lt)) return true;
116
+ if (commandKeywords.has(lt)) return false;
117
+ }
118
+ return false;
119
+ }
120
+
121
+ /**
122
+ * Repair a FRONTED Hebrew accusative marker in transformed output.
123
+ *
124
+ * When an event-handler body leads with a command-modifier (`on click once add …`,
125
+ * via {@link GrammarTransformer.tryTransformEventWithModifierBody}) or is a control
126
+ * block (`on blur if … add … end`, via `tryTransformEventWithBlockBody`), the body
127
+ * command's accusative marker את can be emitted AHEAD of its verb — `add .error to me`
128
+ * renders `… את הוסף .error …` instead of the canonical `… הוסף את .error …`. את before
129
+ * a verb is always ungrammatical Hebrew (it only ever marks a FOLLOWING definite
130
+ * object), and the semantic parser drops the command in every parse path (fused-event,
131
+ * multi-clause, conditional-body) when the marker is fronted but parses it when the
132
+ * marker follows the verb. So an `<accusative-marker> <command-verb>` adjacency is a
133
+ * pure transformer artifact: swap it back. Idempotent and safe — only touches `את
134
+ * <verb>`, never the ~40 generated `<verb> את {patient}` patterns that embed את legitimately.
135
+ */
136
+ function repairHebrewFrontedAccusative(text: string): string {
137
+ const ACC = 'את'; // hebrewProfile.markers patient marker
138
+ const verbs = getCommandKeywordsForLocale('he');
139
+ const tokens = text.split(/\s+/);
140
+ let changed = false;
141
+ for (let i = 0; i + 1 < tokens.length; i++) {
142
+ if (tokens[i] === ACC && verbs.has(tokens[i + 1].toLowerCase())) {
143
+ [tokens[i], tokens[i + 1]] = [tokens[i + 1], tokens[i]];
144
+ changed = true;
145
+ i++; // skip the marker we just moved
146
+ }
147
+ }
148
+ return changed ? tokens.join(' ') : text;
149
+ }
150
+
52
151
  /**
53
152
  * Split a compound statement into parts at "then" boundaries, newlines,
54
153
  * AND command keyword boundaries.
@@ -140,11 +239,14 @@ function extractBlockStructure(input: string, sourceLocale: string): BlockStruct
140
239
  // `unless <cond> <body>`: condition runs up to the first command
141
240
  // keyword in `inner`. Heuristic — works because hyperscript bodies
142
241
  // always start with a command verb, and `unless` conditions rarely
143
- // contain bare command keywords as values.
242
+ // contain bare command keywords as values. A candidate right after a
243
+ // copula (`… is empty`) is a predicate adjective inside the condition,
244
+ // not a body verb — skip it.
144
245
  const commands = getCommandKeywordsForLocale(sourceLocale);
246
+ const copulas = getCopulasForLocale(sourceLocale);
145
247
  let bodyStart = -1;
146
248
  for (let i = 0; i < inner.length; i++) {
147
- if (commands.has(inner[i].toLowerCase())) {
249
+ if (commands.has(inner[i].toLowerCase()) && !isPredicateAdjectivePosition(inner, i, copulas)) {
148
250
  bodyStart = i;
149
251
  break;
150
252
  }
@@ -345,7 +447,13 @@ function reconstructWithLineStructure(
345
447
  * - "on <event> <command>" stays together (event handler with first command)
346
448
  * - Modifiers like "to", "from" don't trigger splits
347
449
  */
348
- /** Modifier keywords that should not trigger command boundary splits */
450
+ /**
451
+ * English modifier keywords that should not trigger command boundary splits.
452
+ * These are the base set; localized equivalents (e.g. Japanese `に`, Spanish
453
+ * `a`, Arabic `إلى`) are layered on per source locale by
454
+ * `getBoundaryModifiersForLocale`, so a preposition in a non-English source
455
+ * is also recognized as a modifier rather than a spurious command boundary.
456
+ */
349
457
  const BOUNDARY_MODIFIERS = new Set([
350
458
  'to',
351
459
  'into',
@@ -360,6 +468,86 @@ const BOUNDARY_MODIFIERS = new Set([
360
468
  'over',
361
469
  ]);
362
470
 
471
+ /**
472
+ * Boundary modifiers resolved for a given source locale: the English base set
473
+ * (always kept, since input may mix English keywords) plus every grammatical
474
+ * marker form declared by the locale's profile. Profile markers are the
475
+ * surface realizations of semantic roles (destination, source, style, …) —
476
+ * they always bind to a following value, so none should be treated as a
477
+ * command boundary. Cached per locale because profiles are static.
478
+ */
479
+ const boundaryModifiersCache = new Map<string, Set<string>>();
480
+
481
+ function getBoundaryModifiersForLocale(locale: string): Set<string> {
482
+ const cached = boundaryModifiersCache.get(locale);
483
+ if (cached) return cached;
484
+
485
+ const modifiers = new Set(BOUNDARY_MODIFIERS);
486
+
487
+ const profile = getProfile(locale);
488
+ profile?.markers.forEach(marker => {
489
+ const form = marker.form.replace(/^-|-$/g, '').toLowerCase();
490
+ if (form) modifiers.add(form);
491
+
492
+ marker.alternatives?.forEach(alt => {
493
+ const altForm = alt.replace(/^-|-$/g, '').toLowerCase();
494
+ if (altForm) modifiers.add(altForm);
495
+ });
496
+ });
497
+
498
+ boundaryModifiersCache.set(locale, modifiers);
499
+ return modifiers;
500
+ }
501
+
502
+ /**
503
+ * Commands for which a trailing `on <element>` is the TARGET the command acts
504
+ * on (a destination), not a new event-handler clause. For these, the locative
505
+ * `on` must neither split the statement (`splitOnCommandBoundaries`) nor be read
506
+ * as a fresh `event` role (`buildArgumentModifierMap`) — see those call sites.
507
+ *
508
+ * Deliberately narrow. `on` is overloaded (event-handler head vs. locative
509
+ * target), and the change has cross-language blast radius, so we only enable
510
+ * the locative reading for the DOM class/attribute mutators where it's the
511
+ * documented idiom (`toggle .open on #menu`, `toggle @hidden on #panel`).
512
+ *
513
+ * `trigger`/`send` — `trigger X on Y` / `send X on Y` fire an event on a target
514
+ * element; the trailing `on Y` is that target (destination), not a new clause.
515
+ * Previously excluded: keeping `on Y` attached produced `trigger X → #y` output
516
+ * that was thought to destabilise the semantic parser's multi-line behaviour
517
+ * fallback (behavior-sortable's `trigger sortable:start on me`). That concern is
518
+ * stale — the recent semantic-parser body increments fold the surrounding block
519
+ * cleanly, and splitting instead injected a spurious `then` (`disparar
520
+ * sortable:start entonces en yo`) that glued the following `repeat until event`
521
+ * loop into a then-chain and dropped it. Keeping `on Y` attached restores
522
+ * behavior-sortable to faithful across the SVO languages.
523
+ *
524
+ * Excluded on purpose:
525
+ * - `set` — `set @attr to V on Y` carries BOTH `to` (value) and `on` (target);
526
+ * English marks both as `destination`, so remapping `on` would collide with
527
+ * and clobber the value. Needs distinct value/target roles first (deferred).
528
+ * - `put` — `put X on Y into Z` has the same dual-destination collision.
529
+ *
530
+ * `add`/`remove` use `to`/`from` for their target in practice (not `on`), so
531
+ * their inclusion is a harmless no-op that documents intent.
532
+ */
533
+ const ON_TARGET_COMMANDS = new Set(['toggle', 'add', 'remove', 'trigger', 'send']);
534
+
535
+ /**
536
+ * Find the command verb of a partially-collected statement: the first command
537
+ * keyword that is neither the event-handler head (`on`/その他 event markers) nor
538
+ * an argument-introducing preposition. For `on click toggle .open` → `toggle`;
539
+ * for `trigger sortable:start` → `trigger`.
540
+ */
541
+ function commandVerbOf(tokens: string[], commandKeywords: Set<string>): string | null {
542
+ for (const token of tokens) {
543
+ const lt = token.toLowerCase();
544
+ if (commandKeywords.has(lt) && !BOUNDARY_MODIFIERS.has(lt) && !EVENT_KEYWORDS.has(lt)) {
545
+ return lt;
546
+ }
547
+ }
548
+ return null;
549
+ }
550
+
363
551
  /**
364
552
  * Block-introducing keywords whose body should not be split at command
365
553
  * boundaries by `splitOnCommandBoundaries`. Inputs starting with one of
@@ -373,8 +561,28 @@ const BOUNDARY_MODIFIERS = new Set([
373
561
  */
374
562
  const BLOCK_HEAD_KEYWORDS = new Set(['live', 'when', 'unless']);
375
563
 
564
+ /**
565
+ * Source-language block-introducing command keywords whose body is a clause plus a
566
+ * branch/loop body (harness source is English, so these are English-keyed). Used to
567
+ * locate a block body inside an event handler and to track block depth when finding
568
+ * a top-level `else`.
569
+ */
570
+ const BLOCK_BODY_KEYWORDS = new Set(['if', 'repeat', 'unless', 'while', 'for']);
571
+
572
+ /**
573
+ * SVO targets that mark the object/patient with a particle (he את, zh 把) and so
574
+ * mangle an inline `on <event> unless <cond> <body>` guard — the unless tail is
575
+ * swept into one patient blob and the marker lands ahead of the condition. These
576
+ * route through `tryTransformEventWithUnlessGuard`. SOV/VSO object-markers
577
+ * (ja/ko/tr/ar) are excluded: their event does not lead, so the SVO event-first
578
+ * emission there would be wrong (and they don't exhibit the artifact today).
579
+ */
580
+ const UNLESS_GUARD_OBJECT_MARKING_LOCALES = new Set(['he', 'zh']);
581
+
376
582
  function splitOnCommandBoundaries(input: string, sourceLocale: string): string[] {
377
583
  const commandKeywords = getCommandKeywordsForLocale(sourceLocale);
584
+ const boundaryModifiers = getBoundaryModifiersForLocale(sourceLocale);
585
+ const { forWords, inWords } = getForLoopWordsForLocale(sourceLocale);
378
586
  const tokens = input.split(/\s+/);
379
587
 
380
588
  if (tokens.length === 0) return [input];
@@ -429,7 +637,51 @@ function splitOnCommandBoundaries(input: string, sourceLocale: string): string[]
429
637
  continue;
430
638
  }
431
639
 
432
- if (!BOUNDARY_MODIFIERS.has(prevLower) && !commandKeywords.has(prevLower)) {
640
+ // A `for` that doesn't head a real loop (`for <var> in <iterable>`) is a
641
+ // role phrase of the current command — see isLoopHeadFor.
642
+ if (forWords.has(lowerToken) && !isLoopHeadFor(tokens, i, inWords, commandKeywords)) {
643
+ currentPart.push(token);
644
+ continue;
645
+ }
646
+
647
+ // Locative `on` for a DOM-target command (`toggle X on Y`) is the target
648
+ // element, NOT a new command. `on` lands in `commandKeywords` only
649
+ // incidentally — the EN dictionary registers `commands.on = 'on'` for the
650
+ // event-handler head — so without this guard the destination `on` split
651
+ // the statement, the join re-inserted a spurious `then` (ثم/pagkatapos/…),
652
+ // and the dangling `on Y` was misread as a second event handler. Keep it
653
+ // attached so the role parser can assign it to `destination`. Restricted
654
+ // to ON_TARGET_COMMANDS so `trigger X on me` etc. keep their prior split.
655
+ if (BOUNDARY_MODIFIERS.has(lowerToken)) {
656
+ const verb = commandVerbOf(currentPart, commandKeywords);
657
+ if (verb && ON_TARGET_COMMANDS.has(verb)) {
658
+ currentPart.push(token);
659
+ continue;
660
+ }
661
+ // `set @attr to V on <scope>` (S1 tabs-aria): the trailing `on <scope>`
662
+ // is the element(s) the attribute is set on, not a new command. `set` is
663
+ // deliberately NOT in ON_TARGET_COMMANDS (its role parser would clobber
664
+ // the value), so this is a dedicated guard — kept attached only when a
665
+ // selector/reference scope follows, then positioned by transformSingle's
666
+ // set-scope handler (transformSetWithScope). A `set` whose `on` is
667
+ // followed by a verb is left to split as before.
668
+ if (lowerToken === 'on' && verb === 'set') {
669
+ const nextTok = tokens[i + 1];
670
+ const nextLower = nextTok?.toLowerCase();
671
+ const scopeLike =
672
+ !!nextTok &&
673
+ (/^[#.<@[]/.test(nextTok) ||
674
+ nextLower === 'me' ||
675
+ nextLower === 'it' ||
676
+ nextLower === 'you');
677
+ if (scopeLike) {
678
+ currentPart.push(token);
679
+ continue;
680
+ }
681
+ }
682
+ }
683
+
684
+ if (!boundaryModifiers.has(prevLower) && !commandKeywords.has(prevLower)) {
433
685
  // This looks like a command boundary - save current part and start new one
434
686
  parts.push(currentPart.join(' '));
435
687
  currentPart = [token];
@@ -533,6 +785,31 @@ function deriveEventKeywordsFromProfiles(): Set<string> {
533
785
  /** Event keywords derived from language profiles */
534
786
  const EVENT_KEYWORDS = deriveEventKeywordsFromProfiles();
535
787
 
788
+ /**
789
+ * Conjunctions that join multiple events in an event handler head
790
+ * (`on click or keypress ...`). Source hyperscript is English, so the
791
+ * canonical form is `or`; localized equivalents are added defensively for
792
+ * non-English sources.
793
+ */
794
+ const EVENT_CONJUNCTIONS = new Set(['or']);
795
+
796
+ /**
797
+ * Command-modifier keywords that may lead an event handler body
798
+ * (`on click async fetch …`, `on click once add …`). They modify the handler /
799
+ * following command rather than acting as the verb, so the transformer must lift
800
+ * them out before role assignment instead of treating them as the action.
801
+ * Source hyperscript is English, so these are the canonical English forms — the
802
+ * semantic parser recognizes the same literals when stripping them pre-parse.
803
+ */
804
+ const BODY_MODIFIER_KEYWORDS = new Set([
805
+ 'async',
806
+ 'once',
807
+ 'debounced',
808
+ 'debounce',
809
+ 'throttled',
810
+ 'throttle',
811
+ ]);
812
+
536
813
  // =============================================================================
537
814
  // Helper: Dynamic Modifier Map
538
815
  // =============================================================================
@@ -572,6 +849,53 @@ function generateModifierMap(profile: LanguageProfile): Record<string, SemanticR
572
849
  return map;
573
850
  }
574
851
 
852
+ /**
853
+ * Modifier map for parsing the ARGUMENTS of a statement (everything after the
854
+ * command verb), as opposed to the statement head.
855
+ *
856
+ * Why a separate map: a few languages reuse one word for both the event-handler
857
+ * head marker and a locative/target preposition. English is the prime case —
858
+ * `on` is the event head (`on click …`) AND the toggle/set target preposition
859
+ * (`toggle .x on #y`). `generateModifierMap` maps `on → event` (from the EN
860
+ * profile's event marker), so when the destination `on` was read in argument
861
+ * position it overwrote the already-captured head event (the `click` got
862
+ * dropped, e.g. `toggle @hidden on #panel` → `… عند #panel` with نقر/click gone).
863
+ *
864
+ * The fix: in argument position, remap the event role to `destination`. This is
865
+ * safe because a trigger event only ever appears at the statement head (which is
866
+ * consumed before argument parsing begins). Any "event marker" reached while
867
+ * scanning arguments is therefore being used as a locative — i.e. the element
868
+ * the command acts ON — which is exactly the `destination` role (the semantic
869
+ * parser also models `toggle .x on #y` as patient `.x` + destination `#y`).
870
+ *
871
+ * Restricted to (a) SVO source profiles and (b) ON_TARGET_COMMANDS:
872
+ * - SVO: in VSO/SOV languages the event marker can legitimately appear
873
+ * mid-statement (Arabic VSO renders an event handler as `بدل X عند نقر`,
874
+ * classified as a command), so remapping there would mis-read a real event
875
+ * as a destination. English — and the en→lang gate path — is SVO.
876
+ * - command: only the DOM class/attr mutators use a locative `on` target.
877
+ * For other commands (`trigger X on me`, `set X to V on Y`) the remap is
878
+ * either destabilising or collides with `to`/`into` — see ON_TARGET_COMMANDS.
879
+ *
880
+ * When neither applies the unmodified map is returned (event stays event).
881
+ */
882
+ function buildArgumentModifierMap(
883
+ profile: LanguageProfile,
884
+ actionVerb: string | undefined
885
+ ): Record<string, SemanticRole> {
886
+ const map = generateModifierMap(profile);
887
+ const verb = actionVerb?.toLowerCase();
888
+ if (profile.wordOrder !== 'SVO' || !verb || !ON_TARGET_COMMANDS.has(verb)) {
889
+ return map;
890
+ }
891
+
892
+ const remapped: Record<string, SemanticRole> = {};
893
+ for (const [form, role] of Object.entries(map)) {
894
+ remapped[form] = role === 'event' ? 'destination' : role;
895
+ }
896
+ return remapped;
897
+ }
898
+
575
899
  // =============================================================================
576
900
  // Statement Parser
577
901
  // =============================================================================
@@ -690,6 +1014,8 @@ function tokenize(input: string, profile: LanguageProfile): string[] {
690
1014
  let current = '';
691
1015
  let inSelector = false;
692
1016
  let selectorDepth = 0;
1017
+ let bracketDepth = 0;
1018
+ let parenDepth = 0;
693
1019
 
694
1020
  for (let i = 0; i < input.length; i++) {
695
1021
  const char = input[i];
@@ -703,8 +1029,41 @@ function tokenize(input: string, profile: LanguageProfile): string[] {
703
1029
  if (selectorDepth === 0) inSelector = false;
704
1030
  }
705
1031
 
706
- // Split on whitespace unless in selector
707
- if (/\s/.test(char) && !inSelector) {
1032
+ // Track event-guard / attribute brackets so `[key is 'Escape']` (which has
1033
+ // internal spaces) stays a single token instead of splitting into
1034
+ // `[key` / `is` / `'Escape']` — which mis-assigns `is` as the action verb.
1035
+ if (char === '[') {
1036
+ bracketDepth++;
1037
+ } else if (char === ']' && bracketDepth > 0) {
1038
+ bracketDepth--;
1039
+ }
1040
+
1041
+ // Track EVERY parenthesized group as a depth scope so it stays ONE token:
1042
+ //
1043
+ // - An ATTACHED `(` (a call or event destructure: `pointerdown(clientX,
1044
+ // clientY)`, `Resizable(a, b)`) must not split at the comma-space — the
1045
+ // event-handler-head reorder would separate the halves and drop the
1046
+ // whole handler (tl behavior-resizable degenerate).
1047
+ // - A STANDALONE `(` opening an expression (`to (the value of #price as
1048
+ // Number) * (my value as Number)`) must be OPAQUE to role segmentation:
1049
+ // left loose, its interior `of`/`as`/`from` keywords hit the argument
1050
+ // modifier map, so the parser split the expression across roles — the
1051
+ // R1 cluster E mangle (computed-value: the transformer reordered INSIDE
1052
+ // the parens, embedded the event phrase mid-expression, and dropped the
1053
+ // whole second operand in every language). Interior keywords still
1054
+ // translate IN PLACE: role values are re-split on whitespace by
1055
+ // translateMultiWordValue, and translateWord strips paren punctuation
1056
+ // before its dictionary lookup (`($count or 0)` → `($count o 0)` in tl —
1057
+ // the operator translates without the group being torn apart).
1058
+ if (char === '(') {
1059
+ parenDepth++;
1060
+ } else if (char === ')' && parenDepth > 0) {
1061
+ parenDepth--;
1062
+ }
1063
+
1064
+ // Split on whitespace unless inside a selector, a bracket guard, or an
1065
+ // attached parenthesized argument list
1066
+ if (/\s/.test(char) && !inSelector && bracketDepth === 0 && parenDepth === 0) {
708
1067
  if (current) {
709
1068
  tokens.push(current);
710
1069
  current = '';
@@ -766,11 +1125,30 @@ function parseEventHandler(tokens: string[], profile: LanguageProfile): ParsedSt
766
1125
 
767
1126
  // Next token is the event
768
1127
  if (tokens[startIndex]) {
1128
+ const eventTokens: string[] = [tokens[startIndex]];
1129
+ startIndex++;
1130
+
1131
+ // Fold "or"-conjoined events into the event value (e.g.
1132
+ // "on click or keypress[...] toggle .active"). Without this, "or" would be
1133
+ // read as the action verb and the second event swept into role values,
1134
+ // hoisting "or <event2>" ahead of the command on reorder. Keeping the whole
1135
+ // "<event1> or <event2>" string in the event role lets it translate and
1136
+ // reorder as a single event clause. Only consumes "or" that immediately
1137
+ // follows the event, before any source/action — so a later "or" in a guard
1138
+ // or value is untouched.
1139
+ while (
1140
+ tokens[startIndex] &&
1141
+ EVENT_CONJUNCTIONS.has(tokens[startIndex].toLowerCase()) &&
1142
+ tokens[startIndex + 1]
1143
+ ) {
1144
+ eventTokens.push(tokens[startIndex], tokens[startIndex + 1]);
1145
+ startIndex += 2;
1146
+ }
1147
+
769
1148
  roles.set('event', {
770
1149
  role: 'event',
771
- value: tokens[startIndex],
1150
+ value: eventTokens.join(' '),
772
1151
  });
773
- startIndex++;
774
1152
  }
775
1153
 
776
1154
  // Check for event source modifier before the action (e.g., "from #source" in "on input from #firstName set ...")
@@ -804,9 +1182,12 @@ function parseEventHandler(tokens: string[], profile: LanguageProfile): ParsedSt
804
1182
  }
805
1183
 
806
1184
  // Parse remaining tokens with modifier awareness (like parseCommand does)
807
- // This handles "by 3" in "on click increment #count by 3"
1185
+ // This handles "by 3" in "on click increment #count by 3".
1186
+ // Argument map: for DOM-target commands the head event is already captured, so
1187
+ // a locative `on` (`toggle @hidden on #panel`) maps to `destination` rather
1188
+ // than overwriting the head `event`.
808
1189
  if (tokens[startIndex]) {
809
- const modifierMap = generateModifierMap(profile);
1190
+ const modifierMap = buildArgumentModifierMap(profile, roles.get('action')?.value);
810
1191
  let currentRole: SemanticRole = 'patient';
811
1192
  let currentValue: string[] = [];
812
1193
 
@@ -866,9 +1247,11 @@ function parseCommand(tokens: string[], profile: LanguageProfile): ParsedStateme
866
1247
  value: tokens[0],
867
1248
  });
868
1249
 
869
- // Generate dynamic modifier map from language profile
870
- // This enables parsing non-English input (e.g., Japanese に, Korean 에, Arabic إلى)
871
- const modifierMap = generateModifierMap(profile);
1250
+ // Generate dynamic modifier map from language profile (argument position).
1251
+ // This enables parsing non-English input (e.g., Japanese に, Korean 에, Arabic إلى).
1252
+ // For a DOM-target command a locative `on` (`toggle .active on me`) maps to
1253
+ // `destination` (SVO sources) rather than spawning a bogus `event` role.
1254
+ const modifierMap = buildArgumentModifierMap(profile, tokens[0]);
872
1255
 
873
1256
  let currentRole: SemanticRole = 'patient';
874
1257
  let currentValue: string[] = [];
@@ -911,6 +1294,74 @@ function parseCommand(tokens: string[], profile: LanguageProfile): ParsedStateme
911
1294
  };
912
1295
  }
913
1296
 
1297
+ /**
1298
+ * Re-assign a command's mis-marked primary argument from the default `patient`
1299
+ * role to its true primary role (e.g. `wait`'s leading argument is a `duration`,
1300
+ * not a fronted object).
1301
+ *
1302
+ * The generic argument parser in `parseCommand` / `parseEventHandler` defaults the
1303
+ * first unmarked argument to `patient`. For most commands that is correct, but for a
1304
+ * command whose primary role is a non-patient *and which the target language does not
1305
+ * give a marker* (a duration, a measure — never a BA/object construction), the
1306
+ * `patient` assignment makes `insertMarkers` emit a spurious object-marker (Chinese
1307
+ * `把`, Japanese `を`, Korean `를`). The marked form is ungrammatical and the semantic
1308
+ * parser fails to match it, dropping the command.
1309
+ *
1310
+ * The fix is deliberately scoped to **literal/measure** primary roles
1311
+ * ({@link LITERAL_PRIMARY_ROLES}: `duration`, `quantity`) — values that are *never*
1312
+ * the object of an object-marking construction in any supported language, so moving
1313
+ * them off `patient` can only ever *remove* a spurious marker. Marker-bearing
1314
+ * primaries are intentionally left alone:
1315
+ * - `set`→destination `到`, `fetch`→source `从` carry a *correct* marker; touching
1316
+ * them risks no benefit.
1317
+ * - `send`/`trigger`→event: in a language without an event marker (e.g. Korean has
1318
+ * no event particle) un-marking the leading argument makes the semantic parser
1319
+ * mis-read it as a bare event handler and emit a phantom `on` action — an
1320
+ * over-generation that recall-based fidelity would not catch. So `event`-primary
1321
+ * commands stay on the `patient` default.
1322
+ *
1323
+ * Roles are still reordered by the safety-net in `reorderRoles`, so no value is lost.
1324
+ * A belt-and-suspenders target-marker guard keeps the change inert should a profile
1325
+ * ever add a marker for one of these literal roles.
1326
+ *
1327
+ * Must run *before* `translateElements`, while the `action` value is still the
1328
+ * source-language (English) keyword, so the schema lookup resolves.
1329
+ *
1330
+ * @see docs-internal/ZH_BLOCK_BODY_SCOPE.md (#1 — transformer role model)
1331
+ */
1332
+ const LITERAL_PRIMARY_ROLES: ReadonlySet<SemanticRole> = new Set<SemanticRole>([
1333
+ 'duration',
1334
+ 'quantity',
1335
+ ]);
1336
+
1337
+ function applyPrimaryRole(parsed: ParsedStatement, targetProfile: LanguageProfile): void {
1338
+ // Only re-mark standalone command statements. In an event handler (`on click
1339
+ // wait 2s …`) a verb-final SOV language without an event particle (e.g. Korean)
1340
+ // relies on the leading argument's patient marker as the structural cue that
1341
+ // anchors the handler; un-marking it makes the semantic parser lose the event.
1342
+ // The block-body / then-chain `wait` clauses this fix targets are each parsed as
1343
+ // their own command statement, so they are still covered.
1344
+ if (parsed.type !== 'command') return;
1345
+
1346
+ const action = parsed.roles.get('action')?.value;
1347
+ if (!action) return;
1348
+
1349
+ const primaryRole = COMMAND_PRIMARY_ROLES[action.toLowerCase()];
1350
+ if (!primaryRole || !LITERAL_PRIMARY_ROLES.has(primaryRole)) return;
1351
+
1352
+ // Only act on a leading argument the generic parser defaulted to `patient`,
1353
+ // and only when the primary slot isn't already filled by an explicit marker.
1354
+ const patientEl = parsed.roles.get('patient');
1355
+ if (!patientEl || parsed.roles.has(primaryRole)) return;
1356
+
1357
+ // Guard: never introduce a marker that wasn't there. If the target language
1358
+ // marks the primary role, leave the command as-is.
1359
+ if (targetProfile.markers.some(m => m.role === primaryRole)) return;
1360
+
1361
+ parsed.roles.delete('patient');
1362
+ parsed.roles.set(primaryRole, { ...patientEl, role: primaryRole });
1363
+ }
1364
+
914
1365
  /**
915
1366
  * Parse a conditional statement
916
1367
  */
@@ -932,6 +1383,17 @@ function parseConditional(tokens: string[], _profile: LanguageProfile): ParsedSt
932
1383
  role: 'condition',
933
1384
  value: conditionValue,
934
1385
  });
1386
+ } else if (thenIndex === -1 && tokens.length > 1) {
1387
+ // Block-style `if <cond>` with the body on following lines (no inline
1388
+ // `then`). Capture everything after `if` as the condition; otherwise the
1389
+ // condition is silently dropped and the rendered block becomes a bare
1390
+ // `if`/`אם`/`如果`, which the semantic block parser then rejects (null
1391
+ // parse). This is the dominant failure for nested control-flow bodies in
1392
+ // non-Latin languages (he, zh).
1393
+ roles.set('condition', {
1394
+ role: 'condition',
1395
+ value: tokens.slice(1).join(' '),
1396
+ });
935
1397
  }
936
1398
 
937
1399
  return {
@@ -959,6 +1421,26 @@ function translateWord(word: string, sourceLocale: string, targetLocale: string)
959
1421
  return word;
960
1422
  }
961
1423
 
1424
+ // A whole parenthesized group fused by the tokenizer (`(the value of #price
1425
+ // as Number)`) reaching a single-token translate path: translate its
1426
+ // interior word-by-word IN ORDER — never reordered, never re-segmented.
1427
+ if (/\s/.test(word) && word.startsWith('(')) {
1428
+ return word
1429
+ .split(/\s+/)
1430
+ .map(w => translateWord(w, sourceLocale, targetLocale))
1431
+ .join(' ');
1432
+ }
1433
+
1434
+ // A word carrying paren punctuation from a fused group after whitespace
1435
+ // re-splitting (`(my` / `valor)` / `(($x`): strip the parens for the
1436
+ // dictionary lookup and re-attach, so interior keywords still translate.
1437
+ if (word.length > 1 && (word.startsWith('(') || word.endsWith(')'))) {
1438
+ const m = word.match(/^(\(*)([^()]+)(\)*)$/);
1439
+ if (m && (m[1] || m[3])) {
1440
+ return m[1] + translateWord(m[2], sourceLocale, targetLocale) + m[3];
1441
+ }
1442
+ }
1443
+
962
1444
  const sourceDict = sourceLocale === 'en' ? null : dictionaries[sourceLocale];
963
1445
  const targetDict = dictionaries[targetLocale];
964
1446
 
@@ -984,7 +1466,7 @@ function translateWord(word: string, sourceLocale: string, targetLocale: string)
984
1466
  */
985
1467
  const POSSESSIVE_MARKERS: Record<
986
1468
  string,
987
- { type: 'prefix' | 'suffix' | 'preposition'; marker: string }
1469
+ { type: 'prefix' | 'suffix' | 'preposition' | 'particle'; marker: string }
988
1470
  > = {
989
1471
  en: { type: 'suffix', marker: "'s" },
990
1472
  es: { type: 'preposition', marker: 'de' },
@@ -995,9 +1477,21 @@ const POSSESSIVE_MARKERS: Record<
995
1477
  ko: { type: 'suffix', marker: '의' },
996
1478
  zh: { type: 'suffix', marker: '的' },
997
1479
  ar: { type: 'preposition', marker: 'لـ' },
998
- tr: { type: 'suffix', marker: "'ın" },
1480
+ // Spaced genitive particle (not the glued `'ın`), so the tokenizer can split
1481
+ // it off the selector — consistent with Turkish's other spaced case markers.
1482
+ tr: { type: 'particle', marker: 'ın' },
999
1483
  id: { type: 'preposition', marker: 'dari' },
1000
- qu: { type: 'suffix', marker: '-pa' },
1484
+ // Latin-script genitive: must be a *spaced* particle (`#picker pa`), since a
1485
+ // glued `#pickerpa` can't be split from the selector by the tokenizer the
1486
+ // way a non-Latin suffix (の/의/র) can.
1487
+ qu: { type: 'particle', marker: 'pa' },
1488
+ // Bengali SOV postposition genitive, like ja/ko — a spaced suffix the
1489
+ // tokenizer splits off as a particle. Previously absent, so it fell back to
1490
+ // the English `'s` marker and its possessive property paths never parsed.
1491
+ // (Hindi `का` is intentionally omitted: its `bind` lacks a verb-final
1492
+ // grammar rule, so fixing its possessive alone yields a wrong `on` parse —
1493
+ // tracked as separate follow-up.)
1494
+ bn: { type: 'suffix', marker: 'র' },
1001
1495
  sw: { type: 'preposition', marker: 'ya' },
1002
1496
  };
1003
1497
 
@@ -1040,6 +1534,10 @@ function translatePossessive(token: string, sourceLocale: string, targetLocale:
1040
1534
  case 'suffix':
1041
1535
  // Japanese/Korean/Chinese: owner + marker (e.g., #buttonの, #button의)
1042
1536
  return `${translatedOwner}${targetMarker.marker}`;
1537
+ case 'particle':
1538
+ // Latin-script spaced genitive (Quechua `pa`): owner + space + marker
1539
+ // so the tokenizer can separate it from the selector.
1540
+ return `${translatedOwner} ${targetMarker.marker}`;
1043
1541
  case 'preposition':
1044
1542
  // Will be handled by caller - return marker + owner format
1045
1543
  // Store as special format to be processed later
@@ -1126,6 +1624,21 @@ function translateMultiWordValue(
1126
1624
  sourceLocale: string,
1127
1625
  targetLocale: string
1128
1626
  ): string {
1627
+ // Mask event-guard / attribute brackets (`[key is 'Escape']`): their contents
1628
+ // are expression syntax, not translatable keywords — translating `is` -> `ni`
1629
+ // etc. inside them breaks the guard. Restore verbatim after translation.
1630
+ if (value.includes('[')) {
1631
+ const guards: string[] = [];
1632
+ const masked = value.replace(/\[[^\]]*\]/g, match => {
1633
+ guards.push(match);
1634
+ return `${guards.length - 1}`;
1635
+ });
1636
+ if (guards.length > 0) {
1637
+ const translated = translateMultiWordValue(masked, sourceLocale, targetLocale);
1638
+ return translated.replace(/(\d+)/g, (_, n) => guards[Number(n)]);
1639
+ }
1640
+ }
1641
+
1129
1642
  // If it's a single word, check for possessive then translate
1130
1643
  if (!value.includes(' ')) {
1131
1644
  // Check for possessive 's
@@ -1228,6 +1741,43 @@ function translateElements(
1228
1741
  }
1229
1742
  }
1230
1743
 
1744
+ // =============================================================================
1745
+ // Caret-scope masking (`^name on <selector>`)
1746
+ // =============================================================================
1747
+
1748
+ /** Private-use sentinels bracketing a masked caret-scope index. */
1749
+ const CARET_SCOPE_OPEN = '\uE000';
1750
+ const CARET_SCOPE_CLOSE = '\uE001';
1751
+
1752
+ /**
1753
+ * Match `^name on <selector>` and the scope's selector form (#id, .class,
1754
+ * <tag/>, [attr]). The `^name` is kept; only the ` on <selector>` scope is masked.
1755
+ */
1756
+ const CARET_SCOPE_RE = /(\^[A-Za-z_][\w-]*)(\s+on\s+(?:[#.][\w-]+|<[^>]*\/>|\[[^\]]+\]))/g;
1757
+
1758
+ /**
1759
+ * Mask the ` on <selector>` scope of every `^name on <selector>` read behind an
1760
+ * opaque token attached to `^name`, so the overloaded `on` doesn't reach the
1761
+ * splitter / event-handler parser. Returns null when there's nothing to mask.
1762
+ */
1763
+ function maskCaretScopes(input: string): { masked: string; scopes: string[] } | null {
1764
+ const scopes: string[] = [];
1765
+ const masked = input.replace(CARET_SCOPE_RE, (_m, varTok: string, scope: string) => {
1766
+ const idx = scopes.length;
1767
+ scopes.push(scope);
1768
+ return `${varTok}${CARET_SCOPE_OPEN}${idx}${CARET_SCOPE_CLOSE}`;
1769
+ });
1770
+ return scopes.length > 0 ? { masked, scopes } : null;
1771
+ }
1772
+
1773
+ /** Restore masked caret-scope tokens to their verbatim ` on <selector>` form. */
1774
+ function restoreCaretScopes(input: string, scopes: string[]): string {
1775
+ return input.replace(
1776
+ new RegExp(`${CARET_SCOPE_OPEN}(\\d+)${CARET_SCOPE_CLOSE}`, 'g'),
1777
+ (_m, n: string) => scopes[Number(n)] ?? ''
1778
+ );
1779
+ }
1780
+
1231
1781
  // =============================================================================
1232
1782
  // Main Transformer
1233
1783
  // =============================================================================
@@ -1255,8 +1805,57 @@ export class GrammarTransformer {
1255
1805
  * For multi-line input, preserves line structure (indentation, blank lines).
1256
1806
  */
1257
1807
  transform(input: string): string {
1808
+ const out = this.transformInternal(input);
1809
+ // Hebrew: repair a fronted accusative marker the body-split heuristics can emit
1810
+ // (`… את הוסף .x …` → `… הוסף את .x …`). Applied to the assembled output; idempotent
1811
+ // across the internal recursion. See repairHebrewFrontedAccusative.
1812
+ return this.targetProfile.code === 'he' ? repairHebrewFrontedAccusative(out) : out;
1813
+ }
1814
+
1815
+ private transformInternal(input: string): string {
1816
+ // Caret-scoped variable reads (`^name on <selector>`) carry a second,
1817
+ // overloaded `on` that the splitter/event-parser would mistake for an event
1818
+ // or command boundary — mangling `put ^count on #host into me`. Mask the
1819
+ // ` on <selector>` scope behind an opaque token attached to `^name` so the
1820
+ // command reorders as if the patient were a single value, then restore it.
1821
+ // `on` is kept verbatim (the semantic caret-scope matcher accepts it by raw
1822
+ // value across languages — passthrough-alignment).
1823
+ const caret = maskCaretScopes(input);
1824
+ if (caret) {
1825
+ return restoreCaretScopes(this.transform(caret.masked), caret.scopes);
1826
+ }
1827
+
1258
1828
  const targetThen = getTargetThenKeyword(this.targetProfile.code);
1259
1829
 
1830
+ // Inline JS blocks (`... js <raw js> end`) must be masked BEFORE any
1831
+ // splitting/reordering: the body is raw JavaScript, not hyperscript, so it
1832
+ // must never be tokenized, translated, or word-order reordered. (Single-line
1833
+ // only here; multi-line js bodies are handled with the behavior work.)
1834
+ if (!input.includes('\n')) {
1835
+ const jsBlock = this.tryTransformJsBlock(input);
1836
+ if (jsBlock !== null) return jsBlock;
1837
+
1838
+ // Event handler whose body leads with a command-modifier
1839
+ // (`on click async fetch …`, `on click once add …`): lift the modifier out
1840
+ // so the real verb isn't mistaken for the action and the SOV reorder keeps
1841
+ // the body patient-first (recoverable by the parser's SOV event extraction).
1842
+ const eventModifier = this.tryTransformEventWithModifierBody(input);
1843
+ if (eventModifier !== null) return eventModifier;
1844
+
1845
+ // Event handler whose body is a block (`on <event> if/repeat/unless … end`):
1846
+ // the block body must be reordered as a self-contained unit, not shredded
1847
+ // across the event handler's role soup.
1848
+ const eventBlock = this.tryTransformEventWithBlockBody(input);
1849
+ if (eventBlock !== null) return eventBlock;
1850
+
1851
+ // Event handler whose body is an un-terminated inline `unless` guard
1852
+ // (`on click unless I match .disabled toggle .selected`): route the guard
1853
+ // through the standalone block path so Hebrew's accusative marker lands on
1854
+ // the body command, not the condition. Hebrew-only; null elsewhere.
1855
+ const eventGuard = this.tryTransformEventWithUnlessGuard(input);
1856
+ if (eventGuard !== null) return eventGuard;
1857
+ }
1858
+
1260
1859
  // Check if input has multi-line structure worth preserving
1261
1860
  const hasMultiLineStructure = input.includes('\n');
1262
1861
 
@@ -1302,12 +1901,25 @@ export class GrammarTransformer {
1302
1901
  return this.transformBlock(block);
1303
1902
  }
1304
1903
 
1904
+ // 0b. `set @attr to V on <scope>` (S1 tabs-aria): strip the trailing
1905
+ // `on <scope>`, transform the scope-less set normally, then re-insert
1906
+ // `on <scope>` where the semantic set patterns expect it.
1907
+ const setScope = this.transformSetWithScope(input);
1908
+ if (setScope !== null) {
1909
+ return setScope;
1910
+ }
1911
+
1305
1912
  // 1. Parse into semantic roles
1306
1913
  const parsed = parseStatement(input, this.sourceProfile.code);
1307
1914
  if (!parsed) {
1308
1915
  return input; // Return unchanged if parsing fails
1309
1916
  }
1310
1917
 
1918
+ // 1b. Re-assign a mis-marked primary argument (e.g. `wait`'s duration) off the
1919
+ // default `patient` role so the target doesn't emit a spurious object-marker.
1920
+ // Runs before translation while `action` is still the English keyword.
1921
+ applyPrimaryRole(parsed, this.targetProfile);
1922
+
1311
1923
  // 2. Translate individual words
1312
1924
  translateElements(parsed, this.sourceProfile.code, this.targetProfile.code);
1313
1925
 
@@ -1339,6 +1951,431 @@ export class GrammarTransformer {
1339
1951
  return joinTokens(reordered.map(e => e.translated || e.value));
1340
1952
  }
1341
1953
 
1954
+ /**
1955
+ * Detect and transform an inline JS block (`[on <event>] js <raw js> end`).
1956
+ *
1957
+ * The `js ... end` body is raw JavaScript: it must not be tokenized,
1958
+ * translated, or word-order reordered. We mask the whole block with a single
1959
+ * opaque placeholder, run the surrounding statement (the event-handler head,
1960
+ * if any) through the normal reorder pipeline so the placeholder lands in the
1961
+ * correct action position, then substitute the translated `js`/`end` keywords
1962
+ * around the verbatim body.
1963
+ *
1964
+ * Returns `null` (fall through to the normal path) when there is no js block,
1965
+ * no matching `end`, or trailing content after `end` (kept tight on purpose).
1966
+ */
1967
+ private tryTransformJsBlock(input: string): string | null {
1968
+ const src = this.sourceProfile.code;
1969
+ const dst = this.targetProfile.code;
1970
+
1971
+ // The `js` / `end` keyword forms in the SOURCE language.
1972
+ const sourceJs = translateWord('js', 'en', src);
1973
+ const sourceEnd = translateWord('end', 'en', src).toLowerCase();
1974
+
1975
+ const tokens = input.split(/\s+/).filter(t => t.length > 0);
1976
+ // The js command token, optionally with a `(locals)` suffix: `js`, `js(me)`.
1977
+ const escapedJs = sourceJs.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1978
+ const jsRe = new RegExp(`^${escapedJs}(\\(.*\\))?$`, 'i');
1979
+ const jsIdx = tokens.findIndex(t => jsRe.test(t));
1980
+ if (jsIdx === -1) return null;
1981
+
1982
+ // First `end` after the js keyword closes the block (raw JS never contains a
1983
+ // bare hyperscript `end` token).
1984
+ let endIdx = -1;
1985
+ for (let i = jsIdx + 1; i < tokens.length; i++) {
1986
+ if (tokens[i].toLowerCase() === sourceEnd) {
1987
+ endIdx = i;
1988
+ break;
1989
+ }
1990
+ }
1991
+ if (endIdx === -1) return null;
1992
+ // Trailing content after `end` — leave for the normal path.
1993
+ if (endIdx !== tokens.length - 1) return null;
1994
+
1995
+ const jsToken = tokens[jsIdx];
1996
+ const jsParen = jsToken.match(jsRe)?.[1] ?? '';
1997
+ const jsKeywordRaw = jsParen ? jsToken.slice(0, jsToken.length - jsParen.length) : jsToken;
1998
+
1999
+ const body = tokens.slice(jsIdx + 1, endIdx).join(' ');
2000
+ const targetJs = translateWord(jsKeywordRaw, src, dst) + jsParen;
2001
+ const targetEnd = translateWord(tokens[endIdx], src, dst);
2002
+ const replacement = [targetJs, body, targetEnd].filter(s => s.length > 0).join(' ');
2003
+
2004
+ const before = tokens.slice(0, jsIdx);
2005
+ // Bare `js ... end` with no leading event-handler head: emit directly.
2006
+ if (before.length === 0) return replacement;
2007
+
2008
+ // Mask the block as one opaque action token, reorder the surrounding
2009
+ // statement, then restore the verbatim block.
2010
+ const placeholder = 'JSBLOCKPLACEHOLDER';
2011
+ const reordered = this.transformSingle([...before, placeholder].join(' '));
2012
+ if (!reordered.includes(placeholder)) return null; // unexpected — fall through
2013
+ return reordered.replace(placeholder, replacement);
2014
+ }
2015
+
2016
+ /**
2017
+ * Transform an event handler whose body is a block command
2018
+ * (`on <event> [from <src>] {if|repeat|unless|while|for} … end`).
2019
+ *
2020
+ * `parseEventHandler` would treat the block keyword as the action and sweep the
2021
+ * condition/body into role values, then reorder them — shredding the block
2022
+ * (`if event.shiftKey call submitAndContinue() end` → scattered tokens). Instead
2023
+ * we mask the whole block as an opaque action placeholder, reorder the event
2024
+ * head normally, transform the block as a self-contained unit, and restitch.
2025
+ *
2026
+ * Returns `null` (fall through) when the input isn't an event handler, has no
2027
+ * block-keyword body, or has no closing `end`.
2028
+ */
2029
+ private tryTransformEventWithBlockBody(input: string): string | null {
2030
+ const tokens = tokenize(input, this.sourceProfile);
2031
+ if (tokens.length === 0) return null;
2032
+ if (!EVENT_KEYWORDS.has(tokens[0]?.toLowerCase())) return null;
2033
+
2034
+ let blockIdx = -1;
2035
+ for (let i = 1; i < tokens.length; i++) {
2036
+ if (BLOCK_BODY_KEYWORDS.has(tokens[i].toLowerCase())) {
2037
+ blockIdx = i;
2038
+ break;
2039
+ }
2040
+ }
2041
+ if (blockIdx <= 0) return null;
2042
+ // Only handle the explicitly-terminated form; un-terminated bodies
2043
+ // (`on click unless X toggle Y`) keep the existing path.
2044
+ if (tokens[tokens.length - 1].toLowerCase() !== 'end') return null;
2045
+
2046
+ const eventHead = tokens.slice(0, blockIdx);
2047
+ const blockTokens = tokens.slice(blockIdx);
2048
+
2049
+ // Event heads carrying a `from <source>` modifier (`on keydown[...] from
2050
+ // .modal if … end`): for SVO/SOV targets, route them through this block-body
2051
+ // path too — masking the block and emitting the event clause (incl. the
2052
+ // translated `from <source>`, which `transformSingle` → `parseEventHandler`
2053
+ // already reorders) first, then the transformed block. This keeps the if-block
2054
+ // body from being shredded across the event handler's roles and properly
2055
+ // translates its inner keywords (`in`/`focus`/`first`/positional); the old
2056
+ // exclusion left them English and mangled the order (this is what cleared
2057
+ // `focus-trap` in tr). VSO targets (ar, tl) are kept on the existing path:
2058
+ // there the event-first emission with a `from`-source reorders incorrectly and
2059
+ // regresses `focus-trap`/`window-keydown`, while the existing path already
2060
+ // parses them. So the `from`-source exclusion is scoped to VSO only.
2061
+ if (this.targetProfile.wordOrder === 'VSO' && eventHead.some(t => t.toLowerCase() === 'from')) {
2062
+ return null;
2063
+ }
2064
+
2065
+ const placeholder = 'EVENTBLOCKPLACEHOLDER';
2066
+ const headOut = this.transformSingle([...eventHead, placeholder].join(' '));
2067
+ if (!headOut.includes(placeholder)) return null;
2068
+
2069
+ const blockOut = this.transformBlockBody(blockTokens);
2070
+
2071
+ // Always emit the event clause first, then the block. An event handler's
2072
+ // event is a leading delimiter, and the semantic parser only matches a
2073
+ // block body when it follows the event — even in verb-first (VSO) languages
2074
+ // whose normal command order would push the event to the end. So strip the
2075
+ // placeholder out of the (possibly reordered) head and append the block,
2076
+ // rather than substituting in place.
2077
+ const eventClause = headOut.replace(placeholder, '').replace(/\s+/g, ' ').trim();
2078
+ return [eventClause, blockOut].filter(s => s.length > 0).join(' ');
2079
+ }
2080
+
2081
+ /**
2082
+ * Transform an event handler whose body is an inline `unless` guard with NO
2083
+ * `end` (`on <event> unless <cond> <body>` — the `unless-condition` shape).
2084
+ *
2085
+ * Object-marking SVO targets (he, zh). `parseEventHandler` reads `unless` as the
2086
+ * action and sweeps the whole `<cond> <body>` tail into a single `patient` blob;
2087
+ * the target then prefixes that blob with its object marker — Hebrew's accusative
2088
+ * את (`… אלא את I match .disabled מתג .selected`) or Chinese's BA particle 把
2089
+ * (`… 除非 把 I match .disabled 切换 .selected`) — and the inner toggle loses its
2090
+ * own marker. The semantic parser can't recover the guard from that: the marker
2091
+ * ahead of the condition blocks the `unless` pattern AND the now-markerless body
2092
+ * command fails its object-marked toggle pattern, so the body collapses (`unless`
2093
+ * dropped). Marker-less languages (de/it/ar/pl) tolerate the same role-blob and
2094
+ * stay faithful, so this is an object-marker artifact, not a general parse gap.
2095
+ *
2096
+ * The standalone `unless <cond> <body>` path already produces the correct shape
2097
+ * (`extractBlockStructure` → `transformBlock`: condition kept marker-free, body
2098
+ * command keeps its marker — he `אלא I match .disabled מתג את .selected`, zh
2099
+ * `除非 I match .disabled 切换 把 .selected`). So we split the event head off,
2100
+ * transform the guard through that path, and emit the event clause first (he and
2101
+ * zh are both SVO — event leads). Returns `null` (fall through) when the input
2102
+ * isn't an object-marking event handler with an un-terminated inline `unless`
2103
+ * guard.
2104
+ */
2105
+ private tryTransformEventWithUnlessGuard(input: string): string | null {
2106
+ // SVO object-marking targets only — these front the unless tail with an object
2107
+ // marker (he את / zh 把) that breaks the parse. Event-leads emission below
2108
+ // assumes SVO, so SOV/VSO object-markers (ja/ko/tr/ar) are intentionally out.
2109
+ if (!UNLESS_GUARD_OBJECT_MARKING_LOCALES.has(this.targetProfile.code)) return null;
2110
+
2111
+ const tokens = tokenize(input, this.sourceProfile);
2112
+ if (tokens.length === 0) return null;
2113
+ if (!EVENT_KEYWORDS.has(tokens[0]?.toLowerCase())) return null;
2114
+
2115
+ let guardIdx = -1;
2116
+ for (let i = 1; i < tokens.length; i++) {
2117
+ if (tokens[i].toLowerCase() === 'unless') {
2118
+ guardIdx = i;
2119
+ break;
2120
+ }
2121
+ }
2122
+ if (guardIdx <= 0) return null;
2123
+ // The terminated form (`… unless … end`) is handled by
2124
+ // tryTransformEventWithBlockBody's masking path; only take the inline guard.
2125
+ if (tokens[tokens.length - 1].toLowerCase() === 'end') return null;
2126
+
2127
+ const eventHead = tokens.slice(0, guardIdx);
2128
+ const guard = tokens.slice(guardIdx).join(' ');
2129
+
2130
+ // `unless` is a BLOCK_HEAD keyword, so transform() routes the guard through
2131
+ // extractBlockStructure → transformBlock (the standalone shape that parses).
2132
+ const guardOut = this.transform(guard);
2133
+ if (!guardOut) return null;
2134
+
2135
+ const placeholder = 'EVENTGUARDPLACEHOLDER';
2136
+ const headOut = this.transformSingle([...eventHead, placeholder].join(' '));
2137
+ if (!headOut.includes(placeholder)) return null;
2138
+ const eventClause = headOut.replace(placeholder, '').replace(/\s+/g, ' ').trim();
2139
+ return [eventClause, guardOut].filter(s => s.length > 0).join(' ');
2140
+ }
2141
+
2142
+ /**
2143
+ * Transform an event handler whose body leads with a command-modifier
2144
+ * (`on <event> [from <src>] {async|once|debounced [at N]|throttled [at N]} <body>`).
2145
+ *
2146
+ * `parseEventHandler` reads the first token after the event as the **action**, so
2147
+ * a leading modifier is mistaken for the verb and the real verb (`fetch`/`add`) is
2148
+ * swept into the patient. For SOV targets the reorder then surfaces that verb
2149
+ * **first** (`取得 /api/data を クリック …`), and the semantic parser matches the
2150
+ * leading `<verb> <patient>` with the low-priority `*-generated-verb-first`
2151
+ * command pattern — returning a bare command and discarding the event + the rest
2152
+ * of the body (degenerate parse).
2153
+ *
2154
+ * Instead, lift the modifier out, transform the modifier-free handler through the
2155
+ * normal path (which keeps the body in canonical patient-first SOV order so the
2156
+ * event sits mid-stream and the existing SOV event-extraction recovers it), then
2157
+ * re-emit the modifier as a **leading English literal**. The semantic parser
2158
+ * strips a leading `once`/`debounced`/`throttled` (`extractStandaloneModifiers`)
2159
+ * and an `async` anywhere (`stripAsyncModifier`) before parsing, so the modifier
2160
+ * is consumed as handler metadata rather than shadowing the body.
2161
+ *
2162
+ * Returns `null` (fall through) when the input isn't an event handler or the body
2163
+ * doesn't lead with a modifier — leaving simple/Mode-B handlers byte-identical.
2164
+ */
2165
+ private tryTransformEventWithModifierBody(input: string): string | null {
2166
+ // The verb-first degenerate parse this works around is specific to SOV
2167
+ // reorder: only there does a leading modifier displace the patient-first
2168
+ // order and surface the verb first. SVO/VSO/V2/other targets keep the body
2169
+ // in an order the parser already handles, so leave them byte-identical.
2170
+ if (this.targetProfile.wordOrder !== 'SOV') return null;
2171
+
2172
+ const tokens = tokenize(input, this.sourceProfile);
2173
+ if (tokens.length === 0) return null;
2174
+ if (!EVENT_KEYWORDS.has(tokens[0]?.toLowerCase())) return null;
2175
+
2176
+ // Walk past the event clause head: event keyword, event token, any
2177
+ // `or`-conjoined events, and an optional `from <source>` modifier — mirroring
2178
+ // parseEventHandler's head parsing — to find where the body begins.
2179
+ let i = 1; // past the event keyword
2180
+ if (!tokens[i]) return null;
2181
+ i++; // past the event token
2182
+ while (tokens[i] && EVENT_CONJUNCTIONS.has(tokens[i].toLowerCase()) && tokens[i + 1]) {
2183
+ i += 2;
2184
+ }
2185
+ if (tokens[i]?.toLowerCase() === 'from' && tokens[i + 1]) {
2186
+ i++; // skip 'from'
2187
+ // Collect source tokens until a command verb or a body modifier.
2188
+ while (
2189
+ tokens[i] &&
2190
+ !ENGLISH_COMMANDS.has(tokens[i].toLowerCase()) &&
2191
+ !BODY_MODIFIER_KEYWORDS.has(tokens[i].toLowerCase())
2192
+ ) {
2193
+ i++;
2194
+ }
2195
+ }
2196
+
2197
+ const modWord = tokens[i]?.toLowerCase();
2198
+ if (!modWord || !BODY_MODIFIER_KEYWORDS.has(modWord)) return null;
2199
+
2200
+ // Consume the modifier phrase. `debounced`/`throttled` may carry an optional
2201
+ // `at <duration>` (or a bare duration). `async`/`once` are single tokens.
2202
+ const modStart = i;
2203
+ let modEnd = i + 1;
2204
+ if (modWord !== 'async' && modWord !== 'once') {
2205
+ if (tokens[modEnd]?.toLowerCase() === 'at') modEnd++;
2206
+ if (tokens[modEnd] && /^\d+(ms|s|m)?$/.test(tokens[modEnd])) modEnd++;
2207
+ }
2208
+
2209
+ const modifierPhrase = tokens.slice(modStart, modEnd).join(' ');
2210
+ const rebuilt = [...tokens.slice(0, modStart), ...tokens.slice(modEnd)].join(' ');
2211
+
2212
+ // A handler with only a modifier and no body has nothing to keep patient-first.
2213
+ if (tokens.length - (modEnd - modStart) <= 2) return null;
2214
+
2215
+ // Re-run the full transform on the modifier-free handler so then-chains and
2216
+ // juxtaposed bodies route through the existing (working) paths. The rebuilt
2217
+ // input no longer leads with a modifier, so this never re-enters here.
2218
+ const bodyOut = this.transform(rebuilt);
2219
+ return [modifierPhrase, bodyOut].filter(s => s.length > 0).join(' ');
2220
+ }
2221
+
2222
+ /**
2223
+ * Transform a `set <stuff> on <scope>` clause (S1 tabs-aria). The trailing
2224
+ * `on <scope>` is the element(s) the attribute is set on — kept attached by
2225
+ * splitOnCommandBoundaries. The semantic parser captures it as a `scope` role
2226
+ * via the passthrough literal `on` (setSchema's scope markerOverride is `on`
2227
+ * in every language), so `on` is emitted verbatim and only the scope *value*
2228
+ * is translated (selectors pass through; `me`/`it`/`you` translate to the
2229
+ * native reference, which the parser also accepts).
2230
+ *
2231
+ * Positioning matches where the set patterns expect the scope: at the clause
2232
+ * end for verb-first orders (SVO/VSO), and immediately before the clause-final
2233
+ * verb for SOV (the generated SOV pattern is `{dest} {patient} on {scope}
2234
+ * {verb}`). Returns null (fall through) when there is no trailing `on <scope>`.
2235
+ *
2236
+ * Source is English in the sync-translations pipeline, so the `set` verb and
2237
+ * `on` marker are matched as English literals.
2238
+ */
2239
+ private transformSetWithScope(input: string): string | null {
2240
+ const src = this.sourceProfile.code;
2241
+ const dst = this.targetProfile.code;
2242
+
2243
+ const m = input.match(/^(.*\bset\b.*\S)\s+on\s+([#.<@[]\S*|me|it|you)\s*$/i);
2244
+ if (!m) return null;
2245
+ const head = m[1];
2246
+ const scopeRaw = m[2];
2247
+
2248
+ // Transform the scope-less clause via the normal path; `head` no longer ends
2249
+ // in `on <scope>`, so this never re-enters transformSetWithScope.
2250
+ const headOut = this.transformSingle(head);
2251
+
2252
+ const scopeT = /^[#.<@[]/.test(scopeRaw) ? scopeRaw : translateWord(scopeRaw, src, dst);
2253
+
2254
+ // SOV needs the scope positioned per how the semantic set patterns match:
2255
+ // - Event-handler set (`on <event> set …`): the dest-first SOV event-handler
2256
+ // pattern is verb-MEDIAL and carries an optional trailing `[on {scope}]`,
2257
+ // so append the scope at the clause end.
2258
+ // - Standalone then-clause set: SOV emits verb-MEDIAL (`{dest} {verb}
2259
+ // {patient}`), but the only command set pattern with scope is verb-LAST
2260
+ // (`{dest} {patient} on {scope} {verb}`). Move the medial verb to the end
2261
+ // and place `on {scope}` before it, so the generated command pattern matches.
2262
+ if (this.targetProfile.wordOrder === 'SOV') {
2263
+ const verb = translateWord('set', 'en', dst);
2264
+ const firstTok = input.trim().split(/\s+/)[0]?.toLowerCase();
2265
+ const isEventHandler = !!firstTok && EVENT_KEYWORDS.has(firstTok);
2266
+ const toks = headOut.split(/\s+/).filter(Boolean);
2267
+ if (!isEventHandler) {
2268
+ // Standalone then-clause set: SOV emits verb-MEDIAL; move the verb to the
2269
+ // end and place `on {scope}` before it so the verb-last command set
2270
+ // pattern (scope before verb) matches.
2271
+ const vIdx = toks.indexOf(verb);
2272
+ if (vIdx >= 0) {
2273
+ toks.splice(vIdx, 1);
2274
+ toks.push('on', scopeT, verb);
2275
+ return toks.join(' ');
2276
+ }
2277
+ } else if (toks.length > 0 && toks[toks.length - 1] === verb) {
2278
+ // Event-handler set whose verb is clause-final (e.g. qu
2279
+ // `{dest} ta {patient} man {event} pi {verb}`): the parser extracts the
2280
+ // event and matches the body as a verb-last command, so the scope must
2281
+ // sit before the verb. Verb-MEDIAL SOV event handlers (ja/ko/tr/bn/hi)
2282
+ // fall through to the append branch, where their fused event-handler set
2283
+ // pattern carries the trailing `[on {scope}]` group.
2284
+ toks.splice(toks.length - 1, 0, 'on', scopeT);
2285
+ return toks.join(' ');
2286
+ }
2287
+ return `${headOut} on ${scopeT}`;
2288
+ }
2289
+
2290
+ // Verb-first (SVO/VSO/V2): append `on <scope>` at the clause end, which the
2291
+ // trailing `[on {scope}]` group on the set patterns matches.
2292
+ return `${headOut} on ${scopeT}`;
2293
+ }
2294
+
2295
+ /**
2296
+ * Transform a self-contained block command (`{head} {clause?} {body} {end}`),
2297
+ * where head ∈ {if, repeat, unless, …}. The clause (condition / `until event …`)
2298
+ * runs up to the first command verb and is translated word-by-word; the body is
2299
+ * recursively transformed (so its inner commands reorder for the target); the
2300
+ * head/tail keywords are translated. The block is never word-order reordered as
2301
+ * a whole — delimiters stay at the edges regardless of target word order.
2302
+ */
2303
+ private transformBlockBody(blockTokens: string[]): string {
2304
+ const src = this.sourceProfile.code;
2305
+ const dst = this.targetProfile.code;
2306
+
2307
+ const head = blockTokens[0];
2308
+ const hasEnd = blockTokens[blockTokens.length - 1]?.toLowerCase() === 'end';
2309
+ const tail = hasEnd ? blockTokens[blockTokens.length - 1] : '';
2310
+ const inner = blockTokens.slice(1, hasEnd ? -1 : undefined);
2311
+
2312
+ // Skip predicate-adjective positions (`… is empty`): the adjective is
2313
+ // part of the condition clause, not the body's first verb — cutting there
2314
+ // displaced it into the next command's argument zone (empty ×8 bn/hi/tr).
2315
+ const commands = getCommandKeywordsForLocale(src);
2316
+ const copulas = getCopulasForLocale(src);
2317
+ let bodyStart = inner.findIndex(
2318
+ (t, i) => commands.has(t.toLowerCase()) && !isPredicateAdjectivePosition(inner, i, copulas)
2319
+ );
2320
+ if (bodyStart < 0) bodyStart = inner.length;
2321
+
2322
+ const clause = inner.slice(0, bodyStart).join(' ');
2323
+ const bodyTokens = inner.slice(bodyStart);
2324
+
2325
+ const headT = translateWord(head, src, dst);
2326
+ const tailT = tail ? translateWord(tail, src, dst) : '';
2327
+ const clauseT = clause ? translateMultiWordValue(clause, src, dst) : '';
2328
+ const bodyT = this.transformConditionalBody(bodyTokens);
2329
+
2330
+ return [headT, clauseT, bodyT, tailT].filter(s => s.length > 0).join(' ');
2331
+ }
2332
+
2333
+ /**
2334
+ * Transform an `if`/`unless` block body, splitting it at a top-level `else` into
2335
+ * a then-branch and an else-branch so each is reordered as a self-contained unit
2336
+ * and the `else` keyword itself is translated. Without this, the body is reordered
2337
+ * as one stream: `else` rides along glued to the preceding clause (and, when that
2338
+ * clause begins with a selector, is marked a selector and left *untranslated*),
2339
+ * and a spurious `then` is inserted around it — both of which break the target
2340
+ * text and the downstream parse. The split is depth-aware so an `else` belonging
2341
+ * to a nested block is not mistaken for this block's separator. Bodies without an
2342
+ * `else` transform exactly as before.
2343
+ */
2344
+ private transformConditionalBody(bodyTokens: string[]): string {
2345
+ const src = this.sourceProfile.code;
2346
+ const dst = this.targetProfile.code;
2347
+
2348
+ const sourceElse = translateWord('else', 'en', src).toLowerCase();
2349
+ let depth = 0;
2350
+ let elseIdx = -1;
2351
+ for (let i = 0; i < bodyTokens.length; i++) {
2352
+ const t = bodyTokens[i].toLowerCase();
2353
+ if (BLOCK_BODY_KEYWORDS.has(t)) depth++;
2354
+ else if (t === 'end' && depth > 0) depth--;
2355
+ else if (t === sourceElse && depth === 0) {
2356
+ elseIdx = i;
2357
+ break;
2358
+ }
2359
+ }
2360
+
2361
+ if (elseIdx === -1) {
2362
+ const body = bodyTokens.join(' ');
2363
+ return body ? this.transform(body) : '';
2364
+ }
2365
+
2366
+ const thenBranch = bodyTokens.slice(0, elseIdx).join(' ');
2367
+ const elseBranch = bodyTokens.slice(elseIdx + 1).join(' ');
2368
+ const elseT = translateWord(bodyTokens[elseIdx], src, dst);
2369
+
2370
+ return [
2371
+ thenBranch ? this.transform(thenBranch) : '',
2372
+ elseT,
2373
+ elseBranch ? this.transform(elseBranch) : '',
2374
+ ]
2375
+ .filter(s => s.length > 0)
2376
+ .join(' ');
2377
+ }
2378
+
1342
2379
  /**
1343
2380
  * Translate a reactive block by translating the head/tail/connector
1344
2381
  * via the dictionary, recursively transforming the body through the