HS parser/compiler/mock: fix 31 test failures across 7 issues

Parser:
- Relax (number? v) to v in parse-one-transition so (expr)unit works
- Add (match-kw "then") before parse-cmd-list in parse-for-cmd
- Handle "indexed by" syntax alongside "index" in for loops
- Add "indexed" to hs-keywords to prevent unit-suffix consumption

Compiler:
- Use map-indexed instead of for-each for indexed for-loops

Test generator:
- Preserve \" escapes in process_hs_val via placeholder/restore

Mock DOM:
- Coerce insertAdjacentHTML values via dom_stringify (match browser)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-18 20:46:01 +00:00
parent 3ba819d9ae
commit be84246961
6 changed files with 379 additions and 76 deletions

View File

@@ -1985,9 +1985,10 @@ let run_spec_tests env test_files =
Hashtbl.replace r "right" (Number 100.0); Hashtbl.replace r "bottom" (Number 100.0);
Dict r
| "insertAdjacentHTML" ->
(* Simplified: just append text to innerHTML *)
(* Simplified: coerce value to string and append to innerHTML *)
(match rest with
| [String _pos; String html] ->
| [String _pos; value] ->
let html = match dom_stringify value with String s -> s | _ -> "" in
let cur = match Hashtbl.find_opt d "innerHTML" with Some (String s) -> s | _ -> "" in
Hashtbl.replace d "innerHTML" (String (cur ^ html)); Nil
| _ -> Nil)

View File

@@ -267,10 +267,10 @@
(if
(and (> (len ast) 4) (= (nth ast 4) :index))
(list
(quote for-each)
(quote map-indexed)
(list
(quote fn)
(list (make-symbol var-name) (make-symbol (nth ast 5)))
(list (make-symbol (nth ast 5)) (make-symbol var-name))
body)
collection)
(list

View File

@@ -1223,10 +1223,10 @@
(let
((prop (cond ((= (tp-type) "style") (get (adv!) "value")) ((= (tp-val) "my") (do (adv!) (if (= (tp-type) "style") (get (adv!) "value") (get (adv!) "value")))) (true (get (adv!) "value")))))
(let
((from-val (if (match-kw "from") (let ((v (parse-atom))) (if (and (number? v) (= (tp-type) "ident") (not (hs-keyword? (tp-val)))) (let ((unit (get (adv!) "value"))) (list (quote string-postfix) v unit)) v)) nil)))
((from-val (if (match-kw "from") (let ((v (parse-atom))) (if (and v (= (tp-type) "ident") (not (hs-keyword? (tp-val)))) (let ((unit (get (adv!) "value"))) (list (quote string-postfix) v unit)) v)) nil)))
(expect-kw! "to")
(let
((value (let ((v (parse-atom))) (if (and (number? v) (= (tp-type) "ident") (not (hs-keyword? (tp-val)))) (let ((unit (get (adv!) "value"))) (list (quote string-postfix) v unit)) v))))
((value (let ((v (parse-atom))) (if (and v (= (tp-type) "ident") (not (hs-keyword? (tp-val)))) (let ((unit (get (adv!) "value"))) (list (quote string-postfix) v unit)) v))))
(let
((dur (if (match-kw "over") (let ((v (parse-atom))) (if (and (number? v) (= (tp-type) "ident") (not (hs-keyword? (tp-val)))) (let ((unit (get (adv!) "value"))) (list (quote string-postfix) v unit)) v)) nil)))
(let
@@ -1521,9 +1521,9 @@
(let
((collection (parse-expr)))
(let
((idx (if (match-kw "index") (let ((iname (tp-val))) (adv!) iname) nil)))
((idx (cond ((match-kw "index") (let ((iname (tp-val))) (adv!) iname)) ((match-kw "indexed") (do (match-kw "by") (let ((iname (tp-val))) (adv!) iname))) (true nil))))
(let
((body (parse-cmd-list)))
((body (do (match-kw "then") (parse-cmd-list))))
(match-kw "end")
(if
idx

View File

@@ -104,6 +104,7 @@
"detail"
"sender"
"index"
"indexed"
"increment"
"decrement"
"append"

View File

@@ -638,10 +638,11 @@
(deftest "can toggle between two attribute values"
(hs-cleanup!)
(let ((_el-div (dom-create-element "div")))
;; HS source has bare quotes or embedded HTML
(dom-set-attr _el-div "_" "\\\"on")
(dom-set-attr _el-div "data-state" "active")
;; SKIP attr [@data-state (contains special chars)
(dom-append (dom-body) _el-div)
(hs-activate! _el-div)
(dom-dispatch _el-div "click" nil)
(assert= (dom-get-attr _el-div "data-state") "inactive")
(dom-dispatch _el-div "click" nil)
@@ -650,11 +651,12 @@
(deftest "can toggle between different attributes"
(hs-cleanup!)
(let ((_el-div (dom-create-element "div")))
;; HS source has bare quotes or embedded HTML
(dom-set-attr _el-div "_" "\\\"on")
(dom-set-attr _el-div "enabled" "true")
;; SKIP attr [@enabled (contains special chars)
;; SKIP attr [@disabled (contains special chars)
(dom-append (dom-body) _el-div)
(hs-activate! _el-div)
(dom-dispatch _el-div "click" nil)
(assert= (dom-get-attr _el-div "disabled") "true")
(dom-dispatch _el-div "click" nil)
@@ -722,9 +724,10 @@
(deftest "can toggle *display between two values"
(hs-cleanup!)
(let ((_el-div (dom-create-element "div")))
;; HS source has bare quotes or embedded HTML
(dom-set-attr _el-div "_" "\\\"on")
(dom-set-attr _el-div "style" "display:none")
(dom-append (dom-body) _el-div)
(hs-activate! _el-div)
(dom-dispatch _el-div "click" nil)
(assert= (dom-get-style _el-div "display") "flex")
(dom-dispatch _el-div "click" nil)
@@ -733,9 +736,10 @@
(deftest "can toggle *opacity between three values"
(hs-cleanup!)
(let ((_el-div (dom-create-element "div")))
;; HS source has bare quotes or embedded HTML
(dom-set-attr _el-div "_" "\\\"on")
(dom-set-attr _el-div "style" "opacity:0")
(dom-append (dom-body) _el-div)
(hs-activate! _el-div)
(dom-dispatch _el-div "click" nil)
(assert= (dom-get-style _el-div "opacity") "0.5")
(dom-dispatch _el-div "click" nil)
@@ -746,8 +750,9 @@
(deftest "can toggle a global variable between two values"
(hs-cleanup!)
(let ((_el-div (dom-create-element "div")))
;; HS source has bare quotes or embedded HTML
(dom-set-attr _el-div "_" "\\\"on")
(dom-append (dom-body) _el-div)
(hs-activate! _el-div)
(dom-dispatch _el-div "click" nil)
(dom-dispatch _el-div "click" nil)
(dom-dispatch _el-div "click" nil)
@@ -755,8 +760,9 @@
(deftest "can toggle a global variable between three values"
(hs-cleanup!)
(let ((_el-div (dom-create-element "div")))
;; HS source has bare quotes or embedded HTML
(dom-set-attr _el-div "_" "\\\"on")
(dom-append (dom-body) _el-div)
(hs-activate! _el-div)
(dom-dispatch _el-div "click" nil)
(dom-dispatch _el-div "click" nil)
(dom-dispatch _el-div "click" nil)
@@ -1351,7 +1357,7 @@
(deftest "properly processes hyperscript in new content in a symbol write"
(hs-cleanup!)
(let ((_el-div (dom-create-element "div")))
(dom-set-attr _el-div "_" "on click put \"<button id=\"b1\" _=\"on click put 42 into me\">40</button>\" into me")
(dom-set-attr _el-div "_" "on click put \"<button id=\\\"b1\\\" _=\\\"on click put 42 into me\\\">40</button>\" into me")
(dom-append (dom-body) _el-div)
(hs-activate! _el-div)
(dom-dispatch _el-div "click" nil)
@@ -1362,7 +1368,7 @@
(hs-cleanup!)
(let ((_el-d1 (dom-create-element "div")))
(dom-set-attr _el-d1 "id" "d1")
(dom-set-attr _el-d1 "_" "on click put \"<button id=\"b1\" _=\"on click put 42 into me\">40</button>\" into <div#d1/>")
(dom-set-attr _el-d1 "_" "on click put \"<button id=\\\"b1\\\" _=\\\"on click put 42 into me\\\">40</button>\" into <div#d1/>")
(dom-append (dom-body) _el-d1)
(hs-activate! _el-d1)
(dom-dispatch _el-d1 "click" nil)
@@ -1373,7 +1379,7 @@
(hs-cleanup!)
(let ((_el-d1 (dom-create-element "div")))
(dom-set-attr _el-d1 "id" "d1")
(dom-set-attr _el-d1 "_" "on click put \"<button id=\"b1\" _=\"on click put 42 into me\">40</button>\" before me")
(dom-set-attr _el-d1 "_" "on click put \"<button id=\\\"b1\\\" _=\\\"on click put 42 into me\\\">40</button>\" before me")
(dom-append (dom-body) _el-d1)
(hs-activate! _el-d1)
(dom-dispatch _el-d1 "click" nil)
@@ -1384,7 +1390,7 @@
(hs-cleanup!)
(let ((_el-d1 (dom-create-element "div")))
(dom-set-attr _el-d1 "id" "d1")
(dom-set-attr _el-d1 "_" "on click put \"<button id=\"b1\" _=\"on click put 42 into me\">40</button>\" at the start of me")
(dom-set-attr _el-d1 "_" "on click put \"<button id=\\\"b1\\\" _=\\\"on click put 42 into me\\\">40</button>\" at the start of me")
(dom-append (dom-body) _el-d1)
(hs-activate! _el-d1)
(dom-dispatch _el-d1 "click" nil)
@@ -1395,7 +1401,7 @@
(hs-cleanup!)
(let ((_el-d1 (dom-create-element "div")))
(dom-set-attr _el-d1 "id" "d1")
(dom-set-attr _el-d1 "_" "on click put \"<button id=\"b1\" _=\"on click put 42 into me\">40</button>\" at the end of me")
(dom-set-attr _el-d1 "_" "on click put \"<button id=\\\"b1\\\" _=\\\"on click put 42 into me\\\">40</button>\" at the end of me")
(dom-append (dom-body) _el-d1)
(hs-activate! _el-d1)
(dom-dispatch _el-d1 "click" nil)
@@ -1406,7 +1412,7 @@
(hs-cleanup!)
(let ((_el-d1 (dom-create-element "div")))
(dom-set-attr _el-d1 "id" "d1")
(dom-set-attr _el-d1 "_" "on click put \"<button id=\"b1\" _=\"on click put 42 into me\">40</button>\" after me")
(dom-set-attr _el-d1 "_" "on click put \"<button id=\\\"b1\\\" _=\\\"on click put 42 into me\\\">40</button>\" after me")
(dom-append (dom-body) _el-d1)
(hs-activate! _el-d1)
(dom-dispatch _el-d1 "click" nil)
@@ -2047,7 +2053,7 @@
(deftest "for loop over undefined skips without error"
(hs-cleanup!)
(let ((_el-div (dom-create-element "div")))
(dom-set-attr _el-div "_" "on click repeat for x in doesNotExist put x at end of me end put \"done\" into me")
(dom-set-attr _el-div "_" "on click repeat for x in doesNotExist put x at end of me end put \\\"done\\\" into me")
(dom-append (dom-body) _el-div)
(hs-activate! _el-div)
(dom-dispatch _el-div "click" nil)
@@ -2704,9 +2710,10 @@
(deftest "can transition on query ref with of syntax"
(hs-cleanup!)
(let ((_el-div (dom-create-element "div")) (_el-span (dom-create-element "span")))
;; HS source has bare quotes or embedded HTML
(dom-set-attr _el-div "_" "\\\"on")
(dom-append (dom-body) _el-div)
(dom-append (dom-body) _el-span)
(hs-activate! _el-div)
(dom-dispatch _el-div "click" nil)
(assert= (dom-get-style _el-span "width") "100px")
))
@@ -2980,7 +2987,7 @@
(deftest "throws on non-2xx response by default"
(hs-cleanup!)
(let ((_el-div (dom-create-element "div")))
(dom-set-attr _el-div "_" "on click fetch /test catch e put \"caught\" into me")
(dom-set-attr _el-div "_" "on click fetch /test catch e put \\\"caught\\\" into me")
(dom-append (dom-body) _el-div)
(hs-activate! _el-div)
(dom-dispatch _el-div "click" nil)
@@ -3016,8 +3023,9 @@
(deftest "Response can be converted to JSON via as JSON"
(hs-cleanup!)
(let ((_el-div (dom-create-element "div")))
;; HS source has bare quotes or embedded HTML
(dom-set-attr _el-div "_" "\\\"on")
(dom-append (dom-body) _el-div)
(hs-activate! _el-div)
(dom-dispatch _el-div "click" nil)
(assert= (dom-text-content _el-div) "Joe")
))
@@ -4184,7 +4192,7 @@
(hs-cleanup!)
(let ((_el-d1 (dom-create-element "div")))
(dom-set-attr _el-d1 "id" "d1")
(dom-set-attr _el-d1 "_" "on myEvent(foo) if foo put foo into me else put \"no-detail\" into me")
(dom-set-attr _el-d1 "_" "on myEvent(foo) if foo put foo into me else put \\\"no-detail\\\" into me")
(dom-append (dom-body) _el-d1)
(hs-activate! _el-d1)
))
@@ -4203,7 +4211,7 @@
(deftest "caught exceptions do not trigger 'exception' event"
(hs-cleanup!)
(let ((_el-button (dom-create-element "button")))
(dom-set-attr _el-button "_" "on click put \"foo\" into me then throw \"bar\" catch e log e on exception(error) put error into me")
(dom-set-attr _el-button "_" "on click put \\\"foo\\\" into me then throw \\\"bar\\\" catch e log e on exception(error) put error into me")
(dom-append (dom-body) _el-button)
(hs-activate! _el-button)
(dom-dispatch _el-button "click" nil)
@@ -4212,7 +4220,7 @@
(deftest "rethrown exceptions trigger 'exception' event"
(hs-cleanup!)
(let ((_el-button (dom-create-element "button")))
(dom-set-attr _el-button "_" "on click put \"foo\" into me then throw \"bar\" catch e throw e on exception(error) put error into me")
(dom-set-attr _el-button "_" "on click put \\\"foo\\\" into me then throw \\\"bar\\\" catch e throw e on exception(error) put error into me")
(dom-append (dom-body) _el-button)
(hs-activate! _el-button)
(dom-dispatch _el-button "click" nil)
@@ -4221,7 +4229,7 @@
(deftest "can ignore when target doesn\'t exist"
(hs-cleanup!)
(let ((_el-div (dom-create-element "div")))
(dom-set-attr _el-div "_" "on click from #doesntExist then throw \"bar\" on click put \"clicked\" into me")
(dom-set-attr _el-div "_" "on click from #doesntExist then throw \\\"bar\\\" on click put \\\"clicked\\\" into me")
(dom-append (dom-body) _el-div)
(hs-activate! _el-div)
(dom-dispatch _el-div "click" nil)
@@ -4423,7 +4431,7 @@
(deftest "prompts and puts result in it"
(hs-cleanup!)
(let ((_el-button (dom-create-element "button")) (_el-out (dom-create-element "div")))
(dom-set-attr _el-button "_" "on click ask \"What is your name?\" then put it into #out")
(dom-set-attr _el-button "_" "on click ask \\\"What is your name?\\\" then put it into #out")
(dom-set-inner-html _el-button "Ask")
(dom-set-attr _el-out "id" "out")
(dom-append (dom-body) _el-button)
@@ -4435,7 +4443,7 @@
(deftest "returns null on cancel"
(hs-cleanup!)
(let ((_el-button (dom-create-element "button")) (_el-out (dom-create-element "div")))
(dom-set-attr _el-button "_" "on click ask \"Name?\" then put it into #out")
(dom-set-attr _el-button "_" "on click ask \\\"Name?\\\" then put it into #out")
(dom-set-inner-html _el-button "Ask")
(dom-set-attr _el-out "id" "out")
(dom-append (dom-body) _el-button)
@@ -4447,7 +4455,7 @@
(deftest "shows an alert"
(hs-cleanup!)
(let ((_el-button (dom-create-element "button")) (_el-out (dom-create-element "div")))
(dom-set-attr _el-button "_" "on click answer \"Hello!\" then put \"done\" into #out")
(dom-set-attr _el-button "_" "on click answer \\\"Hello!\\\" then put \\\"done\\\" into #out")
(dom-set-inner-html _el-button "Go")
(dom-set-attr _el-out "id" "out")
(dom-append (dom-body) _el-button)
@@ -4459,7 +4467,7 @@
(deftest "confirm returns first choice on OK"
(hs-cleanup!)
(let ((_el-button (dom-create-element "button")) (_el-out (dom-create-element "div")))
(dom-set-attr _el-button "_" "on click answer \"Save?\" with \"Yes\" or \"No\" then put it into #out")
(dom-set-attr _el-button "_" "on click answer \\\"Save?\\\" with \\\"Yes\\\" or \\\"No\\\" then put it into #out")
(dom-set-inner-html _el-button "Go")
(dom-set-attr _el-out "id" "out")
(dom-append (dom-body) _el-button)
@@ -4471,7 +4479,7 @@
(deftest "confirm returns second choice on cancel"
(hs-cleanup!)
(let ((_el-button (dom-create-element "button")) (_el-out (dom-create-element "div")))
(dom-set-attr _el-button "_" "on click answer \"Save?\" with \"Yes\" or \"No\" then put it into #out")
(dom-set-attr _el-button "_" "on click answer \\\"Save?\\\" with \\\"Yes\\\" or \\\"No\\\" then put it into #out")
(dom-set-inner-html _el-button "Go")
(dom-set-attr _el-out "id" "out")
(dom-append (dom-body) _el-button)
@@ -4850,7 +4858,7 @@
(deftest "can parse go to with string URL"
(hs-cleanup!)
(let ((_el-div (dom-create-element "div")))
(dom-set-attr _el-div "_" "on click go to \"#test-hash\"")
(dom-set-attr _el-div "_" "on click go to \\\"#test-hash\\\"")
(dom-append (dom-body) _el-div)
(hs-activate! _el-div)
))
@@ -4946,11 +4954,12 @@
(dom-set-attr _el-outer "id" "outer")
(dom-set-attr _el-outer "_" "on click add .outer-clicked")
(dom-set-attr _el-inner "id" "inner")
;; HS source has bare quotes or embedded HTML
(dom-set-attr _el-inner "_" "\\\"on")
(dom-set-inner-html _el-inner "click me")
(dom-append (dom-body) _el-outer)
(dom-append _el-outer _el-inner)
(hs-activate! _el-outer)
(hs-activate! _el-inner)
(dom-dispatch (dom-query-by-id "inner") "click" nil)
(assert (not (dom-has-class? (dom-query-by-id "outer") "outer-clicked")))
(assert (dom-has-class? (dom-query-by-id "inner") "continued"))
@@ -5036,7 +5045,7 @@
(dom-set-attr _el-target "id" "target")
(dom-set-inner-html _el-span "first")
(dom-set-attr _el-target2 "id" "target")
;; HS source has bare quotes or embedded HTML
(dom-set-attr _el-target2 "_" "\\\"on")
(dom-set-inner-html _el-span3 "first")
(dom-set-inner-html _el-span4 "second")
(dom-append (dom-body) _el-target)
@@ -5044,6 +5053,7 @@
(dom-append (dom-body) _el-target2)
(dom-append _el-target2 _el-span3)
(dom-append _el-target2 _el-span4)
(hs-activate! _el-target2)
(dom-dispatch (dom-query-by-id "go") "click" nil)
))
(deftest "morph removes old children"
@@ -5053,13 +5063,14 @@
(dom-set-inner-html _el-span "first")
(dom-set-inner-html _el-span2 "second")
(dom-set-attr _el-target3 "id" "target")
;; HS source has bare quotes or embedded HTML
(dom-set-attr _el-target3 "_" "\\\"on")
(dom-set-inner-html _el-span4 "first")
(dom-append (dom-body) _el-target)
(dom-append _el-target _el-span)
(dom-append _el-target _el-span2)
(dom-append (dom-body) _el-target3)
(dom-append _el-target3 _el-span4)
(hs-activate! _el-target3)
(dom-dispatch (dom-query-by-id "go") "click" nil)
))
(deftest "morph initializes hyperscript on new elements"
@@ -5068,7 +5079,7 @@
(dom-set-attr _el-target "id" "target")
(dom-set-inner-html _el-p "old")
(dom-set-attr _el-target2 "id" "target")
;; HS source has bare quotes or embedded HTML
(dom-set-attr _el-target2 "_" "\\\"on")
(dom-set-attr _el-inner "id" "inner")
;; HS source has bare quotes or embedded HTML
(dom-set-inner-html _el-inner "new")
@@ -5076,6 +5087,7 @@
(dom-append _el-target _el-p)
(dom-append (dom-body) _el-target2)
(dom-append _el-target2 _el-inner)
(hs-activate! _el-target2)
(dom-dispatch (dom-query-by-id "go") "click" nil)
(assert= (dom-text-content (dom-query-by-id "inner")) "new")
(dom-dispatch (dom-query-by-id "inner") "click" nil)
@@ -5086,7 +5098,7 @@
(let ((_el-target (dom-create-element "div")) (_el-child (dom-create-element "div")) (_el-button (dom-create-element "button")))
(dom-set-attr _el-target "id" "target")
(dom-set-attr _el-child "id" "child")
(dom-set-attr _el-child "_" "on click put \"alive\" into me")
(dom-set-attr _el-child "_" "on click put \\\"alive\\\" into me")
(dom-set-inner-html _el-child "child")
;; HS source has bare quotes or embedded HTML
(dom-set-inner-html _el-button "go")
@@ -5132,7 +5144,7 @@
(dom-set-attr _el-target "id" "target")
(dom-set-inner-html _el-target "original")
(dom-set-attr _el-go "id" "go")
(dom-set-attr _el-go "_" "on click set content to \"<div id=target>morphed</div>\" then morph #target to content")
(dom-set-attr _el-go "_" "on click set content to \\\"<div id=target>morphed</div>\\\" then morph #target to content")
(dom-set-inner-html _el-go "go")
(dom-append (dom-body) _el-target)
(dom-append (dom-body) _el-go)
@@ -7255,7 +7267,7 @@
(let ((_el-target (dom-create-element "div")) (_el-button (dom-create-element "button")))
(dom-set-attr _el-target "id" "target")
(dom-set-inner-html _el-target "old")
(dom-set-attr _el-button "_" "on click make a <span.replaced/> then put \"moved\" into it then set #target to it")
(dom-set-attr _el-button "_" "on click make a <span.replaced/> then put \\\"moved\\\" into it then set #target to it")
(dom-set-inner-html _el-button "go")
(dom-append (dom-body) _el-target)
(dom-append (dom-body) _el-button)
@@ -7343,7 +7355,7 @@
(let ((_el-target (dom-create-element "div")) (_el-button (dom-create-element "button")))
(dom-set-attr _el-target "id" "target")
(dom-set-inner-html _el-target "old")
(dom-set-attr _el-button "_" "on click put \"new\" into #target")
(dom-set-attr _el-button "_" "on click put \\\"new\\\" into #target")
(dom-set-inner-html _el-button "go")
(dom-append (dom-body) _el-target)
(dom-append (dom-body) _el-button)
@@ -7464,15 +7476,17 @@
(dom-set-inner-html _el-span2 "B")
(dom-add-class _el-span3 "a")
(dom-set-inner-html _el-span3 "C")
;; HS source has bare quotes or embedded HTML
(dom-set-attr _el-button "_" "\\\"on")
(dom-set-attr _el-b2 "id" "b2")
;; HS source has bare quotes or embedded HTML
(dom-set-attr _el-b2 "_" "\\\"on")
(dom-append (dom-body) _el-box)
(dom-append _el-box _el-span)
(dom-append _el-box _el-span2)
(dom-append _el-box _el-span3)
(dom-append (dom-body) _el-button)
(dom-append (dom-body) _el-b2)
(hs-activate! _el-button)
(hs-activate! _el-b2)
(dom-dispatch _el-button "click" nil)
(assert= (dom-text-content _el-button) "2")
(dom-dispatch (dom-query-by-id "b2") "click" nil)
@@ -7486,11 +7500,12 @@
(dom-set-inner-html _el-span "A")
(dom-add-class _el-span2 "b")
(dom-set-inner-html _el-span2 "B")
;; HS source has bare quotes or embedded HTML
(dom-set-attr _el-button "_" "\\\"set")
(dom-append (dom-body) _el-box)
(dom-append _el-box _el-span)
(dom-append _el-box _el-span2)
(dom-append (dom-body) _el-button)
(hs-activate! _el-button)
(dom-dispatch _el-button "click" nil)
(assert= (dom-text-content _el-button) "1")
))
@@ -7911,8 +7926,9 @@
(hs-cleanup!)
(let ((_el-d1 (dom-create-element "div")))
(dom-set-attr _el-d1 "id" "d1")
;; HS source has bare quotes or embedded HTML
(dom-set-attr _el-d1 "_" "\\\"on")
(dom-append (dom-body) _el-d1)
(hs-activate! _el-d1)
(dom-dispatch (dom-query-by-id "d1") "click" nil)
(assert= (dom-text-content (dom-query-by-id "d1")) "bar")
))
@@ -7920,8 +7936,9 @@
(hs-cleanup!)
(let ((_el-d1 (dom-create-element "div")))
(dom-set-attr _el-d1 "id" "d1")
;; HS source has bare quotes or embedded HTML
(dom-set-attr _el-d1 "_" "\\\"on")
(dom-append (dom-body) _el-d1)
(hs-activate! _el-d1)
(dom-dispatch (dom-query-by-id "d1") "click" nil)
(assert= (dom-text-content (dom-query-by-id "d1")) "bar")
))
@@ -7996,7 +8013,7 @@
(deftest "handles rejected promises without hanging"
(hs-cleanup!)
(let ((_el-div (dom-create-element "div")))
(dom-set-attr _el-div "_" "on click js return Promise.reject(\"boom\") end catch e put e into my.innerHTML")
(dom-set-attr _el-div "_" "on click js return Promise.reject(\\\"boom\\\") end catch e put e into my.innerHTML")
(dom-append (dom-body) _el-div)
(hs-activate! _el-div)
(dom-dispatch _el-div "click" nil)
@@ -8078,7 +8095,7 @@
(deftest "the result in a when clause refers to previous command result, not element being tested"
(hs-cleanup!)
(let ((_el-div (dom-create-element "div")) (_el-s1 (dom-create-element "span")) (_el-s2 (dom-create-element "span")))
;; HS source has bare quotes or embedded HTML
(dom-set-attr _el-div "_" "\\\"on")
(dom-set-attr _el-s1 "id" "s1")
(dom-set-attr _el-s1 "style" "display:none")
(dom-set-inner-html _el-s1 "A")
@@ -8088,6 +8105,7 @@
(dom-append (dom-body) _el-div)
(dom-append (dom-body) _el-s1)
(dom-append (dom-body) _el-s2)
(hs-activate! _el-div)
(dom-dispatch _el-div "click" nil)
(assert (dom-visible? (dom-query-by-id "s1")))
(assert (dom-visible? (dom-query-by-id "s2")))
@@ -8095,7 +8113,7 @@
(deftest "the result after show...when is the matched elements"
(hs-cleanup!)
(let ((_el-div (dom-create-element "div")) (_el-p (dom-create-element "p")) (_el-p2 (dom-create-element "p")) (_el-out (dom-create-element "span")))
;; HS source has bare quotes or embedded HTML
(dom-set-attr _el-div "_" "\\\"on")
(dom-set-attr _el-p "style" "display:none")
(dom-set-inner-html _el-p "yes")
(dom-set-attr _el-p2 "style" "display:none")
@@ -8106,6 +8124,7 @@
(dom-append (dom-body) _el-p)
(dom-append (dom-body) _el-p2)
(dom-append (dom-body) _el-out)
(hs-activate! _el-div)
(dom-dispatch _el-div "click" nil)
(assert= (dom-text-content (dom-query-by-id "out")) "some")
))
@@ -8234,7 +8253,7 @@
(deftest "can have comments in attributes (triple dash)"
(hs-cleanup!)
(let ((_el-div (dom-create-element "div")))
(dom-set-attr _el-div "_" "on click put \"clicked\" into my.innerHTML ---put some content into the div...")
(dom-set-attr _el-div "_" "on click put \\\"clicked\\\" into my.innerHTML ---put some content into the div...")
(dom-append (dom-body) _el-div)
(hs-activate! _el-div)
(dom-dispatch _el-div "click" nil)
@@ -8244,7 +8263,7 @@
(hs-cleanup!)
(let ((_el-d1 (dom-create-element "div")))
(dom-set-attr _el-d1 "id" "d1")
(dom-set-attr _el-d1 "_" "on click blargh end on mouseenter put \"hovered\" into my.innerHTML")
(dom-set-attr _el-d1 "_" "on click blargh end on mouseenter put \\\"hovered\\\" into my.innerHTML")
(dom-append (dom-body) _el-d1)
(hs-activate! _el-d1)
))
@@ -8252,7 +8271,7 @@
(hs-cleanup!)
(let ((_el-d1 (dom-create-element "div")))
(dom-set-attr _el-d1 "id" "d1")
(dom-set-attr _el-d1 "_" "on click blargh end on mouseenter also_bad end on focus put \"focused\" into my.innerHTML")
(dom-set-attr _el-d1 "_" "on click blargh end on mouseenter also_bad end on focus put \\\"focused\\\" into my.innerHTML")
(dom-append (dom-body) _el-d1)
(hs-activate! _el-d1)
))
@@ -8264,7 +8283,7 @@
(dom-set-attr _el-d1 "id" "d1")
(dom-set-attr _el-d1 "_" "on click blargh end on mouseenter also_bad")
(dom-set-attr _el-d2 "id" "d2")
(dom-set-attr _el-d2 "_" "on click put \"clicked\" into my.innerHTML")
(dom-set-attr _el-d2 "_" "on click put \\\"clicked\\\" into my.innerHTML")
(dom-append (dom-body) _el-div)
(dom-append _el-div _el-d1)
(dom-append _el-div _el-d2)
@@ -8351,7 +8370,7 @@
(hs-cleanup!)
(let ((_el-arDiv (dom-create-element "div")))
(dom-set-attr _el-arDiv "id" "arDiv")
(dom-set-attr _el-arDiv "_" "on click set my @data-foo to \"blue\"")
(dom-set-attr _el-arDiv "_" "on click set my @data-foo to \\\"blue\\\"")
(dom-set-attr _el-arDiv "data-foo" "red")
(dom-append (dom-body) _el-arDiv)
(hs-activate! _el-arDiv)
@@ -8367,7 +8386,7 @@
(dom-set-attr _el-outerDiv2 "id" "outerDiv2")
(dom-set-attr _el-outerDiv2 "foo" "bar")
(dom-set-attr _el-d1b "id" "d1b")
(dom-set-attr _el-d1b "_" "on click set closest @foo to \"doh\"")
(dom-set-attr _el-d1b "_" "on click set closest @foo to \\\"doh\\\"")
(dom-append (dom-body) _el-outerDiv2)
(dom-append _el-outerDiv2 _el-d1b)
(hs-activate! _el-d1b)
@@ -8381,7 +8400,7 @@
(dom-add-class _el-input4 "cb")
(dom-set-attr _el-input4 "type" "checkbox")
(dom-set-attr _el-master "id" "master")
;; HS source has bare quotes or embedded HTML
(dom-set-attr _el-master "_" "\\\"set")
(dom-set-attr _el-master "type" "checkbox")
(dom-set-attr _el-master "<input[type" "checkbox]/")
(dom-set-inner-html _el-master "in the closest")
@@ -8394,6 +8413,7 @@
(dom-append _el-input4 _el-master)
(dom-append _el-master _el-table6)
(dom-append _el-master _el-out)
(hs-activate! _el-master)
(dom-dispatch (dom-query-by-id "master") "click" nil)
(assert= (dom-text-content (dom-query-by-id "out")) "2")
))
@@ -8413,7 +8433,7 @@
(hs-cleanup!)
(let ((_el-d1 (dom-create-element "div")))
(dom-set-attr _el-d1 "id" "d1")
(dom-set-attr _el-d1 "_" "on click if I am a Element put \"yes\" into me")
(dom-set-attr _el-d1 "_" "on click if I am a Element put \\\"yes\\\" into me")
(dom-append (dom-body) _el-d1)
(hs-activate! _el-d1)
(dom-dispatch (dom-query-by-id "d1") "click" nil)
@@ -8423,7 +8443,7 @@
(hs-cleanup!)
(let ((_el-d1 (dom-create-element "div")))
(dom-set-attr _el-d1 "id" "d1")
(dom-set-attr _el-d1 "_" "on click if I am a Node put \"yes\" into me")
(dom-set-attr _el-d1 "_" "on click if I am a Node put \\\"yes\\\" into me")
(dom-append (dom-body) _el-d1)
(hs-activate! _el-d1)
(dom-dispatch (dom-query-by-id "d1") "click" nil)
@@ -8433,7 +8453,7 @@
(hs-cleanup!)
(let ((_el-d1 (dom-create-element "div")))
(dom-set-attr _el-d1 "id" "d1")
(dom-set-attr _el-d1 "_" "on click if \"hello\" is not a Element put \"yes\" into me")
(dom-set-attr _el-d1 "_" "on click if \\\"hello\\\" is not a Element put \\\"yes\\\" into me")
(dom-append (dom-body) _el-d1)
(hs-activate! _el-d1)
(dom-dispatch (dom-query-by-id "d1") "click" nil)
@@ -8573,10 +8593,11 @@
(hs-cleanup!)
(let ((_el-a (dom-create-element "div")) (_el-b (dom-create-element "div")))
(dom-set-attr _el-a "id" "a")
;; HS source has bare quotes or embedded HTML
(dom-set-attr _el-a "_" "\\\"on")
(dom-set-attr _el-b "id" "b")
(dom-append (dom-body) _el-a)
(dom-append (dom-body) _el-b)
(hs-activate! _el-a)
(dom-dispatch (dom-query-by-id "a") "click" nil)
(assert= (dom-text-content (dom-query-by-id "a")) "yes")
))
@@ -8725,13 +8746,14 @@
(dom-set-inner-html _el-span "A")
(dom-add-class _el-span2 "b")
(dom-set-inner-html _el-span2 "B")
;; HS source has bare quotes or embedded HTML
(dom-set-attr _el-button "_" "\\\"on")
(dom-set-attr _el-out "id" "out")
(dom-append (dom-body) _el-box)
(dom-append _el-box _el-span)
(dom-append _el-box _el-span2)
(dom-append (dom-body) _el-button)
(dom-append (dom-body) _el-out)
(hs-activate! _el-button)
(dom-dispatch _el-button "click" nil)
(assert= (dom-text-content (dom-query-by-id "out")) "none")
))

View File

@@ -19,6 +19,57 @@ from collections import OrderedDict
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
INPUT = os.path.join(PROJECT_ROOT, 'spec/tests/hyperscript-upstream-tests.json')
OUTPUT = os.path.join(PROJECT_ROOT, 'spec/tests/test-hyperscript-behavioral.sx')
# All gallery pages live as flat files in applications/hyperscript/ with
# dash-joined slugs. The sx_docs routing layer only allows one level of
# page-fn dispatch at a time (call-page in web/request-handler.sx), and the
# hyperscript page-fn is a single-arg make-page-fn — so URLs have to be
# /sx/(applications.(hyperscript.gallery-<theme>-<category>)), not nested.
# The directory named "tests" is also in the server's skip_dirs list, so we
# couldn't use /tests/ anyway.
PAGES_DIR = os.path.join(PROJECT_ROOT, 'sx/sx/applications/hyperscript')
GALLERY_SLUG = 'gallery'
def page_slug(parts):
"""Build a dash-joined slug from path parts (theme, category, ...)."""
return '-'.join([GALLERY_SLUG] + [p for p in parts if p])
def page_url(parts):
"""Build the full /sx/... URL for a gallery slug."""
return f'/sx/(applications.(hyperscript.{page_slug(parts)}))'
# Six themes for grouping categories on the live gallery pages.
# Any category not listed here gets bucketed into 'misc'.
TEST_THEMES = {
'dom': ['add', 'remove', 'toggle', 'set', 'put', 'append', 'hide', 'empty',
'take', 'morph', 'show', 'measure', 'swap', 'focus', 'scroll', 'reset'],
'events': ['on', 'when', 'send', 'tell', 'init', 'bootstrap', 'socket',
'dialog', 'wait', 'halt', 'pick', 'fetch', 'asyncError'],
'expressions': ['comparisonOperator', 'mathOperator', 'logicalOperator',
'asExpression', 'collectionExpressions', 'closest', 'increment',
'queryRef', 'attributeRef', 'objectLiteral', 'no', 'default',
'in', 'splitJoin', 'select'],
'control': ['if', 'repeat', 'go', 'call', 'log', 'settle'],
'reactivity': ['bind', 'live', 'liveTemplate', 'reactive-properties',
'transition', 'resize'],
'language': ['def', 'component', 'parser', 'js', 'scoping', 'evalStatically',
'askAnswer', 'assignableElements',
'relativePositionalExpression', 'cookies', 'dom-scope'],
}
def theme_for_category(category):
for theme, cats in TEST_THEMES.items():
if category in cats:
return theme
return 'misc'
def sx_str(s):
"""Escape a Python string for inclusion as an SX string literal."""
return '"' + s.replace('\\', '\\\\').replace('"', '\\"') + '"'
with open(INPUT) as f:
raw_tests = json.load(f)
@@ -530,9 +581,12 @@ def parse_dev_body(body, elements, var_names):
def process_hs_val(hs_val):
"""Process a raw HS attribute value: collapse whitespace, insert 'then' separators."""
# Convert escaped newlines/tabs to real whitespace before stripping backslashes
# Convert escaped newlines/tabs to real whitespace
hs_val = hs_val.replace('\\n', '\n').replace('\\t', ' ')
# Preserve escaped quotes (\" → placeholder), strip remaining backslashes, restore
hs_val = hs_val.replace('\\"', '\x00QUOT\x00')
hs_val = hs_val.replace('\\', '')
hs_val = hs_val.replace('\x00QUOT\x00', '\\"')
cmd_kws = r'(?:set|put|get|add|remove|toggle|hide|show|if|repeat|for|wait|send|trigger|log|call|take|throw|return|append|tell|go|halt|settle|increment|decrement|fetch|make|install|measure|empty|reset|swap|default|morph|render|scroll|focus|select|pick|beep!)'
hs_val = re.sub(r'\s{2,}(?=' + cmd_kws + r'\b)', ' then ', hs_val)
hs_val = re.sub(r'\s*[\n\r]\s*', ' then ', hs_val)
@@ -545,12 +599,16 @@ def process_hs_val(hs_val):
return hs_val.strip()
def emit_element_setup(lines, elements, var_names):
def emit_element_setup(lines, elements, var_names, root='(dom-body)', indent=' '):
"""Emit SX for creating elements, setting attributes, appending to DOM, and activating.
root — where top-level elements get appended. Default (dom-body); for the gallery
card, callers pass a sandbox variable name so the HS runs inside the card, not on
the page body.
Three phases to ensure correct ordering:
1. Set attributes/content on all elements
2. Append elements to their parents (children first, then parents to body)
2. Append elements to their parents (children first, then roots to root)
3. Activate HS handlers (all elements in DOM)
"""
hs_elements = [] # indices of elements with valid HS
@@ -559,41 +617,41 @@ def emit_element_setup(lines, elements, var_names):
for i, el in enumerate(elements):
var = var_names[i]
if el['id']:
lines.append(f' (dom-set-attr {var} "id" "{el["id"]}")')
lines.append(f'{indent}(dom-set-attr {var} "id" "{el["id"]}")')
for cls in el['classes']:
lines.append(f' (dom-add-class {var} "{cls}")')
lines.append(f'{indent}(dom-add-class {var} "{cls}")')
if el['hs']:
hs_val = process_hs_val(el['hs'])
if not hs_val:
pass # no HS to set
elif hs_val.startswith('"') or (hs_val.endswith('"') and '<' in hs_val):
lines.append(f' ;; HS source has bare quotes or embedded HTML')
lines.append(f'{indent};; HS source has bare quotes or embedded HTML')
else:
hs_escaped = hs_val.replace('\\', '\\\\').replace('"', '\\"')
lines.append(f' (dom-set-attr {var} "_" "{hs_escaped}")')
lines.append(f'{indent}(dom-set-attr {var} "_" "{hs_escaped}")')
hs_elements.append(i)
for aname, aval in el['attrs'].items():
if '\\' in aval or '\n' in aval or aname.startswith('['):
lines.append(f' ;; SKIP attr {aname} (contains special chars)')
lines.append(f'{indent};; SKIP attr {aname} (contains special chars)')
continue
aval_escaped = aval.replace('"', '\\"')
lines.append(f' (dom-set-attr {var} "{aname}" "{aval_escaped}")')
lines.append(f'{indent}(dom-set-attr {var} "{aname}" "{aval_escaped}")')
if el['inner']:
inner_escaped = el['inner'].replace('\\', '\\\\').replace('"', '\\"')
lines.append(f' (dom-set-inner-html {var} "{inner_escaped}")')
lines.append(f'{indent}(dom-set-inner-html {var} "{inner_escaped}")')
# Phase 2: Append elements (children to parents, roots to body)
# Phase 2: Append elements (children to parents, roots to `root`)
for i, el in enumerate(elements):
var = var_names[i]
if el['parent_idx'] is not None:
parent_var = var_names[el['parent_idx']]
lines.append(f' (dom-append {parent_var} {var})')
lines.append(f'{indent}(dom-append {parent_var} {var})')
else:
lines.append(f' (dom-append (dom-body) {var})')
lines.append(f'{indent}(dom-append {root} {var})')
# Phase 3: Activate HS handlers (all elements now in DOM)
for i in hs_elements:
lines.append(f' (hs-activate! {var_names[i]})')
lines.append(f'{indent}(hs-activate! {var_names[i]})')
def generate_test_chai(test, elements, var_names, idx):
@@ -905,6 +963,215 @@ def generate_test(test, idx):
return generate_test_chai(test, elements, var_names, idx)
# ── Live gallery pages ────────────────────────────────────────────
PAGE_HEADER = (
';; AUTO-GENERATED from spec/tests/hyperscript-upstream-tests.json\n'
';; DO NOT EDIT — regenerate with:\n'
';; python3 tests/playwright/generate-sx-tests.py --emit-pages\n'
)
# Actions/checks that we can't yet compile into a runner body emit a placeholder
# runner that throws; the card still renders so users can see the source. This
# keeps gallery coverage 1:1 with the JSON source of truth.
NOT_DEMONSTRABLE = '(error "not yet runnable in gallery — see test suite")'
def emit_runner_body(test, elements, var_names):
"""Emit the body of the runner lambda that runs inside a sandbox element.
Returns an SX expression string or None if the test can't be reproduced
(no HTML, unparseable action, etc.)."""
if not elements:
return None
ref = make_ref_fn(elements, var_names)
actions = parse_action(test.get('action', ''), ref)
checks_parsed = parse_checks(test.get('check', ''))
# Skip-only action list (no real action) → nothing to demonstrate
real_actions = [a for a in actions if not a.startswith(';;')]
if not real_actions:
return None
lines = []
bindings = ' '.join(
f'({var_names[i]} (dom-create-element "{el["tag"]}"))'
for i, el in enumerate(elements)
)
lines.append(f'(fn (sandbox)')
lines.append(f' (let ({bindings})')
emit_element_setup(lines, elements, var_names, root='sandbox', indent=' ')
for a in actions:
lines.append(f' {a}')
for c in checks_parsed:
sx = check_to_sx(c, ref)
lines.append(f' {sx}')
lines.append(' ))')
return '\n'.join(lines)
def emit_card(test):
"""Return an SX (~hyperscript/hs-test-card ...) call for one test."""
name_sx = sx_str(test['name'])
html_sx = sx_str(test.get('html', '') or '')
action_sx = sx_str(test.get('action', '') or '')
check_sx = sx_str(test.get('check', '') or '')
elements = parse_html(test.get('html', ''))
var_names = assign_var_names(elements) if elements else []
runner = emit_runner_body(test, elements, var_names)
if runner is None:
runner = f'(fn (sandbox) {NOT_DEMONSTRABLE})'
# :run-src is SX SOURCE TEXT — a string the island parses + evals at Run
# time. Ordinary lambda kwargs (and even bare quoted `(fn ...)` lists)
# end up lambda-ified by the prop pipeline and print as "<lambda>"
# through aser, which can't round-trip. Strings do.
run_src = sx_str(runner)
return (
f'(~hyperscript/hs-test-card\n'
f' :name {name_sx}\n'
f' :html {html_sx}\n'
f' :action {action_sx}\n'
f' :check {check_sx}\n'
f' :run-src {run_src})'
)
def emit_category_page(theme, category, tests):
"""Return SX source for one category page (all tests in that category)."""
total = len(tests)
runnable = sum(
1 for t in tests
if parse_html(t.get('html', '')) and
any(not a.startswith(';;') for a in
parse_action(t.get('action', ''),
make_ref_fn(parse_html(t.get('html', '')),
assign_var_names(parse_html(t.get('html', ''))))))
)
cards = '\n'.join(emit_card(t) for t in tests)
title = f'Hyperscript: {category} ({total} tests — {runnable} runnable)'
intro = (
f'Live cards for the upstream {category} tests. '
f'{runnable} of {total} are reproducible in-browser; '
f'the remainder show their source for reference.'
)
return (
PAGE_HEADER + '\n'
f'(defcomp ()\n'
f' (~docs/page :title {sx_str(title)}\n'
f' (p :style "color:#57534e;margin-bottom:1rem" {sx_str(intro)})\n'
f' (p :style "color:#78716c;font-size:0.875rem;margin-bottom:1rem"\n'
f' "Theme: " (a :href {sx_str(page_url([theme]))}\n'
f' :style "color:#7c3aed" {sx_str(theme)}))\n'
f' (div :style "display:flex;flex-direction:column"\n'
f' {cards})))\n'
)
def emit_theme_index(theme, cats_in_theme, cats_to_tests):
"""Return SX source for a theme index page (list of its categories)."""
total = sum(len(cats_to_tests.get(c, [])) for c in cats_in_theme)
links = []
for cat in cats_in_theme:
if cat not in cats_to_tests:
continue
n = len(cats_to_tests[cat])
href = page_url([theme, cat])
links.append(
f' (li :style "margin-bottom:0.25rem"\n'
f' (a :href {sx_str(href)} :style "color:#7c3aed;text-decoration:underline"\n'
f' {sx_str(cat)})\n'
f' (span :style "color:#78716c;margin-left:0.5rem;font-size:0.875rem"\n'
f' {sx_str(f"({n} tests)")}))'
)
title = f'Hyperscript tests: {theme} ({total} tests)'
return (
PAGE_HEADER + '\n'
f'(defcomp ()\n'
f' (~docs/page :title {sx_str(title)}\n'
f' (p :style "color:#57534e;margin-bottom:1rem"\n'
f' "Pick a category to see its live test cards.")\n'
f' (ul :style "list-style:disc;padding-left:1.5rem"\n'
+ '\n'.join(links) + '\n'
f' )))\n'
)
def emit_top_index(themes_with_counts):
"""Return SX source for the top-level /tests index page."""
links = []
for theme, count in themes_with_counts:
href = page_url([theme])
links.append(
f' (li :style "margin-bottom:0.25rem"\n'
f' (a :href {sx_str(href)} :style "color:#7c3aed;text-decoration:underline;font-weight:500"\n'
f' {sx_str(theme)})\n'
f' (span :style "color:#78716c;margin-left:0.5rem;font-size:0.875rem"\n'
f' {sx_str(f"({count} tests)")}))'
)
grand_total = sum(c for _, c in themes_with_counts)
title = f'Hyperscript test gallery ({grand_total} tests)'
return (
PAGE_HEADER + '\n'
f'(defcomp ()\n'
f' (~docs/page :title {sx_str(title)}\n'
f' (p :style "color:#57534e;margin-bottom:1rem"\n'
f' "Live cards for every upstream _hyperscript behavioural test. "\n'
f' "Each card renders the HTML into a sandbox, activates the hyperscript, "\n'
f' "dispatches the action, and runs the assertion. Pass/fail is shown "\n'
f' "with the same runtime path as the SX test suite.")\n'
f' (ul :style "list-style:disc;padding-left:1.5rem"\n'
+ '\n'.join(links) + '\n'
f' )))\n'
)
def write_page_files(categories):
"""Write gallery files. Everything is flat in applications/hyperscript/ —
gallery.sx (top), gallery-<theme>.sx, gallery-<theme>-<cat>.sx —
because the /sx/ router only dispatches one level per page-fn call."""
# Bucket categories by theme
themed = OrderedDict() # theme -> [(cat, tests)]
for cat, tests in categories.items():
theme = theme_for_category(cat)
themed.setdefault(theme, []).append((cat, tests))
# Remove any previous gallery-*.sx files so stale themes don't linger
if os.path.isdir(PAGES_DIR):
for fname in os.listdir(PAGES_DIR):
if fname == f'{GALLERY_SLUG}.sx' or fname.startswith(f'{GALLERY_SLUG}-'):
try: os.remove(os.path.join(PAGES_DIR, fname))
except OSError: pass
themes_with_counts = []
written = []
for theme, cat_pairs in themed.items():
cats_in_theme = [c for c, _ in cat_pairs]
cats_to_tests = {c: ts for c, ts in cat_pairs}
for cat, tests in cat_pairs:
fname = f'{page_slug([theme, cat])}.sx'
with open(os.path.join(PAGES_DIR, fname), 'w') as f:
f.write(emit_category_page(theme, cat, tests))
written.append(fname)
fname = f'{page_slug([theme])}.sx'
with open(os.path.join(PAGES_DIR, fname), 'w') as f:
f.write(emit_theme_index(theme, cats_in_theme, cats_to_tests))
written.append(fname)
themes_with_counts.append((theme, sum(len(ts) for _, ts in cat_pairs)))
fname = f'{GALLERY_SLUG}.sx'
with open(os.path.join(PAGES_DIR, fname), 'w') as f:
f.write(emit_top_index(themes_with_counts))
written.append(fname)
return themed, written
# ── Output generation ─────────────────────────────────────────────
output = []
@@ -989,3 +1256,15 @@ print(f' Categories: {len(categories)}')
for cat, (gen, stub) in generated_counts.items():
marker = '' if stub == 0 else f' ({stub} stubs)'
print(f' {cat}: {gen}{marker}')
# ── Optional: live gallery pages ──────────────────────────────────
import sys
if '--emit-pages' in sys.argv:
themed, written = write_page_files(categories)
print(f'\nGallery pages written under {PAGES_DIR} ({len(written)} files)')
for theme, pairs in themed.items():
cats = ', '.join(c for c, _ in pairs)
total_t = sum(len(ts) for _, ts in pairs)
print(f' {theme} ({total_t} tests, {len(pairs)} categories): {cats}')