HS runtime + generator: make, Values, toggle styles, scoped storage, array ops, fetch coercion, scripts in PW bodies

Runtime (lib/hyperscript/ + shared/static/wasm/sx/hs-*.sx):
- make: parser accepts `<tag.class#id/>` selectors and `from <expr>,…`; compiler
  emits via scoped-set so `called <name>` persists; `called $X` lands on
  window; runtime dispatches element vs host-new constructor by type.
- Values: `x as Values` walks form inputs/selects/textareas, producing
  {name: value | [value,…]}; duplicates promote to array; multi-select and
  checkbox/radio handled.
- toggle *display/*visibility/*opacity: paired with sensible inline defaults
  in the mock DOM so toggle flips block/visible/1 ↔ none/hidden/0.
- add/remove/put at array: emit-set paths route list mutations back through
  the scoped binding; add hs-put-at! / hs-splice-at! / hs-dict-without.
- remove OBJ.KEY / KEY of OBJ: rebuild dict via hs-dict-without and reassign,
  since SX dicts are copy-on-read across the bridge.
- dom-set-data: use (host-new "Object") rather than (dict) so element-local
  storage actually persists between reads.
- fetch: hs-fetch normalizes JSON/Object/Text/Response format aliases;
  compiler sets `the-result` when wrapping a fetch in the `let ((it …))`
  chain, and __get-cmd shares one evaluation via __hs-g.

Mock DOM (tests/hs-run-filtered.js):
- parseHTMLFragments accepts void elements (<input>, <br>, …);
- setAttribute tracks name/type/checked/selected/multiple;
- select.options populated on appendChild;
- insertAdjacentHTML parses fragments and inserts real El children into the
  parent so HS-activated handlers attach.

Generator (tests/playwright/generate-sx-tests.py):
- process_hs_val strips `//` / `--` line comments before newline→then
  collapse, and strips spurious `then` before else/end/catch/finally.
- parse_dev_body interleaves window-setup ops and DOM resets between
  actions/assertions; pre-html setups still emit up front.
- generate_test_pw compiles any `<script type=text/hyperscript>` (flattened
  across JS string-concat) under guard, exposing def blocks.
- Ordered ops for `run()`-style tests check window.obj.prop via new
  _js_window_expr_to_sx; add DOM-constructing evaluate + _hyperscript
  pattern for `as Values` tests (result.key[i].toBe(…)).
- js_val_to_sx handles backticks and escapes embedded quotes.

Net delta across suites:
- if 16→18, make 0→8, toggle 12→21, add 9→10, remove 11→16, put 29→31,
  fetch 11→15, repeat 14→26, expressions/asExpression 20→25, set 27→28,
  core/scoping 12→14, when 39→39 (no regression).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 16:08:07 +00:00
parent b90aa54dd0
commit 5b100cac17
10 changed files with 1833 additions and 762 deletions

View File

@@ -21,17 +21,27 @@ function setStepLimit(n) { K.setStepLimit(n); }
function resetStepCount() { K.resetStepCount(); }
// ─── DOM mock ──────────────────────────────────────────────────
function mkStyle() { const s={}; s.setProperty=function(p,v){s[p]=v;}; s.getPropertyValue=function(p){return s[p]||'';}; s.removeProperty=function(p){delete s[p];}; return s; }
// Default CSS values for unset inline styles (matches browser UA defaults
// for block-level elements — our tests mostly exercise divs).
function mkStyle(tag) {
const inline = ['SPAN','A','B','I','EM','STRONG','CODE','LABEL','SMALL','SUB','SUP','U','MARK'];
const display = (tag && inline.includes(tag.toUpperCase())) ? 'inline' : 'block';
const s = { display, visibility: 'visible', opacity: '1' };
s.setProperty = function(p, v) { s[p] = v; };
s.getPropertyValue = function(p) { return s[p] || ''; };
s.removeProperty = function(p) { delete s[p]; };
return s;
}
class El {
constructor(t) { this.tagName=t.toUpperCase(); this.nodeName=this.tagName; this.nodeType=1; this.id=''; this.className=''; this.classList=new CL(this); this.style=mkStyle(); this.attributes={}; this.children=[]; this.childNodes=[]; this.childNodes.item=function(i){return this[i]||null;}; this.parentElement=null; this.parentNode=null; this.textContent=''; this.innerHTML=''; this._listeners={}; this.dataset={}; this.open=false; this.value=''; this.checked=false; this.disabled=false; this.type=''; this.name=''; this.selectedIndex=-1; this.options=[]; }
setAttribute(n,v) { this.attributes[n]=String(v); if(n==='id')this.id=v; if(n==='class'){this.className=v;this.classList._sync(v);} if(n==='value')this.value=v; if(n==='disabled')this.disabled=true; if(n==='style'){const s=String(v);for(const d of s.split(';')){const c=d.indexOf(':');if(c>0){const k=d.slice(0,c).trim();const val=d.slice(c+1).trim();if(k)this.style.setProperty(k,val);}} } }
constructor(t) { this.tagName=t.toUpperCase(); this.nodeName=this.tagName; this.nodeType=1; this.id=''; this.className=''; this.classList=new CL(this); this.style=mkStyle(this.tagName); this.attributes={}; this.children=[]; this.childNodes=[]; this.childNodes.item=function(i){return this[i]||null;}; this.parentElement=null; this.parentNode=null; this.textContent=''; this.innerHTML=''; this._listeners={}; this.dataset={}; this.open=false; this.value=''; this.checked=false; this.disabled=false; this.type=''; this.name=''; this.selectedIndex=-1; this.options=[]; }
setAttribute(n,v) { this.attributes[n]=String(v); if(n==='id')this.id=v; if(n==='class'){this.className=v;this.classList._sync(v);} if(n==='value')this.value=v; if(n==='name')this.name=v; if(n==='type')this.type=v; if(n==='checked')this.checked=true; if(n==='selected')this.selected=true; if(n==='multiple')this.multiple=true; if(n==='disabled')this.disabled=true; if(n==='style'){const s=String(v);for(const d of s.split(';')){const c=d.indexOf(':');if(c>0){const k=d.slice(0,c).trim();const val=d.slice(c+1).trim();if(k)this.style.setProperty(k,val);}} } }
getAttribute(n) { return this.attributes[n]!==undefined?this.attributes[n]:null; }
removeAttribute(n) { delete this.attributes[n]; if(n==='disabled')this.disabled=false; }
hasAttribute(n) { return n in this.attributes; }
addEventListener(e,f) { if(!this._listeners[e])this._listeners[e]=[]; this._listeners[e].push(f); }
removeEventListener(e,f) { if(this._listeners[e])this._listeners[e]=this._listeners[e].filter(x=>x!==f); }
dispatchEvent(ev) { ev.target=ev.target||this; ev.currentTarget=this; const fns=[...(this._listeners[ev.type]||[])]; for(const f of fns){if(ev._si)break;try{f.call(this,ev);}catch(e){}} if(ev.bubbles&&!ev._sp&&this.parentElement){this.parentElement.dispatchEvent(ev);} return !ev.defaultPrevented; }
appendChild(c) { if(c.parentElement)c.parentElement.removeChild(c); c.parentElement=this; c.parentNode=this; this.children.push(c); this.childNodes.push(c); this._syncText(); return c; }
appendChild(c) { if(c.parentElement)c.parentElement.removeChild(c); c.parentElement=this; c.parentNode=this; this.children.push(c); this.childNodes.push(c); if(this.tagName==='SELECT'&&c.tagName==='OPTION'){this.options.push(c);if(c.selected&&this.selectedIndex<0)this.selectedIndex=this.options.length-1;} this._syncText(); return c; }
removeChild(c) { this.children=this.children.filter(x=>x!==c); this.childNodes=this.childNodes.filter(x=>x!==c); c.parentElement=null; c.parentNode=null; this._syncText(); return c; }
insertBefore(n,r) { if(n.parentElement)n.parentElement.removeChild(n); const i=this.children.indexOf(r); if(i>=0){this.children.splice(i,0,n);this.childNodes.splice(i,0,n);}else{this.children.push(n);this.childNodes.push(n);} n.parentElement=this;n.parentNode=this; this._syncText(); return n; }
replaceChild(n,o) { const i=this.children.indexOf(o); if(i>=0){this.children[i]=n;this.childNodes[i]=n;} n.parentElement=this;n.parentNode=this; o.parentElement=null;o.parentNode=null; this._syncText(); return o; }
@@ -74,27 +84,44 @@ class El {
get scrollHeight() { return 100; } get scrollWidth() { return 100; }
get clientHeight() { return 100; } get clientWidth() { return 100; }
insertAdjacentHTML(pos, html) {
// For non-HTML content (plain text/numbers), just append to innerHTML
// Parse the HTML into real El instances so they can be queried, clicked,
// and HS-activated. Text-only content becomes a textContent append.
if (typeof html !== 'string') html = String(html);
if (pos === 'beforeend' || pos === 'beforeEnd') {
const parsed = parseHTMLFragments(html);
const p = (pos || '').toLowerCase();
if (parsed.length === 0) {
if (p === 'beforeend' || p === 'afterbegin') this.textContent = (this.textContent || '') + html;
return;
}
if (p === 'beforeend') {
for (const c of parsed) this.appendChild(c);
this.innerHTML = (this.innerHTML || '') + html;
this.textContent = (this.textContent || '') + html.replace(/<[^>]*>/g, '');
} else if (pos === 'afterbegin' || pos === 'afterBegin') {
} else if (p === 'afterbegin') {
const first = this.children[0] || null;
for (const c of parsed) { if (first) this.insertBefore(c, first); else this.appendChild(c); }
this.innerHTML = html + (this.innerHTML || '');
this.textContent = html.replace(/<[^>]*>/g, '') + (this.textContent || '');
} else if (pos === 'beforebegin' || pos === 'beforeBegin') {
if (this.parentElement) { this.parentElement.insertAdjacentHTML('beforeend', html); }
} else if (pos === 'afterend' || pos === 'afterEnd') {
if (this.parentElement) { this.parentElement.insertAdjacentHTML('beforeend', html); }
} else if (p === 'beforebegin' && this.parentElement) {
for (const c of parsed) this.parentElement.insertBefore(c, this);
} else if (p === 'afterend' && this.parentElement) {
const parent = this.parentElement;
const idx = parent.children.indexOf(this);
const nextSibling = parent.children[idx + 1] || null;
for (const c of parsed) {
if (nextSibling) parent.insertBefore(c, nextSibling);
else parent.appendChild(c);
}
}
}
}
class CL { constructor(e){this._el=e;this._set=new Set();} _sync(str){this._set=new Set((str||'').split(/\s+/).filter(Boolean));} add(...c){for(const x of c)this._set.add(x);this._el.className=[...this._set].join(' ');this._el.attributes['class']=this._el.className;} remove(...c){for(const x of c)this._set.delete(x);this._el.className=[...this._set].join(' ');this._el.attributes['class']=this._el.className;} toggle(c,f){if(f!==undefined){if(f)this.add(c);else this.remove(c);return f;} if(this._set.has(c)){this.remove(c);return false;}else{this.add(c);return true;}} contains(c){return this._set.has(c);} get length(){return this._set.size;} [Symbol.iterator](){return this._set[Symbol.iterator]();} }
class Ev { constructor(t,o={}){this.type=t;this.bubbles=o.bubbles||false;this.cancelable=o.cancelable!==false;this.defaultPrevented=false;this._sp=false;this._si=false;this.target=null;this.currentTarget=null;this.detail=o.detail||null;} preventDefault(){this.defaultPrevented=true;} stopPropagation(){this._sp=true;} stopImmediatePropagation(){this._sp=true;this._si=true;} }
const VOID_TAGS = new Set(['input','br','hr','img','meta','link','area','base','col','embed','source','track','wbr']);
function parseHTMLFragments(html) {
const results = [];
const re = /<(\w+)([^>]*?)(?:\/>|>([\s\S]*?)<\/\1>)/g;
// Match self-closing `<tag .../>`, paired `<tag>...</tag>`, or void
// elements with no close tag (input, br, hr, etc.).
const re = /<(\w+)([^>]*?)(?:\/>|>([\s\S]*?)<\/\1>|>)/g;
let m;
let lastIndex = 0;
while ((m = re.exec(html)) !== null) {
@@ -106,10 +133,20 @@ function parseHTMLFragments(html) {
// Can't push text nodes directly to El children; wrap if needed
}
}
const tag = m[1]; const attrs = m[2]; const inner = m[3] || '';
const tag = m[1]; const attrs = m[2]; let inner = m[3] || '';
// If the regex matched a void-style `<tag ...>` with no close tag,
// ensure we don't treat subsequent markup as inner content.
if (inner === '' && !m[0].endsWith('/>') && !VOID_TAGS.has(tag.toLowerCase()) && m[0].endsWith('>')) {
// Generic `<tag ...>` with no close tag — still leave inner empty;
// this keeps behaviour lenient without running past the next tag.
}
const el = new El(tag);
const attrRe = /([\w-]+)="([^"]*)"/g; let am;
while ((am = attrRe.exec(attrs))) el.setAttribute(am[1], am[2]);
const attrRe = /([\w-]+)(?:="([^"]*)")?/g; let am;
while ((am = attrRe.exec(attrs))) {
const nm = am[1]; const val = am[2];
if (val !== undefined) el.setAttribute(nm, val);
else el.setAttribute(nm, '');
}
// Also handle boolean attrs like disabled
const boolRe = /\s(\w+)(?=\s|\/|>|$)/g;
if (inner) {