@lokascript/hyperscript-adapter 2.10.0 → 2.11.1
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/README.md +16 -11
- package/dist/hyperscript-i18n-ar.global.js +5 -6
- package/dist/hyperscript-i18n-bn.global.js +5 -6
- package/dist/hyperscript-i18n-de.global.js +5 -6
- package/dist/hyperscript-i18n-east-asian.global.js +5 -6
- package/dist/hyperscript-i18n-en.global.js +7 -8
- package/dist/hyperscript-i18n-es.global.js +5 -6
- package/dist/hyperscript-i18n-fr.global.js +5 -6
- package/dist/hyperscript-i18n-he.global.js +5 -6
- package/dist/hyperscript-i18n-hi.global.js +5 -6
- package/dist/hyperscript-i18n-id.global.js +5 -6
- package/dist/hyperscript-i18n-it.global.js +5 -6
- package/dist/hyperscript-i18n-ja.global.js +5 -6
- package/dist/hyperscript-i18n-ko.global.js +5 -6
- package/dist/hyperscript-i18n-lite.global.js +2 -2
- package/dist/hyperscript-i18n-ms.global.js +5 -6
- package/dist/hyperscript-i18n-pl.global.js +5 -6
- package/dist/hyperscript-i18n-pt.global.js +5 -6
- package/dist/hyperscript-i18n-qu.global.js +5 -6
- package/dist/hyperscript-i18n-ru.global.js +5 -6
- package/dist/hyperscript-i18n-slavic.global.js +5 -6
- package/dist/hyperscript-i18n-south-asian.global.js +5 -6
- package/dist/hyperscript-i18n-southeast-asian.global.js +5 -6
- package/dist/hyperscript-i18n-sw.global.js +5 -6
- package/dist/hyperscript-i18n-th.global.js +5 -6
- package/dist/hyperscript-i18n-tl.global.js +5 -6
- package/dist/hyperscript-i18n-tr.global.js +5 -6
- package/dist/hyperscript-i18n-uk.global.js +5 -6
- package/dist/hyperscript-i18n-vi.global.js +5 -6
- package/dist/hyperscript-i18n-western.global.js +5 -6
- package/dist/hyperscript-i18n-zh.global.js +5 -6
- package/dist/hyperscript-i18n.global.js +27 -28
- package/dist/hyperscript-i18n.global.js.map +1 -1
- package/dist/index.cjs +107 -86
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +75 -12
- package/dist/index.d.ts +75 -12
- package/dist/index.js +107 -93
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/attribute-translator.ts +12 -7
- package/src/browser-lite.ts +18 -3
- package/src/host-validate.ts +63 -0
- package/src/hyperscript-renderer.ts +64 -5
- package/src/index.ts +6 -1
- package/src/language-resolver.ts +15 -3
- package/src/plugin.ts +57 -7
- package/src/preprocessor-core.ts +217 -0
- package/src/preprocessor.ts +14 -215
- package/src/slim-plugin.ts +18 -3
- package/src/slim-preprocessor.ts +16 -154
|
@@ -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('
|
|
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
|
-
|
|
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 {
|
|
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';
|
package/src/language-resolver.ts
CHANGED
|
@@ -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
|
|
15
|
-
*
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|