@lokascript/hyperscript-adapter 2.9.4 → 2.11.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 (53) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/README.md +16 -11
  3. package/dist/hyperscript-i18n-ar.global.js +5 -6
  4. package/dist/hyperscript-i18n-bn.global.js +5 -6
  5. package/dist/hyperscript-i18n-de.global.js +5 -6
  6. package/dist/hyperscript-i18n-east-asian.global.js +5 -6
  7. package/dist/hyperscript-i18n-en.global.js +7 -8
  8. package/dist/hyperscript-i18n-es.global.js +5 -6
  9. package/dist/hyperscript-i18n-fr.global.js +5 -6
  10. package/dist/hyperscript-i18n-he.global.js +5 -6
  11. package/dist/hyperscript-i18n-hi.global.js +5 -6
  12. package/dist/hyperscript-i18n-id.global.js +5 -6
  13. package/dist/hyperscript-i18n-it.global.js +5 -6
  14. package/dist/hyperscript-i18n-ja.global.js +5 -6
  15. package/dist/hyperscript-i18n-ko.global.js +5 -6
  16. package/dist/hyperscript-i18n-lite.global.js +2 -2
  17. package/dist/hyperscript-i18n-ms.global.js +5 -6
  18. package/dist/hyperscript-i18n-pl.global.js +5 -6
  19. package/dist/hyperscript-i18n-pt.global.js +5 -6
  20. package/dist/hyperscript-i18n-qu.global.js +5 -6
  21. package/dist/hyperscript-i18n-ru.global.js +5 -6
  22. package/dist/hyperscript-i18n-slavic.global.js +5 -6
  23. package/dist/hyperscript-i18n-south-asian.global.js +5 -6
  24. package/dist/hyperscript-i18n-southeast-asian.global.js +5 -6
  25. package/dist/hyperscript-i18n-sw.global.js +5 -6
  26. package/dist/hyperscript-i18n-th.global.js +5 -6
  27. package/dist/hyperscript-i18n-tl.global.js +5 -6
  28. package/dist/hyperscript-i18n-tr.global.js +5 -6
  29. package/dist/hyperscript-i18n-uk.global.js +5 -6
  30. package/dist/hyperscript-i18n-vi.global.js +5 -6
  31. package/dist/hyperscript-i18n-western.global.js +5 -6
  32. package/dist/hyperscript-i18n-zh.global.js +5 -6
  33. package/dist/hyperscript-i18n.global.js +27 -28
  34. package/dist/hyperscript-i18n.global.js.map +1 -1
  35. package/dist/index.cjs +107 -86
  36. package/dist/index.cjs.map +1 -1
  37. package/dist/index.d.cts +75 -12
  38. package/dist/index.d.ts +75 -12
  39. package/dist/index.js +107 -93
  40. package/dist/index.js.map +1 -1
  41. package/package.json +2 -2
  42. package/src/attribute-translator.ts +12 -7
  43. package/src/browser-lite.ts +18 -3
  44. package/src/generated/syntax-table.ts +4 -4
  45. package/src/host-validate.ts +63 -0
  46. package/src/hyperscript-renderer.ts +64 -5
  47. package/src/index.ts +6 -1
  48. package/src/language-resolver.ts +15 -3
  49. package/src/plugin.ts +57 -7
  50. package/src/preprocessor-core.ts +217 -0
  51. package/src/preprocessor.ts +14 -215
  52. package/src/slim-plugin.ts +18 -3
  53. package/src/slim-preprocessor.ts +16 -154
@@ -43,12 +43,12 @@ export const SYNTAX: Record<string, readonly [string, string][]> = {
43
43
  log: [['patient', '']],
44
44
  make: [['patient', '']],
45
45
  measure: [['patient', ''], ['source', 'of']],
46
- morph: [['patient', ''], ['destination', 'to']],
46
+ morph: [['patient', ''], ['destination', 'to'], ['manner', 'using view']],
47
47
  on: [['event', ''], ['source', 'from']],
48
48
  open: [['patient', ''], ['style', 'as']],
49
49
  pick: [['patient', ''], ['source', 'from']],
50
50
  prepend: [['patient', ''], ['destination', 'to']],
51
- process: [['patient', 'partials in']],
51
+ process: [['patient', 'partials in'], ['manner', 'using view']],
52
52
  push: [['patient', 'url']],
53
53
  put: [['patient', ''], ['destination', 'into']],
54
54
  remove: [['patient', ''], ['source', 'from']],
@@ -64,8 +64,8 @@ export const SYNTAX: Record<string, readonly [string, string][]> = {
64
64
  settle: [['patient', '']],
65
65
  show: [['patient', ''], ['style', 'with']],
66
66
  socket: [],
67
- swap: [['method', ''], ['destination', 'of'], ['patient', 'with']],
68
- take: [['patient', ''], ['source', 'from']],
67
+ swap: [['method', ''], ['destination', 'of'], ['patient', 'with'], ['manner', 'using view']],
68
+ take: [['patient', ''], ['source', 'from'], ['recipient', 'for']],
69
69
  tell: [['destination', '']],
70
70
  throw: [['patient', '']],
71
71
  toggle: [['patient', ''], ['destination', 'on'], ['duration', 'for']],
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Host-parser validity gate (review item F8).
3
+ *
4
+ * After the preprocessor rewrites an attribute to English, the plugin asks
5
+ * the HOST _hyperscript runtime — the same parser that will consume the
6
+ * rewrite moments later — whether the result actually parses. On rejection
7
+ * the plugin falls back to the author's original text, so any parse error
8
+ * the author then sees names code they wrote, not invisible generated
9
+ * English. This is the runtime analog of the offline R4 canonical-validity
10
+ * gate, and the F5 arc measured its failure class shipping in practice:
11
+ * until the whole-string-first reorder (#899), 256 corpus rows rendered
12
+ * English the engine rejects, with no warning anywhere.
13
+ *
14
+ * The engine has two failure channels — `parse().errors` collects grammar
15
+ * errors, and the tokenizer THROWS on an unknown character — folded here
16
+ * the same way test/whole-string-first.test.ts folds them.
17
+ *
18
+ * Zero-dependency module: shared by the full, slim, and lite plugin
19
+ * variants, which must not share heavier import chains (the slim/lite
20
+ * bundles exclude the full semantic package by construction).
21
+ */
22
+
23
+ export interface HyperscriptParseHost {
24
+ parse?: (src: string) => { errors?: unknown[] } | null | undefined;
25
+ }
26
+
27
+ /**
28
+ * True when the host's parser accepts `src`. Also true when the host
29
+ * exposes no `parse()` — with nothing to validate against, the gate
30
+ * degrades to a no-op rather than suppressing translation on unusual
31
+ * builds (same graceful posture as the `addBeforeProcessHook` check).
32
+ */
33
+ export function acceptedByHost(hs: HyperscriptParseHost, src: string): boolean {
34
+ if (typeof hs.parse !== 'function') return true;
35
+ try {
36
+ const result = hs.parse(src);
37
+ return !result?.errors || result.errors.length === 0;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ /** Languages already warned about a rejected translation this page load —
44
+ * same warn-once-per-lang convention as the unchanged-translation warning
45
+ * (and htmx-adapter's warnMissingLangOnce). */
46
+ const warnedRejectedLang = new Set<string>();
47
+
48
+ /** Reset the warn-once state. Mainly for tests. */
49
+ export function resetHostValidationWarnings(): void {
50
+ warnedRejectedLang.clear();
51
+ }
52
+
53
+ export function warnRejectedOnce(lang: string, src: string, english: string): void {
54
+ if (warnedRejectedLang.has(lang)) return;
55
+ warnedRejectedLang.add(lang);
56
+ console.warn(
57
+ `[hyperscript-i18n] Translation for lang="${lang}" rendered hyperscript the host parser ` +
58
+ `rejects — falling back to the original text. ` +
59
+ `Source: "${src.length > 60 ? src.slice(0, 60) + '…' : src}" → ` +
60
+ `"${english.length > 60 ? english.slice(0, 60) + '…' : english}". ` +
61
+ 'Further elements in this language stay quiet — enable { debug: true } for per-element detail.'
62
+ );
63
+ }
@@ -28,6 +28,27 @@ import type {
28
28
  import { SYNTAX } from './generated/syntax-table';
29
29
  export { SYNTAX };
30
30
 
31
+ // ---------------------------------------------------------------------------
32
+ // Statement-join rules. Mirrors the semantic package's renderer
33
+ // (explicit/renderer.ts) — the slim path has to reach the same English, and a
34
+ // chain word in the wrong seam is a hard parse error on the real engine, not a
35
+ // style difference.
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * Block-header commands: their body follows the header DIRECTLY. `repeat 3
40
+ * times then add …` is rejected ("Expected 'end' but found 'then'"), as is
41
+ * `tell #panel then add …`.
42
+ */
43
+ const BLOCK_HEADER_ACTIONS = new Set(['repeat', 'for', 'while', 'tell']);
44
+
45
+ /**
46
+ * Commands whose captured body is an open-ended block that must be closed by an
47
+ * explicit `end` when a sibling follows: `js foo() then add …` otherwise bleeds
48
+ * the following hyperscript into the JavaScript body.
49
+ */
50
+ const BLOCK_NEEDS_TRAILING_END = new Set(['js']);
51
+
31
52
  // ---------------------------------------------------------------------------
32
53
  // Main entry point
33
54
  // ---------------------------------------------------------------------------
@@ -53,10 +74,14 @@ export function renderToHyperscript(node: SemanticNode): string {
53
74
  function renderEventHandler(node: EventHandlerSemanticNode): string {
54
75
  const parts: string[] = ['on'];
55
76
 
56
- // Event name
77
+ // Event name. Rendered BARE, never through renderValue: the parser
78
+ // produces the event as a string-typed literal, which renderValue would
79
+ // quote — and `on "click"` is a hard parse error on the real engine
80
+ // ("Expected event name"). Event names are identifiers in canonical
81
+ // hyperscript (`on click`, `on draggable:start`), never quoted strings.
57
82
  const event = node.roles.get('event');
58
83
  if (event) {
59
- parts.push(renderValue(event));
84
+ parts.push(event.type === 'literal' ? String(event.value) : renderValue(event));
60
85
  }
61
86
 
62
87
  // Event source (from #element)
@@ -65,10 +90,13 @@ function renderEventHandler(node: EventHandlerSemanticNode): string {
65
90
  parts.push('from', renderValue(source));
66
91
  }
67
92
 
68
- // Body commands
93
+ // Body commands. Space-joined, matching the semantic renderer: the chain word
94
+ // between sibling body commands is optional in canonical hyperscript, and
95
+ // joining with ` then ` unconditionally injected one after a block header
96
+ // (`on click tell #panel then add .open` — rejected by the real engine).
69
97
  if (node.body && node.body.length > 0) {
70
98
  const bodyParts = node.body.map(renderToHyperscript);
71
- parts.push(bodyParts.join(' then '));
99
+ parts.push(bodyParts.join(' '));
72
100
  }
73
101
 
74
102
  return parts.join(' ');
@@ -76,7 +104,26 @@ function renderEventHandler(node: EventHandlerSemanticNode): string {
76
104
 
77
105
  function renderCompound(node: CompoundSemanticNode): string {
78
106
  const chainWord = node.chainType === 'async' ? 'async' : node.chainType;
79
- return node.statements.map(renderToHyperscript).join(` ${chainWord} `);
107
+ const rendered = node.statements.map(renderToHyperscript);
108
+ let out = rendered[0] ?? '';
109
+ for (let i = 1; i < rendered.length; i++) {
110
+ const prev = node.statements[i - 1];
111
+ const cur = node.statements[i];
112
+ const afterBlockHeader = prev.kind === 'command' && BLOCK_HEADER_ACTIONS.has(prev.action);
113
+ // Consecutive top-level `bind` features are separate reactive features, not
114
+ // a then-chain: `bind $x to #a then bind $x to #b` is rejected ("Unexpected
115
+ // Token : then" between features).
116
+ const betweenBindFeatures =
117
+ prev.kind === 'command' &&
118
+ prev.action === 'bind' &&
119
+ cur.kind === 'command' &&
120
+ cur.action === 'bind';
121
+ if (prev.kind === 'command' && BLOCK_NEEDS_TRAILING_END.has(prev.action)) {
122
+ out += ' end';
123
+ }
124
+ out += (afterBlockHeader || betweenBindFeatures ? ' ' : ` ${chainWord} `) + rendered[i];
125
+ }
126
+ return out;
80
127
  }
81
128
 
82
129
  function renderCommand(node: SemanticNode): string {
@@ -92,6 +139,18 @@ function renderCommand(node: SemanticNode): string {
92
139
  // `add .active` not `add .active to me`; `remove .hidden` not `remove .hidden
93
140
  // from me`. Mirrors the semantic renderer's implicit-me suppression so the
94
141
  // full and slim (custom-renderer) paths agree.
142
+ //
143
+ // KNOWN GAP, deliberately unfixed here: semantic's renderer EXCEPTS a
144
+ // string-content patient (`add "<p>Line</p>" to me` keeps the
145
+ // destination — the bare form is rejected by the engine). Mirroring
146
+ // that exception is correct in isolation, but it may not ship before
147
+ // the generated repeat patterns capture quantity: measured on the
148
+ // parity corpus, the exception alone turns the es `repeat` row's slim
149
+ // output from engine-INVALID (host-validate gate → safe fallback to
150
+ // the author's text) into the engine-VALID `on click repeat add
151
+ // "<p>Line</p>" to me` — a bare `repeat` is FOREVER, so a warned
152
+ // no-op becomes a committed infinite loop. The parity slim test pins
153
+ // that row's output staying invalid until both fixes land together.
95
154
  if (
96
155
  (role === 'destination' || role === 'source') &&
97
156
  value.type === 'reference' &&
package/src/index.ts CHANGED
@@ -22,6 +22,11 @@
22
22
  * _hyperscript(english);
23
23
  */
24
24
 
25
- export { hyperscriptI18n, preprocess, type PluginOptions } from './plugin';
25
+ export {
26
+ hyperscriptI18n,
27
+ preprocess,
28
+ resetTranslationWarnings,
29
+ type PluginOptions,
30
+ } from './plugin';
26
31
  export { preprocessToEnglish, type PreprocessorConfig } from './preprocessor';
27
32
  export { resolveLanguage } from './language-resolver';
@@ -11,8 +11,16 @@
11
11
  * Resolution order:
12
12
  * 1. `data-lang` attribute on the element itself
13
13
  * 2. `data-hyperscript-lang` attribute on the element or closest ancestor
14
- * 3. `lang` attribute on `<html>` element
15
- * 4. null (assume English, no preprocessing needed)
14
+ * 3. `lang` attribute on the element or closest ancestor (the HTML-standard
15
+ * cascade — a `<section lang="es">` localizes everything inside it, and a
16
+ * nested `lang="en"` opts back out). This is how the paired htmx-adapter
17
+ * (`langOf()`) and loka-js resolve language, so `hx-*` and `_` attributes
18
+ * on the same element agree.
19
+ * 4. `lang` on `<html>` via `document.documentElement` — only reachable when
20
+ * the element is DETACHED (step 3's ancestor walk covers `<html>` for
21
+ * attached elements): a hook processing a not-yet-inserted fragment still
22
+ * picks up the page default.
23
+ * 5. null (assume English, no preprocessing needed)
16
24
  */
17
25
  export function resolveLanguage(elt: Element): string | null {
18
26
  // 1. Explicit per-element
@@ -25,7 +33,11 @@ export function resolveLanguage(elt: Element): string | null {
25
33
  elt.closest?.('[data-hyperscript-lang]')?.getAttribute('data-hyperscript-lang');
26
34
  if (hsLang) return normalizeLangCode(hsLang);
27
35
 
28
- // 3. Document-level lang
36
+ // 3. Standard lang cascade (nearest ancestor wins)
37
+ const closestLang = elt.closest?.('[lang]')?.getAttribute('lang');
38
+ if (closestLang) return normalizeLangCode(closestLang);
39
+
40
+ // 4. Document-level lang (detached elements only — see doc comment)
29
41
  const htmlLang = typeof document !== 'undefined' ? document.documentElement?.lang : null;
30
42
  if (htmlLang && htmlLang !== 'en') return normalizeLangCode(htmlLang);
31
43
 
package/src/plugin.ts CHANGED
@@ -8,6 +8,12 @@
8
8
  import { resolveLanguage } from './language-resolver';
9
9
  import { preprocessToEnglish, type PreprocessorConfig } from './preprocessor';
10
10
  import { installAttributeTranslator, type HyperscriptHost } from './attribute-translator';
11
+ import {
12
+ acceptedByHost,
13
+ warnRejectedOnce,
14
+ resetHostValidationWarnings,
15
+ type HyperscriptParseHost,
16
+ } from './host-validate';
11
17
 
12
18
  export interface PluginOptions extends Partial<PreprocessorConfig> {
13
19
  /** Default language for all elements (overridable per-element). */
@@ -16,6 +22,38 @@ export interface PluginOptions extends Partial<PreprocessorConfig> {
16
22
  languageAttribute?: string;
17
23
  /** Enable debug logging to console. Default: false */
18
24
  debug?: boolean;
25
+ /**
26
+ * Validate rendered English on the HOST parser before committing the
27
+ * rewrite; on rejection, fall back to the original text (so parse errors
28
+ * name the author's code, not generated English). Default: true.
29
+ * No-op on host builds that expose no `parse()`.
30
+ */
31
+ validateWithHost?: boolean;
32
+ }
33
+
34
+ /** Languages already warned about an unchanged translation this page load.
35
+ * Unchanged output is common and often legitimate (canonical-English
36
+ * hyperscript under a non-en lang scope), so warning per element per
37
+ * processNode was pure noise — mirror htmx-adapter's warn-once-per-lang
38
+ * convention and leave per-element detail to `debug: true`. */
39
+ const warnedUnchangedLang = new Set<string>();
40
+
41
+ /** Reset the warn-once state (unchanged + host-rejected). Mainly for tests. */
42
+ export function resetTranslationWarnings(): void {
43
+ warnedUnchangedLang.clear();
44
+ resetHostValidationWarnings();
45
+ }
46
+
47
+ function warnUnchangedOnce(lang: string, src: string): void {
48
+ if (warnedUnchangedLang.has(lang)) return;
49
+ warnedUnchangedLang.add(lang);
50
+ console.warn(
51
+ `[hyperscript-i18n] Translation unchanged for lang="${lang}": "${src.length > 60 ? src.slice(0, 60) + '…' : src}". ` +
52
+ 'This is fine if the source is already canonical English; otherwise the input may not match ' +
53
+ 'any known pattern, or the language may not be registered. Original text is passed to ' +
54
+ '_hyperscript as-is. Further elements in this language stay quiet — enable { debug: true } ' +
55
+ 'for per-element detail.'
56
+ );
19
57
  }
20
58
 
21
59
  /**
@@ -35,7 +73,8 @@ export interface PluginOptions extends Partial<PreprocessorConfig> {
35
73
  */
36
74
  export function hyperscriptI18n(options: PluginOptions = {}) {
37
75
  return function plugin(hs: unknown): void {
38
- installAttributeTranslator(hs as HyperscriptHost, (src, elt) => {
76
+ const host = hs as HyperscriptHost & HyperscriptParseHost;
77
+ installAttributeTranslator(host, (src, elt) => {
39
78
  // Resolve language
40
79
  const lang = resolveLanguageWithOptions(elt, options);
41
80
 
@@ -46,16 +85,27 @@ export function hyperscriptI18n(options: PluginOptions = {}) {
46
85
  const english = preprocessToEnglish(src, lang, options);
47
86
 
48
87
  if (english !== src) {
88
+ // Validity gate: the host parser is the consumer of this rewrite —
89
+ // if it rejects the English, committing it would only trade a
90
+ // translation gap for a parse error naming code the author never
91
+ // wrote. Fall back to the original text instead.
92
+ if (options.validateWithHost !== false && !acceptedByHost(host, english)) {
93
+ if (options.debug) {
94
+ console.log(
95
+ `[hyperscript-i18n] ${lang}: host rejected "${english}" — keeping "${src}"`
96
+ );
97
+ } else {
98
+ warnRejectedOnce(lang, src, english);
99
+ }
100
+ return src;
101
+ }
49
102
  if (options.debug) {
50
103
  console.log(`[hyperscript-i18n] ${lang}: "${src}" → "${english}"`);
51
104
  }
105
+ } else if (options.debug) {
106
+ console.log(`[hyperscript-i18n] ${lang}: unchanged "${src}"`);
52
107
  } else {
53
- // Translation produced no change — likely a failure
54
- console.warn(
55
- `[hyperscript-i18n] Translation unchanged for lang="${lang}": "${src.length > 60 ? src.slice(0, 60) + '…' : src}". ` +
56
- 'The input may not match any known pattern, or the language may not be registered. ' +
57
- 'Original text will be passed to _hyperscript as-is.'
58
- );
108
+ warnUnchangedOnce(lang, src);
59
109
  }
60
110
 
61
111
  return english;
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Preprocessor core — the shared skeleton of the full and slim
3
+ * preprocessors.
4
+ *
5
+ * Everything that is NOT genuinely different between the two paths lives
6
+ * here exactly once: config defaults/threshold resolution, strategy
7
+ * ordering (semantic → optional i18n), event-prefix stripping, and the
8
+ * return-original fallback. The two real differences stay with each path,
9
+ * injected as hooks:
10
+ *
11
+ * - HOW one statement is parsed and rendered to English
12
+ * (`translateSingle`): the full path uses semantic's parseSemantic +
13
+ * render + a translate() rescue for confident-but-nodeless parses;
14
+ * the slim path uses parseWithConfidence + the custom
15
+ * hyperscript-renderer (no English language data).
16
+ * - WHERE the registry lookup comes from (`isLanguageRegistered`):
17
+ * `@lokascript/semantic` vs `…/core` — under tsup's split dist these
18
+ * are separate registry instances, so each path must bring its own.
19
+ *
20
+ * Behavior is pinned by the parity ratchet
21
+ * (test/preprocessor-parity.*.test.ts + the committed fixture): both paths
22
+ * are byte-identical to their pre-extraction outputs over the corpus.
23
+ *
24
+ * HISTORY: the skeleton used to carry a compound-splitting fallback
25
+ * (`splitStatements` on localized `then` keywords + newlines,
26
+ * `translateCompound` rejoining pieces with a hardcoded ` then `), run when
27
+ * the whole-string parse declined. Deleted 2026-08-07 after measurement:
28
+ * over all 3703 patterns.db corpus translations plus the parity corpus, on
29
+ * BOTH paths, the split arm produced ZERO final outputs (full: 115
30
+ * invocations, all returned null; slim: 32, all null) — post-whole-string-
31
+ * first (#899), an input whose pieces each parse would have parsed whole,
32
+ * so the arm's success condition is self-defeating. It was also defective
33
+ * twice over: the regex split was string-literal-blind (`say 'now then
34
+ * later'` split inside the literal) and the ` then ` rejoin is invalid
35
+ * immediately after block headers. Deleting it erased both defect classes
36
+ * wholesale; the parity fixture regenerated byte-identical.
37
+ */
38
+
39
+ export interface PreprocessorConfig {
40
+ /**
41
+ * Minimum confidence threshold for semantic parsing (0-1). Default: 0.5.
42
+ * Can be a single number (applies to all languages) or a per-language map.
43
+ * Per-language thresholds are useful because SOV languages (ja, ko, tr) produce
44
+ * inherently lower confidence scores than SVO languages (es, fr, de).
45
+ *
46
+ * @example
47
+ * // Single threshold
48
+ * { confidenceThreshold: 0.5 }
49
+ *
50
+ * @example
51
+ * // Per-language thresholds
52
+ * { confidenceThreshold: { es: 0.7, ja: 0.1, ko: 0.05, '*': 0.5 } }
53
+ */
54
+ confidenceThreshold: number | Record<string, number>;
55
+ /** Strategy: 'semantic' (default), 'i18n', or 'auto' (semantic then i18n) */
56
+ strategy: 'semantic' | 'i18n' | 'auto';
57
+ /**
58
+ * @deprecated Never implemented — `preprocessToEnglish` always returns a
59
+ * string, and on translation failure that string is the original source
60
+ * (there is nothing else it could return). The option has had no effect in
61
+ * any released version and is ignored; it will be removed in a future major.
62
+ */
63
+ fallbackToOriginal?: boolean;
64
+ /** Optional i18n toEnglish function (loaded dynamically if available) */
65
+ i18nToEnglish?: (input: string, locale: string) => string;
66
+ }
67
+
68
+ /** The per-path seam — see module doc. */
69
+ export interface PreprocessorHooks {
70
+ isLanguageRegistered(lang: string): boolean;
71
+ /**
72
+ * Parse ONE statement in `lang` and render it to English. Return null
73
+ * when it cannot be translated (below-threshold confidence, no node, …);
74
+ * the skeleton then tries the remaining strategies / falls back.
75
+ */
76
+ translateSingle(src: string, lang: string, threshold: number): string | null;
77
+ }
78
+
79
+ const DEFAULT_THRESHOLD = 0.5;
80
+
81
+ const DEFAULT_CONFIG: PreprocessorConfig = {
82
+ confidenceThreshold: DEFAULT_THRESHOLD,
83
+ strategy: 'semantic',
84
+ };
85
+
86
+ /**
87
+ * Resolve the confidence threshold for a specific language.
88
+ * Supports both a single number and a per-language map with '*' as default.
89
+ */
90
+ function resolveThreshold(threshold: number | Record<string, number>, lang: string): number {
91
+ if (typeof threshold === 'number') return threshold;
92
+ return threshold[lang] ?? threshold['*'] ?? DEFAULT_THRESHOLD;
93
+ }
94
+
95
+ /**
96
+ * Match _hyperscript event handler prefix: "on [every] <event>[filter][.modifiers] "
97
+ *
98
+ * Examples:
99
+ * "on click toggle .active" → prefix: "on click ", commands: "toggle .active"
100
+ * "on every click toggle .active" → prefix: "on every click ", commands: "toggle .active"
101
+ * "on click.debounce(300) toggle .x" → prefix: "on click.debounce(300) ", commands: "toggle .x"
102
+ * "on keyup[key=='Enter'] set x to 1" → prefix: "on keyup[key=='Enter'] ", commands: "set x to 1"
103
+ * "on click from body toggle .active" → prefix: "on click from body ", commands: "toggle .active"
104
+ */
105
+ const EVENT_PREFIX_RE =
106
+ /^(on\s+(?:every\s+)?[\w-]+(?:\[.*?\])?(?:\.[\w-]+(?:\([^)]*\))?)*(?:\s+from\s+\S+)?(?:\s+queue\s+\w+)?\s+)/;
107
+
108
+ function stripEventPrefix(src: string): { prefix: string; commands: string } | null {
109
+ const match = src.match(EVENT_PREFIX_RE);
110
+ if (!match) return null;
111
+ const prefix = match[1];
112
+ const commands = src.slice(prefix.length);
113
+ if (!commands) return null;
114
+ return { prefix, commands };
115
+ }
116
+
117
+ /**
118
+ * Build a `preprocessToEnglish` function from the per-path hooks.
119
+ *
120
+ * Handles _hyperscript feature prefixes (e.g. "on click", "on every keyup")
121
+ * by stripping them, translating only the command portion, then reassembling.
122
+ */
123
+ export function createPreprocessToEnglish(
124
+ hooks: PreprocessorHooks
125
+ ): (src: string, lang: string, config?: Partial<PreprocessorConfig>) => string {
126
+ /**
127
+ * Try semantic translation: parse the WHOLE string in the source
128
+ * language, render it to English. Returns null if confidence is below
129
+ * threshold. The semantic parser handles `then`-sequences, newlines,
130
+ * loop/tell bodies and behavior blocks natively (top-level
131
+ * command-sequence support landed with the multiset-recall arc), so
132
+ * there is deliberately no pre-splitting here — see the module doc's
133
+ * HISTORY note for the measured deletion of the old split fallback.
134
+ */
135
+ function trySemanticTranslation(src: string, lang: string, threshold: number): string | null {
136
+ try {
137
+ // Check if the semantic parser can handle this
138
+ if (!hooks.isLanguageRegistered(lang)) return null;
139
+
140
+ return hooks.translateSingle(src, lang, threshold);
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Try i18n grammar transformation to English.
148
+ * Returns null if the result is identical to input (no translation happened).
149
+ */
150
+ function tryI18nTranslation(
151
+ src: string,
152
+ lang: string,
153
+ toEnglish: (input: string, locale: string) => string
154
+ ): string | null {
155
+ try {
156
+ const result = toEnglish(src, lang);
157
+ return result !== src ? result : null;
158
+ } catch {
159
+ return null;
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Try all configured translation strategies on the input.
165
+ * Returns null if none succeed.
166
+ */
167
+ function tryTranslateWithStrategies(
168
+ src: string,
169
+ lang: string,
170
+ cfg: PreprocessorConfig
171
+ ): string | null {
172
+ // Strategy: semantic-only
173
+ if (cfg.strategy === 'semantic' || cfg.strategy === 'auto') {
174
+ const threshold = resolveThreshold(cfg.confidenceThreshold, lang);
175
+ const result = trySemanticTranslation(src, lang, threshold);
176
+ if (result !== null) return result;
177
+ }
178
+
179
+ // Strategy: i18n fallback (auto mode or i18n-only)
180
+ if ((cfg.strategy === 'auto' || cfg.strategy === 'i18n') && cfg.i18nToEnglish) {
181
+ const result = tryI18nTranslation(src, lang, cfg.i18nToEnglish);
182
+ if (result !== null) return result;
183
+ }
184
+
185
+ return null;
186
+ }
187
+
188
+ return function preprocessToEnglish(
189
+ src: string,
190
+ lang: string,
191
+ config: Partial<PreprocessorConfig> = {}
192
+ ): string {
193
+ // English→English is identity; skip semantic parsing which may mangle
194
+ // the input. (Historically full-path only — the slim path gained it in
195
+ // the shared-skeleton extraction, deliberately: it is a mangle guard.)
196
+ if (lang === 'en') return src;
197
+
198
+ const cfg = { ...DEFAULT_CONFIG, ...config };
199
+
200
+ // Try translating the full string first
201
+ const fullResult = tryTranslateWithStrategies(src, lang, cfg);
202
+ if (fullResult !== null) return fullResult;
203
+
204
+ // If full translation failed, try stripping event/feature prefix.
205
+ // _hyperscript attributes often contain "on <event> <commands>" — the semantic
206
+ // parser only understands command syntax, not event declarations.
207
+ const stripped = stripEventPrefix(src);
208
+ if (stripped) {
209
+ const translated = tryTranslateWithStrategies(stripped.commands, lang, cfg);
210
+ if (translated !== null) return stripped.prefix + translated;
211
+ }
212
+
213
+ // Fallback: return original (unconditional — see fallbackToOriginal's
214
+ // deprecation note; the string contract leaves nothing else to return)
215
+ return src;
216
+ };
217
+ }