Add comprehensive TW test suite (627 tests), fix 4 bugs, add 9 features

Bugs fixed:
- line-through: check full token not just head after hyphen split
- gap-x-N/gap-y-N: compound 2-part spacing prefix handler in tw-layout
- Negative values (-mt-4): replace ":" with ":-" instead of no-op
- Class name doubling: chain replace calls instead of concatenating

New features in tw-process-token:
- !important modifier (!p-4 → padding:1rem !important)
- dark: variant (class-based, .dark ancestor selector)
- group-hover:/group-focus:/group-active: (parent state)
- peer-focus:/peer-hover:/peer-checked:/peer-disabled: (sibling state)
- @container queries (@md:flex → @container(min-width:448px))
- Colour opacity modifier (bg-sky-500/50 → hsl with alpha)
- Ring colours (ring-sky-500 → --tw-ring-color)
- Arbitrary values (w-[300px], grid-cols-[1fr_2fr], bg-[#ff0000])
- colour-with-alpha helper for HSL+alpha generation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-01 21:55:33 +00:00
parent 0019f8e56a
commit 33350ced6d
4 changed files with 2642 additions and 48 deletions

View File

@@ -244,6 +244,20 @@ env["render-sx"] = function(source) {
return parts.join("");
};
// Mock request/state primitives for test-handlers.sx
const _mockState = {};
env["now"] = function(fmt) { return new Date().toISOString(); };
env["state-get"] = function(key, dflt) { return key in _mockState ? _mockState[key] : (dflt !== undefined ? dflt : null); };
env["state-set!"] = function(key, val) { _mockState[key] = val; return val; };
env["state-clear!"] = function(key) { delete _mockState[key]; return null; };
env["request-method"] = function() { return "GET"; };
env["request-arg"] = function(name, dflt) { return dflt !== undefined ? dflt : null; };
env["request-form"] = function(name, dflt) { return dflt !== undefined ? dflt : ""; };
env["request-headers-all"] = function() { return {}; };
env["request-form-all"] = function() { return {}; };
env["request-args-all"] = function() { return {}; };
env["request-content-type"] = function() { return "text/html"; };
// Platform test functions
env["try-call"] = function(thunk) {
try {
@@ -315,6 +329,33 @@ if (fs.existsSync(canonicalPath)) {
}
}
// Load sx-swap.sx (needed by spec/tests/test-sx-swap.sx)
const swapPath = path.join(projectDir, "lib", "sx-swap.sx");
if (fs.existsSync(swapPath)) {
const swapSrc = fs.readFileSync(swapPath, "utf8");
const swapExprs = Sx.parse(swapSrc);
for (const expr of swapExprs) {
try { Sx.eval(expr, env); } catch (e) {
console.error(`Error loading sx-swap.sx: ${e.message}`);
}
}
}
// Load tw system (needed by spec/tests/test-tw.sx)
const twDir = path.join(projectDir, "shared", "sx", "templates");
for (const twFile of ["tw-type.sx", "tw-layout.sx", "tw.sx"]) {
const twPath = path.join(twDir, twFile);
if (fs.existsSync(twPath)) {
const twSrc = fs.readFileSync(twPath, "utf8");
const twExprs = Sx.parse(twSrc);
for (const expr of twExprs) {
try { Sx.eval(expr, env); } catch (e) {
console.error(`Error loading ${twFile}: ${e.message}`);
}
}
}
}
// Load compiler + VM from lib/ when running full tests
if (fullBuild) {
const libDir = path.join(projectDir, "lib");

View File

@@ -24,6 +24,13 @@
((tmpl (get tw-spacing-props head))
(v (tw-spacing-value (first rest))))
(if (nil? v) nil (tw-template tmpl v)))
(and
(= (len rest) 2)
(get tw-spacing-props (str head "-" (first rest))))
(let
((tmpl (get tw-spacing-props (str head "-" (first rest))))
(v (tw-spacing-value (last rest))))
(if (nil? v) nil (tw-template tmpl v)))
(and
(= head "space")
(= (len rest) 2)

View File

@@ -1,5 +1,20 @@
(define colour-bases {:orange {:s 95 :h 25} :cyan {:s 94 :h 188} :sky {:s 89 :h 199} :pink {:s 81 :h 330} :zinc {:s 5 :h 240} :amber {:s 92 :h 38} :neutral {:s 0 :h 0} :lime {:s 78 :h 84} :violet {:s 70 :h 263} :fuchsia {:s 84 :h 292} :stone {:s 6 :h 25} :black {:s 0 :h 0} :teal {:s 80 :h 173} :gray {:s 9 :h 220} :red {:s 72 :h 0} :rose {:s 89 :h 350} :blue {:s 91 :h 217} :emerald {:s 84 :h 160} :green {:s 71 :h 142} :yellow {:s 96 :h 48} :purple {:s 81 :h 271} :indigo {:s 84 :h 239} :white {:s 0 :h 0} :slate {:s 16 :h 215}})
(define
colour-with-alpha
(fn
(name shade alpha)
(let
((base (get colour-bases name)))
(if
(nil? base)
name
(let
((h (get base "h"))
(s (get base "s"))
(l (shade-to-lightness shade)))
(str "hsl(" h "," s "%," (round l) "%," alpha ")"))))))
(define lerp (fn (a b t) (+ a (* t (- b a)))))
(define
@@ -58,6 +73,10 @@
(define tw-states {:focus ":focus" :before "::before" :first ":first-child" :disabled ":disabled" :required ":required" :even ":nth-child(even)" :hover ":hover" :focus-visible ":focus-visible" :last ":last-child" :visited ":visited" :odd ":nth-child(odd)" :active ":active" :focus-within ":focus-within" :checked ":checked" :placeholder "::placeholder" :after "::after"})
(define tw-selector-states {:group-hover ".group:hover " :peer-disabled ".peer:disabled~" :dark ".dark " :peer-hover ".peer:hover~" :group-focus-within ".group:focus-within " :peer-checked ".peer:checked~" :group-focus ".group:focus " :group-active ".group:active " :peer-invalid ".peer:invalid~" :peer-required ".peer:required~" :peer-focus ".peer:focus~"})
(define tw-container-sizes {:xs "320px" :3xl "768px" :7xl "1280px" :sm "384px" :xl "576px" :md "448px" :6xl "1152px" :5xl "1024px" :lg "512px" :2xl "672px" :4xl "896px"})
(define
tw-spacing-value
(fn
@@ -108,6 +127,30 @@
(define tw-border-widths {:0 "0px" :2 "2px" :8 "8px" :4 "4px" : "1px"})
(define tw-arbitrary-props {:outline-color "outline-color" :max-h "max-height" :mt "margin-top" :max-w "max-width" :inset-x "inset-inline" :font-size "font-size" :leading "line-height" :columns "columns" :size "width" :bg "background-color" :delay "transition-delay" :m "margin" :top "top" :left "left" :grid-cols "grid-template-columns" :my "margin-block" :border "border-width" :pb "padding-bottom" :order "order" :gap "gap" :basis "flex-basis" :mx "margin-inline" :rounded "border-radius" :ml "margin-left" :grid-rows "grid-template-rows" :mr "margin-right" :font "font-family" :border-color "border-color" :mb "margin-bottom" :pl "padding-left" :aspect "aspect-ratio" :gap-y "row-gap" :inset "inset" :indent "text-indent" :accent "accent-color" :gap-x "column-gap" :opacity "opacity" :w "width" :stroke "stroke" :px "padding-inline" :pr "padding-right" :right "right" :text "color" :p "padding" :min-h "min-height" :tracking "letter-spacing" :bottom "bottom" :inset-y "inset-block" :z "z-index" :min-w "min-width" :fill "fill" :pt "padding-top" :py "padding-block" :h "height" :duration "transition-duration" :shadow "box-shadow"})
(define
tw-resolve-arbitrary
(fn
(token)
(let
((bracket-start (index-of token "[")))
(if
(or (nil? bracket-start) (< bracket-start 1))
nil
(let
((bracket-end (index-of token "]")))
(if
(nil? bracket-end)
nil
(let
((prefix (substring token 0 (- bracket-start 1)))
(raw-val (substring token (+ bracket-start 1) bracket-end)))
(let
((val (replace raw-val "_" " "))
(prop (get tw-arbitrary-props prefix)))
(if (nil? prop) nil (str prop ":" val))))))))))
(define
tw-resolve-style
(fn
@@ -120,15 +163,41 @@
(and
(get tw-colour-props head)
(>= (len rest) 2)
(not (nil? (parse-int (last rest) nil)))
(not
(nil?
(get colour-bases (join "-" (slice rest 0 (- (len rest) 1)))))))
(let
((shade-str (last rest)) (slash-pos (index-of shade-str "/")))
(let
((shade-part (if (and slash-pos (>= slash-pos 0)) (substring shade-str 0 slash-pos) shade-str)))
(and
(not (nil? (parse-int shade-part nil)))
(not
(nil?
(get
colour-bases
(join "-" (slice rest 0 (- (len rest) 1))))))))))
(let
((css-prop (get tw-colour-props head))
(cname (join "-" (slice rest 0 (- (len rest) 1))))
(shade (parse-int (last rest) 0)))
(str css-prop ":" (colour cname shade)))
(shade-str (last rest))
(slash-pos (index-of shade-str "/"))
(cname (join "-" (slice rest 0 (- (len rest) 1)))))
(let
((shade-part (if (and slash-pos (>= slash-pos 0)) (substring shade-str 0 slash-pos) shade-str))
(alpha-part
(if
(and slash-pos (>= slash-pos 0))
(substring shade-str (+ slash-pos 1) (len shade-str))
nil)))
(let
((shade (parse-int shade-part 0)))
(if
alpha-part
(str
css-prop
":"
(colour-with-alpha
cname
shade
(/ (parse-int alpha-part 100) 100)))
(str css-prop ":" (colour cname shade))))))
(and
(get tw-colour-props head)
(= (len rest) 1)
@@ -264,6 +333,17 @@
(let
((n (parse-int (first rest) nil)))
(if (nil? n) nil (str "opacity:" (/ n 100))))
(and
(= head "ring")
(>= (len rest) 2)
(not (nil? (parse-int (last rest) nil)))
(not
(nil?
(get colour-bases (join "-" (slice rest 0 (- (len rest) 1)))))))
(let
((cname (join "-" (slice rest 0 (- (len rest) 1))))
(shade (parse-int (last rest) 0)))
(str "--tw-ring-color:" (colour cname shade)))
(and (= head "ring") (or (empty? rest) (= (len rest) 1)))
(let
((w (if (empty? rest) "3px" (let ((n (parse-int (first rest) nil))) (if (nil? n) nil (str n "px"))))))
@@ -327,13 +407,11 @@
(str "user-select:" (first rest))
(and (= head "appearance") (= (len rest) 1))
(str "appearance:" (first rest))
(and
(= (len parts) 1)
(or
(= head "underline")
(= head "overline")
(= head "line-through")))
(str "text-decoration-line:" head)
(or
(= token "underline")
(= token "overline")
(= token "line-through"))
(str "text-decoration-line:" token)
(and (= (len parts) 2) (= head "no") (= (first rest) "underline"))
"text-decoration-line:none"
(and (= head "scale") (= (len rest) 1))
@@ -362,58 +440,87 @@
(fn
(token)
(let
((colon-parts (split token ":")) (n (len colon-parts)))
((important (starts-with? token "!"))
(clean-token
(if
(starts-with? token "!")
(substring token 1 (len token))
token)))
(let
((bp nil) (state nil) (base nil))
((colon-parts (split clean-token ":"))
(n (len (split clean-token ":")))
(bp nil)
(state nil)
(sel-prefix nil)
(container nil)
(base nil))
(cond
(= n 1)
(do (set! base (first colon-parts)))
(set! base (first colon-parts))
(= n 2)
(do
(if
(get tw-breakpoints (first colon-parts))
(do
(set! bp (first colon-parts))
(set! base (nth colon-parts 1)))
(do
(set! state (first colon-parts))
(set! base (nth colon-parts 1)))))
(let
((prefix (first colon-parts)))
(set! base (nth colon-parts 1))
(cond
(get tw-breakpoints prefix)
(set! bp prefix)
(get tw-selector-states prefix)
(set! sel-prefix (get tw-selector-states prefix))
(starts-with? prefix "@")
(set! container (substring prefix 1 (len prefix)))
true
(set! state prefix))))
(= n 3)
(do
(set! bp (first colon-parts))
(set! state (nth colon-parts 1))
(set! base (nth colon-parts 2)))
:else (do (set! base token)))
(let
((p1 (first colon-parts)) (p2 (nth colon-parts 1)))
(set! base (nth colon-parts 2))
(cond
(get tw-breakpoints p1)
(set! bp p1)
(get tw-selector-states p1)
(set! sel-prefix (get tw-selector-states p1))
(starts-with? p1 "@")
(set! container (substring p1 1 (len p1)))
true
(set! bp p1))
(if
(get tw-selector-states p2)
(set! sel-prefix (get tw-selector-states p2))
(set! state p2))))
true
(set! base clean-token))
(let
((negative (and (>= (len base) 2) (= (substring base 0 1) "-")))
(actual-base (if negative (substring base 1 (len base)) base)))
((negative (and base (starts-with? base "-")))
(actual-base
(if
(and base (starts-with? base "-"))
(substring base 1 (len base))
base)))
(let
((css (or (tw-resolve-style actual-base) (tw-resolve-layout actual-base) (tw-resolve-type actual-base))))
((css (or (tw-resolve-style actual-base) (tw-resolve-layout actual-base) (tw-resolve-type actual-base) (tw-resolve-arbitrary actual-base))))
(if
(nil? css)
nil
(let
((final-css (if negative (str css) css))
((neg-css (if negative (replace css ":" ":-") css))
(final-css
(if important (str neg-css " !important") neg-css))
(cls
(str
"sx-"
(replace token ":" "-")
(replace token "." "d")))
(replace (replace clean-token ":" "-") "." "d")))
(pseudo
(if state (or (get tw-states state) (str ":" state)) ""))
(selector
(str "." cls (if state (get tw-states state) "")))
(rule
(if
bp
(str
"@media(min-width:"
(get tw-breakpoints bp)
"){"
selector
"{"
final-css
"}}")
(str selector "{" final-css "}"))))
{:rule rule :cls cls}))))))))
sel-prefix
(str sel-prefix "." cls pseudo)
(str "." cls pseudo))))
(let
((rule (cond bp (str "@media(min-width:" (get tw-breakpoints bp) "){" selector "{" final-css "}}") container (let ((csize (get tw-container-sizes container))) (if csize (str "@container(min-width:" csize "){" selector "{" final-css "}}") (str "@container{" selector "{" final-css "}}"))) true (str selector "{" final-css "}"))))
{:rule rule :cls cls})))))))))
(defcomp
~tw

2439
spec/tests/test-tw.sx Normal file

File diff suppressed because it is too large Load Diff