@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.
- package/dist/browser.cjs +1274 -154
- package/dist/browser.cjs.map +1 -1
- package/dist/browser.d.cts +3 -3
- package/dist/browser.d.ts +3 -3
- package/dist/browser.js +1274 -155
- package/dist/browser.js.map +1 -1
- package/dist/dictionaries/index.cjs +364 -133
- package/dist/dictionaries/index.cjs.map +1 -1
- package/dist/dictionaries/index.d.cts +1 -1
- package/dist/dictionaries/index.d.ts +1 -1
- package/dist/dictionaries/index.js +364 -133
- package/dist/dictionaries/index.js.map +1 -1
- package/dist/index.cjs +8860 -7740
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +37 -37
- package/dist/index.d.ts +37 -37
- package/dist/index.js +8860 -7741
- package/dist/index.js.map +1 -1
- package/dist/lokascript-i18n.min.js +1 -1
- package/dist/lokascript-i18n.min.js.map +1 -1
- package/dist/lokascript-i18n.mjs +1697 -146
- package/dist/lokascript-i18n.mjs.map +1 -1
- package/dist/plugins/vite.cjs +359 -132
- package/dist/plugins/vite.cjs.map +1 -1
- package/dist/plugins/vite.js +359 -132
- package/dist/plugins/vite.js.map +1 -1
- package/dist/plugins/webpack.cjs +359 -132
- package/dist/plugins/webpack.cjs.map +1 -1
- package/dist/plugins/webpack.js +359 -132
- package/dist/plugins/webpack.js.map +1 -1
- package/dist/{transformer-BLv389qz.d.ts → transformer-BA8YX9u1.d.ts} +120 -2
- package/dist/{transformer-PFsYELrc.d.cts → transformer-DM7iplCU.d.cts} +120 -2
- package/dist/{types-BcALAO6h.d.cts → types-BYtpqGq3.d.cts} +1 -1
- package/dist/{types-BcALAO6h.d.ts → types-BYtpqGq3.d.ts} +1 -1
- package/package.json +6 -6
- package/src/browser.ts +1 -0
- package/src/command-primary-roles.test.ts +59 -0
- package/src/constants.ts +51 -0
- package/src/dictionaries/ar.ts +8 -3
- package/src/dictionaries/bn.ts +3 -1
- package/src/dictionaries/de.ts +19 -4
- package/src/dictionaries/derive.ts +4 -0
- package/src/dictionaries/en.ts +9 -0
- package/src/dictionaries/es.ts +2 -2
- package/src/dictionaries/fr.ts +6 -2
- package/src/dictionaries/he.ts +7 -1
- package/src/dictionaries/hi.ts +17 -10
- package/src/dictionaries/id.ts +15 -8
- package/src/dictionaries/it.ts +6 -3
- package/src/dictionaries/ja.ts +10 -4
- package/src/dictionaries/ko.ts +9 -4
- package/src/dictionaries/ms.ts +2 -1
- package/src/dictionaries/pl.ts +11 -3
- package/src/dictionaries/pt.ts +10 -4
- package/src/dictionaries/qu.ts +46 -22
- package/src/dictionaries/ru.ts +14 -7
- package/src/dictionaries/sw.ts +37 -10
- package/src/dictionaries/th.ts +3 -1
- package/src/dictionaries/tl.ts +14 -7
- package/src/dictionaries/tr.ts +22 -9
- package/src/dictionaries/uk.ts +13 -7
- package/src/dictionaries/vi.ts +3 -3
- package/src/dictionaries/zh.ts +10 -2
- package/src/examples/new-languages.ts +1 -1
- package/src/grammar/grammar.test.ts +1242 -1
- package/src/grammar/index.ts +1 -0
- package/src/grammar/profiles/index.ts +358 -12
- package/src/grammar/transformer.ts +1053 -16
- package/src/grammar/types.ts +22 -1
- package/src/index.ts +1 -0
- package/src/new-languages.test.ts +7 -3
- package/src/parser/parser-integration.test.ts +5 -1
- 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
|
-
/**
|
|
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
|
-
|
|
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
|
-
//
|
|
707
|
-
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|