HS parser: fix number+comparison keyword collision, eval-hs uses hs-compile
Parser: skip unit suffix when next ident is a comparison keyword (starts, ends, contains, matches, is, does, in, precedes, follows). Fixes "123 starts with '12'" returning "123starts" instead of true. eval-hs: use hs-compile directly instead of hs-to-sx-from-source with "return " prefix, which was causing the parser to consume the comparison as a string suffix. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
48
sx/sx/applications/cssx/async/index.sx
Normal file
48
sx/sx/applications/cssx/async/index.sx
Normal file
@@ -0,0 +1,48 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Async CSS
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ()
|
||||
(~docs/page :title "Async CSS"
|
||||
|
||||
(~docs/section :title "The Pattern" :id "pattern"
|
||||
(p "A CSSX component that needs CSS it doesn't have yet can "
|
||||
(strong "fetch and cache it before rendering") ". This is just "
|
||||
(code "~shared:pages/suspense") " combined with a style component — no new infrastructure:")
|
||||
(highlight
|
||||
"(defcomp ~cssx/styled (&key css-url css-hash fallback &rest children)\n (if (css-cached? css-hash)\n ;; Already have it — render immediately\n children\n ;; Don't have it — suspense while we fetch\n (~shared:pages/suspense :id (str \"css-\" css-hash)\n :fallback (or fallback (span \"\"))\n (do\n (fetch-css css-url css-hash)\n children))))"
|
||||
"lisp")
|
||||
(p "The consumer never knows:")
|
||||
(highlight
|
||||
"(~cssx/styled :css-url \"/css/charts.css\" :css-hash \"abc123\"\n (~bar-chart :data metrics))"
|
||||
"lisp"))
|
||||
|
||||
(~docs/section :title "Use Cases" :id "use-cases"
|
||||
(~docs/subsection :title "Federated Components"
|
||||
(p "A " (code "~cssx/btn") " from another site arrives via IPFS with a CID pointing "
|
||||
"to its CSS. The component fetches and caches it before rendering. "
|
||||
"No coordination needed between sites.")
|
||||
(highlight
|
||||
"(defcomp ~cssx/federated-widget (&key cid &rest children)\n (let ((css-cid (str cid \"/style.css\"))\n (cached (css-cached? css-cid)))\n (if cached\n children\n (~shared:pages/suspense :id (str \"fed-\" cid)\n :fallback (div :class \"animate-pulse bg-stone-100 rounded h-20\")\n (do (fetch-css (str \"https://ipfs.io/ipfs/\" css-cid) css-cid)\n children)))))"
|
||||
"lisp"))
|
||||
|
||||
(~docs/subsection :title "Heavy UI Libraries"
|
||||
(p "Code editors, chart libraries, rich text editors — their CSS only loads "
|
||||
"when the component actually appears on screen:")
|
||||
(highlight
|
||||
"(defcomp ~cssx/code-editor (&key language value on-change)\n (~cssx/styled :css-url \"/css/codemirror.css\" :css-hash (asset-hash \"codemirror\")\n :fallback (pre :class \"p-4 bg-stone-900 text-stone-300 rounded\" value)\n (div :class \"cm-editor\"\n :data-language language\n :data-value value)))"
|
||||
"lisp"))
|
||||
|
||||
(~docs/subsection :title "Lazy Themes"
|
||||
(p "Theme CSS loads on first use, then is instant on subsequent visits:")
|
||||
(highlight
|
||||
"(defcomp ~cssx/lazy-theme (&key name &rest children)\n (let ((css-url (str \"/css/themes/\" name \".css\"))\n (hash (str \"theme-\" name)))\n (~cssx/styled :css-url css-url :css-hash hash\n :fallback children ;; render unstyled immediately\n children)))"
|
||||
"lisp")))
|
||||
|
||||
(~docs/section :title "How It Composes" :id "composition"
|
||||
(p "Async CSS composes with everything already in SX:")
|
||||
(ul (~tw :tokens "list-disc pl-5 space-y-1 text-stone-700")
|
||||
(li (code "~shared:pages/suspense") " handles the async gap with fallback content")
|
||||
(li "localStorage handles caching across sessions")
|
||||
(li (code "<style id=\"sx-css\">") " is the injection target (same as CSS class delivery)")
|
||||
(li "Component content-hashing tracks what the client has")
|
||||
(li "No new types, no new delivery protocol, no new spec code")))))
|
||||
60
sx/sx/applications/cssx/comparison/index.sx
Normal file
60
sx/sx/applications/cssx/comparison/index.sx
Normal file
@@ -0,0 +1,60 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Comparison with CSS Technologies
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ()
|
||||
(~docs/page :title "Comparisons"
|
||||
|
||||
(~docs/section :title "styled-components / Emotion" :id "styled-components"
|
||||
(p (a :href "https://styled-components.com" (~tw :tokens "text-violet-600 hover:underline") "styled-components")
|
||||
" pioneered the idea that styling belongs in components. But it generates CSS "
|
||||
"at runtime, injects " (code "<style>") " tags, and produces opaque hashed class "
|
||||
"names (" (code "class=\"sc-bdfBwQ fNMpVx\"") "). Open DevTools and you see gibberish. "
|
||||
"It also carries significant runtime cost — parsing CSS template literals, hashing, "
|
||||
"deduplicating — and needs a separate SSR extraction step (" (code "ServerStyleSheet") ").")
|
||||
(p "CSSX components share the core insight (" (em "styling is a component concern")
|
||||
") but without the runtime machinery. When a component applies Tailwind classes, "
|
||||
"there's zero CSS generation overhead. When it does emit " (code "<style>")
|
||||
" blocks, it's explicit — not hidden behind a tagged template literal. "
|
||||
"And the DOM is always readable."))
|
||||
|
||||
(~docs/section :title "CSS Modules" :id "css-modules"
|
||||
(p (a :href "https://github.com/css-modules/css-modules" (~tw :tokens "text-violet-600 hover:underline") "CSS Modules")
|
||||
" scope class names to avoid collisions by rewriting them at build time: "
|
||||
(code ".button") " becomes " (code ".button_abc123")
|
||||
". This solves the global namespace problem but creates the same opacity issue — "
|
||||
"hashed names in the DOM that you can't grep for or reason about.")
|
||||
(p "CSSX components don't need scoping because component boundaries already provide "
|
||||
"isolation. A " (code "~cssx/btn") " owns its markup. There's nothing to collide with."))
|
||||
|
||||
(~docs/section :title "Tailwind CSS" :id "tailwind"
|
||||
(p "Tailwind is " (em "complementary") ", not competitive. CSSX components are the "
|
||||
"semantic layer on top. Raw Tailwind in markup — "
|
||||
(code ":class \"px-4 py-2 bg-blue-600 text-white font-medium rounded hover:bg-blue-700\"")
|
||||
" — is powerful but verbose and duplicated across call sites.")
|
||||
(p "A CSSX component wraps that string once: " (code "(~cssx/btn :variant \"primary\" \"Submit\")")
|
||||
". The Tailwind classes are still there, readable in DevTools, but consumers don't "
|
||||
"repeat them. This is the same pattern Tailwind's own docs recommend ("
|
||||
(em "\"extracting components\"") ") — CSSX components are just SX's native way of doing it."))
|
||||
|
||||
(~docs/section :title "Vanilla Extract" :id "vanilla-extract"
|
||||
(p (a :href "https://vanilla-extract.style" (~tw :tokens "text-violet-600 hover:underline") "Vanilla Extract")
|
||||
" is zero-runtime CSS-in-JS: styles are written in TypeScript, compiled to static "
|
||||
"CSS at build time, and referenced by generated class names. It avoids the runtime "
|
||||
"cost of styled-components but still requires a build step, a bundler plugin, and "
|
||||
"TypeScript. The generated class names are again opaque.")
|
||||
(p "CSSX components need no build step for styling — they're evaluated at render time "
|
||||
"like any other component. And since the component chooses its own strategy, it can "
|
||||
"reference pre-built classes (zero runtime) " (em "or") " generate CSS on the fly — "
|
||||
"same API either way."))
|
||||
|
||||
(~docs/section :title "Design Tokens / Style Dictionary" :id "design-tokens"
|
||||
(p "The " (a :href "https://amzn.github.io/style-dictionary/" (~tw :tokens "text-violet-600 hover:underline") "Style Dictionary")
|
||||
" pattern — a JSON/YAML file mapping token names to values, compiled to "
|
||||
"platform-specific output — is essentially what the old CSSX was. It's the "
|
||||
"industry standard for design systems.")
|
||||
(p "The problem is that it's a parallel system: separate file format, separate build "
|
||||
"pipeline, separate caching, separate tooling. CSSX components eliminate all of "
|
||||
"that by expressing tokens as component parameters: "
|
||||
(code "(~cssx/theme :primary \"#7c3aed\")") " instead of "
|
||||
(code "{\"color\": {\"primary\": {\"value\": \"#7c3aed\"}}}")
|
||||
". Same result, no parallel infrastructure."))))
|
||||
64
sx/sx/applications/cssx/delivery/index.sx
Normal file
64
sx/sx/applications/cssx/delivery/index.sx
Normal file
@@ -0,0 +1,64 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; CSS Delivery
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ()
|
||||
(~docs/page :title "CSS Delivery"
|
||||
|
||||
(~docs/section :title "Multiple Strategies" :id "strategies"
|
||||
(p "A CSSX component chooses its own styling strategy — and each strategy has its "
|
||||
"own delivery path:")
|
||||
(ul (~tw :tokens "list-disc pl-5 space-y-1 text-stone-700")
|
||||
(li (strong "Tailwind classes: ") "Delivered via the on-demand protocol below. "
|
||||
"The server ships only the rules the page actually uses.")
|
||||
(li (strong "Inline styles: ") "No delivery needed — " (code ":style") " attributes "
|
||||
"are part of the markup.")
|
||||
(li (strong "Emitted " (code "<style>") " blocks: ") "Components can emit CSS rules "
|
||||
"directly. They arrive as part of the rendered HTML — keyframes, custom properties, "
|
||||
"scoped rules, anything.")
|
||||
(li (strong "External stylesheets: ") "Components can reference pre-loaded CSS files "
|
||||
"or lazy-load them via " (code "~shared:pages/suspense") " (see " (a :href "/sx/(applications.(cssx.async))" "Async CSS") ").")
|
||||
(li (strong "Custom properties: ") "A " (code "~cssx/theme") " component sets "
|
||||
(code "--color-primary") " etc. via a " (code "<style>") " block. Everything "
|
||||
"using " (code "var()") " repaints automatically."))
|
||||
(p "The protocol below handles the Tailwind utility class case — the most common "
|
||||
"strategy — but it's not the only game in town."))
|
||||
|
||||
(~docs/section :title "On-Demand Tailwind Delivery" :id "on-demand"
|
||||
(p "When components use Tailwind utility classes, SX ships " (em "only the CSS rules "
|
||||
"actually used on the page") ", computed at render time. No build step, no purging, "
|
||||
"no unused CSS.")
|
||||
(p "The server pre-parses the full Tailwind CSS file into an in-memory registry at "
|
||||
"startup. When a response is rendered, SX scans all " (code ":class") " values in "
|
||||
"the output, looks up only those classes, and embeds the matching rules."))
|
||||
|
||||
(~docs/section :title "The Protocol" :id "protocol"
|
||||
(p "First page load gets the full set of used rules. Subsequent navigations send a "
|
||||
"hash of what the client already has, and the server ships only the delta:")
|
||||
(highlight
|
||||
"# First page load:\nGET / HTTP/1.1\n\nHTTP/1.1 200 OK\nContent-Type: text/html\n# Full CSS in <style id=\"sx-css\"> + hash in <meta name=\"sx-css-classes\">\n\n# Subsequent navigation:\nGET /about HTTP/1.1\nSX-Css: a1b2c3d4\n\nHTTP/1.1 200 OK\nContent-Type: text/sx\nSX-Css-Hash: e5f6g7h8\nSX-Css-Add: bg-blue-500,text-white,rounded-lg\n# Only new rules in <style data-sx-css>"
|
||||
"bash")
|
||||
(p "The client merges new rules into the existing " (code "<style id=\"sx-css\">")
|
||||
" block and updates its hash. No flicker, no duplicate rules."))
|
||||
|
||||
(~docs/section :title "Component-Aware Scanning" :id "scanning"
|
||||
(p "CSS scanning happens at two levels:")
|
||||
(ul (~tw :tokens "list-disc pl-5 space-y-1 text-stone-700")
|
||||
(li (strong "Registration time: ") "When a component is defined via " (code "defcomp")
|
||||
", its body is scanned for class strings. The component records which CSS classes "
|
||||
"it uses.")
|
||||
(li (strong "Render time: ") "The rendered output is scanned for any classes the "
|
||||
"static scan missed — dynamically constructed class strings, conditional classes, etc."))
|
||||
(p "This means the CSS registry knows roughly what a page needs before rendering, "
|
||||
"and catches any stragglers after."))
|
||||
|
||||
(~docs/section :title "Trade-offs" :id "tradeoffs"
|
||||
(ul (~tw :tokens "list-disc pl-5 space-y-2 text-stone-700")
|
||||
(li (strong "Full Tailwind in memory: ") "The parsed CSS registry is ~4MB. This is "
|
||||
"a one-time startup cost per app instance.")
|
||||
(li (strong "Regex scanning: ") "Class detection uses regex, so dynamically "
|
||||
"constructed class names (e.g. " (code "(str \"bg-\" color \"-500\")") ") can be "
|
||||
"missed. Use complete class strings in " (code "case") "/" (code "cond") " branches.")
|
||||
(li (strong "No @apply: ") "Tailwind's " (code "@apply") " is a build-time feature. "
|
||||
"On-demand delivery works with utility classes directly.")
|
||||
(li (strong "Tailwind-shaped: ") "The registry parser understands Tailwind's naming "
|
||||
"conventions. Non-Tailwind CSS works via " (code "<style>") " blocks in components.")))))
|
||||
70
sx/sx/applications/cssx/index.sx
Normal file
70
sx/sx/applications/cssx/index.sx
Normal file
@@ -0,0 +1,70 @@
|
||||
;; CSSX — Styling as Components
|
||||
;; Documentation for the CSSX approach: no parallel style infrastructure,
|
||||
;; just defcomp components that decide how to style their children.
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Overview
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ()
|
||||
(~docs/page :title "CSSX Components"
|
||||
|
||||
(~docs/section :title "The Idea" :id "idea"
|
||||
(p (strong "Styling is just components.") " A CSSX component is a regular "
|
||||
(code "defcomp") " that decides how to style its children. It might apply "
|
||||
"Tailwind classes, or hand-written CSS classes, or inline styles, or generate "
|
||||
"rules at runtime. The implementation is the component's private business. "
|
||||
"The consumer just calls " (code "(~cssx/btn :variant \"primary\" \"Submit\")") " and doesn't care.")
|
||||
(p "Because it's " (code "defcomp") ", you get everything for free: caching, bundling, "
|
||||
"dependency scanning, server/client rendering, composition. No parallel infrastructure."))
|
||||
|
||||
(~docs/section :title "Why Not a Style Dictionary?" :id "why"
|
||||
(p "SX previously had a parallel CSS system: a style dictionary (JSON blob of "
|
||||
"atom-to-declaration mappings), a " (code "StyleValue") " type threaded through "
|
||||
"the evaluator and renderer, content-addressed hash class names (" (code "sx-a3f2b1")
|
||||
"), runtime CSS injection, and a separate caching pipeline (cookies, localStorage).")
|
||||
(p "This was ~3,000 lines of code across the spec, bootstrappers, and host implementations. "
|
||||
"It was never adopted. The codebase voted with its feet: " (code ":class") " strings "
|
||||
"with " (code "defcomp") " already covered every real use case.")
|
||||
(p "The result of that system: elements in the DOM got opaque class names like "
|
||||
(code "class=\"sx-a3f2b1\"") ". DevTools became useless. You couldn't inspect an "
|
||||
"element and understand its styling. " (strong "That was a deal breaker.")))
|
||||
|
||||
(~docs/section :title "Key Advantages" :id "advantages"
|
||||
(ul (~tw :tokens "list-disc pl-5 space-y-2 text-stone-700")
|
||||
(li (strong "Readable DOM: ") "Elements have real class names, not content-addressed "
|
||||
"hashes. DevTools works.")
|
||||
(li (strong "Data-driven styling: ") "Components receive data and decide styling. "
|
||||
(code "(~cssx/metric :value 150)") " renders red because " (code "value > 100")
|
||||
" — logic lives in the component, not a CSS preprocessor.")
|
||||
(li (strong "One system: ") "No separate " (code "StyleValue") " type, no style "
|
||||
"dictionary JSON, no injection pipeline. Components ARE the styling abstraction.")
|
||||
(li (strong "One cache: ") "Component hash/localStorage handles everything. No "
|
||||
"separate style dict caching.")
|
||||
(li (strong "Composable: ") (code "(~card :elevated true (~cssx/metric :value v))")
|
||||
" — styling composes like any other component.")
|
||||
(li (strong "Strategy-agnostic: ") "A component can apply Tailwind classes, emit "
|
||||
(code "<style>") " blocks, use inline styles, generate CSS custom properties, or "
|
||||
"any combination. Swap strategies without touching call sites.")))
|
||||
|
||||
(~docs/section :title "What Changed" :id "changes"
|
||||
(~docs/subsection :title "Removed (~3,000 lines)"
|
||||
(ul (~tw :tokens "list-disc pl-5 space-y-1 text-stone-600 text-sm")
|
||||
(li (code "StyleValue") " type and all plumbing (type checks in eval, render, serialize)")
|
||||
(li (code "cssx.sx") " spec module (resolve-style, resolve-atom, split-variant, hash, injection)")
|
||||
(li "Style dictionary JSON format, loading, caching (" (code "<script type=\"text/sx-styles\">") ", localStorage)")
|
||||
(li (code "style_dict.py") " (782 lines) and " (code "style_resolver.py") " (254 lines)")
|
||||
(li (code "css") " and " (code "merge-styles") " primitives")
|
||||
(li "Platform interface: " (code "fnv1a-hash") ", " (code "compile-regex") ", " (code "make-style-value") ", " (code "inject-style-value"))
|
||||
(li (code "defkeyframes") " special form")
|
||||
(li "Style dict cookies and localStorage keys")))
|
||||
|
||||
(~docs/subsection :title "Kept"
|
||||
(ul (~tw :tokens "list-disc pl-5 space-y-1 text-stone-600 text-sm")
|
||||
(li (code "defstyle") " — simplified to bind any value (string, function, etc.)")
|
||||
(li (code "tw.css") " — the compiled Tailwind stylesheet, delivered via CSS class tracking")
|
||||
(li (code ":class") " attribute — just takes strings, no special-casing")
|
||||
(li "CSS class delivery (" (code "SX-Css") " headers, " (code "<style id=\"sx-css\">") ")")
|
||||
(li "All component infrastructure (defcomp, caching, bundling, deps)")))
|
||||
|
||||
(~docs/subsection :title "Added"
|
||||
(p "Nothing. CSSX components are just " (code "defcomp") ". The only new thing is "
|
||||
"a convention: components whose primary purpose is styling.")))))
|
||||
63
sx/sx/applications/cssx/live/index.sx
Normal file
63
sx/sx/applications/cssx/live/index.sx
Normal file
@@ -0,0 +1,63 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Live Styles
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ()
|
||||
(~docs/page :title "Live Styles"
|
||||
|
||||
(~docs/section :title "Styles That Respond to Events" :id "concept"
|
||||
(p "Combine " (code "~live") " (SSE) or " (code "~ws") " (WebSocket) with style "
|
||||
"components, and you get styles that change in real-time in response to server "
|
||||
"events. No new infrastructure — just components receiving data through a "
|
||||
"persistent transport."))
|
||||
|
||||
(~docs/section :title "SSE: Live Theme Updates" :id "sse-theme"
|
||||
(p "A " (code "~live") " component declares a persistent connection to an SSE "
|
||||
"endpoint. When the server pushes a new event, " (code "resolveSuspense")
|
||||
" replaces the content:")
|
||||
(highlight
|
||||
"(~live :src \"/api/stream/brand\"\n (~shared:pages/suspense :id \"theme\"\n (~cssx/theme :primary \"#7c3aed\" :surface \"#fafaf9\")))"
|
||||
"lisp")
|
||||
(p "Server pushes a new theme:")
|
||||
(highlight
|
||||
"event: sx-resolve\ndata: {\"id\": \"theme\", \"sx\": \"(~cssx/theme :primary \\\"#2563eb\\\" :surface \\\"#1e1e2e\\\")\"}"
|
||||
"text")
|
||||
(p "The " (code "~cssx/theme") " component emits CSS custom properties. Everything "
|
||||
"using " (code "var(--color-primary)") " repaints instantly:")
|
||||
(highlight
|
||||
"(defcomp ~cssx/theme (&key primary surface)\n (style (str \":root {\"\n \"--color-primary:\" (or primary \"#7c3aed\") \";\"\n \"--color-surface:\" (or surface \"#fafaf9\") \"}\")))"
|
||||
"lisp"))
|
||||
|
||||
(~docs/section :title "SSE: Live Dashboard Metrics" :id "sse-metrics"
|
||||
(p "Style changes driven by live data — the component decides the visual treatment:")
|
||||
(highlight
|
||||
"(~live :src \"/api/stream/dashboard\"\n (~shared:pages/suspense :id \"cpu\"\n (~cssx/metric :value 0 :label \"CPU\" :threshold 80))\n (~shared:pages/suspense :id \"memory\"\n (~cssx/metric :value 0 :label \"Memory\" :threshold 90))\n (~shared:pages/suspense :id \"requests\"\n (~cssx/metric :value 0 :label \"RPS\" :threshold 1000)))"
|
||||
"lisp")
|
||||
(p "Server pushes updated values. " (code "~cssx/metric") " turns red when "
|
||||
(code "value > threshold") " — the styling logic lives in the component, "
|
||||
"not in CSS selectors or JavaScript event handlers."))
|
||||
|
||||
(~docs/section :title "WebSocket: Collaborative Design" :id "ws-design"
|
||||
(p "Bidirectional channel for real-time collaboration. A designer adjusts a color, "
|
||||
"all connected clients see the change:")
|
||||
(highlight
|
||||
"(~ws :src \"/ws/design-studio\"\n (~shared:pages/suspense :id \"canvas-theme\"\n (~cssx/theme :primary \"#7c3aed\")))"
|
||||
"lisp")
|
||||
(p "Client sends a color change:")
|
||||
(highlight
|
||||
";; Designer picks a new primary color\n(sx-send ws-conn '(theme-update :primary \"#dc2626\"))"
|
||||
"lisp")
|
||||
(p "Server broadcasts to all connected clients via " (code "sx-resolve") " — "
|
||||
"every client's " (code "~cssx/theme") " component re-renders with the new color."))
|
||||
|
||||
(~docs/section :title "Why This Works" :id "why"
|
||||
(p "Every one of these patterns is just a " (code "defcomp") " receiving data "
|
||||
"through a persistent transport. The styling strategy — CSS custom properties, "
|
||||
"class swaps, inline styles, " (code "<style>") " blocks — is the component's "
|
||||
"private business. The transport doesn't know or care.")
|
||||
(p "A parallel style system would have needed its own streaming, its own caching, "
|
||||
"its own delta protocol for each of these use cases — duplicating what components "
|
||||
"already do.")
|
||||
(p (~tw :tokens "mt-4 text-stone-500 italic")
|
||||
"Note: ~live and ~ws are planned (see Live Streaming). The patterns shown here "
|
||||
"will work as described once the streaming transport is implemented. The component "
|
||||
"and suspense infrastructure they depend on already exists."))))
|
||||
51
sx/sx/applications/cssx/patterns/index.sx
Normal file
51
sx/sx/applications/cssx/patterns/index.sx
Normal file
@@ -0,0 +1,51 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Patterns
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ()
|
||||
(~docs/page :title "Patterns"
|
||||
|
||||
(~docs/section :title "Class Mapping" :id "class-mapping"
|
||||
(p "The simplest pattern: a component that maps semantic keywords to class strings.")
|
||||
(highlight
|
||||
"(defcomp ~cssx/btn (&key variant disabled &rest children)\n (button\n :class (str \"px-4 py-2 rounded font-medium transition \"\n (case variant\n \"primary\" \"bg-blue-600 text-white hover:bg-blue-700\"\n \"danger\" \"bg-red-600 text-white hover:bg-red-700\"\n \"ghost\" \"bg-transparent hover:bg-stone-100\"\n \"bg-stone-200 hover:bg-stone-300\")\n (when disabled \" opacity-50 cursor-not-allowed\"))\n :disabled disabled\n children))"
|
||||
"lisp")
|
||||
(p "Consumers call " (code "(~cssx/btn :variant \"primary\" \"Submit\")") ". The Tailwind "
|
||||
"classes are readable in DevTools but never repeated across call sites."))
|
||||
|
||||
(~docs/section :title "Data-Driven Styling" :id "data-driven"
|
||||
(p "Styling that responds to data values — impossible with static CSS:")
|
||||
(highlight
|
||||
"(defcomp ~cssx/metric (&key value label threshold)\n (let ((t (or threshold 10)))\n (div :class (str \"p-3 rounded font-bold \"\n (cond\n ((> value (* t 10)) \"bg-red-500 text-white\")\n ((> value t) \"bg-amber-200 text-amber-900\")\n (:else \"bg-green-100 text-green-800\")))\n (span :class \"text-sm\" label) \": \" (span (str value)))))"
|
||||
"lisp")
|
||||
(p "The component makes a " (em "decision") " about styling based on data. "
|
||||
"No CSS preprocessor or class name convention can express \"red when value > 100\"."))
|
||||
|
||||
(~docs/section :title "Style Functions" :id "style-functions"
|
||||
(p "Reusable style logic that returns class strings — no wrapping element needed:")
|
||||
(highlight
|
||||
"(define card-classes\n (fn (&key elevated bordered)\n (str \"rounded-lg p-4 \"\n (if elevated \"shadow-lg\" \"shadow-sm\")\n (when bordered \" border border-stone-200\"))))\n\n;; Usage:\n(div :class (card-classes :elevated true) ...)\n(article :class (card-classes :bordered true) ...)"
|
||||
"lisp")
|
||||
(p "Or with " (code "defstyle") " for named bindings:")
|
||||
(highlight
|
||||
"(defstyle card-base \"rounded-lg p-4 shadow-sm\")\n(defstyle card-elevated \"rounded-lg p-4 shadow-lg\")\n\n(div :class card-base ...)"
|
||||
"lisp"))
|
||||
|
||||
(~docs/section :title "Responsive Layouts" :id "responsive"
|
||||
(p "Components that encode responsive breakpoints:")
|
||||
(highlight
|
||||
"(defcomp ~cssx/responsive-grid (&key cols &rest children)\n (div :class (str \"grid gap-4 \"\n (case (or cols 3)\n 1 \"grid-cols-1\"\n 2 \"grid-cols-1 md:grid-cols-2\"\n 3 \"grid-cols-1 md:grid-cols-2 lg:grid-cols-3\"\n 4 \"grid-cols-2 md:grid-cols-3 lg:grid-cols-4\"))\n children))"
|
||||
"lisp"))
|
||||
|
||||
(~docs/section :title "Emitting CSS Directly" :id "emitting-css"
|
||||
(p "Components are not limited to referencing existing classes. They can generate "
|
||||
"CSS — " (code "<style>") " tags, keyframes, custom properties — as part of their output:")
|
||||
(highlight
|
||||
"(defcomp ~cssx/pulse (&key color duration &rest children)\n (<>\n (style (str \"@keyframes sx-pulse {\"\n \"0%,100% { opacity:1 } 50% { opacity:.5 } }\"))\n (div :style (str \"animation: sx-pulse \" (or duration \"2s\") \" infinite;\"\n \"color:\" (or color \"inherit\"))\n children)))"
|
||||
"lisp")
|
||||
(highlight
|
||||
"(defcomp ~cssx/theme (&key primary surface &rest children)\n (<>\n (style (str \":root {\"\n \"--color-primary:\" (or primary \"#7c3aed\") \";\"\n \"--color-surface:\" (or surface \"#fafaf9\") \"}\"))\n children))"
|
||||
"lisp")
|
||||
(p "The CSS strategy is the component's private implementation detail. Consumers call "
|
||||
(code "(~cssx/pulse :color \"red\" \"Loading...\")") " or "
|
||||
(code "(~cssx/theme :primary \"#2563eb\" ...)") " without knowing or caring whether the "
|
||||
"component uses classes, inline styles, generated rules, or all three."))))
|
||||
45
sx/sx/applications/cssx/philosophy/index.sx
Normal file
45
sx/sx/applications/cssx/philosophy/index.sx
Normal file
@@ -0,0 +1,45 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Philosophy
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ()
|
||||
(~docs/page :title "Philosophy"
|
||||
|
||||
(~docs/section :title "The Collapse" :id "collapse"
|
||||
(p "The web has spent two decades building increasingly complex CSS tooling: "
|
||||
"preprocessors, CSS-in-JS, atomic CSS, utility frameworks, design tokens, style "
|
||||
"dictionaries. Each solves a real problem but adds a new system with its own "
|
||||
"caching, bundling, and mental model.")
|
||||
(p "CSSX components collapse all of this back to the simplest possible thing: "
|
||||
(strong "a function that takes data and returns markup with classes.")
|
||||
" That's what a component already is. There is no separate styling system because "
|
||||
"there doesn't need to be."))
|
||||
|
||||
(~docs/section :title "Proof by Deletion" :id "proof"
|
||||
(p "The strongest validation: we built the full parallel system — style dictionary, "
|
||||
"StyleValue type, content-addressed hashing, runtime injection, localStorage "
|
||||
"caching — and then deleted it because nobody used it. The codebase already had "
|
||||
"the answer: " (code "defcomp") " with " (code ":class") " strings.")
|
||||
(p "3,000 lines of infrastructure removed. Zero lines added. Every use case still works."))
|
||||
|
||||
(~docs/section :title "The Right Abstraction Level" :id "abstraction"
|
||||
(p "CSS-in-JS puts styling " (em "below") " components — you style elements, then compose "
|
||||
"them. Utility CSS puts styling " (em "beside") " components — classes in markup, logic "
|
||||
"elsewhere. Both create a seam between what something does and how it looks.")
|
||||
(p "CSSX components put styling " (em "inside") " components — at the same level as "
|
||||
"structure and behavior. A " (code "~cssx/metric") " component knows its own thresholds, "
|
||||
"its own color scheme, its own responsive behavior. Styling is just another "
|
||||
"decision the component makes, not a separate concern."))
|
||||
|
||||
(~docs/section :title "Relationship to Other Plans" :id "relationships"
|
||||
(ul (~tw :tokens "list-disc pl-5 space-y-2 text-stone-700")
|
||||
(li (strong "Content-Addressed Components: ") "CSSX components get CIDs like any "
|
||||
"other component. A " (code "~cssx/btn") " from one site can be shared to another via "
|
||||
"IPFS. No " (code ":css-atoms") " manifest field needed — the component carries "
|
||||
"its own styling logic.")
|
||||
(li (strong "Isomorphic Rendering: ") "Components render the same on server and client. "
|
||||
"No style injection timing issues, no FOUC from late CSS loading.")
|
||||
(li (strong "Component Bundling: ") "deps.sx already handles transitive component deps. "
|
||||
"Style components are just more components in the bundle — no separate style bundling.")
|
||||
(li (strong "Live Streaming: ") "SSE and WebSocket transports push data to components. "
|
||||
"Style components react to that data like any other component — no separate style "
|
||||
"streaming protocol.")))))
|
||||
57
sx/sx/applications/graphql/_islands/parse-island.sx
Normal file
57
sx/sx/applications/graphql/_islands/parse-island.sx
Normal file
@@ -0,0 +1,57 @@
|
||||
|
||||
;; Live GraphQL parse island — parses input and shows AST + serialized output
|
||||
(defisland
|
||||
()
|
||||
(let
|
||||
((input (signal "{\n user(id: 1) {\n name\n email\n }\n}"))
|
||||
(ast-output (signal ""))
|
||||
(ser-output (signal "")))
|
||||
(define
|
||||
do-parse!
|
||||
(fn
|
||||
(e)
|
||||
(let
|
||||
((src (deref input)))
|
||||
(when
|
||||
(not (empty? src))
|
||||
(let
|
||||
((doc (gql-parse src)))
|
||||
(do
|
||||
(set-signal! ast-output (sx-serialize doc))
|
||||
(set-signal! ser-output (gql-serialize doc))))))))
|
||||
(div
|
||||
(~tw :tokens "flex flex-col gap-3")
|
||||
(textarea
|
||||
:rows "5"
|
||||
(~tw
|
||||
:tokens "w-full font-mono text-sm p-3 border border-gray-300 rounded-lg focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none")
|
||||
:placeholder "{ user(id: 1) { name email } }"
|
||||
:value (deref input)
|
||||
:on-input (fn (e) (set-signal! input (get-prop e "target.value"))))
|
||||
(button
|
||||
(~tw
|
||||
:tokens "self-start px-4 py-2 bg-violet-600 text-white rounded-lg hover:bg-violet-700 transition-colors")
|
||||
:on-click do-parse!
|
||||
"Parse")
|
||||
(when
|
||||
(not (empty? (deref ast-output)))
|
||||
(div
|
||||
(~tw :tokens "space-y-4")
|
||||
(div
|
||||
(h4
|
||||
(~tw
|
||||
:tokens "text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2")
|
||||
"AST")
|
||||
(pre
|
||||
(~tw
|
||||
:tokens "bg-gray-900 text-green-400 p-4 rounded-lg text-sm overflow-x-auto")
|
||||
(deref ast-output)))
|
||||
(div
|
||||
(h4
|
||||
(~tw
|
||||
:tokens "text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2")
|
||||
"Serialized")
|
||||
(pre
|
||||
(~tw
|
||||
:tokens "bg-gray-900 text-amber-400 p-4 rounded-lg text-sm overflow-x-auto")
|
||||
(deref ser-output))))))))
|
||||
274
sx/sx/applications/graphql/index.sx
Normal file
274
sx/sx/applications/graphql/index.sx
Normal file
@@ -0,0 +1,274 @@
|
||||
|
||||
;; GraphQL demo — parser, executor, and hyperscript integration
|
||||
(defcomp
|
||||
()
|
||||
(~docs/page
|
||||
:title "GraphQL"
|
||||
(p
|
||||
(~tw :tokens "text-lg text-gray-600 mb-2")
|
||||
"A pure SX implementation of the "
|
||||
(a
|
||||
:href "https://spec.graphql.org/"
|
||||
:target "_blank"
|
||||
(~tw :tokens "text-violet-600 underline")
|
||||
"GraphQL query language")
|
||||
". The parser, executor, and serializer are all s-expressions "
|
||||
"compiled to bytecode by the same kernel that runs the rest of the site.")
|
||||
(p
|
||||
(~tw :tokens "text-gray-600 mb-6")
|
||||
"GraphQL operations map directly to the existing "
|
||||
(code "defquery")
|
||||
"/"
|
||||
(code "defaction")
|
||||
" system. Queries become IO suspension calls, mutations become actions, "
|
||||
"and field selection is projection over result dicts. The hyperscript "
|
||||
"integration adds "
|
||||
(code "fetch gql { ... }")
|
||||
" as a native command.")
|
||||
(~docs/section
|
||||
:title "Parser"
|
||||
:id "parser"
|
||||
(p
|
||||
(~tw :tokens "text-gray-600 mb-3")
|
||||
"The tokenizer and recursive-descent parser live in "
|
||||
(code "lib/graphql.sx")
|
||||
". Feed it any GraphQL source and get an SX AST back.")
|
||||
(~docs/code :src "(gql-parse \"{ user(id: 1) { name email } }\")")
|
||||
(p (~tw :tokens "text-gray-500 text-sm mb-3") "Result:")
|
||||
(~docs/code
|
||||
:src (str
|
||||
"(gql-doc\n"
|
||||
" (gql-query nil () ()\n"
|
||||
" ((gql-field \"user\" ((\"id\" 1)) ()\n"
|
||||
" ((gql-field \"name\" () () ())\n"
|
||||
" (gql-field \"email\" () () ()))))))"))
|
||||
(p
|
||||
(~tw :tokens "text-gray-600 mt-3")
|
||||
"Every GraphQL concept has an SX node type: "
|
||||
(code "gql-query")
|
||||
", "
|
||||
(code "gql-mutation")
|
||||
", "
|
||||
(code "gql-field")
|
||||
", "
|
||||
(code "gql-fragment")
|
||||
", "
|
||||
(code "gql-var")
|
||||
", "
|
||||
(code "gql-directive")
|
||||
". "
|
||||
"Accessors like "
|
||||
(code "gql-field-name")
|
||||
" and "
|
||||
(code "gql-op-selections")
|
||||
" make the AST easy to walk."))
|
||||
(~docs/section
|
||||
:title "Variables and Operations"
|
||||
:id "variables"
|
||||
(p
|
||||
(~tw :tokens "text-gray-600 mb-3")
|
||||
"Named operations, variable definitions with types and defaults, "
|
||||
"and variable references all parse to structured AST nodes.")
|
||||
(~docs/code
|
||||
:src "query GetUser($id: ID!, $limit: Int = 10) {\n user(id: $id) {\n name\n posts(limit: $limit) { title }\n }\n}")
|
||||
(p
|
||||
(~tw :tokens "text-gray-500 text-sm mt-3 mb-3")
|
||||
"Variable definitions become "
|
||||
(code "(gql-var-def name type default)")
|
||||
" nodes. "
|
||||
"References become "
|
||||
(code "(gql-var name)")
|
||||
" nodes that the executor substitutes "
|
||||
"from a provided bindings dict."))
|
||||
(~docs/section
|
||||
:title "Fragments"
|
||||
:id "fragments"
|
||||
(p
|
||||
(~tw :tokens "text-gray-600 mb-3")
|
||||
"Fragment definitions, fragment spreads, and inline fragments:")
|
||||
(~docs/code
|
||||
:src "query {\n user {\n ...UserFields\n ... on Admin { role }\n }\n}\n\nfragment UserFields on User {\n name\n email\n}")
|
||||
(p
|
||||
(~tw :tokens "text-gray-600 mt-3")
|
||||
"The executor collects fragment definitions from the document "
|
||||
"and resolves spreads during projection. Inline fragments apply "
|
||||
"unconditionally (type checking is available via "
|
||||
(code "spec/types.sx")
|
||||
")."))
|
||||
(~docs/section
|
||||
:title "Executor"
|
||||
:id "executor"
|
||||
(p
|
||||
(~tw :tokens "text-gray-600 mb-3")
|
||||
"The executor in "
|
||||
(code "lib/graphql-exec.sx")
|
||||
" walks the parsed AST and dispatches each root field through a resolver. "
|
||||
"The default resolver uses "
|
||||
(code "perform io-gql-resolve")
|
||||
" to dispatch via IO suspension, exactly like "
|
||||
(code "defquery")
|
||||
".")
|
||||
(~docs/code
|
||||
:src "(let ((doc (gql-parse \"{ user(id: 1) { name email } }\"))\n (resolver (fn (field-name args op-type)\n (if (= field-name \"user\")\n {:name \"Alice\" :email \"alice@test.com\" :age 30}\n nil))))\n (gql-execute doc {} resolver))")
|
||||
(p (~tw :tokens "text-gray-500 text-sm mb-3") "Result:")
|
||||
(~docs/code
|
||||
:src "{:data {:user {:email \"alice@test.com\" :name \"Alice\"}}}")
|
||||
(p
|
||||
(~tw :tokens "text-gray-600 mt-3")
|
||||
"Notice "
|
||||
(code ":age")
|
||||
" is absent from the result. "
|
||||
"The executor projects each resolver result down to only the fields "
|
||||
"the query requested. Nested selections, list results, and aliased "
|
||||
"fields all work."))
|
||||
(~docs/section
|
||||
:title "Aliases"
|
||||
:id "aliases"
|
||||
(p
|
||||
(~tw :tokens "text-gray-600 mb-3")
|
||||
"Query the same field multiple times with different arguments:")
|
||||
(~docs/code
|
||||
:src "{\n me: user(id: 1) { name }\n them: user(id: 2) { name }\n}")
|
||||
(p
|
||||
(~tw :tokens "text-gray-600 mt-3")
|
||||
"Each aliased field calls the resolver independently. "
|
||||
"Results are keyed by the alias name: "
|
||||
(code "{:data {:me {:name ...} :them {:name ...}}}")
|
||||
"."))
|
||||
(~docs/section
|
||||
:title "Serializer"
|
||||
:id "serializer"
|
||||
(p
|
||||
(~tw :tokens "text-gray-600 mb-3")
|
||||
"The serializer converts an AST back to GraphQL source. "
|
||||
"Useful for logging, debugging, and wire format.")
|
||||
(~docs/code
|
||||
:src "(gql-serialize (gql-parse \"query GetUser($id: ID!) { user(id: $id) { name } }\"))")
|
||||
(p
|
||||
(~tw :tokens "text-gray-500 text-sm mt-3")
|
||||
"Round-trips cleanly. Parse, transform the AST, serialize back."))
|
||||
(~docs/section
|
||||
:title "Hyperscript: fetch gql"
|
||||
:id "hyperscript"
|
||||
(p
|
||||
(~tw :tokens "text-gray-600 mb-3")
|
||||
"The "
|
||||
(code "fetch gql")
|
||||
" command embeds GraphQL queries directly in hyperscript. "
|
||||
"Write queries inline "
|
||||
(em "in the HTML attribute")
|
||||
", compiled to bytecode "
|
||||
"alongside the rest of the hyperscript.")
|
||||
(~docs/code
|
||||
:src "<!-- shorthand query -->\n<button _=\"on click fetch gql { user(id: 1) { name } } then put it.data.user.name into #result\">\n Load User\n</button>\n\n<!-- with explicit endpoint -->\n<button _=\"on click fetch gql { posts { title } } from '/api/graphql' then ...\">\n Load Posts\n</button>\n\n<!-- mutation -->\n<button _=\"on click fetch gql mutation { deletePost(id: 42) { success } } then ...\">\n Delete\n</button>")
|
||||
(p
|
||||
(~tw :tokens "text-gray-600 mt-3 mb-3")
|
||||
"The parser collects the GraphQL body between braces, preserving nested "
|
||||
"structure. At runtime, "
|
||||
(code "hs-fetch-gql")
|
||||
" sends a standard "
|
||||
(code "{ \"query\": \"...\", \"variables\": {} }")
|
||||
" JSON POST to the endpoint (defaults to "
|
||||
(code "/graphql")
|
||||
").")
|
||||
(p
|
||||
(~tw :tokens "text-gray-600")
|
||||
"The compilation pipeline: hyperscript source "
|
||||
(span (~tw :tokens "text-gray-400") " → ")
|
||||
"HS tokenizer "
|
||||
(span (~tw :tokens "text-gray-400") " → ")
|
||||
"HS parser ("
|
||||
(code "fetch-gql")
|
||||
" AST) "
|
||||
(span (~tw :tokens "text-gray-400") " → ")
|
||||
"HS compiler ("
|
||||
(code "hs-fetch-gql")
|
||||
" call) "
|
||||
(span (~tw :tokens "text-gray-400") " → ")
|
||||
"bytecode. The GraphQL source string is a constant in the compiled output."))
|
||||
(~docs/section
|
||||
:title "GraphQL → SX Mapping"
|
||||
:id "mapping"
|
||||
(p
|
||||
(~tw :tokens "text-gray-600 mb-3")
|
||||
"Every GraphQL concept has a direct SX equivalent:")
|
||||
(table
|
||||
(~tw :tokens "w-full text-sm border-collapse mb-4")
|
||||
(thead
|
||||
(tr
|
||||
(~tw :tokens "border-b border-gray-200")
|
||||
(th
|
||||
(~tw :tokens "text-left py-2 pr-4 text-gray-500 font-medium")
|
||||
"GraphQL")
|
||||
(th
|
||||
(~tw :tokens "text-left py-2 pr-4 text-gray-500 font-medium")
|
||||
"SX")
|
||||
(th
|
||||
(~tw :tokens "text-left py-2 text-gray-500 font-medium")
|
||||
"Mechanism")))
|
||||
(tbody
|
||||
(tr
|
||||
(~tw :tokens "border-b border-gray-100")
|
||||
(td (~tw :tokens "py-2 pr-4") "Query")
|
||||
(td (~tw :tokens "py-2 pr-4") (code "defquery"))
|
||||
(td (~tw :tokens "py-2") "IO suspension"))
|
||||
(tr
|
||||
(~tw :tokens "border-b border-gray-100")
|
||||
(td (~tw :tokens "py-2 pr-4") "Mutation")
|
||||
(td (~tw :tokens "py-2 pr-4") (code "defaction"))
|
||||
(td (~tw :tokens "py-2") "IO suspension"))
|
||||
(tr
|
||||
(~tw :tokens "border-b border-gray-100")
|
||||
(td (~tw :tokens "py-2 pr-4") "Subscription")
|
||||
(td (~tw :tokens "py-2 pr-4") "SSE + signals")
|
||||
(td (~tw :tokens "py-2") "Reactive islands"))
|
||||
(tr
|
||||
(~tw :tokens "border-b border-gray-100")
|
||||
(td (~tw :tokens "py-2 pr-4") "Fragment")
|
||||
(td (~tw :tokens "py-2 pr-4") (code "defcomp"))
|
||||
(td (~tw :tokens "py-2") "Component composition"))
|
||||
(tr
|
||||
(~tw :tokens "border-b border-gray-100")
|
||||
(td (~tw :tokens "py-2 pr-4") "Schema")
|
||||
(td (~tw :tokens "py-2 pr-4") (code "spec/types.sx"))
|
||||
(td (~tw :tokens "py-2") "Gradual type system"))
|
||||
(tr
|
||||
(~tw :tokens "border-b border-gray-100")
|
||||
(td (~tw :tokens "py-2 pr-4") "Resolver")
|
||||
(td (~tw :tokens "py-2 pr-4") (code "perform"))
|
||||
(td (~tw :tokens "py-2") "CEK IO suspension"))
|
||||
(tr
|
||||
(td (~tw :tokens "py-2 pr-4") "Field selection")
|
||||
(td (~tw :tokens "py-2 pr-4") (code "gql-project"))
|
||||
(td (~tw :tokens "py-2") "Dict projection")))))
|
||||
(~docs/section
|
||||
:title "Live Parse"
|
||||
:id "live-parse"
|
||||
(p
|
||||
(~tw :tokens "text-gray-600 mb-3")
|
||||
"Type a GraphQL query and see the SX AST. "
|
||||
"Parsed by the same "
|
||||
(code "lib/graphql.sx")
|
||||
" that compiles to bytecode.")
|
||||
(div
|
||||
(~tw :tokens "flex flex-col gap-3")
|
||||
(textarea
|
||||
:id "gql-input"
|
||||
:name "source"
|
||||
:rows "5"
|
||||
(~tw
|
||||
:tokens "w-full font-mono text-sm p-3 border border-gray-300 rounded-lg focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none")
|
||||
:placeholder "{ user(id: 1) { name email } }"
|
||||
"{\n user(id: 1) {\n name\n email\n }\n}")
|
||||
(button
|
||||
(~tw
|
||||
:tokens "self-start px-4 py-2 bg-violet-600 text-white rounded-lg hover:bg-violet-700 transition-colors cursor-pointer")
|
||||
:id "gql-parse-btn"
|
||||
"Parse")
|
||||
(div
|
||||
:id "gql-output"
|
||||
(~tw
|
||||
:tokens "min-h-24 p-3 bg-gray-50 rounded-lg border border-gray-200 font-mono text-sm text-gray-700 whitespace-pre-wrap")
|
||||
(span (~tw :tokens "text-gray-400") "AST will appear here")))
|
||||
(script
|
||||
"document.getElementById('gql-parse-btn').addEventListener('click', function() { var src = document.getElementById('gql-input').value; fetch('/sx/(applications.(graphql.(api.parse-demo)))?source=' + encodeURIComponent(src)).then(function(r) { return r.text(); }).then(function(html) { document.getElementById('gql-output').innerHTML = html; }); });"))))
|
||||
137
sx/sx/applications/htmx/_test/test-demo.sx
Normal file
137
sx/sx/applications/htmx/_test/test-demo.sx
Normal file
@@ -0,0 +1,137 @@
|
||||
;; Tests for the htmx demo page
|
||||
;; Each deftest declares its runner: :playwright (live browser) or :harness (mock IO)
|
||||
|
||||
;; ── Click to Load ──────────────────────────────────────────────
|
||||
|
||||
(deftest
|
||||
click-to-load
|
||||
:runner :playwright
|
||||
:url "/sx/(applications.(htmx))"
|
||||
(click "button[hx-get]")
|
||||
(wait 2000)
|
||||
(assert-text "#click-result" :contains "Content loaded!")
|
||||
(assert-text "#click-result" :contains "Fetched from the server via sx-get"))
|
||||
|
||||
(deftest
|
||||
click-to-load-network
|
||||
:runner :playwright
|
||||
:url "/sx/(applications.(htmx))"
|
||||
(click "button[hx-get]")
|
||||
(assert-network :method "GET" :url-contains "api.click"))
|
||||
|
||||
;; ── Active Search ──────────────────────────────────────────────
|
||||
|
||||
(deftest
|
||||
search-debounce
|
||||
:runner :playwright
|
||||
:url "/sx/(applications.(htmx))"
|
||||
(fill "input[hx-get]" "swap")
|
||||
(wait 1500)
|
||||
(assert-network
|
||||
:method "GET"
|
||||
:url-contains "api.search"
|
||||
:url-contains "q=swap")
|
||||
(assert-text "#search-results" :not-contains "Type to search"))
|
||||
|
||||
(deftest
|
||||
search-empty
|
||||
:runner :playwright
|
||||
:url "/sx/(applications.(htmx))"
|
||||
(fill "input[hx-get]" "xyznonexistent")
|
||||
(wait 1500)
|
||||
(assert-text "#search-results" :contains "No results"))
|
||||
|
||||
;; ── Tabs ───────────────────────────────────────────────────────
|
||||
|
||||
(deftest
|
||||
tab-switch
|
||||
:runner :playwright
|
||||
:url "/sx/(applications.(htmx))"
|
||||
(click "button[hx-get*=\"tab=features\"]")
|
||||
(wait 2000)
|
||||
(assert-text "#htmx-tab-content" :not-contains "Click a tab")
|
||||
(assert-network :method "GET" :url-contains "api.tab"))
|
||||
|
||||
(deftest
|
||||
tab-overview
|
||||
:runner :playwright
|
||||
:url "/sx/(applications.(htmx))"
|
||||
(click "button[hx-get*=\"tab=overview\"]")
|
||||
(wait 2000)
|
||||
(assert-text "#htmx-tab-content" :contains "htmx gives you access"))
|
||||
|
||||
;; ── Append Items ───────────────────────────────────────────────
|
||||
|
||||
(deftest
|
||||
append-item
|
||||
:runner :playwright
|
||||
:url "/sx/(applications.(htmx))"
|
||||
(assert-count "#item-list > *" 0)
|
||||
(click "button[hx-post*=\"api.append\"]")
|
||||
(wait 2000)
|
||||
(assert-count "#item-list > *" :gte 1)
|
||||
(assert-network :method "POST" :url-contains "api.append"))
|
||||
|
||||
(deftest
|
||||
append-multiple
|
||||
:runner :playwright
|
||||
:url "/sx/(applications.(htmx))"
|
||||
(click "button[hx-post*=\"api.append\"]")
|
||||
(wait 1500)
|
||||
(click "button[hx-post*=\"api.append\"]")
|
||||
(wait 1500)
|
||||
(assert-count "#item-list > *" :gte 2))
|
||||
|
||||
;; ── Delete with Confirm ────────────────────────────────────────
|
||||
|
||||
(deftest
|
||||
delete-item
|
||||
:runner :playwright
|
||||
:url "/sx/(applications.(htmx))"
|
||||
(assert-count "#delete-list .item" 3)
|
||||
(accept-dialog)
|
||||
(click "button[hx-delete]:first-of-type")
|
||||
(wait 2000)
|
||||
(assert-count "#delete-list .item" 2)
|
||||
(assert-network :method "DELETE" :url-contains "api.delete"))
|
||||
|
||||
;; ── Form Submission ────────────────────────────────────────────
|
||||
|
||||
(deftest
|
||||
form-submit
|
||||
:runner :playwright
|
||||
:url "/sx/(applications.(htmx))"
|
||||
(fill "form[hx-post] input[name=\"name\"]" "Alice")
|
||||
(fill "form[hx-post] input[name=\"email\"]" "alice@example.com")
|
||||
(click "form[hx-post] button[type=\"submit\"]")
|
||||
(wait 2000)
|
||||
(assert-text "#form-result" :contains "Alice")
|
||||
(assert-text "#form-result" :contains "alice@example.com")
|
||||
(assert-network :method "POST" :url-contains "api.form"))
|
||||
|
||||
;; ── Boot & Activation ──────────────────────────────────────────
|
||||
|
||||
(deftest
|
||||
htmx-boot-activates
|
||||
:runner :playwright
|
||||
:url "/sx/(applications.(htmx))"
|
||||
(assert-count "[hx-get],[hx-post],[hx-delete]" :gte 8)
|
||||
(eval "(type-of htmx-boot-subtree!)" :equals "function"))
|
||||
|
||||
(deftest
|
||||
no-console-errors
|
||||
:runner :playwright
|
||||
:url "/sx/(applications.(htmx))"
|
||||
(assert-no-errors)
|
||||
(assert-text "[data-sx-ready]" :exists true))
|
||||
|
||||
;; ── OOB Swap Filtering ────────────────────────────────────────
|
||||
|
||||
(deftest
|
||||
oob-not-visible
|
||||
:runner :playwright
|
||||
:url "/sx/(applications.(htmx))"
|
||||
(click "button[hx-get]")
|
||||
(wait 2000)
|
||||
(assert-text "#click-result" :not-contains "defcomp")
|
||||
(assert-text "#click-result" :not-contains "~examples"))
|
||||
313
sx/sx/applications/htmx/index.sx
Normal file
313
sx/sx/applications/htmx/index.sx
Normal file
@@ -0,0 +1,313 @@
|
||||
;; htmx demo — live interactive examples using hx-* attributes
|
||||
(defcomp
|
||||
()
|
||||
(~docs/page
|
||||
:title "htmx"
|
||||
(p
|
||||
(~tw :tokens "text-lg text-gray-600 mb-2")
|
||||
"Every "
|
||||
(code "hx-*")
|
||||
" attribute is syntactic sugar for a "
|
||||
(a
|
||||
:href "/sx/(applications.(hyperscript))"
|
||||
(~tw :tokens "text-violet-600 underline")
|
||||
"_hyperscript")
|
||||
" event handler. Same runtime, same bytecode, zero duplication.")
|
||||
(p
|
||||
(~tw :tokens "text-gray-500 mb-8")
|
||||
"These demos use real "
|
||||
(code "hx-*")
|
||||
" attributes processed by "
|
||||
(code "htmx-activate!")
|
||||
" — not "
|
||||
(code "_=\"...\"")
|
||||
" hyperscript syntax. "
|
||||
"Click buttons, type in inputs, and watch the network tab.")
|
||||
(~docs/section
|
||||
:title "Click to Load"
|
||||
:id "click-to-load"
|
||||
(p
|
||||
(~tw :tokens "text-gray-600 mb-3")
|
||||
"The simplest htmx pattern: a button with "
|
||||
(code "hx-get")
|
||||
" that loads server content into a target element.")
|
||||
(~docs/code
|
||||
:src "<button hx-get=\"/sx/(applications.(htmx.(api.click)))\"\n hx-target=\"#click-result\"\n hx-swap=\"innerHTML\">\n Load Content\n</button>")
|
||||
(div
|
||||
(~tw :tokens "mt-4 flex flex-col gap-3")
|
||||
(button
|
||||
(~tw
|
||||
:tokens "self-start px-4 py-2 bg-violet-600 text-white rounded-lg hover:bg-violet-700 transition-colors")
|
||||
:hx-get "/sx/(applications.(htmx.(api.click)))"
|
||||
:hx-target "#click-result"
|
||||
:hx-swap "innerHTML"
|
||||
"Load Content")
|
||||
(div
|
||||
:id "click-result"
|
||||
(~tw
|
||||
:tokens "min-h-16 border border-dashed border-gray-200 rounded-lg flex items-center justify-center")
|
||||
(span
|
||||
(~tw :tokens "text-gray-400 text-sm")
|
||||
"Content will appear here"))))
|
||||
(~docs/section
|
||||
:title "Active Search"
|
||||
:id "active-search"
|
||||
(p
|
||||
(~tw :tokens "text-gray-600 mb-3")
|
||||
"Search-as-you-type with "
|
||||
(code "hx-trigger=\"input changed delay:300ms\"")
|
||||
" — debounces input and only fires when the value actually changes.")
|
||||
(~docs/code
|
||||
:src "<input hx-get=\"/sx/(applications.(htmx.(api.search)))\"\n hx-trigger=\"input changed delay:300ms\"\n hx-target=\"#search-results\"\n name=\"q\"\n placeholder=\"Search patterns...\">")
|
||||
(div
|
||||
(~tw :tokens "mt-4 space-y-3")
|
||||
(input
|
||||
(~tw
|
||||
:tokens "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-violet-300 focus:border-violet-400 outline-none")
|
||||
:type "text"
|
||||
:name "q"
|
||||
:placeholder "Search patterns… (try 'tab', 'modal', 'scroll')"
|
||||
:hx-get "/sx/(applications.(htmx.(api.search)))"
|
||||
:hx-trigger "input changed delay:300ms"
|
||||
:hx-target "#search-results")
|
||||
(div
|
||||
:id "search-results"
|
||||
(~tw :tokens "border border-gray-200 rounded-lg overflow-hidden")
|
||||
(p (~tw :tokens "text-gray-400 text-sm p-3") "Type to search…"))))
|
||||
(~docs/section
|
||||
:title "Tabs"
|
||||
:id "tabs"
|
||||
(p
|
||||
(~tw :tokens "text-gray-600 mb-3")
|
||||
"Tab buttons with "
|
||||
(code "hx-get")
|
||||
" loading content from the server. "
|
||||
"Each click replaces the tab panel with server-rendered HTML.")
|
||||
(~docs/code
|
||||
:src "<button hx-get=\"/api/tab?tab=overview\"\n hx-target=\"#tab-content\"\n hx-swap=\"innerHTML\">\n Overview\n</button>")
|
||||
(div
|
||||
(~tw :tokens "mt-4 border border-gray-200 rounded-lg overflow-hidden")
|
||||
(div
|
||||
(~tw :tokens "flex border-b border-gray-200 bg-gray-50")
|
||||
(button
|
||||
(~tw
|
||||
:tokens "px-4 py-2 text-sm font-medium text-violet-700 border-b-2 border-violet-600 bg-white")
|
||||
:hx-get "/sx/(applications.(htmx.(api.tab)))?tab=overview"
|
||||
:hx-target "#htmx-tab-content"
|
||||
:hx-swap "innerHTML"
|
||||
"Overview")
|
||||
(button
|
||||
(~tw
|
||||
:tokens "px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-700")
|
||||
:hx-get "/sx/(applications.(htmx.(api.tab)))?tab=features"
|
||||
:hx-target "#htmx-tab-content"
|
||||
:hx-swap "innerHTML"
|
||||
"Features")
|
||||
(button
|
||||
(~tw
|
||||
:tokens "px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-700")
|
||||
:hx-get "/sx/(applications.(htmx.(api.tab)))?tab=code"
|
||||
:hx-target "#htmx-tab-content"
|
||||
:hx-swap "innerHTML"
|
||||
"Code"))
|
||||
(div
|
||||
:id "htmx-tab-content"
|
||||
(~tw :tokens "p-4")
|
||||
(p
|
||||
(~tw :tokens "text-gray-400 text-sm")
|
||||
"Click a tab to load content"))))
|
||||
(~docs/section
|
||||
:title "Append Items"
|
||||
:id "append"
|
||||
(p
|
||||
(~tw :tokens "text-gray-600 mb-3")
|
||||
"Using "
|
||||
(code "hx-swap=\"beforeend\"")
|
||||
" to append new items instead of replacing content. "
|
||||
"The htmx v4 alias "
|
||||
(code "append")
|
||||
" also works.")
|
||||
(~docs/code
|
||||
:src "<button hx-post=\"/api/append\"\n hx-target=\"#item-list\"\n hx-swap=\"beforeend\">\n Add Item\n</button>")
|
||||
(div
|
||||
(~tw :tokens "mt-4 space-y-3")
|
||||
(button
|
||||
(~tw
|
||||
:tokens "px-4 py-2 bg-violet-600 text-white rounded-lg hover:bg-violet-700 transition-colors")
|
||||
:hx-post "/sx/(applications.(htmx.(api.append)))"
|
||||
:hx-target "#item-list"
|
||||
:hx-swap "beforeend"
|
||||
"Add Item")
|
||||
(div
|
||||
:id "item-list"
|
||||
(~tw
|
||||
:tokens "min-h-16 border border-dashed border-gray-200 rounded-lg p-2")
|
||||
(span
|
||||
(~tw :tokens "text-gray-400 text-sm")
|
||||
"Items will append here"))))
|
||||
(~docs/section
|
||||
:title "Delete with Confirm"
|
||||
:id "delete"
|
||||
(p
|
||||
(~tw :tokens "text-gray-600 mb-3")
|
||||
(code "hx-confirm")
|
||||
" shows a native dialog before firing the request. "
|
||||
(code "hx-delete")
|
||||
" sends a DELETE method. The empty response removes the target.")
|
||||
(~docs/code
|
||||
:src "<button hx-delete=\"/api/delete\"\n hx-target=\"closest .item\"\n hx-swap=\"outerHTML\"\n hx-confirm=\"Are you sure?\">\n Delete\n</button>")
|
||||
(div
|
||||
:id "delete-demo"
|
||||
(~tw :tokens "mt-4 space-y-2")
|
||||
(div
|
||||
(~tw
|
||||
:tokens "flex items-center justify-between p-3 bg-gray-50 border border-gray-200 rounded-lg item")
|
||||
(span "First item")
|
||||
(button
|
||||
(~tw
|
||||
:tokens "px-3 py-1 text-sm text-red-600 border border-red-200 rounded hover:bg-red-50")
|
||||
:hx-delete "/sx/(applications.(htmx.(api.delete)))"
|
||||
:hx-target "closest .item"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-confirm "Delete this item?"
|
||||
"Delete"))
|
||||
(div
|
||||
(~tw
|
||||
:tokens "flex items-center justify-between p-3 bg-gray-50 border border-gray-200 rounded-lg item")
|
||||
(span "Second item")
|
||||
(button
|
||||
(~tw
|
||||
:tokens "px-3 py-1 text-sm text-red-600 border border-red-200 rounded hover:bg-red-50")
|
||||
:hx-delete "/sx/(applications.(htmx.(api.delete)))"
|
||||
:hx-target "closest .item"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-confirm "Delete this item?"
|
||||
"Delete"))
|
||||
(div
|
||||
(~tw
|
||||
:tokens "flex items-center justify-between p-3 bg-gray-50 border border-gray-200 rounded-lg item")
|
||||
(span "Third item")
|
||||
(button
|
||||
(~tw
|
||||
:tokens "px-3 py-1 text-sm text-red-600 border border-red-200 rounded hover:bg-red-50")
|
||||
:hx-delete "/sx/(applications.(htmx.(api.delete)))"
|
||||
:hx-target "closest .item"
|
||||
:hx-swap "outerHTML"
|
||||
:hx-confirm "Delete this item?"
|
||||
"Delete"))))
|
||||
(~docs/section
|
||||
:title "Form Submission"
|
||||
:id "form"
|
||||
(p
|
||||
(~tw :tokens "text-gray-600 mb-3")
|
||||
"A form with "
|
||||
(code "hx-post")
|
||||
" sends form data via AJAX. "
|
||||
"The response replaces the target — no full page reload.")
|
||||
(~docs/code
|
||||
:src "<form hx-post=\"/api/form\"\n hx-target=\"#form-result\"\n hx-swap=\"innerHTML\">\n <input name=\"name\" />\n <input name=\"email\" type=\"email\" />\n <button type=\"submit\">Submit</button>\n</form>")
|
||||
(div
|
||||
(~tw :tokens "mt-4 space-y-3")
|
||||
(form
|
||||
(~tw :tokens "space-y-3")
|
||||
:hx-post "/sx/(applications.(htmx.(api.form)))"
|
||||
:hx-target "#form-result"
|
||||
:hx-swap "innerHTML"
|
||||
(div
|
||||
(~tw :tokens "flex gap-3")
|
||||
(input
|
||||
(~tw
|
||||
:tokens "flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-violet-300 outline-none")
|
||||
:type "text"
|
||||
:name "name"
|
||||
:placeholder "Name")
|
||||
(input
|
||||
(~tw
|
||||
:tokens "flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-violet-300 outline-none")
|
||||
:type "email"
|
||||
:name "email"
|
||||
:placeholder "Email"))
|
||||
(button
|
||||
(~tw
|
||||
:tokens "px-4 py-2 bg-violet-600 text-white rounded-lg hover:bg-violet-700 transition-colors")
|
||||
:type "submit"
|
||||
"Submit"))
|
||||
(div
|
||||
:id "form-result"
|
||||
(~tw :tokens "min-h-12")
|
||||
(span
|
||||
(~tw :tokens "text-gray-400 text-sm")
|
||||
"Submit the form to see the response"))))
|
||||
(~docs/section
|
||||
:title "How It Works"
|
||||
:id "how-it-works"
|
||||
(~docs/note
|
||||
(p
|
||||
(strong "Zero separate runtime. ")
|
||||
"htmx attributes are translated to the same runtime calls as "
|
||||
(a
|
||||
:href "/sx/(applications.(hyperscript))"
|
||||
(~tw :tokens "text-violet-600 underline")
|
||||
"_hyperscript")
|
||||
" event handlers. "
|
||||
(code "hx-get")
|
||||
" becomes "
|
||||
(code "perform io-fetch")
|
||||
". "
|
||||
(code "hx-swap=\"innerHTML\"")
|
||||
" becomes "
|
||||
(code "dom-set-inner-html")
|
||||
". "
|
||||
"Same bytecode path."))
|
||||
(p
|
||||
(~tw :tokens "text-gray-600 mt-3")
|
||||
"When the page loads, "
|
||||
(code "htmx-boot!")
|
||||
" scans the DOM for elements with "
|
||||
(code "hx-get")
|
||||
", "
|
||||
(code "hx-post")
|
||||
", etc. For each element, "
|
||||
(code "htmx-activate!")
|
||||
" reads the attributes, builds a handler function "
|
||||
"from the same primitives the hyperscript compiler emits, and registers "
|
||||
"it via "
|
||||
(code "hs-on")
|
||||
".")
|
||||
(ol
|
||||
(~tw :tokens "mt-3 space-y-1 text-gray-600 list-decimal list-inside")
|
||||
(li
|
||||
(code "htmx-boot!")
|
||||
" finds all "
|
||||
(code "[hx-get],[hx-post],…")
|
||||
" elements")
|
||||
(li
|
||||
(code "htmx-activate!")
|
||||
" reads "
|
||||
(code "hx-get")
|
||||
", "
|
||||
(code "hx-target")
|
||||
", "
|
||||
(code "hx-swap")
|
||||
", "
|
||||
(code "hx-trigger")
|
||||
" attributes")
|
||||
(li
|
||||
"Builds a handler: "
|
||||
(code
|
||||
"(fn (evt) (perform (io-fetch url method)) (hx-swap! target ...))"))
|
||||
(li
|
||||
"Registers via "
|
||||
(code "hs-on")
|
||||
" — same event system as hyperscript")
|
||||
(li "On trigger, handler fires through the same bytecode VM"))
|
||||
(p
|
||||
(~tw :tokens "text-gray-600 mt-3")
|
||||
"The full implementation is ~400 lines in "
|
||||
(code "lib/hyperscript/htmx.sx")
|
||||
" with "
|
||||
(a
|
||||
:href "/sx/(applications.(hyperscript.htmx))"
|
||||
(~tw :tokens "text-violet-600 underline")
|
||||
"57 tests")
|
||||
" covering parsing, swap modes, triggers, and v4 features."))))
|
||||
32
sx/sx/applications/hyperscript/_islands/compile-result.sx
Normal file
32
sx/sx/applications/hyperscript/_islands/compile-result.sx
Normal file
@@ -0,0 +1,32 @@
|
||||
;; _hyperscript Playground
|
||||
;; Lives under Applications: /sx/(applications.(hyperscript))
|
||||
;; ── Compile result component (server-side rendered) ─────────────
|
||||
(defcomp
|
||||
(&key source)
|
||||
(if
|
||||
(or (nil? source) (empty? source))
|
||||
(p
|
||||
(~tw :tokens "text-sm text-gray-400 italic")
|
||||
"Enter some hyperscript and click Compile")
|
||||
(let
|
||||
((compiled (hs-to-sx-from-source source)))
|
||||
(div
|
||||
(~tw :tokens "space-y-4")
|
||||
(div
|
||||
(h4
|
||||
(~tw
|
||||
:tokens "text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2")
|
||||
"Compiled SX")
|
||||
(pre
|
||||
(~tw
|
||||
:tokens "bg-gray-900 text-green-400 p-4 rounded-lg text-sm overflow-x-auto")
|
||||
(sx-serialize compiled)))
|
||||
(div
|
||||
(h4
|
||||
(~tw
|
||||
:tokens "text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2")
|
||||
"Parse Tree")
|
||||
(pre
|
||||
(~tw
|
||||
:tokens "bg-gray-900 text-amber-400 p-4 rounded-lg text-sm overflow-x-auto")
|
||||
(sx-serialize (hs-compile source))))))))
|
||||
30
sx/sx/applications/hyperscript/_islands/example.sx
Normal file
30
sx/sx/applications/hyperscript/_islands/example.sx
Normal file
@@ -0,0 +1,30 @@
|
||||
;; ── Compile handler (POST endpoint) ─────────────────────────────
|
||||
(defcomp
|
||||
(&key source description)
|
||||
(div
|
||||
(~tw :tokens "border border-gray-200 rounded-lg p-4 mb-4")
|
||||
(when
|
||||
description
|
||||
(p (~tw :tokens "text-sm text-gray-600 mb-2") description))
|
||||
(div
|
||||
(~tw :tokens "flex gap-4 items-start mb-3")
|
||||
(div
|
||||
(~tw :tokens "flex-1")
|
||||
(h4
|
||||
(~tw
|
||||
:tokens "text-xs font-semibold uppercase tracking-wider text-gray-400 mb-1")
|
||||
"Source")
|
||||
(pre
|
||||
(~tw
|
||||
:tokens "bg-gray-50 text-gray-900 p-3 rounded text-sm font-mono whitespace-pre-wrap break-words")
|
||||
(str "_=\"" source "\"")))
|
||||
(when
|
||||
(string-contains? source "on click")
|
||||
(div
|
||||
(~tw :tokens "flex-shrink-0 pt-5")
|
||||
(button
|
||||
(~tw
|
||||
:tokens "px-4 py-2 border border-violet-300 rounded-lg text-sm font-medium text-violet-700 hover:bg-violet-50 transition-colors")
|
||||
:_ source
|
||||
"Try it"))))
|
||||
(~hyperscript/compile-result :source source)))
|
||||
36
sx/sx/applications/hyperscript/_islands/live-demo.sx
Normal file
36
sx/sx/applications/hyperscript/_islands/live-demo.sx
Normal file
@@ -0,0 +1,36 @@
|
||||
;; ── Playground island ───────────────────────────────────────────
|
||||
(defcomp
|
||||
()
|
||||
(div
|
||||
(~tw :tokens "space-y-4")
|
||||
(style
|
||||
"\n .bg-violet-600 { background-color: rgb(124 58 237); }\n .text-white { color: white; }\n .animate-bounce { animation: bounce 1s infinite; }\n @keyframes bounce {\n 0%, 100% { transform: translateY(-25%); animation-timing-function: cubic-bezier(0.8,0,1,1); }\n 50% { transform: none; animation-timing-function: cubic-bezier(0,0,0.2,1); }\n }\n ")
|
||||
(p
|
||||
(~tw :tokens "text-sm text-gray-600")
|
||||
"These buttons have "
|
||||
(code "_=\"...\"")
|
||||
" attributes — hyperscript compiled to SX and activated at boot.")
|
||||
(div
|
||||
(~tw :tokens "flex gap-3 items-center")
|
||||
(button
|
||||
(~tw
|
||||
:tokens "px-4 py-2 border border-gray-300 rounded-lg text-sm transition-colors")
|
||||
:_ "on click toggle .bg-violet-600 on me then toggle .text-white on me"
|
||||
"Toggle Color")
|
||||
(button
|
||||
(~tw
|
||||
:tokens "px-4 py-2 border border-gray-300 rounded-lg text-sm transition-colors")
|
||||
:_ "on click add .animate-bounce to me then wait 1s then remove .animate-bounce from me"
|
||||
"Bounce")
|
||||
(span
|
||||
(~tw :tokens "text-sm text-gray-500")
|
||||
:id "click-counter"
|
||||
"0 clicks"))
|
||||
(div
|
||||
(~tw :tokens "mt-2")
|
||||
(button
|
||||
(~tw
|
||||
:tokens "px-4 py-2 border border-gray-300 rounded-lg text-sm transition-colors")
|
||||
:_ "on click increment @data-count on me then set #click-counter's innerHTML to my @data-count"
|
||||
:data-count "0"
|
||||
"Count Clicks"))))
|
||||
28
sx/sx/applications/hyperscript/_islands/playground.sx
Normal file
28
sx/sx/applications/hyperscript/_islands/playground.sx
Normal file
@@ -0,0 +1,28 @@
|
||||
;; ── Pipeline example component ──────────────────────────────────
|
||||
(defcomp
|
||||
()
|
||||
(div
|
||||
(~tw :tokens "space-y-4")
|
||||
(form
|
||||
:sx-post "/sx/(applications.(hyperscript.(api.compile)))"
|
||||
:sx-target "#hs-playground-result"
|
||||
:sx-swap "innerHTML"
|
||||
(div
|
||||
(label
|
||||
(~tw :tokens "block text-sm font-medium text-gray-700 mb-1")
|
||||
"Hyperscript source")
|
||||
(textarea
|
||||
:name "source"
|
||||
(~tw
|
||||
:tokens "w-full h-24 font-mono text-sm p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-violet-500 focus:border-transparent")
|
||||
"on click add .active to me"))
|
||||
(div
|
||||
(~tw :tokens "mt-2")
|
||||
(button
|
||||
:type "submit"
|
||||
(~tw
|
||||
:tokens "px-4 py-2 bg-violet-600 text-white rounded-lg hover:bg-violet-700 text-sm font-medium")
|
||||
"Compile")))
|
||||
(div
|
||||
:id "hs-playground-result"
|
||||
(~hyperscript/compile-result :source "on click add .active to me"))))
|
||||
32
sx/sx/applications/hyperscript/_islands/translation.sx
Normal file
32
sx/sx/applications/hyperscript/_islands/translation.sx
Normal file
@@ -0,0 +1,32 @@
|
||||
;; Side-by-side htmx ↔ hyperscript translation display
|
||||
(defcomp
|
||||
(&key htmx-html hs-source description)
|
||||
(div
|
||||
(~tw :tokens "border border-gray-200 rounded-lg overflow-hidden mb-6")
|
||||
(when
|
||||
description
|
||||
(div
|
||||
(~tw :tokens "px-4 py-2 bg-gray-50 border-b border-gray-200")
|
||||
(p (~tw :tokens "text-sm font-medium text-gray-700") description)))
|
||||
(div
|
||||
(~tw :tokens "grid grid-cols-2 divide-x divide-gray-200")
|
||||
(div
|
||||
(~tw :tokens "p-4")
|
||||
(h4
|
||||
(~tw
|
||||
:tokens "text-xs font-semibold uppercase tracking-wide text-gray-400 mb-2")
|
||||
"htmx")
|
||||
(pre
|
||||
(~tw
|
||||
:tokens "bg-stone-100 text-gray-900 p-3 rounded text-sm font-mono overflow-x-auto whitespace-pre")
|
||||
htmx-html))
|
||||
(div
|
||||
(~tw :tokens "p-4")
|
||||
(h4
|
||||
(~tw
|
||||
:tokens "text-xs font-semibold uppercase tracking-wide text-violet-400 mb-2")
|
||||
"_hyperscript")
|
||||
(pre
|
||||
(~tw
|
||||
:tokens "bg-stone-100 text-gray-900 p-3 rounded text-sm font-mono overflow-x-auto whitespace-pre")
|
||||
hs-source)))))
|
||||
330
sx/sx/applications/hyperscript/htmx/index.sx
Normal file
330
sx/sx/applications/hyperscript/htmx/index.sx
Normal file
@@ -0,0 +1,330 @@
|
||||
;; htmx 4.0 compatibility — demo page
|
||||
(defcomp
|
||||
()
|
||||
(~docs/page
|
||||
:title "htmx.sx"
|
||||
(p
|
||||
(~tw :tokens "text-lg text-gray-600 mb-2")
|
||||
"Every "
|
||||
(code "hx-")
|
||||
" attribute is syntactic sugar for a hyperscript event handler. "
|
||||
"The htmx compat layer translates attributes to the same runtime calls "
|
||||
"the "
|
||||
(code "_=\"...\"")
|
||||
" compiler emits. Same bytecode. Zero duplication.")
|
||||
(p
|
||||
(~tw :tokens "text-sm text-gray-500 mb-8")
|
||||
"htmx 4.0 compatible: "
|
||||
(code "hx-action")
|
||||
"/"
|
||||
(code "hx-method")
|
||||
", "
|
||||
(code ":inherited")
|
||||
" modifiers, swap aliases, "
|
||||
(code "hx-status:XXX")
|
||||
", "
|
||||
(code "hx-sync")
|
||||
", SSE in core.")
|
||||
(~docs/section
|
||||
:title "The Translation"
|
||||
:id "translation"
|
||||
(p
|
||||
"An htmx element and its hyperscript equivalent compile to identical runtime calls:")
|
||||
(~hyperscript/translation
|
||||
:htmx-html "<button hx-get=\"/api/items\"\n hx-target=\"#list\"\n hx-swap=\"innerHTML\"\n hx-indicator=\"#spinner\"\n hx-confirm=\"Sure?\"\n hx-push-url=\"true\">"
|
||||
:hs-source "on click\n confirm 'Sure?'\n add .htmx-request to #spinner\n fetch /api/items\n put the result into #list\n push url '/api/items'\n remove .htmx-request from #spinner"
|
||||
:description "A button that fetches, swaps, confirms, indicates, and pushes history")
|
||||
(p
|
||||
(~tw :tokens "text-sm text-gray-600 mt-4")
|
||||
"Both go through: "
|
||||
(code "hs-on")
|
||||
" → "
|
||||
(code "perform io-fetch")
|
||||
" → "
|
||||
(code "dom-set-inner-html")
|
||||
". The htmx path skips parsing/compilation and "
|
||||
"calls the runtime primitives directly."))
|
||||
(~docs/section
|
||||
:title "How It Works"
|
||||
:id "how-it-works"
|
||||
(~docs/note
|
||||
(p
|
||||
(strong "Architecture: ")
|
||||
"htmx-activate! scans "
|
||||
(code "hx-*")
|
||||
" attributes, "
|
||||
"builds a handler function from the same runtime primitives the hyperscript compiler emits "
|
||||
"(hs-on, dom-add-class, perform io-fetch, etc.), and registers it via "
|
||||
(code "hs-on")
|
||||
". "
|
||||
"No separate runtime. No duplication."))
|
||||
(p "The activation pipeline:")
|
||||
(ol
|
||||
(~tw :tokens "list-decimal pl-6 space-y-1 text-sm text-gray-700 mb-4")
|
||||
(li
|
||||
(code "hs-activate!(el)")
|
||||
" reads "
|
||||
(code "_=\"...\"")
|
||||
" → parse → compile → eval")
|
||||
(li
|
||||
(code "htmx-activate!(el)")
|
||||
" reads "
|
||||
(code "hx-*")
|
||||
" → build handler → "
|
||||
(code "hs-on"))
|
||||
(li "Both produce the same runtime calls")
|
||||
(li
|
||||
(code "hs-boot-subtree!")
|
||||
" runs both on every DOM subtree (page load + after swaps)"))
|
||||
(~docs/code
|
||||
:src (highlight
|
||||
"(define htmx-activate!\n (fn (el)\n (when (not (dom-get-attr el \"hx-ignore\"))\n (let ((verb-info (hx-verb-info el)))\n (when (and verb-info (not (dom-get-data el \"hx-active\")))\n (dom-set-data el \"hx-active\" true)\n (let ((method (first verb-info))\n (url (nth verb-info 1)))\n (let ((trigger (hx-parse-trigger\n (dom-get-attr el \"hx-trigger\") el))\n (handler (hx-make-handler el method url)))\n (hx-register-trigger! el trigger handler))))))))"
|
||||
"lisp")))
|
||||
(~docs/section
|
||||
:title "What's Shared (Zero Duplication)"
|
||||
:id "shared"
|
||||
(~docs/table
|
||||
:headers ("Capability" "Runtime Function" "Used By")
|
||||
:rows (list
|
||||
(list "Event dispatch" "hs-on / dom-listen" "both")
|
||||
(list "HTTP fetch" "perform io-fetch" "both")
|
||||
(list
|
||||
"DOM swap"
|
||||
"dom-set-inner-html / dom-insert-adjacent-html"
|
||||
"both")
|
||||
(list "CSS transitions" "dom-add-class / dom-remove-class" "both")
|
||||
(list "History API" "perform io-push-state" "both")
|
||||
(list "Indicator toggle" "dom-add-class .htmx-request" "both")
|
||||
(list "Confirm dialog" "perform io-confirm" "both")
|
||||
(list "Wait/settle" "hs-wait" "both")
|
||||
(list "Subtree boot" "hs-boot-subtree!" "both"))))
|
||||
(~docs/section
|
||||
:title "Live Demos"
|
||||
:id "demos"
|
||||
(p
|
||||
"These demos use "
|
||||
(code "_=\"...\"")
|
||||
" hyperscript syntax. "
|
||||
"The equivalent "
|
||||
(code "hx-*")
|
||||
" attributes would produce identical behavior "
|
||||
"through the same runtime.")
|
||||
(~docs/subsection
|
||||
:title "Toggle (Class Manipulation)"
|
||||
(~hyperscript/translation
|
||||
:htmx-html "<button hx-get=\"\" hx-swap=\"none\"\n hx-trigger=\"click\">"
|
||||
:hs-source "on click toggle .bg-violet-600 on me\n toggle .text-white on me"
|
||||
:description "Toggle class on click")
|
||||
(div
|
||||
(~tw :tokens "flex gap-3 items-center mt-3 mb-6")
|
||||
(button
|
||||
(~tw
|
||||
:tokens "px-4 py-2 border border-gray-300 rounded-lg text-sm transition-colors duration-200")
|
||||
:_ "on click toggle .bg-violet-600 on me then toggle .text-white on me"
|
||||
"Click to Toggle")))
|
||||
(~docs/subsection
|
||||
:title "Counter (State + DOM Update)"
|
||||
(~hyperscript/translation
|
||||
:htmx-html "<button hx-get=\"/api/increment\"\n hx-target=\"#count\"\n hx-swap=\"innerHTML\">"
|
||||
:hs-source "on click\n get the innerHTML of #demo-count\n set x to it as Int\n set the innerHTML of #demo-count\n to (x + 1)"
|
||||
:description "Increment a counter")
|
||||
(div
|
||||
(~tw :tokens "flex gap-3 items-center mt-3 mb-6")
|
||||
(button
|
||||
(~tw
|
||||
:tokens "px-4 py-2 border border-violet-300 rounded-lg text-sm bg-violet-50 hover:bg-violet-100")
|
||||
:_ "on click get the innerHTML of #demo-count then set x to it as Int then set the innerHTML of #demo-count to (x + 1)"
|
||||
"+1")
|
||||
(span (~tw :tokens "text-lg font-mono") :id "demo-count" "0")))
|
||||
(~docs/subsection
|
||||
:title "Show/Hide (Indicator Pattern)"
|
||||
(~hyperscript/translation
|
||||
:htmx-html "<button hx-get=\"/api/data\"\n hx-indicator=\"#demo-spinner\">"
|
||||
:hs-source "on click\n add .htmx-request to #demo-spinner\n wait 1s\n remove .htmx-request from #demo-spinner"
|
||||
:description "Show indicator during request")
|
||||
(style
|
||||
"\n #demo-spinner { opacity: 0; transition: opacity 0.2s; }\n #demo-spinner.htmx-request { opacity: 1; }\n ")
|
||||
(div
|
||||
(~tw :tokens "flex gap-3 items-center mt-3 mb-6")
|
||||
(button
|
||||
(~tw :tokens "px-4 py-2 border border-gray-300 rounded-lg text-sm")
|
||||
:_ "on click add .htmx-request to #demo-spinner then wait 1s then remove .htmx-request from #demo-spinner"
|
||||
"Load Data")
|
||||
(span
|
||||
:id "demo-spinner"
|
||||
(~tw :tokens "text-sm text-violet-600")
|
||||
"Loading...")))
|
||||
(~docs/subsection
|
||||
:title "Swap Modes"
|
||||
(~hyperscript/translation
|
||||
:htmx-html "<div hx-get=\"/fragment\"\n hx-target=\"#demo-swap-target\"\n hx-swap=\"beforeend\">"
|
||||
:hs-source "on click\n fetch /fragment\n put the result at the end of\n #demo-swap-target"
|
||||
:description "Append content (hx-swap=\"beforeend\" = v4 alias \"append\")")
|
||||
(div
|
||||
(~tw :tokens "mt-3 mb-6")
|
||||
(div
|
||||
:id "demo-swap-target"
|
||||
(~tw
|
||||
:tokens "border border-dashed border-gray-300 rounded-lg p-3 min-h-12 text-sm text-gray-500")
|
||||
(div (~tw :tokens "text-gray-400") "Target area"))
|
||||
(button
|
||||
(~tw
|
||||
:tokens "mt-2 px-4 py-2 border border-gray-300 rounded-lg text-sm")
|
||||
:_ "on click put '<div class=\"py-1 text-violet-700\">Appended at ' + Date() + '</div>' at the end of #demo-swap-target"
|
||||
"Append Item"))))
|
||||
(~docs/section
|
||||
:title "htmx 4.0 Features"
|
||||
:id "v4"
|
||||
(~docs/subsection
|
||||
:title "Swap Aliases"
|
||||
(p "v4 adds short aliases that normalize to canonical names:")
|
||||
(~docs/table
|
||||
:headers ("v4 Alias" "Canonical" "Meaning")
|
||||
:rows (list
|
||||
(list
|
||||
(code "before")
|
||||
(code "beforebegin")
|
||||
"Insert before element")
|
||||
(list (code "after") (code "afterend") "Insert after element")
|
||||
(list
|
||||
(code "prepend")
|
||||
(code "afterbegin")
|
||||
"Insert as first child")
|
||||
(list (code "append") (code "beforeend") "Insert as last child")
|
||||
(list
|
||||
(code "innerMorph")
|
||||
(code "innerMorph")
|
||||
"Morph children (idiomorph)")
|
||||
(list
|
||||
(code "outerMorph")
|
||||
(code "outerMorph")
|
||||
"Morph entire element")
|
||||
(list
|
||||
(code "textContent")
|
||||
(code "textContent")
|
||||
"Set text, no HTML parsing"))))
|
||||
(~docs/subsection
|
||||
:title "hx-action / hx-method"
|
||||
(p "v4 alternative to " (code "hx-get") "/" (code "hx-post") ":")
|
||||
(~docs/code
|
||||
:src (highlight
|
||||
";; These are equivalent:\n(button :hx-post \"/api/submit\")\n(button :hx-action \"/api/submit\" :hx-method \"post\")"
|
||||
"lisp")))
|
||||
(~docs/subsection
|
||||
:title "Explicit Inheritance"
|
||||
(p
|
||||
"v4 requires "
|
||||
(code ":inherited")
|
||||
" modifier for attribute inheritance:")
|
||||
(~docs/code
|
||||
:src (highlight
|
||||
";; v4: parent target inherited by children\n(div :hx-target:inherited \"#results\"\n (button :hx-get \"/search\" \"Search\")\n (button :hx-get \"/filter\" \"Filter\"))"
|
||||
"lisp"))
|
||||
(p
|
||||
(~tw :tokens "text-sm text-gray-500 mt-2")
|
||||
"Both buttons target "
|
||||
(code "#results")
|
||||
" via inherited attribute, "
|
||||
"not implicit inheritance."))
|
||||
(~docs/subsection
|
||||
:title "Status-Based Swap (hx-status:XXX)"
|
||||
(p "Override swap behavior per HTTP status code:")
|
||||
(~docs/code
|
||||
:src (highlight
|
||||
"(form :hx-post \"/api/submit\"\n :hx-target \"#result\"\n :hx-status:422 \"swap:innerHTML target:#errors\"\n :hx-status:5xx \"swap:none\")"
|
||||
"lisp"))
|
||||
(p
|
||||
(~tw :tokens "text-sm text-gray-500 mt-2")
|
||||
"Specificity: exact ("
|
||||
(code "422")
|
||||
") > two-digit ("
|
||||
(code "50x")
|
||||
") > one-digit ("
|
||||
(code "5xx")
|
||||
")."))
|
||||
(~docs/subsection
|
||||
:title "Request Sync (hx-sync)"
|
||||
(p "Synchronize concurrent requests:")
|
||||
(~docs/table
|
||||
:headers ("Strategy" "Behavior")
|
||||
:rows (list
|
||||
(list (code "drop") "Skip if request already in flight (default)")
|
||||
(list (code "abort") "Cancel existing request, start new")
|
||||
(list (code "replace") "Cancel existing, replace with new")
|
||||
(list
|
||||
(code "queue last")
|
||||
"Queue latest request until current completes")))
|
||||
(~docs/code
|
||||
:src (highlight
|
||||
";; Search: cancel stale requests\n(input :hx-get \"/search\"\n :hx-trigger \"keyup changed delay:500ms\"\n :hx-sync \"this:replace\")"
|
||||
"lisp")))
|
||||
(~docs/subsection
|
||||
:title "Server-Sent Events"
|
||||
(p "SSE is built into core in v4 (no extension needed):")
|
||||
(~docs/code
|
||||
:src (highlight
|
||||
"(div :hx-sse-connect \"/events\"\n :hx-sse-swap \"message:#notifications,alert:#alerts:outerHTML\")"
|
||||
"lisp"))
|
||||
(p
|
||||
(~tw :tokens "text-sm text-gray-500 mt-2")
|
||||
"Each event type maps to a target element and swap mode.")))
|
||||
(~docs/section
|
||||
:title "Swap Modifiers"
|
||||
:id "swap-modifiers"
|
||||
(p
|
||||
"The "
|
||||
(code "hx-swap")
|
||||
" value accepts space-separated modifiers after the mode:")
|
||||
(~docs/table
|
||||
:headers ("Modifier" "Example" "Effect")
|
||||
:rows (list
|
||||
(list (code "swap:") (code "swap:100ms") "Delay before swap")
|
||||
(list
|
||||
(code "settle:")
|
||||
(code "settle:200ms")
|
||||
"Delay between swap and settle")
|
||||
(list
|
||||
(code "scroll:")
|
||||
(code "scroll:top")
|
||||
"Auto-scroll after swap")
|
||||
(list
|
||||
(code "transition:")
|
||||
(code "transition:true")
|
||||
"Use View Transitions API")
|
||||
(list
|
||||
(code "strip:")
|
||||
(code "strip:true")
|
||||
"Remove outer wrapper before swap")
|
||||
(list
|
||||
(code "target:")
|
||||
(code "target:#alt")
|
||||
"Override target inline")
|
||||
(list
|
||||
(code "ignoreTitle:")
|
||||
(code "ignoreTitle:true")
|
||||
"Don't update page title")))
|
||||
(~docs/code
|
||||
:src (highlight
|
||||
"(button :hx-get \"/page\"\n :hx-swap \"innerHTML swap:50ms settle:100ms scroll:top transition:true\")"
|
||||
"lisp")))
|
||||
(~docs/section
|
||||
:title "Implementation"
|
||||
:id "implementation"
|
||||
(p "The total implementation:")
|
||||
(ul
|
||||
(~tw :tokens "list-disc pl-6 space-y-1 text-sm text-gray-700")
|
||||
(li (code "lib/hyperscript/htmx.sx") " — ~400 lines, 55 functions")
|
||||
(li
|
||||
"2 lines changed in "
|
||||
(code "integration.sx")
|
||||
" to hook into "
|
||||
(code "hs-activate!"))
|
||||
(li "57 tests across 11 suites")
|
||||
(li "Runtime cost: zero — it " (em "is") " the hyperscript runtime"))
|
||||
(~docs/note
|
||||
(p
|
||||
(strong "The insight: ")
|
||||
"htmx and hyperscript are not two competing libraries. "
|
||||
"htmx is a declarative attribute syntax for a subset of what hyperscript can express. "
|
||||
"The 'htmx compat layer' is just a thin attribute scanner "
|
||||
"that builds the same handler functions the hyperscript compiler produces.")))))
|
||||
83
sx/sx/applications/hyperscript/index.sx
Normal file
83
sx/sx/applications/hyperscript/index.sx
Normal file
@@ -0,0 +1,83 @@
|
||||
;; ── Live demo: actual hyperscript running ───────────────────────
|
||||
(defcomp
|
||||
()
|
||||
(~docs/page
|
||||
:title "_hyperscript Playground"
|
||||
(p
|
||||
:class "text-lg text-gray-600 mb-6"
|
||||
"Compile "
|
||||
(code "_hyperscript")
|
||||
" to SX expressions. "
|
||||
"The same "
|
||||
(code "_=\"...\"")
|
||||
" syntax, compiled to cached bytecode instead of re-parsed every page load.")
|
||||
(~docs/section
|
||||
:title "Interactive Playground"
|
||||
:id "playground"
|
||||
(p
|
||||
"Edit the hyperscript source and click Compile to see the tokenized, parsed, and compiled SX output.")
|
||||
(~hyperscript/playground))
|
||||
(~docs/section
|
||||
:title "Live Demo"
|
||||
:id "live-demo"
|
||||
(p
|
||||
"These buttons have "
|
||||
(code "_=\"...\"")
|
||||
" attributes — hyperscript compiled to SX and activated at boot.")
|
||||
(~hyperscript/live-demo))
|
||||
(~docs/section
|
||||
:title "Pipeline"
|
||||
:id "pipeline"
|
||||
(p "Every hyperscript string passes through three stages:")
|
||||
(ol
|
||||
:class "list-decimal list-inside space-y-1 text-sm text-gray-700 mb-4"
|
||||
(li
|
||||
(strong "Tokenize")
|
||||
" — source string to typed token stream (keywords, classes, ids, operators, strings)")
|
||||
(li
|
||||
(strong "Parse")
|
||||
" — token stream to AST (commands, expressions, features)")
|
||||
(li
|
||||
(strong "Compile")
|
||||
" — AST to SX expressions targeting "
|
||||
(code "web/lib/dom.sx")
|
||||
" primitives"))
|
||||
(p
|
||||
"The compiled SX is wrapped in "
|
||||
(code "(fn (me) ...)")
|
||||
" and evaluated with "
|
||||
(code "me")
|
||||
" bound to the element. Hyperscript variables are visible to "
|
||||
(code "eval (sx-expr)")
|
||||
" escapes."))
|
||||
(~docs/section
|
||||
:title "Examples"
|
||||
:id "examples"
|
||||
(style
|
||||
"\n button.active { background-color: #7c3aed !important; color: white !important; border-color: #7c3aed !important; }\n button.active:hover { background-color: #6d28d9 !important; }\n button.light { background-color: #fef3c7 !important; color: #92400e !important; border-color: #f59e0b !important; }\n button.light:hover { background-color: #fde68a !important; }\n button.dark { background-color: #1e293b !important; color: #e2e8f0 !important; border-color: #475569 !important; }\n button.dark:hover { background-color: #334155 !important; }\n .animate-bounce { animation: bounce 1s; }\n @keyframes bounce {\n 0%, 100% { transform: translateY(0); }\n 50% { transform: translateY(-25%); }\n }")
|
||||
(~hyperscript/example
|
||||
:source "on click add .active to me"
|
||||
:description "Event handler: click adds a CSS class")
|
||||
(~hyperscript/example
|
||||
:source "on click toggle between .light and .dark on me"
|
||||
:description "Toggle between two states")
|
||||
(~hyperscript/example
|
||||
:source "on click set my innerHTML to eval (str \"Hello from \" (+ 2 3) \" worlds\")"
|
||||
:description "SX escape: evaluate SX expressions from hyperscript")
|
||||
(~hyperscript/example
|
||||
:source "on click put \"<b>Rendered!</b>\" into #target"
|
||||
:description "Target another element by CSS selector")
|
||||
(div
|
||||
:id "target"
|
||||
(~tw
|
||||
:tokens "border border-dashed border-gray-300 rounded-lg p-3 min-h-[2rem] text-sm text-gray-400")
|
||||
"← render target")
|
||||
(~hyperscript/example
|
||||
:source "def double(n) return n + n end"
|
||||
:description "Define reusable functions")
|
||||
(~hyperscript/example
|
||||
:source "on click repeat 3 times add .active to me then wait 300ms then remove .active from me then wait 300ms end"
|
||||
:description "Iteration: repeat with timed animation (continuation after loop pending)")
|
||||
(~hyperscript/example
|
||||
:source "behavior Draggable on mousedown add .dragging to me end on mouseup remove .dragging from me end end"
|
||||
:description "Reusable behaviors — install on any element"))))
|
||||
155
sx/sx/applications/native-browser/index.sx
Normal file
155
sx/sx/applications/native-browser/index.sx
Normal file
@@ -0,0 +1,155 @@
|
||||
(defcomp
|
||||
()
|
||||
(~docs/page
|
||||
:title "Native SX Browser"
|
||||
(~docs/section
|
||||
:title "The idea"
|
||||
:id "idea"
|
||||
(p
|
||||
"SX runs in web browsers today via a WASM kernel. This works, but it's indirect — the evaluator produces HTML, the browser's layout engine positions it, the browser's paint engine draws it. Three systems, two serialization boundaries, thirty million lines of code in between.")
|
||||
(p
|
||||
"A native SX browser eliminates the middleman. S-expressions go in, pixels come out. No HTML parser. No CSS cascade. No JavaScript engine. No DOM. Just the SX evaluator talking to a graphics library.")
|
||||
(p
|
||||
"The entire rendering pipeline — parse, evaluate, lay out, paint — runs in the same process, in the same language. The browser "
|
||||
(em "is")
|
||||
" an SX program."))
|
||||
(~docs/section
|
||||
:title "Why this is feasible"
|
||||
:id "feasible"
|
||||
(p
|
||||
"The SX kernel already exists in OCaml: parser, CEK evaluator, bytecode VM, reactive signals, component model. It's roughly 3,500 lines. A native renderer adds about 1,200 lines on top — types, layout, painting, events, and the main loop.")
|
||||
(p
|
||||
"The hard parts of a web browser are HTML parsing (~500K lines in Blink), CSS layout (~800K lines), JavaScript execution (~2M lines in V8), and 2,000+ Web APIs. SX needs none of them. Components declare their structure as s-expressions. Layout is a pure function of the component tree. Painting is a walk over positioned boxes.")
|
||||
(div
|
||||
(~tw :tokens "overflow-x-auto rounded border border-stone-200 my-4")
|
||||
(table
|
||||
(~tw :tokens "w-full text-left text-sm")
|
||||
(thead
|
||||
(tr
|
||||
(~tw :tokens "border-b border-stone-200 bg-stone-100")
|
||||
(th (~tw :tokens "px-3 py-2 font-medium text-stone-600") "Layer")
|
||||
(th (~tw :tokens "px-3 py-2 font-medium text-stone-600") "Web browser")
|
||||
(th (~tw :tokens "px-3 py-2 font-medium text-stone-600") "SX native")))
|
||||
(tbody
|
||||
(tr
|
||||
(~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "px-3 py-2 text-stone-700") "Parse")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-600")
|
||||
"HTML parser (~500K lines)")
|
||||
(td (~tw :tokens "px-3 py-2 text-stone-600") "SX parser (225 lines)"))
|
||||
(tr
|
||||
(~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "px-3 py-2 text-stone-700") "Evaluate")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-600")
|
||||
"V8 JavaScript engine (~2M lines)")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-600")
|
||||
"CEK evaluator + bytecode VM (~1,300 lines)"))
|
||||
(tr
|
||||
(~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "px-3 py-2 text-stone-700") "Layout")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-600")
|
||||
"CSS cascade + flexbox + grid (~800K lines)")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-600")
|
||||
"Flexbox subset (~250 lines)"))
|
||||
(tr
|
||||
(~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "px-3 py-2 text-stone-700") "Paint")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-600")
|
||||
"Composited layer tree + GPU raster")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-600")
|
||||
"Cairo 2D draw calls (~150 lines)"))
|
||||
(tr
|
||||
(td (~tw :tokens "px-3 py-2 text-stone-700 font-semibold") "Total")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-700 font-semibold")
|
||||
"~35 million lines")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-700 font-semibold")
|
||||
"~5,000 lines"))))))
|
||||
(~docs/section
|
||||
:title "Architecture"
|
||||
:id "architecture"
|
||||
(p
|
||||
"The native host sits alongside the existing browser and server hosts. It reuses the kernel unchanged and adds a rendering pipeline that converts SX value trees to positioned boxes to pixels.")
|
||||
(~docs/code
|
||||
:src (highlight
|
||||
"SX source\n | sx_parser\n v\nSX value tree\n | CEK/VM eval (component expansion)\n v\nExpanded tree\n | render tree builder\n v\nRender nodes (tag, style, children, event handlers)\n | layout engine (flexbox)\n v\nPositioned boxes (x, y, width, height)\n | painter (Cairo + Pango)\n v\nPixels in window (SDL2)"
|
||||
"text"))
|
||||
(p
|
||||
"Each layer is a pure function of the previous. Change a signal, re-evaluate the subtree, re-layout, re-paint. The reactive system from the web version works identically — "
|
||||
(code "swap!")
|
||||
" triggers "
|
||||
(code "notify-subscribers")
|
||||
" triggers repaint."))
|
||||
(~docs/section
|
||||
:title "The platform interface"
|
||||
:id "platform"
|
||||
(p
|
||||
"A web browser provides thousands of APIs. The native SX browser needs about fifteen primitives — the minimal surface that SX code calls into for rendering and input:")
|
||||
(~docs/code
|
||||
:src (highlight
|
||||
";; Window\n(window-create width height title)\n(poll-event) ; → {:type \"click\" :x 100 :y 200} or nil\n(blit) ; flush surface to screen\n\n;; Drawing\n(draw-rect x y w h color)\n(draw-rounded-rect x y w h radius color)\n(draw-text x y text font-size weight color)\n(measure-text text font-size weight) ; → {:width N :height N}\n(fill-background color)\n(set-clip x y w h)\n(clear-clip)"
|
||||
"lisp"))
|
||||
(p
|
||||
"Everything above these primitives — layout algorithm, style resolution, hit testing, reactive scheduling, component expansion — is SX evaluating SX. The browser is a program written in the language it renders."))
|
||||
(~docs/section
|
||||
:title "The strange loop"
|
||||
:id "strange-loop"
|
||||
(p
|
||||
"The native browser can be written "
|
||||
(em "in SX itself")
|
||||
". The layout engine is SX functions. The style parser is SX pattern matching. The event dispatcher is SX closures. The render tree is SX data. Only the fifteen leaf primitives are foreign.")
|
||||
(p "This means the browser is:")
|
||||
(ul
|
||||
(~tw :tokens "list-disc list-inside space-y-2 mt-2")
|
||||
(li
|
||||
(strong "CID-addressable")
|
||||
" — the layout engine has a content hash, the style system has a content hash, the event model has a content hash")
|
||||
(li
|
||||
(strong "Hot-swappable")
|
||||
" — replace the layout algorithm at runtime by loading a different CID")
|
||||
(li
|
||||
(strong "Self-hosting")
|
||||
" — the browser renders itself, if you point it at its own source")
|
||||
(li
|
||||
(strong "Verifiable")
|
||||
" — the provenance chain from spec to bytecode to running browser is all content-addressed s-expressions")))
|
||||
(~docs/section
|
||||
:title "Adoption path"
|
||||
:id "adoption"
|
||||
(p
|
||||
"The native browser doesn't need to replace the web. It sits alongside it, the same way React Native apps, Electron apps, and Flutter apps coexist with web browsers.")
|
||||
(ul
|
||||
(~tw :tokens "list-disc list-inside space-y-2 mt-2")
|
||||
(li
|
||||
(strong "Today:")
|
||||
" SX runs in standard web browsers via the WASM kernel. No install, no app store.")
|
||||
(li
|
||||
(strong "Traction:")
|
||||
" Sites built with SX prove the component model, reactive signals, and bytecode compilation on real traffic.")
|
||||
(li
|
||||
(strong "Native:")
|
||||
" The same SX components render natively. Same code, no HTML middleman. Faster, lighter, offline-capable.")
|
||||
(li
|
||||
(strong "Platforms:")
|
||||
" SDL2 gives Linux, macOS, and Windows. A future host with platform-native rendering gives iOS and Android."))
|
||||
(p
|
||||
"The difference from Electron: Electron ships an entire Chromium (~200MB). The native SX browser is a 5-10MB binary — the OCaml kernel plus Cairo and SDL2."))
|
||||
(~docs/section
|
||||
:title "Proof of concept"
|
||||
:id "poc"
|
||||
(p
|
||||
"The POC renders a single page: a reactive counter with buttons, text, and flexbox layout. It demonstrates the full pipeline from SX source to interactive pixels.")
|
||||
(~docs/code
|
||||
:src (highlight
|
||||
"(let ((count (signal 0)))\n (div :class \"flex flex-col items-center gap-6 p-8 bg-stone-50\"\n (h1 :class \"text-3xl font-bold text-stone-800\"\n \"SX Native Counter\")\n (div :class \"flex items-center gap-4\"\n (button :class \"bg-violet-600 text-white px-4 py-2 rounded-lg\"\n :on-click (fn () (swap! count dec)) \"−\")\n (span :class \"text-4xl font-bold\" (deref count))\n (button :class \"bg-violet-600 text-white px-4 py-2 rounded-lg\"\n :on-click (fn () (swap! count inc)) \"+\"))\n (p :class \"text-stone-500 text-sm\"\n \"Native rendering — no HTML, no CSS, no JavaScript\")))"
|
||||
"lisp"))
|
||||
(p
|
||||
"Click the button. The signal updates. The display changes. No browser involved."))))
|
||||
146
sx/sx/applications/pretext/_islands/live.sx
Normal file
146
sx/sx/applications/pretext/_islands/live.sx
Normal file
@@ -0,0 +1,146 @@
|
||||
;; Pretext island — effect as let binding
|
||||
(defisland
|
||||
()
|
||||
(let
|
||||
((words (split "In the beginning was the Word, and the Word was with God, and the Word was God. The same was in the beginning with God. All things were made by him; and without him was not any thing made that was made. In him was life; and the life was the light of men." " "))
|
||||
(mxw (signal 500))
|
||||
(font-size (signal 16))
|
||||
(use-optimal (signal true))
|
||||
(doc (host-global "document"))
|
||||
(canvas (host-call doc "createElement" "canvas"))
|
||||
(ctx (host-call canvas "getContext" "2d"))
|
||||
(el-ref (signal nil))
|
||||
(_eff
|
||||
(effect
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((el (deref el-ref))
|
||||
(w (deref mxw))
|
||||
(sz (deref font-size))
|
||||
(opt (deref use-optimal)))
|
||||
(when
|
||||
el
|
||||
(host-set!
|
||||
ctx
|
||||
"font"
|
||||
(str sz "px 'Pretext Serif', DejaVu Serif, serif"))
|
||||
(let
|
||||
((lh (* sz 1.5))
|
||||
(widths
|
||||
(map
|
||||
(fn
|
||||
(wd)
|
||||
(host-get (host-call ctx "measureText" wd) "width"))
|
||||
words))
|
||||
(spw
|
||||
(host-get (host-call ctx "measureText" " ") "width")))
|
||||
(let
|
||||
((ranges (if opt (break-lines widths spw w) (break-lines-greedy widths spw w))))
|
||||
(let
|
||||
((lines (pretext-layout-lines words widths ranges spw w lh))
|
||||
(info (host-call doc "createElement" "div"))
|
||||
(container (host-call doc "createElement" "div")))
|
||||
(host-set! el "innerHTML" "")
|
||||
(host-set! info "className" "px-4 pt-3 pb-1")
|
||||
(host-set!
|
||||
info
|
||||
"innerHTML"
|
||||
(str
|
||||
"<span class='text-xs font-medium uppercase tracking-wide text-stone-400'>"
|
||||
(len lines)
|
||||
" lines — "
|
||||
w
|
||||
"px / "
|
||||
sz
|
||||
"px / "
|
||||
(if opt "optimal" "greedy")
|
||||
"</span>"))
|
||||
(host-call el "appendChild" info)
|
||||
(host-set!
|
||||
container
|
||||
"style"
|
||||
(str
|
||||
"position:relative;height:"
|
||||
(+ (* (len lines) lh) 24)
|
||||
"px;padding:12px 16px"))
|
||||
(let
|
||||
rl
|
||||
((li 0))
|
||||
(when
|
||||
(< li (len lines))
|
||||
(let
|
||||
((line (nth lines li))
|
||||
(wds (get (nth lines li) :words)))
|
||||
(let
|
||||
rw
|
||||
((wi 0))
|
||||
(when
|
||||
(< wi (len wds))
|
||||
(let
|
||||
((pw (nth wds wi))
|
||||
(span
|
||||
(host-call doc "createElement" "span")))
|
||||
(host-set!
|
||||
span
|
||||
"textContent"
|
||||
(get pw :word))
|
||||
(host-set!
|
||||
span
|
||||
"style"
|
||||
(str
|
||||
"position:absolute;left:"
|
||||
(+ (get pw :x) 16)
|
||||
"px;top:"
|
||||
(+ (get line :y) 12)
|
||||
"px;font:"
|
||||
sz
|
||||
"px 'Pretext Serif',serif;white-space:nowrap"))
|
||||
(host-call container "appendChild" span)
|
||||
(rw (+ wi 1))))))
|
||||
(rl (+ li 1))))
|
||||
(host-call el "appendChild" container))))))))))
|
||||
(div
|
||||
(~tw :tokens "space-y-4")
|
||||
(div
|
||||
(~tw :tokens "flex flex-wrap gap-4 items-end")
|
||||
(div
|
||||
(label (~tw :tokens "block text-xs text-stone-500 mb-1") "Width")
|
||||
(input
|
||||
:type "range"
|
||||
:min "200"
|
||||
:max "700"
|
||||
:value (deref mxw)
|
||||
(~tw :tokens "w-32")
|
||||
:on-input (fn
|
||||
(e)
|
||||
(reset!
|
||||
mxw
|
||||
(parse-number (host-get (host-get e "target") "value"))))))
|
||||
(div
|
||||
(label
|
||||
(~tw :tokens "block text-xs text-stone-500 mb-1")
|
||||
"Font size")
|
||||
(input
|
||||
:type "range"
|
||||
:min "10"
|
||||
:max "24"
|
||||
:value (deref font-size)
|
||||
(~tw :tokens "w-24")
|
||||
:on-input (fn
|
||||
(e)
|
||||
(reset!
|
||||
font-size
|
||||
(parse-number (host-get (host-get e "target") "value"))))))
|
||||
(div
|
||||
(label
|
||||
(~tw :tokens "block text-xs text-stone-500 mb-1")
|
||||
"Algorithm")
|
||||
(button
|
||||
(~tw :tokens "px-3 py-1 rounded border text-sm")
|
||||
:on-click (fn (e) (reset! use-optimal (not (deref use-optimal))))
|
||||
(if (deref use-optimal) "Knuth-Plass" "Greedy"))))
|
||||
(div
|
||||
:class "rounded-lg border border-stone-200 bg-white"
|
||||
:ref (fn (el) (reset! el-ref el))
|
||||
""))))
|
||||
49
sx/sx/applications/pretext/_islands/render-paragraph.sx
Normal file
49
sx/sx/applications/pretext/_islands/render-paragraph.sx
Normal file
@@ -0,0 +1,49 @@
|
||||
;; Pretext demo — DOM-free text layout
|
||||
;;
|
||||
;; Visual-first: shows typeset text, then explains how.
|
||||
;; Uses measure-text (perform) for glyph measurement.
|
||||
;; Compute positioned word data for one line.
|
||||
(defcomp
|
||||
(&key lines max-width line-height n-words label)
|
||||
(let
|
||||
((lh (or line-height 24)) (n-lines (len lines)))
|
||||
(div
|
||||
:class "relative rounded-lg border border-stone-200 bg-white overflow-hidden"
|
||||
(when
|
||||
label
|
||||
(div
|
||||
:class "px-4 pt-3 pb-1"
|
||||
(span
|
||||
:class "text-xs font-medium uppercase tracking-wide text-stone-400"
|
||||
label)))
|
||||
(div
|
||||
:style (str
|
||||
"position:relative;height:"
|
||||
(* n-lines lh)
|
||||
"px;padding:12px 16px;")
|
||||
(map
|
||||
(fn
|
||||
(line)
|
||||
(let
|
||||
((y (get line :y)))
|
||||
(map
|
||||
(fn
|
||||
(pw)
|
||||
(span
|
||||
:style (str
|
||||
"position:absolute;left:"
|
||||
(+ (get pw :x) 16)
|
||||
"px;top:"
|
||||
(+ y 12)
|
||||
"px;font-size:15px;line-height:"
|
||||
lh
|
||||
"px;white-space:nowrap;")
|
||||
(get pw :word)))
|
||||
(get line :words))))
|
||||
lines))
|
||||
(div
|
||||
:class "px-4 py-2 border-t border-stone-100 bg-stone-50 flex justify-between"
|
||||
(span
|
||||
:class "text-xs text-stone-400"
|
||||
(str n-lines " lines, " n-words " words"))
|
||||
(span :class "text-xs text-stone-400" (str "width: " max-width "px"))))))
|
||||
211
sx/sx/applications/pretext/index.sx
Normal file
211
sx/sx/applications/pretext/index.sx
Normal file
@@ -0,0 +1,211 @@
|
||||
;; Compute all positioned lines for a paragraph.
|
||||
(defcomp
|
||||
()
|
||||
(let
|
||||
((sample-words (split "In the beginning was the Word, and the Word was with God, and the Word was God. The same was in the beginning with God. All things were made by him; and without him was not any thing made that was made. In him was life; and the life was the light of men." " "))
|
||||
(font "serif")
|
||||
(size 15))
|
||||
(let
|
||||
((sw (list)) (n-words (len sample-words)))
|
||||
(for-each
|
||||
(fn
|
||||
(w)
|
||||
(let
|
||||
((m (measure-text font size w)))
|
||||
(append! sw (get m :width))))
|
||||
sample-words)
|
||||
(let
|
||||
((space-m (measure-text font size " "))
|
||||
(space-w (get (measure-text font size " ") :width)))
|
||||
(div
|
||||
(begin
|
||||
(~tw :tokens "space-y-10")
|
||||
(~font
|
||||
:family "Pretext Serif"
|
||||
:src "/static/fonts/DejaVuSerif.ttf"))
|
||||
(div
|
||||
(~tw :tokens "space-y-4")
|
||||
(div
|
||||
(h1
|
||||
(~tw
|
||||
:tokens "text-3xl font-bold text-stone-900 tracking-tight")
|
||||
"Pretext")
|
||||
(p
|
||||
(~tw :tokens "mt-1 text-lg text-stone-500")
|
||||
"DOM-free text layout. One IO boundary. Pure arithmetic."))
|
||||
(let
|
||||
((hero-max 520) (hero-ranges (break-lines sw space-w 520)))
|
||||
(div
|
||||
(~tw :tokens "max-w-xl mx-auto mt-6")
|
||||
(~pretext-demo/render-paragraph
|
||||
:lines (pretext-layout-lines
|
||||
sample-words
|
||||
sw
|
||||
hero-ranges
|
||||
space-w
|
||||
hero-max
|
||||
24)
|
||||
:max-width hero-max
|
||||
:n-words n-words
|
||||
:label "Knuth-Plass optimal line breaking — John 1:1–4"))))
|
||||
(div
|
||||
(~tw
|
||||
:tokens "rounded-lg border border-violet-200 bg-violet-50 p-5")
|
||||
(p
|
||||
(~tw :tokens "text-sm text-violet-800")
|
||||
(strong "One ")
|
||||
(code (~tw :tokens "bg-violet-100 px-1 rounded") "perform")
|
||||
" for glyph measurement. Everything else — line breaking, positioning, hyphenation, justification — is pure SX functions over numbers. "
|
||||
"Server renders with font-table lookups. Browser uses "
|
||||
(code "canvas.measureText")
|
||||
". Same algorithm, same output."))
|
||||
(div
|
||||
(~tw :tokens "space-y-3")
|
||||
(h2
|
||||
(~tw :tokens "text-xl font-semibold text-stone-800")
|
||||
"Greedy vs optimal")
|
||||
(p
|
||||
(~tw :tokens "text-sm text-stone-500")
|
||||
"Most web text uses greedy word wrap — break when the next word doesn't fit. "
|
||||
"Knuth-Plass considers all possible breaks simultaneously, minimizing total raggedness.")
|
||||
(let
|
||||
((nm 340))
|
||||
(div
|
||||
(~tw :tokens "grid grid-cols-1 md:grid-cols-2 gap-4")
|
||||
(~pretext-demo/render-paragraph
|
||||
:lines (pretext-layout-lines
|
||||
sample-words
|
||||
sw
|
||||
(break-lines-greedy sw space-w nm)
|
||||
space-w
|
||||
nm
|
||||
22)
|
||||
:max-width nm
|
||||
:line-height 22
|
||||
:n-words n-words
|
||||
:label "Greedy (browser default)")
|
||||
(~pretext-demo/render-paragraph
|
||||
:lines (pretext-layout-lines
|
||||
sample-words
|
||||
sw
|
||||
(break-lines sw space-w nm)
|
||||
space-w
|
||||
nm
|
||||
22)
|
||||
:max-width nm
|
||||
:line-height 22
|
||||
:n-words n-words
|
||||
:label "Knuth-Plass optimal"))))
|
||||
(div
|
||||
(~tw :tokens "space-y-3")
|
||||
(h2
|
||||
(~tw :tokens "text-xl font-semibold text-stone-800")
|
||||
"How lines are scored")
|
||||
(p
|
||||
(~tw :tokens "text-sm text-stone-500")
|
||||
"Each line gets a badness score — how far it deviates from ideal width. "
|
||||
"The algorithm minimizes total demerits (1 + badness)² across all lines.")
|
||||
(div
|
||||
(~tw :tokens "grid grid-cols-4 md:grid-cols-8 gap-2")
|
||||
(map
|
||||
(fn
|
||||
(used)
|
||||
(let
|
||||
((bad (line-badness used 100))
|
||||
(pct (str (min used 100) "%")))
|
||||
(div
|
||||
(~tw
|
||||
:tokens "rounded border border-stone-200 p-2 text-center")
|
||||
(div
|
||||
:style (str
|
||||
"height:4px;background:linear-gradient(90deg,hsl(263,70%,50%) "
|
||||
pct
|
||||
",#e7e5e4 "
|
||||
pct
|
||||
");border-radius:2px;margin-bottom:6px;")
|
||||
"")
|
||||
(div
|
||||
(~tw :tokens "text-sm font-mono font-bold")
|
||||
(if
|
||||
(>= bad 100000)
|
||||
(span (~tw :tokens "text-red-500") "∞")
|
||||
(span (~tw :tokens "text-stone-700") (str bad))))
|
||||
(div
|
||||
(~tw :tokens "text-xs text-stone-400 mt-0.5")
|
||||
(str used "%")))))
|
||||
(list 100 95 90 85 80 70 50 110))))
|
||||
(div
|
||||
(~tw :tokens "space-y-3")
|
||||
(h2
|
||||
(~tw :tokens "text-xl font-semibold text-stone-800")
|
||||
"Hyphenation")
|
||||
(p
|
||||
(~tw :tokens "text-sm text-stone-500")
|
||||
"Liang's algorithm: a trie of character patterns with numeric levels. "
|
||||
"Odd levels mark valid break points.")
|
||||
(let
|
||||
((trie (make-hyphenation-trie (list "hy1p" "he2n" "hen3at" "hena4t" "1na" "n2at" "1tio" "2io" "o2i" "1tic" "1mo" "4m1p" "1pu" "put1" "1er" "pro1g" "1gram" "2gra" "program5" "pro3" "ty1" "1graph" "2ph"))))
|
||||
(div
|
||||
(~tw :tokens "flex flex-wrap gap-3")
|
||||
(map
|
||||
(fn
|
||||
(word)
|
||||
(let
|
||||
((syllables (hyphenate-word trie word)))
|
||||
(div
|
||||
(~tw
|
||||
:tokens "rounded-lg border border-stone-200 bg-white px-4 py-3 text-center")
|
||||
(div
|
||||
(~tw
|
||||
:tokens "text-lg font-mono font-semibold text-stone-800 tracking-wide")
|
||||
(map-indexed
|
||||
(fn
|
||||
(i syl)
|
||||
(if
|
||||
(= i 0)
|
||||
(span syl)
|
||||
(<>
|
||||
(span :class "text-violet-400 mx-0.5" "·")
|
||||
(span syl))))
|
||||
syllables))
|
||||
(div (~tw :tokens "text-xs text-stone-400 mt-1") word))))
|
||||
(list "hyphen" "computation" "programming" "typography")))))
|
||||
(div
|
||||
(~tw
|
||||
:tokens "rounded-lg border border-stone-200 bg-stone-50 p-5 space-y-2")
|
||||
(h3
|
||||
(~tw
|
||||
:tokens "text-sm font-semibold text-stone-600 uppercase tracking-wide")
|
||||
"The pipeline")
|
||||
(ol
|
||||
(~tw
|
||||
:tokens "list-decimal list-inside text-sm text-stone-600 space-y-1")
|
||||
(li
|
||||
(code "measure-text")
|
||||
" — the only IO. Server: font tables. Browser: "
|
||||
(code "canvas.measureText"))
|
||||
(li
|
||||
(code "break-lines")
|
||||
" — Knuth-Plass DP over word widths → optimal break points")
|
||||
(li
|
||||
(code "position-lines")
|
||||
" — pure arithmetic: widths + breaks → x,y coordinates")
|
||||
(li
|
||||
(code "hyphenate-word")
|
||||
" — Liang's trie: character patterns → syllable boundaries")
|
||||
(li
|
||||
"All layout is "
|
||||
(strong "deterministic")
|
||||
" — same widths → same positions, every time")))
|
||||
(div
|
||||
(~tw :tokens "space-y-3")
|
||||
(h2
|
||||
(~tw :tokens "text-xl font-semibold text-stone-800")
|
||||
"Live layout (client-side)")
|
||||
(p
|
||||
(~tw :tokens "text-sm text-stone-500")
|
||||
"Same algorithm running in the browser. "
|
||||
(code "canvas.measureText")
|
||||
" gives pixel-perfect metrics — the browser that measures is the browser that renders. "
|
||||
"Drag the sliders to re-layout in real time.")
|
||||
(div :data-sx-island "pretext-demo/live" "")))))))
|
||||
10
sx/sx/applications/protocol/activitypub/index.sx
Normal file
10
sx/sx/applications/protocol/activitypub/index.sx
Normal file
@@ -0,0 +1,10 @@
|
||||
(defcomp ()
|
||||
(~docs/page :title "ActivityPub"
|
||||
(~docs/note
|
||||
(p "Honest note: ActivityPub wire format is JSON-LD, not sx. This documents how AP integrates with the sx rendering layer."))
|
||||
(~docs/section :title "AP activities" :id "activities"
|
||||
(p (~tw :tokens "text-stone-600")
|
||||
"Rose Ash services communicate cross-domain writes via ActivityPub-shaped activities. Each service has a virtual actor. Activities are JSON-LD objects sent to /internal/inbox endpoints. RSA signatures authenticate the sender."))
|
||||
(~docs/section :title "Event bus" :id "bus"
|
||||
(p (~tw :tokens "text-stone-600")
|
||||
"The event bus dispatches activities to registered handlers. Handlers are async functions that process the activity and may trigger side effects. The bus runs as a background processor in each service."))))
|
||||
14
sx/sx/applications/protocol/fragments/index.sx
Normal file
14
sx/sx/applications/protocol/fragments/index.sx
Normal file
@@ -0,0 +1,14 @@
|
||||
(defcomp ()
|
||||
(~docs/page :title "Cross-Service Fragments"
|
||||
(~docs/section :title "Fragment protocol" :id "protocol"
|
||||
(p (~tw :tokens "text-stone-600")
|
||||
"Rose Ash runs as independent microservices. Each service can expose HTML or sx fragments that other services compose into their pages. Fragment endpoints return text/sx or text/html.")
|
||||
(p (~tw :tokens "text-stone-600")
|
||||
"The frag resolver is an I/O primitive in the render tree:")
|
||||
(~docs/code :src (highlight "(frag \"blog\" \"link-card\" :slug \"hello\")" "lisp")))
|
||||
(~docs/section :title "SxExpr wrapping" :id "wrapping"
|
||||
(p (~tw :tokens "text-stone-600")
|
||||
"When a fragment returns text/sx, the response is wrapped in an SxExpr and embedded directly in the render tree. When it returns text/html, it's wrapped in a ~rich-text component that inserts the HTML via raw!. This allows transparent composition across service boundaries."))
|
||||
(~docs/section :title "fetch_fragments()" :id "fetch"
|
||||
(p (~tw :tokens "text-stone-600")
|
||||
"The Python helper fetch_fragments() fetches multiple fragments in parallel via asyncio.gather(). Fragments are cached in Redis with short TTLs. Each fragment request is HMAC-signed for authentication."))))
|
||||
16
sx/sx/applications/protocol/future/index.sx
Normal file
16
sx/sx/applications/protocol/future/index.sx
Normal file
@@ -0,0 +1,16 @@
|
||||
(defcomp ()
|
||||
(~docs/page :title "Future Possibilities"
|
||||
(~docs/note
|
||||
(p "This page is speculative. Nothing here is implemented. It documents ideas that may or may not happen."))
|
||||
(~docs/section :title "Custom protocol schemes" :id "schemes"
|
||||
(p (~tw :tokens "text-stone-600")
|
||||
"sx:// and sxs:// as custom URI schemes for content addressing or deep linking. An sx:// URI could resolve to an sx expression from a federated registry. This is technically feasible but practically unnecessary for a single-site deployment."))
|
||||
(~docs/section :title "Sx as AP serialization" :id "ap-sx"
|
||||
(p (~tw :tokens "text-stone-600")
|
||||
"ActivityPub objects could be serialized as s-expressions instead of JSON-LD. S-expressions are more compact and easier to parse. The practical barrier: the entire AP ecosystem expects JSON-LD."))
|
||||
(~docs/section :title "Sx-native federation" :id "federation"
|
||||
(p (~tw :tokens "text-stone-600")
|
||||
"Federated services could exchange sx fragments directly — render a remote user's profile card by fetching its sx source from their server. This requires trust and standardization that doesn't exist yet."))
|
||||
(~docs/section :title "Realistic assessment" :id "realistic"
|
||||
(p (~tw :tokens "text-stone-600")
|
||||
"The most likely near-term improvement is sx:// deep linking for client-side component resolution. Everything else requires ecosystem adoption that one project can't drive alone."))))
|
||||
23
sx/sx/applications/protocol/index.sx
Normal file
23
sx/sx/applications/protocol/index.sx
Normal file
@@ -0,0 +1,23 @@
|
||||
;; Protocol documentation pages — fully self-contained
|
||||
(defcomp ()
|
||||
(~docs/page :title "Wire Format"
|
||||
(~docs/section :title "The text/sx content type" :id "content-type"
|
||||
(p (~tw :tokens "text-stone-600")
|
||||
"sx responses use content type text/sx. The body is s-expression source code. The client parses and evaluates it, then renders the result into the DOM.")
|
||||
(~docs/code :src (highlight "HTTP/1.1 200 OK\nContent-Type: text/sx\nSX-Css-Hash: a1b2c3d4\n\n(div :class \"p-4\"\n (~card :title \"Hello\"))" "bash")))
|
||||
(~docs/section :title "Request lifecycle" :id "lifecycle"
|
||||
(p (~tw :tokens "text-stone-600")
|
||||
"1. User interacts with an element that has sx-get/sx-post/etc.")
|
||||
(p (~tw :tokens "text-stone-600")
|
||||
"2. sx.js fires sx:beforeRequest, then sends the HTTP request with SX-Request: true header.")
|
||||
(p (~tw :tokens "text-stone-600")
|
||||
"3. Server builds s-expression tree, scans CSS classes, prepends missing component definitions.")
|
||||
(p (~tw :tokens "text-stone-600")
|
||||
"4. Client receives text/sx response, parses it, evaluates it, renders to DOM.")
|
||||
(p (~tw :tokens "text-stone-600")
|
||||
"5. sx.js fires sx:afterSwap and sx:afterSettle.")
|
||||
(p (~tw :tokens "text-stone-600")
|
||||
"6. Any sx-swap-oob elements are swapped into their targets elsewhere in the DOM."))
|
||||
(~docs/section :title "Component definitions" :id "components"
|
||||
(p (~tw :tokens "text-stone-600")
|
||||
"On full page loads, component definitions are in <script type=\"text/sx\" data-components>. On subsequent sx requests, missing definitions are prepended to the response body. The client caches definitions in localStorage keyed by a content hash."))))
|
||||
14
sx/sx/applications/protocol/internal-services/index.sx
Normal file
14
sx/sx/applications/protocol/internal-services/index.sx
Normal file
@@ -0,0 +1,14 @@
|
||||
(defcomp ()
|
||||
(~docs/page :title "Internal Services"
|
||||
(~docs/note
|
||||
(p "Honest note: the internal service protocol is JSON, not sx. Sx is the composition layer on top. The protocols below are the plumbing underneath."))
|
||||
(~docs/section :title "HMAC-signed HTTP" :id "hmac"
|
||||
(p (~tw :tokens "text-stone-600")
|
||||
"Services communicate via HMAC-signed HTTP requests with short timeouts:")
|
||||
(ul (~tw :tokens "space-y-2 text-stone-600 font-mono text-sm")
|
||||
(li "GET /internal/data/{query} — read data (3s timeout)")
|
||||
(li "POST /internal/actions/{action} — execute write (5s timeout)")
|
||||
(li "POST /internal/inbox — ActivityPub-shaped event delivery")))
|
||||
(~docs/section :title "fetch_data / call_action" :id "fetch"
|
||||
(p (~tw :tokens "text-stone-600")
|
||||
"Python helpers fetch_data() and call_action() handle HMAC signing, serialization, and error handling. They resolve service URLs from environment variables (INTERNAL_URL_BLOG, etc) and fall back to public URLs in development."))))
|
||||
15
sx/sx/applications/protocol/resolver-io/index.sx
Normal file
15
sx/sx/applications/protocol/resolver-io/index.sx
Normal file
@@ -0,0 +1,15 @@
|
||||
(defcomp ()
|
||||
(~docs/page :title "Resolver I/O"
|
||||
(~docs/section :title "Async I/O primitives" :id "primitives"
|
||||
(p (~tw :tokens "text-stone-600")
|
||||
"The sx resolver identifies I/O nodes in the render tree, groups them, executes them in parallel via asyncio.gather(), and substitutes results back in.")
|
||||
(p (~tw :tokens "text-stone-600")
|
||||
"I/O primitives:")
|
||||
(ul (~tw :tokens "space-y-2 text-stone-600")
|
||||
(li (span (~tw :tokens "font-mono text-violet-700") "frag") " — fetch a cross-service fragment")
|
||||
(li (span (~tw :tokens "font-mono text-violet-700") "query") " — read data from another service")
|
||||
(li (span (~tw :tokens "font-mono text-violet-700") "action") " — execute a write on another service")
|
||||
(li (span (~tw :tokens "font-mono text-violet-700") "current-user") " — resolve the current authenticated user")))
|
||||
(~docs/section :title "Execution model" :id "execution"
|
||||
(p (~tw :tokens "text-stone-600")
|
||||
"The render tree is walked to find I/O nodes. All nodes at the same depth are gathered and executed in parallel. Results replace the I/O nodes in the tree. The walk continues until no more I/O nodes are found. This typically completes in 1-2 passes."))))
|
||||
617
sx/sx/applications/sx-urls/index.sx
Normal file
617
sx/sx/applications/sx-urls/index.sx
Normal file
@@ -0,0 +1,617 @@
|
||||
;; SX URLs — Comprehensive documentation for s-expression URL addressing.
|
||||
;; Lives under the Applications section: /(applications.(sx-urls))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Main documentation page
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ()
|
||||
(~docs/page :title "SX URLs"
|
||||
(p (~tw :tokens "text-stone-500 text-sm italic mb-8")
|
||||
"S-expression URLs — where the address of a thing and the thing itself are the same kind of thing.")
|
||||
|
||||
;; -----------------------------------------------------------------
|
||||
(~docs/section :title "What Is an SX URL?" :id "what"
|
||||
(p "An SX URL is a URL whose path is an s-expression. Instead of "
|
||||
"a flat sequence of slash-separated path segments, the URL encodes "
|
||||
"a nested function call that the server evaluates to produce a page.")
|
||||
(p "Every page on this site is addressed by an SX URL. You are currently reading:")
|
||||
(~docs/code :src (highlight
|
||||
"/sx/(applications.(sx-urls))"
|
||||
"lisp"))
|
||||
(p "This is a function call: " (code "applications") " is called with the result of "
|
||||
(code "(sx-urls)") ". The server evaluates it, wraps the content in a layout, "
|
||||
"and returns the page. "
|
||||
"The URL is simultaneously a query, a render instruction, and an address."))
|
||||
|
||||
;; -----------------------------------------------------------------
|
||||
(~docs/section :title "Dots as Spaces" :id "dots"
|
||||
(p "Spaces in URLs are ugly — they become " (code "%20") " in the address bar, "
|
||||
"break when copy-pasted into terminals, and confuse proxies. "
|
||||
"Dots are unreserved in "
|
||||
(a :href "https://www.rfc-editor.org/rfc/rfc3986" (~tw :tokens "text-violet-600 hover:underline") "RFC 3986")
|
||||
", never percent-encoded, and read naturally.")
|
||||
(p "The rule: " (strong "dot = space, nothing more") ". "
|
||||
"Before parsing, the server replaces every dot with a space. "
|
||||
"Parens carry all structural meaning.")
|
||||
(~docs/code :src (highlight
|
||||
";; What you type in the browser:\n/(language.(doc.introduction))\n\n;; After dot→space transform:\n(language (doc introduction))\n\n;; This is standard SX. Parens are nesting. Atoms are arguments.\n;; 'introduction' is a string slug, 'doc' is a function, 'language' is a function."
|
||||
"lisp"))
|
||||
|
||||
(p "More examples:")
|
||||
(div (~tw :tokens "space-y-2")
|
||||
(p (a :href "/sx/(geography.(hypermedia.(example.progress-bar)))"
|
||||
(~tw :tokens "font-mono text-violet-600 hover:underline text-sm")
|
||||
"/(geography.(hypermedia.(example.progress-bar)))") " — progress bar example")
|
||||
(p (a :href "/sx/(language.(spec.signals))"
|
||||
(~tw :tokens "font-mono text-violet-600 hover:underline text-sm")
|
||||
"/(language.(spec.signals))") " — signals spec")
|
||||
(p (a :href "/sx/(etc.(essay.sx-sucks))"
|
||||
(~tw :tokens "font-mono text-violet-600 hover:underline text-sm")
|
||||
"/(etc.(essay.sx-sucks))") " — the honest critique"))
|
||||
|
||||
(p "The dot-to-space transform is the " (em "only") " URL-specific syntax. "
|
||||
"Everything else is standard s-expression parsing."))
|
||||
|
||||
;; -----------------------------------------------------------------
|
||||
(~docs/section :title "Nesting Is Scoping" :id "nesting"
|
||||
(p "REST URLs have an inherent ambiguity: "
|
||||
"does a filter apply to the last segment, or the whole path? "
|
||||
"S-expression nesting makes scope explicit.")
|
||||
(~docs/code :src (highlight
|
||||
";; REST — ambiguous:\n/users/123/posts?filter=published\n;; Does 'filter' apply to posts? To the user? To the whole query?\n;; The answer depends on API documentation.\n\n;; SX URLs — explicit scoping:\n/(users.(posts.123.(filter.published))) ;; filter scoped to posts\n/(users.posts.123.(filter.published)) ;; filter scoped to the whole expression\n\n;; These are structurally different. The paren boundaries ARE scope boundaries.\n;; No documentation needed — the syntax tells you."
|
||||
"lisp"))
|
||||
|
||||
(p "This extends to every level of nesting on this site:")
|
||||
(~docs/code :src (highlight
|
||||
";; These all have different scoping:\n/(language.(spec.signals)) ;; 'signals' scoped to spec\n/(language.(spec.(explore.signals))) ;; 'signals' scoped to explore\n/(language.(spec.(explore.signals.:section.\"batch\"))) ;; keyword scoped to explore call"
|
||||
"lisp"))
|
||||
(p "What took REST thirty years of convention documents to approximate, "
|
||||
"SX URLs express in the syntax itself."))
|
||||
|
||||
;; -----------------------------------------------------------------
|
||||
(~docs/section :title "The URL Hierarchy" :id "hierarchy"
|
||||
(p "Every URL on this site follows a hierarchical structure. "
|
||||
"Sections are functions that receive the result of their inner expressions.")
|
||||
|
||||
(~docs/subsection :title "Section functions"
|
||||
(p "Top-level sections are structural. "
|
||||
"Called with no arguments, they return their index page. "
|
||||
"Called with content, they pass it through:")
|
||||
(div (~tw :tokens "space-y-1 ml-4")
|
||||
(p (a :href "/sx/(language)" (~tw :tokens "font-mono text-violet-600 hover:underline text-sm") "/(language)"))
|
||||
(p (a :href "/sx/(geography)" (~tw :tokens "font-mono text-violet-600 hover:underline text-sm") "/(geography)"))
|
||||
(p (a :href "/sx/(applications)" (~tw :tokens "font-mono text-violet-600 hover:underline text-sm") "/(applications)"))
|
||||
(p (a :href "/sx/(etc)" (~tw :tokens "font-mono text-violet-600 hover:underline text-sm") "/(etc)"))))
|
||||
|
||||
(~docs/subsection :title "Sub-sections"
|
||||
(p "Sub-sections nest inside sections:")
|
||||
(div (~tw :tokens "space-y-1 ml-4")
|
||||
(p (a :href "/sx/(geography.(hypermedia))" (~tw :tokens "font-mono text-violet-600 hover:underline text-sm")
|
||||
"/(geography.(hypermedia))"))
|
||||
(p (a :href "/sx/(geography.(reactive))" (~tw :tokens "font-mono text-violet-600 hover:underline text-sm")
|
||||
"/(geography.(reactive))"))
|
||||
(p (a :href "/sx/(geography.(isomorphism))" (~tw :tokens "font-mono text-violet-600 hover:underline text-sm")
|
||||
"/(geography.(isomorphism))"))
|
||||
(p (a :href "/sx/(language.(doc))" (~tw :tokens "font-mono text-violet-600 hover:underline text-sm")
|
||||
"/(language.(doc))"))
|
||||
(p (a :href "/sx/(language.(spec))" (~tw :tokens "font-mono text-violet-600 hover:underline text-sm")
|
||||
"/(language.(spec))"))
|
||||
(p (a :href "/sx/(applications.(cssx))" (~tw :tokens "font-mono text-violet-600 hover:underline text-sm")
|
||||
"/(applications.(cssx))"))))
|
||||
|
||||
(~docs/subsection :title "Leaf pages"
|
||||
(p "Leaf pages are the innermost function calls. "
|
||||
"The slug becomes a string argument to the page function:")
|
||||
(~docs/code :src (highlight
|
||||
";; doc(\"introduction\") — the slug auto-quotes to a string\n/(language.(doc.introduction))\n\n;; spec(\"core\") — same pattern, different function\n/(language.(spec.core))\n\n;; explore(\"signals\") — nested deeper\n/(language.(spec.(explore.signals)))\n\n;; example(\"progress-bar\") — three levels of nesting\n/(geography.(hypermedia.(example.progress-bar)))"
|
||||
"lisp"))
|
||||
|
||||
(p "Every page on this site:")
|
||||
(div (~tw :tokens "grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-1 mt-2")
|
||||
;; Language
|
||||
(div
|
||||
(p (~tw :tokens "font-semibold text-stone-700 text-sm mt-2 mb-1") "Language — Docs")
|
||||
(p (a :href "/sx/(language.(doc.introduction))" (~tw :tokens "font-mono text-violet-600 hover:underline text-xs") "/(language.(doc.introduction))"))
|
||||
(p (a :href "/sx/(language.(doc.getting-started))" (~tw :tokens "font-mono text-violet-600 hover:underline text-xs") "/(language.(doc.getting-started))"))
|
||||
(p (a :href "/sx/(language.(doc.components))" (~tw :tokens "font-mono text-violet-600 hover:underline text-xs") "/(language.(doc.components))"))
|
||||
(p (a :href "/sx/(language.(doc.evaluator))" (~tw :tokens "font-mono text-violet-600 hover:underline text-xs") "/(language.(doc.evaluator))"))
|
||||
(p (a :href "/sx/(language.(doc.primitives))" (~tw :tokens "font-mono text-violet-600 hover:underline text-xs") "/(language.(doc.primitives))"))
|
||||
(p (a :href "/sx/(language.(doc.special-forms))" (~tw :tokens "font-mono text-violet-600 hover:underline text-xs") "/(language.(doc.special-forms))"))
|
||||
(p (a :href "/sx/(language.(doc.server-rendering))" (~tw :tokens "font-mono text-violet-600 hover:underline text-xs") "/(language.(doc.server-rendering))"))
|
||||
(p (~tw :tokens "font-semibold text-stone-700 text-sm mt-2 mb-1") "Language — Specs")
|
||||
(p (a :href "/sx/(language.(spec.core))" (~tw :tokens "font-mono text-violet-600 hover:underline text-xs") "/(language.(spec.core))"))
|
||||
(p (a :href "/sx/(language.(spec.parser))" (~tw :tokens "font-mono text-violet-600 hover:underline text-xs") "/(language.(spec.parser))"))
|
||||
(p (a :href "/sx/(language.(spec.evaluator))" (~tw :tokens "font-mono text-violet-600 hover:underline text-xs") "/(language.(spec.evaluator))"))
|
||||
(p (a :href "/sx/(language.(spec.signals))" (~tw :tokens "font-mono text-violet-600 hover:underline text-xs") "/(language.(spec.signals))"))
|
||||
(p (a :href "/sx/(language.(spec.(explore.signals)))" (~tw :tokens "font-mono text-violet-600 hover:underline text-xs") "/(language.(spec.(explore.signals)))"))
|
||||
(p (~tw :tokens "font-semibold text-stone-700 text-sm mt-2 mb-1") "Language — Other")
|
||||
(p (a :href "/sx/(language.(bootstrapper.python))" (~tw :tokens "font-mono text-violet-600 hover:underline text-xs") "/(language.(bootstrapper.python))"))
|
||||
(p (a :href "/sx/(language.(bootstrapper.self-hosting))" (~tw :tokens "font-mono text-violet-600 hover:underline text-xs") "/(language.(bootstrapper.self-hosting))"))
|
||||
(p (a :href "/sx/(language.(test.eval))" (~tw :tokens "font-mono text-violet-600 hover:underline text-xs") "/(language.(test.eval))"))
|
||||
(p (a :href "/sx/(language.(test.router))" (~tw :tokens "font-mono text-violet-600 hover:underline text-xs") "/(language.(test.router))")))
|
||||
;; Geography + Applications + Etc
|
||||
(div
|
||||
(p (~tw :tokens "font-semibold text-stone-700 text-sm mt-2 mb-1") "Geography")
|
||||
(p (a :href "/sx/(geography.(reactive.(examples.counter)))" (~tw :tokens "font-mono text-violet-600 hover:underline text-xs") "/(geography.(reactive.(examples.counter)))"))
|
||||
(p (a :href "/sx/(geography.(hypermedia.(reference.attributes)))" (~tw :tokens "font-mono text-violet-600 hover:underline text-xs") "/(geography.(hypermedia.(reference.attributes)))"))
|
||||
(p (a :href "/sx/(geography.(hypermedia.(example.click-to-load)))" (~tw :tokens "font-mono text-violet-600 hover:underline text-xs") "/(geography.(hypermedia.(example.click-to-load)))"))
|
||||
(p (a :href "/sx/(geography.(hypermedia.(example.infinite-scroll)))" (~tw :tokens "font-mono text-violet-600 hover:underline text-xs") "/(geography.(hypermedia.(example.infinite-scroll)))"))
|
||||
(p (a :href "/sx/(geography.(isomorphism.bundle-analyzer))" (~tw :tokens "font-mono text-violet-600 hover:underline text-xs") "/(geography.(isomorphism.bundle-analyzer))"))
|
||||
(p (~tw :tokens "font-semibold text-stone-700 text-sm mt-2 mb-1") "Applications")
|
||||
(p (a :href "/sx/(applications.(sx-urls))" (~tw :tokens "font-mono text-violet-600 hover:underline text-xs") "/(applications.(sx-urls))") " — this page")
|
||||
(p (a :href "/sx/(applications.(cssx.patterns))" (~tw :tokens "font-mono text-violet-600 hover:underline text-xs") "/(applications.(cssx.patterns))"))
|
||||
(p (a :href "/sx/(applications.(protocol.wire-format))" (~tw :tokens "font-mono text-violet-600 hover:underline text-xs") "/(applications.(protocol.wire-format))"))
|
||||
(p (~tw :tokens "font-semibold text-stone-700 text-sm mt-2 mb-1") "Etc")
|
||||
(p (a :href "/sx/(etc.(essay.sx-sucks))" (~tw :tokens "font-mono text-violet-600 hover:underline text-xs") "/(etc.(essay.sx-sucks))"))
|
||||
(p (a :href "/sx/(etc.(essay.self-defining-medium))" (~tw :tokens "font-mono text-violet-600 hover:underline text-xs") "/(etc.(essay.self-defining-medium))"))
|
||||
(p (a :href "/sx/(etc.(philosophy.sx-manifesto))" (~tw :tokens "font-mono text-violet-600 hover:underline text-xs") "/(etc.(philosophy.sx-manifesto))"))
|
||||
(p (a :href "/sx/(etc.(plan.spec-explorer))" (~tw :tokens "font-mono text-violet-600 hover:underline text-xs") "/(etc.(plan.spec-explorer))"))
|
||||
(p (a :href "/sx/(etc.(plan.sx-urls))" (~tw :tokens "font-mono text-violet-600 hover:underline text-xs") "/(etc.(plan.sx-urls))"))))))
|
||||
|
||||
;; -----------------------------------------------------------------
|
||||
(~docs/section :title "Direct Component URLs" :id "direct"
|
||||
(p "Every " (code "defcomp") " in the component environment is directly "
|
||||
"addressable by its " (code "~name") " — no page function, no routing wiring, no case statement.")
|
||||
(~docs/code :src (highlight
|
||||
";; Any component is instantly a URL:\n/(~essays/sx-sucks/essay-sx-sucks) ;; the essay\n/(~plans/sx-urls/plan-sx-urls-content) ;; the SX URLs plan\n/(~docs-content/docs-evaluator-content) ;; evaluator docs\n/(~analyzer/bundle-analyzer-content) ;; bundle analyzer tool"
|
||||
"lisp"))
|
||||
(p "Try it:")
|
||||
(div (~tw :tokens "space-y-1 ml-4")
|
||||
(p (a :href "/sx/(~essays/sx-sucks/essay-sx-sucks)" (~tw :tokens "font-mono text-violet-600 hover:underline text-sm")
|
||||
"/(~essays/sx-sucks/essay-sx-sucks)"))
|
||||
(p (a :href "/sx/(~plans/sx-urls/plan-sx-urls-content)" (~tw :tokens "font-mono text-violet-600 hover:underline text-sm")
|
||||
"/(~plans/sx-urls/plan-sx-urls-content)"))
|
||||
(p (a :href "/sx/(~docs-content/docs-evaluator-content)" (~tw :tokens "font-mono text-violet-600 hover:underline text-sm")
|
||||
"/(~docs-content/docs-evaluator-content)")))
|
||||
(p "New components are URL-accessible the moment they are defined. "
|
||||
"No registration, no routing table update, no deploy."))
|
||||
|
||||
;; -----------------------------------------------------------------
|
||||
(~docs/section :title "Routing Is Functional Application" :id "functional"
|
||||
(p "A URL is a function call. The server evaluates it. "
|
||||
"There is no routing table, no pattern matching, no controller dispatch. "
|
||||
"The URL " (em "is") " the code.")
|
||||
|
||||
(~docs/subsection :title "Section functions pass through"
|
||||
(p (code "language") ", " (code "geography") ", " (code "applications") ", " (code "etc")
|
||||
" are identity on their argument. They exist to provide structure:")
|
||||
(~docs/code :src (highlight
|
||||
";; Section function definition:\n(define language\n (fn (content)\n (if (nil? content) nil content)))\n\n;; /(language.(doc.introduction))\n;; Eval steps:\n;; 1. (doc \"introduction\") → page content AST\n;; 2. (language <content>) → passes content through\n;; 3. Wrap in (~layouts/doc :path \"...\" <content>)"
|
||||
"lisp"))
|
||||
(p "Section functions with no argument return their index page:")
|
||||
(~docs/code :src (highlight
|
||||
"(define home\n (fn (content)\n (if (nil? content) '(~docs-content/home-content) content)))\n\n;; /(home) → (~docs-content/home-content)\n;; / → same thing"
|
||||
"lisp")))
|
||||
|
||||
(~docs/subsection :title "Page functions dispatch on slug"
|
||||
(p "Page functions take a slug string and return a quoted component expression:")
|
||||
(~docs/code :src (highlight
|
||||
"(define doc\n (fn (slug)\n (if (nil? slug)\n '(~docs-content/docs-introduction-content)\n (case slug\n \"introduction\" '(~docs-content/docs-introduction-content)\n \"getting-started\" '(~docs-content/docs-getting-started-content)\n \"components\" '(~docs-content/docs-components-content)\n \"evaluator\" '(~docs-content/docs-evaluator-content)\n \"primitives\"\n (let ((data (primitives-data)))\n `(~docs-content/docs-primitives-content\n :prims (~docs/primitives-tables :primitives ,data)))\n :else '(~docs-content/docs-introduction-content)))))"
|
||||
"lisp"))
|
||||
(p "The " (code "'") " (quote) is critical: page functions return "
|
||||
(em "unevaluated ASTs") ". The router evaluates the page function to get the AST, "
|
||||
"then passes it through " (code "aser") " (the wire-format renderer) which expands "
|
||||
"components and handles HTML tags."))
|
||||
|
||||
(~docs/subsection :title "Data-dependent pages"
|
||||
(p "Some pages need server data. The page function calls an IO helper, "
|
||||
"then splices data into the component call with quasiquote:")
|
||||
(~docs/code :src (highlight
|
||||
"(define isomorphism\n (fn (slug)\n (case slug\n \"bundle-analyzer\"\n (let ((data (bundle-analyzer-data))) ;; IO: reads component env\n `(~analyzer/bundle-analyzer-content\n :pages ,(get data \"pages\")\n :total-components ,(get data \"total-components\")\n :pure-count ,(get data \"pure-count\")\n :io-count ,(get data \"io-count\")))\n :else '(~plans/isomorphic/plan-isomorphic-content))))"
|
||||
"lisp"))
|
||||
(p "Visit "
|
||||
(a :href "/sx/(geography.(isomorphism.bundle-analyzer))" (~tw :tokens "text-violet-600 hover:underline")
|
||||
"/(geography.(isomorphism.bundle-analyzer))") " to see this in action — "
|
||||
"the page function calls " (code "bundle-analyzer-data") ", "
|
||||
"which analyzes the live component environment and returns statistics.")))
|
||||
|
||||
;; -----------------------------------------------------------------
|
||||
(~docs/section :title "Server-Side: URL → eval → Response" :id "server"
|
||||
(p "The Python route handler is generic infrastructure. "
|
||||
"All routing semantics live in the SX spec (" (code "router.sx") "). "
|
||||
"The handler does four things:")
|
||||
|
||||
(~docs/subsection :title "The handler"
|
||||
(~docs/code :src (highlight
|
||||
";; Python handler (sx_router.py) — simplified:\n\nasync def eval_sx_url(raw_path):\n # 1. Build env: all components + all page functions\n env = get_component_env() | get_page_helpers(\"sx\")\n\n # 2. Spec function: parse URL, auto-quote unknowns\n # This is bootstrapped from router.sx → sx_ref.py\n expr = prepare_url_expr(path, env)\n\n # 3. Evaluate: page functions resolve, data fetched\n page_ast = await async_eval(expr, env, ctx)\n\n # 4. Wrap in layout, render to HTML\n wrapped = [Symbol(\"~layouts/doc\"), Keyword(\"path\"), path, page_ast]\n content_sx = await _eval_slot(wrapped, env, ctx)\n return full_page_sx(content_sx)"
|
||||
"python"))
|
||||
(p "The handler imports " (code "prepare_url_expr") " from the bootstrapped "
|
||||
(code "sx_ref.py") " — the same function the JavaScript client uses for "
|
||||
"client-side route resolution. One spec, two hosts."))
|
||||
|
||||
(~docs/subsection :title "Auto-quoting: soft eval"
|
||||
(p "When the parser reads " (code "(language (doc introduction))")
|
||||
", all three atoms are symbols. But " (code "introduction")
|
||||
" isn't a function — it's a slug. The " (code "auto-quote-unknowns") " function "
|
||||
"walks the AST and replaces unknown symbols with their string name:")
|
||||
(~docs/code :src (highlight
|
||||
";; From router.sx — the auto-quoting spec:\n(define auto-quote-unknowns :effects []\n (fn (expr env)\n (if (not (list? expr)) expr\n (if (empty? expr) expr\n ;; Head stays as symbol (function position)\n (cons (first expr)\n (map (fn (child)\n (cond\n (list? child)\n (auto-quote-unknowns child env)\n (= (type-of child) \"symbol\")\n (let ((name (symbol-name child)))\n (if (or (env-has? env name)\n (starts-with? name \":\")\n (starts-with? name \"~\")\n (starts-with? name \"!\"))\n child ;; known → keep as symbol\n name)) ;; unknown → string\n :else child))\n (rest expr)))))))"
|
||||
"lisp"))
|
||||
(p "This is checked against the " (em "actual environment") " at request time — "
|
||||
"not a hardcoded list. If you define a new page function, "
|
||||
"it's immediately recognized as a function name, not a slug."))
|
||||
|
||||
(~docs/subsection :title "Defhandler endpoints"
|
||||
(p "API endpoints are defined with " (code "defhandler") " and use the same "
|
||||
"nested SX URL structure. These are live endpoints on this site — "
|
||||
"they return SX wire format that the client renders:")
|
||||
(~docs/code :src (highlight
|
||||
";; From sx/sx/handlers/ref-api.sx — live API endpoint:\n(defhandler ref-time :method \"GET\"\n :path \"/sx/(geography.(hypermedia.(reference.(api.time))))\"\n (span :id \"time\" (str \"Server time: \" (format-time (now)))))\n\n;; The endpoint is an SX expression that returns SX.\n;; The URL IS the address. The handler IS the content."
|
||||
"lisp"))
|
||||
(p "Try these live endpoints — each returns SX wire format:")
|
||||
(div (~tw :tokens "space-y-1 ml-4")
|
||||
(p (a :href "/sx/(geography.(hypermedia.(reference.(api.time))))"
|
||||
(~tw :tokens "font-mono text-violet-600 hover:underline text-sm")
|
||||
"/(geography.(hypermedia.(reference.(api.time))))") " — current server time")
|
||||
(p (a :href "/sx/(geography.(hypermedia.(reference.(api.swap-item))))"
|
||||
(~tw :tokens "font-mono text-violet-600 hover:underline text-sm")
|
||||
"/(geography.(hypermedia.(reference.(api.swap-item))))") " — a timestamped item")
|
||||
(p (a :href "/sx/(geography.(hypermedia.(example.(api.click))))"
|
||||
(~tw :tokens "font-mono text-violet-600 hover:underline text-sm")
|
||||
"/(geography.(hypermedia.(example.(api.click))))") " — click-to-load demo content"))
|
||||
|
||||
(p "Notice the URL structure: the API endpoint " (em "is") " a nested expression. "
|
||||
(code "(geography (hypermedia (reference (api time))))") " — "
|
||||
"the handler lives at the same address as its content. "
|
||||
"There is no separation between \"the API\" and \"the page.\"")
|
||||
|
||||
(p "More handler examples from the "
|
||||
(a :href "/sx/(geography.(hypermedia.(reference.attributes)))"
|
||||
(~tw :tokens "text-violet-600 hover:underline") "attributes reference") ":")
|
||||
(~docs/code :src (highlight
|
||||
";; POST handler — receives form data, returns SX:\n(defhandler ref-greet :method \"POST\"\n :path \"/sx/(geography.(hypermedia.(reference.(api.greet))))\"\n (let ((name (request-form \"name\")))\n (div :id \"result\" :class \"p-4 border rounded\"\n (p (str \"Hello, \" (if (empty? name) \"world\" name) \"!\")))))\n\n;; DELETE handler — receives path parameter:\n(defhandler ref-delete-item :method \"DELETE\"\n :path \"/sx/(geography.(hypermedia.(reference.(api.(item.<sx:item_id>)))))\"\n \"\")\n\n;; GET handler with query params:\n(defhandler ref-trigger-search :method \"GET\"\n :path \"/sx/(geography.(hypermedia.(reference.(api.trigger-search))))\"\n (let ((q (request-arg \"q\")))\n (div :id \"search-results\"\n (p (str \"Results for: \" q)))))"
|
||||
"lisp"))))
|
||||
|
||||
;; -----------------------------------------------------------------
|
||||
(~docs/section :title "Client-Side: eval in the Browser" :id "client"
|
||||
(p "The same URL algebra runs in the browser. When you click a link on this site, "
|
||||
"the client-side router checks if the page can be rendered locally "
|
||||
"without a server round-trip.")
|
||||
|
||||
(~docs/subsection :title "The client route check"
|
||||
(~docs/code :src (highlight
|
||||
";; From orchestration.sx — client-side route decision:\n;;\n;; 1. Match the URL against the page registry\n;; 2. Check if target layout matches current layout\n;; 3. Check if all component dependencies are loaded\n;; 4. If pure (no IO): render client-side, no server request\n;; 5. If data-dependent: fetch data, then render\n;; 6. If layout changes: fall through to server (needs OOB header)\n\n(define try-client-route :effects [mutation io]\n (fn (pathname target-sel)\n (let ((match (find-matching-route pathname _page-routes)))\n (if (nil? match)\n false ;; no match → server handles it\n (if (not (= (get match \"layout\") (current-page-layout)))\n false ;; layout change → server (needs OOB update)\n ;; ... render client-side\n )))))"
|
||||
"lisp"))
|
||||
(p "Pure pages (no " (code "has-data") " flag) render instantly in the browser. "
|
||||
"Data pages fetch from the server, then render client-side. "
|
||||
"Layout changes always go to the server for OOB shell updates."))
|
||||
|
||||
(~docs/subsection :title "URL parsing in the browser"
|
||||
(p "The " (code "prepare-url-expr") " function from " (code "router.sx") " is bootstrapped "
|
||||
"to JavaScript as " (code "Sx.prepareUrlExpr") ". The browser uses it for "
|
||||
"client-side navigation, relative URL resolution, and the address bar REPL:")
|
||||
(~docs/code :src (highlight
|
||||
";; From router.sx — bootstrapped to both Python and JavaScript:\n(define prepare-url-expr :effects []\n (fn (url-path env)\n (let ((expr (url-to-expr url-path)))\n (if (empty? expr)\n expr\n (auto-quote-unknowns expr env)))))\n\n;; url-to-expr: strip /, dots→spaces, parse\n;; auto-quote-unknowns: unknown symbols → strings\n;; Result: ready for standard eval"
|
||||
"lisp"))
|
||||
(p "One spec, two hosts. The Python server and JavaScript client share "
|
||||
"the same URL parsing, auto-quoting, and resolution logic. "
|
||||
"The spec is in " (code "router.sx") "; the bootstrappers compile it to "
|
||||
(code "sx_ref.py") " and " (code "sx-browser.js") ".")))
|
||||
|
||||
;; -----------------------------------------------------------------
|
||||
(~docs/section :title "Relative URLs as Function Application" :id "relative"
|
||||
(p "Relative URLs are not string manipulation — they are structural transforms "
|
||||
"on the expression tree. The dots tell you how many levels of nesting to pop. "
|
||||
"This is function application over URL structure.")
|
||||
|
||||
(~docs/subsection :title "One dot: apply at current level"
|
||||
(p "A single dot appends at the current nesting depth. "
|
||||
"It's like calling a function with an extra argument:")
|
||||
(~docs/code :src (highlight
|
||||
";; Current page: /(geography.(hypermedia.(example.progress-bar)))\n;;\n;; .click-to-load\n;; = \"at the current level, apply click-to-load\"\n;; → /(geography.(hypermedia.(example.click-to-load)))\n;;\n;; The inner expression (example.progress-bar) becomes (example.click-to-load).\n;; The outer nesting (geography.(hypermedia.(...))) is preserved."
|
||||
"lisp"))
|
||||
(p "This is a sibling navigation — same parent, different leaf."))
|
||||
|
||||
(~docs/subsection :title "Two dots: pop one level, apply"
|
||||
(p "Two dots remove the innermost nesting level, then optionally apply a new one. "
|
||||
"Like " (code "cd ..") " in a filesystem — but structural, not string-based:")
|
||||
(~docs/code :src (highlight
|
||||
";; Current: /(geography.(hypermedia.(example.progress-bar)))\n;;\n;; .. → /(geography.(hypermedia.(example)))\n;; ..inline-edit → /(geography.(hypermedia.(example.inline-edit)))\n;; ..reference → /(geography.(hypermedia.(reference)))\n;;\n;; Pop the innermost expression, replace with the new slug.\n;; The outer nesting is preserved."
|
||||
"lisp")))
|
||||
|
||||
(~docs/subsection :title "Three+ dots: pop multiple levels"
|
||||
(p "Each additional dot pops one more level of nesting. "
|
||||
"N dots = pop N-1 levels:")
|
||||
(~docs/code :src (highlight
|
||||
";; Current: /(geography.(hypermedia.(example.progress-bar)))\n;;\n;; ... → /(geography.(hypermedia)) ;; pop 2 levels\n;; .... → /(geography) ;; pop 3 levels\n;; ..... → / ;; pop 4 levels (root)\n;;\n;; Combine with a slug to navigate across sections:\n;; ...reactive.(examples) → /(geography.(reactive.(examples))) ;; pop 2, into reactive\n;; ....language.(doc.intro)\n;; → /(language.(doc.intro)) ;; pop 3, into language"
|
||||
"lisp")))
|
||||
|
||||
(~docs/subsection :title "Why this is functional, not textual"
|
||||
(p "REST relative URLs are string operations — remove path segments, append new ones. "
|
||||
"This breaks when the hierarchy is structural rather than linear.")
|
||||
(~docs/code :src (highlight
|
||||
";; REST relative: ../inline-edit on /geography/hypermedia/examples/progress-bar\n;; → /geography/hypermedia/examples/inline-edit\n;; Works! But only because the hierarchy happens to be flat.\n\n;; What about navigating to a sibling section?\n;; REST: ../../reference/attributes\n;; How many ../ do you need? You have to count path segments.\n;; The answer changes if the base path changes.\n\n;; SX relative: ..reference.attributes\n;; from /(geography.(hypermedia.(example.progress-bar)))\n;; → /(geography.(hypermedia.(reference.attributes)))\n;;\n;; The dots operate on nesting depth, not string segments.\n;; Add a level of nesting? The relative URL still works.\n;; Restructure the tree? Relative links within a subtree are unaffected."
|
||||
"lisp"))
|
||||
(p "Relative SX URLs are " (em "structurally stable") " — "
|
||||
"they navigate the expression tree, not the string representation."))
|
||||
|
||||
(~docs/subsection :title "Keyword arguments: functional parameters"
|
||||
(p "URLs can carry keyword arguments that parameterize the innermost expression. "
|
||||
"Keywords use the same " (code ":name") " syntax as SX function calls:")
|
||||
(~docs/code :src (highlight
|
||||
";; Set a keyword on a page:\n/(language.(spec.(explore.signals.:page.3)))\n→ (language (spec (explore signals :page 3)))\n\n;; :page is a keyword argument to the explore function.\n;; It's the same syntax you'd use when calling a component:\n;; (~paginator :current-page 3 :total-pages 10)\n\n;; Delta values — relative keyword modification:\n;; .:page.+1 → increment current :page by 1\n;; .:page.-1 → decrement current :page by 1\n;;\n;; This is pagination as URL algebra:\n;; Current: /(language.(spec.(explore.signals.:page.3)))\n;; .:page.+1 → /(language.(spec.(explore.signals.:page.4)))\n;; No JavaScript, no state management. A URL transform."
|
||||
"lisp")))
|
||||
|
||||
(~docs/subsection :title "The resolve-relative-url spec"
|
||||
(p "Relative resolution is defined in " (code "router.sx") " as a pure function. "
|
||||
"It parses the current URL's expression structure, pops levels, "
|
||||
"splices the new content, and serializes back:")
|
||||
(~docs/code :src (highlight
|
||||
";; From router.sx — pure function, no IO:\n(define resolve-relative-url :effects []\n (fn (current-url relative-url)\n ;; Parse current URL's expression\n ;; Count dots to determine pop level\n ;; Pop that many nesting levels\n ;; Append new content\n ;; Serialize back to URL format\n ...))\n\n;; Tested with 50+ cases in test-router.sx:\n(resolve-relative-url\n \"/sx/(geography.(hypermedia.(example.progress-bar)))\"\n \"..inline-edit\")\n→ \"/sx/(geography.(hypermedia.(example.inline-edit)))\"\n\n(resolve-relative-url\n \"/sx/(language.(spec.(explore.signals.:page.3)))\"\n \".:page.+1\")\n→ \"/sx/(language.(spec.(explore.signals.:page.4)))\""
|
||||
"lisp"))
|
||||
(p "This function is bootstrapped to both Python and JavaScript. "
|
||||
"Server-side redirects and client-side link resolution use the same code.")))
|
||||
|
||||
;; -----------------------------------------------------------------
|
||||
(~docs/section :title "URL Special Forms" :id "special-forms"
|
||||
(p "Special forms are meta-operations on URLs. They transform how content "
|
||||
"is resolved or displayed. The " (code "!") " prefix distinguishes them "
|
||||
"from section and page function names.")
|
||||
|
||||
(~docs/subsection :title "The six special forms"
|
||||
(div (~tw :tokens "overflow-x-auto mt-4 mb-4")
|
||||
(table (~tw :tokens "w-full text-sm text-left")
|
||||
(thead
|
||||
(tr (~tw :tokens "border-b border-stone-200")
|
||||
(th (~tw :tokens "py-2 px-3 font-semibold text-stone-700") "Form")
|
||||
(th (~tw :tokens "py-2 px-3 font-semibold text-stone-700") "Example URL")
|
||||
(th (~tw :tokens "py-2 px-3 font-semibold text-stone-700") "Effect")))
|
||||
(tbody (~tw :tokens "text-stone-600")
|
||||
(tr (~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "py-2 px-3 font-mono text-violet-700") "!source")
|
||||
(td (~tw :tokens "py-2 px-3 font-mono text-xs") "/sx/(!source.(~essays/sx-sucks/essay-sx-sucks))")
|
||||
(td (~tw :tokens "py-2 px-3") "Show the defcomp source code"))
|
||||
(tr (~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "py-2 px-3 font-mono text-violet-700") "!inspect")
|
||||
(td (~tw :tokens "py-2 px-3 font-mono text-xs") "/sx/(!inspect.(language.(doc.primitives)))")
|
||||
(td (~tw :tokens "py-2 px-3") "Dependencies, CSS footprint, render plan, IO refs"))
|
||||
(tr (~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "py-2 px-3 font-mono text-violet-700") "!diff")
|
||||
(td (~tw :tokens "py-2 px-3 font-mono text-xs") "/sx/(!diff.(spec.signals).(spec.eval))")
|
||||
(td (~tw :tokens "py-2 px-3") "Side-by-side comparison of two expressions"))
|
||||
(tr (~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "py-2 px-3 font-mono text-violet-700") "!search")
|
||||
(td (~tw :tokens "py-2 px-3 font-mono text-xs") "/sx/(!search.\"define\".:in.(spec.signals))")
|
||||
(td (~tw :tokens "py-2 px-3") "Grep within a page or spec source"))
|
||||
(tr (~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "py-2 px-3 font-mono text-violet-700") "!raw")
|
||||
(td (~tw :tokens "py-2 px-3 font-mono text-xs") "/sx/(!raw.(~some-component))")
|
||||
(td (~tw :tokens "py-2 px-3") "Skip " (code "~layouts/doc") " wrapping — return raw content"))
|
||||
(tr (~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "py-2 px-3 font-mono text-violet-700") "!json")
|
||||
(td (~tw :tokens "py-2 px-3 font-mono text-xs") "/sx/(!json.(language.(doc.primitives)))")
|
||||
(td (~tw :tokens "py-2 px-3") "Return data as JSON instead of rendered content"))))))
|
||||
|
||||
(~docs/subsection :title "How special forms are parsed"
|
||||
(p "The " (code "parse-sx-url") " function in " (code "router.sx") " detects and "
|
||||
"decomposes special form URLs:")
|
||||
(~docs/code :src (highlight
|
||||
";; From router.sx — special form detection:\n(define parse-sx-url :effects []\n (fn (url)\n ;; Returns a typed descriptor:\n ;; {:type \"special-form\" :form \"!source\" :inner \"(~essay)\"}\n ;; {:type \"absolute\" :raw \"/(language.(doc.intro))\"}\n ;; {:type \"relative\" :raw \"..eval\"}\n ;; {:type \"direct-component\" :name \"~essay-sx-sucks\"}\n ...))\n\n;; The ! prefix is detected in the expression head:\n;; /(!source.(~essay)) → head is !source → special form\n;; /(language.(doc.intro)) → head is language → normal\n\n;; Each special form takes the inner expression as its argument:\n;; !source wraps whatever follows in a source viewer\n;; !inspect wraps whatever follows in an analysis view\n;; !diff takes two inner expressions"
|
||||
"lisp"))
|
||||
(p "Special forms compose with the rest of the URL algebra. "
|
||||
(code "!source") " can wrap any valid SX URL expression — "
|
||||
"a page function call, a direct component, or a nested section:"))
|
||||
|
||||
(~docs/subsection :title "The four SX sigils"
|
||||
(p "SX uses four single-character sigils, each marking a different kind of name:")
|
||||
(div (~tw :tokens "overflow-x-auto mt-2 mb-2")
|
||||
(table (~tw :tokens "w-full text-sm text-left")
|
||||
(thead
|
||||
(tr (~tw :tokens "border-b border-stone-200")
|
||||
(th (~tw :tokens "py-1 px-3 font-semibold text-stone-700") "Sigil")
|
||||
(th (~tw :tokens "py-1 px-3 font-semibold text-stone-700") "Meaning")
|
||||
(th (~tw :tokens "py-1 px-3 font-semibold text-stone-700") "Examples")))
|
||||
(tbody (~tw :tokens "text-stone-600")
|
||||
(tr (~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "py-1 px-3 font-mono text-lg text-violet-700") "~")
|
||||
(td (~tw :tokens "py-1 px-3") "Component")
|
||||
(td (~tw :tokens "py-1 px-3 font-mono text-xs") "~layouts/doc ~essays/sx-sucks/essay-sx-sucks"))
|
||||
(tr (~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "py-1 px-3 font-mono text-lg text-violet-700") ":")
|
||||
(td (~tw :tokens "py-1 px-3") "Keyword")
|
||||
(td (~tw :tokens "py-1 px-3 font-mono text-xs") ":title :page :format"))
|
||||
(tr (~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "py-1 px-3 font-mono text-lg text-violet-700") ".")
|
||||
(td (~tw :tokens "py-1 px-3") "Relative navigation")
|
||||
(td (~tw :tokens "py-1 px-3 font-mono text-xs") ".slug .. ...intro"))
|
||||
(tr (~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "py-1 px-3 font-mono text-lg text-violet-700") "!")
|
||||
(td (~tw :tokens "py-1 px-3") "URL special form")
|
||||
(td (~tw :tokens "py-1 px-3 font-mono text-xs") "!source !inspect !diff")))))))
|
||||
|
||||
;; -----------------------------------------------------------------
|
||||
(~docs/section :title "The Evaluation Model" :id "eval"
|
||||
(p "When the server receives an SX URL, it evaluates it in four steps:")
|
||||
|
||||
(~docs/subsection :title "Step 1: Parse"
|
||||
(p "Strip the leading " (code "/") ", replace dots with spaces, parse as SX. "
|
||||
"This is the " (code "url-to-expr") " function from " (code "router.sx") ":")
|
||||
(~docs/code :src (highlight
|
||||
";; Input: /sx/(language.(doc.introduction))\n;; Strip prefix: (language.(doc.introduction))\n;; Dots→spaces: (language (doc introduction))\n;; Parse: [Symbol(\"language\"), [Symbol(\"doc\"), Symbol(\"introduction\")]]"
|
||||
"lisp")))
|
||||
|
||||
(~docs/subsection :title "Step 2: Auto-quote"
|
||||
(p "Unknown symbols become strings. Known functions stay as symbols. "
|
||||
"This is " (code "auto-quote-unknowns") " — checked against the live env:")
|
||||
(~docs/code :src (highlight
|
||||
";; 'language' is in env (section function) → stays Symbol\n;; 'doc' is in env (page function) → stays Symbol\n;; 'introduction' is NOT in env → becomes \"introduction\"\n;;\n;; Result: [Symbol(\"language\"), [Symbol(\"doc\"), \"introduction\"]]"
|
||||
"lisp")))
|
||||
|
||||
(~docs/subsection :title "Step 3: Evaluate"
|
||||
(p "Standard inside-out evaluation — same as any SX expression:")
|
||||
(~docs/code :src (highlight
|
||||
";; 1. Eval \"introduction\" → \"introduction\" (string, self-evaluating)\n;; 2. Eval (doc \"introduction\") → calls doc function\n;; → returns '(~docs-content/docs-introduction-content)\n;; 3. Eval (language <ast>) → calls language function\n;; → passes content through (identity)\n;; 4. Result: [Symbol(\"~docs-content/docs-introduction-content\")]"
|
||||
"lisp")))
|
||||
|
||||
(~docs/subsection :title "Step 4: Wrap and render"
|
||||
(p "The router wraps the result in " (code "~layouts/doc") " with the URL as " (code ":path") ":")
|
||||
(~docs/code :src (highlight
|
||||
";; Wrap: (~layouts/doc :path \"/sx/(language.(doc.introduction))\" <content>)\n;; Render: aser expands ~layouts/doc → nav, breadcrumbs, layout shell\n;; aser expands ~docs-content/docs-introduction-content → page HTML\n;; Return: full HTML page (or OOB wire format for HTMX requests)"
|
||||
"lisp")))
|
||||
|
||||
(p "The entire routing layer is one function call. There is no routing table, "
|
||||
"no URL pattern matching, no middleware chain. The URL is an expression. "
|
||||
"The server evaluates it. The result is a page."))
|
||||
|
||||
;; -----------------------------------------------------------------
|
||||
(~docs/section :title "SX URLs in Hypermedia" :id "hypermedia"
|
||||
(p "SX URLs integrate with the SX hypermedia attributes. "
|
||||
"Links, fetches, and progressive enhancement all accept SX URLs.")
|
||||
|
||||
(~docs/subsection :title "Links"
|
||||
(p "Standard anchor tags with SX URL hrefs. Every link below is live:")
|
||||
(div (~tw :tokens "space-y-1 ml-4 mb-3")
|
||||
(p (a :href "/sx/(language.(doc.introduction))" (~tw :tokens "font-mono text-violet-600 hover:underline text-sm")
|
||||
"/(language.(doc.introduction))") " — Introduction")
|
||||
(p (a :href "/sx/(etc.(essay.sx-sucks))" (~tw :tokens "font-mono text-violet-600 hover:underline text-sm")
|
||||
"/(etc.(essay.sx-sucks))") " — SX Sucks (the honest critique)")
|
||||
(p (a :href "/sx/(geography.(hypermedia.(example.progress-bar)))" (~tw :tokens "font-mono text-violet-600 hover:underline text-sm")
|
||||
"/(geography.(hypermedia.(example.progress-bar)))") " — Progress bar demo")
|
||||
(p (a :href "/sx/(language.(test.router))" (~tw :tokens "font-mono text-violet-600 hover:underline text-sm")
|
||||
"/(language.(test.router))") " — Router spec tests (115 tests)")))
|
||||
|
||||
(~docs/subsection :title "sx-get — HTMX-style fetching"
|
||||
(p (code "sx-get") " fetches content from an SX URL and swaps it into the DOM:")
|
||||
(~docs/code :src (highlight
|
||||
";; Fetch and swap a section:\n(div :sx-get \"/sx/(geography.(hypermedia.(example.progress-bar)))\"\n :sx-trigger \"click\"\n :sx-target \"#content\"\n :sx-swap \"innerHTML\"\n \"Load Progress Bar Example\")\n\n;; Paginated content with keyword deltas:\n(button :sx-get \".:page.+1\"\n :sx-trigger \"click\"\n :sx-target \"#results\"\n :sx-swap \"innerHTML\"\n \"Next Page\")"
|
||||
"lisp")))
|
||||
|
||||
(~docs/subsection :title "Pagination — URLs as algebra"
|
||||
(p "The relative URL algebra makes pagination trivial. "
|
||||
"No state, no event handlers — just URLs:")
|
||||
(~docs/code :src (highlight
|
||||
";; Paginator component — pure URL navigation:\n(defcomp ~paginator (&key current-page total-pages)\n (nav :class \"flex gap-2\"\n (when (> current-page 1)\n (a :href \".:page.-1\" \"Previous\")) ;; delta: decrement :page\n (span (str \"Page \" current-page \" of \" total-pages))\n (when (< current-page total-pages)\n (a :href \".:page.+1\" \"Next\")))) ;; delta: increment :page\n\n;; On /(language.(spec.(explore.signals.:page.3))):\n;; \"Previous\" → .:page.-1 → :page becomes 2\n;; \"Next\" → .:page.+1 → :page becomes 4\n;;\n;; Each link is a static href. Server renders the right page.\n;; Back button works. Bookmarkable. Shareable. Cacheable."
|
||||
"lisp"))))
|
||||
|
||||
;; -----------------------------------------------------------------
|
||||
(~docs/section :title "GraphSX: URLs as Queries" :id "graphsx"
|
||||
(p "The SX URL scheme is not just a routing convention — it is a query language. "
|
||||
"The structural parallel with "
|
||||
(a :href "https://graphql.org/" (~tw :tokens "text-violet-600 hover:underline") "GraphQL")
|
||||
" is exact:")
|
||||
|
||||
(div (~tw :tokens "overflow-x-auto mt-4 mb-4")
|
||||
(table (~tw :tokens "w-full text-sm text-left")
|
||||
(thead
|
||||
(tr (~tw :tokens "border-b border-stone-200")
|
||||
(th (~tw :tokens "py-2 px-3 font-semibold text-stone-700") "Concept")
|
||||
(th (~tw :tokens "py-2 px-3 font-semibold text-stone-700") "GraphQL")
|
||||
(th (~tw :tokens "py-2 px-3 font-semibold text-stone-700") "SX URLs")))
|
||||
(tbody (~tw :tokens "text-stone-600")
|
||||
(tr (~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "py-2 px-3 font-semibold") "Endpoint")
|
||||
(td (~tw :tokens "py-2 px-3") (code "/graphql"))
|
||||
(td (~tw :tokens "py-2 px-3") "Catch-all " (code "/<path:expr>")))
|
||||
(tr (~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "py-2 px-3 font-semibold") "Query structure")
|
||||
(td (~tw :tokens "py-2 px-3") "Nested fields " (code "{ language { doc { ... } } }"))
|
||||
(td (~tw :tokens "py-2 px-3") "Nested s-expressions " (code "(language (doc ...))")))
|
||||
(tr (~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "py-2 px-3 font-semibold") "Resolvers")
|
||||
(td (~tw :tokens "py-2 px-3") "Per-field functions")
|
||||
(td (~tw :tokens "py-2 px-3") "Page functions + " (code "~components")))
|
||||
(tr (~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "py-2 px-3 font-semibold") "Arguments")
|
||||
(td (~tw :tokens "py-2 px-3") (code "doc(slug: \"intro\")"))
|
||||
(td (~tw :tokens "py-2 px-3") (code "(doc introduction)")))
|
||||
(tr (~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "py-2 px-3 font-semibold") "Scoping")
|
||||
(td (~tw :tokens "py-2 px-3") "Flat — query-level only")
|
||||
(td (~tw :tokens "py-2 px-3") "Structural — parens are scope boundaries"))
|
||||
(tr (~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "py-2 px-3 font-semibold") "Transport")
|
||||
(td (~tw :tokens "py-2 px-3") "POST JSON body")
|
||||
(td (~tw :tokens "py-2 px-3") "GET — the URL IS the query"))
|
||||
(tr (~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "py-2 px-3 font-semibold") "Response")
|
||||
(td (~tw :tokens "py-2 px-3") "JSON (needs rendering)")
|
||||
(td (~tw :tokens "py-2 px-3") "Content — already meaningful")))))
|
||||
|
||||
(p "The killer difference: in GraphQL, query and rendering are separate concerns. "
|
||||
"You fetch JSON, then a React app renders it. In SX, "
|
||||
(strong "the query language and the rendering language are the same thing") ". "
|
||||
(code "(language (doc introduction))") " is simultaneously a query, a render instruction, and a URL.")
|
||||
|
||||
(p "And because SX URLs are GET requests, they are cacheable, bookmarkable, "
|
||||
"shareable, and indexable — everything GraphQL had to sacrifice by using POST."))
|
||||
|
||||
;; -----------------------------------------------------------------
|
||||
(~docs/section :title "HTTP Alignment" :id "http"
|
||||
(p "SX URLs are GET requests. The query is the URL path. "
|
||||
"This means:")
|
||||
(ul (~tw :tokens "space-y-1 text-stone-600 list-disc pl-5")
|
||||
(li (strong "Cacheable") " — CDNs cache by URL, and these are URLs")
|
||||
(li (strong "Bookmarkable") " — save " (code "/sx/(language.(spec.signals))") " in your browser")
|
||||
(li (strong "Shareable") " — paste it in chat, it works")
|
||||
(li (strong "Indexable") " — crawlers follow " (code "<a href>") " links")
|
||||
(li (strong "No client library") " — " (code "curl") " returns content"))
|
||||
|
||||
(p "HTTP verbs align naturally with SX URL semantics:")
|
||||
(~docs/code :src (highlight
|
||||
";; GET — pure evaluation, cacheable:\nGET /sx/(language.(doc.introduction))\n\n;; POST — side effects via defhandler:\nPOST /sx/(geography.(hypermedia.(reference.(api.greet))))\n\n;; DELETE — with path parameters:\nDELETE /sx/(geography.(hypermedia.(reference.(api.(item.42)))))\n\n;; PUT — full replacement:\nPUT /sx/(geography.(hypermedia.(example.(api.putpatch))))"
|
||||
"lisp")))
|
||||
|
||||
;; -----------------------------------------------------------------
|
||||
(~docs/section :title "The Router Spec" :id "spec"
|
||||
(p "SX URLs are specified in SX itself. "
|
||||
"The " (a :href "/sx/(language.(spec.router))" (~tw :tokens "text-violet-600 hover:underline") "router spec")
|
||||
" (" (code "router.sx") ") defines URL parsing, matching, relative resolution, "
|
||||
"special form detection, and auto-quoting as pure functions.")
|
||||
|
||||
(p "Key functions:")
|
||||
(~docs/code :src (highlight
|
||||
";; URL → expression (dots→spaces, parse):\n(url-to-expr \"(language.(doc.intro))\")\n→ (language (doc intro))\n\n;; Auto-quote unknowns against env:\n(auto-quote-unknowns '(language (doc intro)) env)\n→ (language (doc \"intro\")) ;; intro not in env → string\n\n;; Full pipeline:\n(prepare-url-expr \"(language.(doc.intro))\" env)\n→ (language (doc \"intro\")) ;; ready for eval\n\n;; Classify URL type:\n(parse-sx-url \"/sx/(language.(doc.intro))\")\n→ {:type \"absolute\" :raw \"/sx/(language.(doc.intro))\"}\n\n(parse-sx-url \"/sx/(!source.(~essay))\")\n→ {:type \"special-form\" :form \"!source\" :inner \"(~essay)\"}\n\n(parse-sx-url \"..eval\")\n→ {:type \"relative\" :raw \"..eval\"}\n\n;; Resolve relative URLs:\n(resolve-relative-url\n \"/sx/(geography.(hypermedia.(example.progress-bar)))\"\n \"..inline-edit\")\n→ \"/sx/(geography.(hypermedia.(example.inline-edit)))\""
|
||||
"lisp"))
|
||||
|
||||
(p "These functions are "
|
||||
(a :href "/sx/(language.(test.router))" (~tw :tokens "text-violet-600 hover:underline") "tested with 115 SX tests")
|
||||
" covering structural navigation, keyword operations, "
|
||||
"delta values, special form parsing, and bare-dot shorthand. "
|
||||
"The spec bootstraps to Python (" (code "sx_ref.py") ") and "
|
||||
"JavaScript (" (code "sx-browser.js") ") — server and client share the same URL algebra."))
|
||||
|
||||
;; -----------------------------------------------------------------
|
||||
(~docs/section :title "The Lisp Tax" :id "parens"
|
||||
(p "People will object to the parentheses. Consider what they already accept:")
|
||||
(~docs/code :src (highlight
|
||||
";; Developers write this every day:\nhttps://api.site.com/v2/users/123/posts?filter=published&sort=date&order=desc&limit=10\n\n;; And would complain about this?\nhttps://site.com/(users.(posts.123.(filter.published.sort.date.limit.10)))\n\n;; The second is shorter, structured, unambiguous, and composable."
|
||||
"lisp"))
|
||||
|
||||
(p "The real question is who reads URLs:")
|
||||
(ul (~tw :tokens "space-y-1 text-stone-600 list-disc pl-5")
|
||||
(li (strong "End users") " barely look at URLs — they live in address bars that autocomplete")
|
||||
(li (strong "Developers") " will love the structure once they understand it")
|
||||
(li (strong "Crawlers") " do not care about syntax — they follow links"))
|
||||
(p "And this site is " (em "about") " SX, " (em "implemented in") " SX. "
|
||||
"Every URL is a live example. Visiting a page is evaluating an expression. "
|
||||
"The parentheses are not a tax — they are the point."))
|
||||
|
||||
;; -----------------------------------------------------------------
|
||||
(~docs/section :title "The Site Is a REPL" :id "repl"
|
||||
(p "The address bar is the input line. The page is the output. Try these:")
|
||||
(div (~tw :tokens "space-y-2 ml-4 mb-4")
|
||||
(div
|
||||
(p (~tw :tokens "text-stone-500 text-xs") "A page:")
|
||||
(p (a :href "/sx/(language.(spec.signals))" (~tw :tokens "font-mono text-violet-600 hover:underline text-sm")
|
||||
"/(language.(spec.signals))")))
|
||||
(div
|
||||
(p (~tw :tokens "text-stone-500 text-xs") "Its tests:")
|
||||
(p (a :href "/sx/(language.(test.router))" (~tw :tokens "font-mono text-violet-600 hover:underline text-sm")
|
||||
"/(language.(test.router))")))
|
||||
(div
|
||||
(p (~tw :tokens "text-stone-500 text-xs") "A component directly:")
|
||||
(p (a :href "/sx/(~essays/sx-sucks/essay-sx-sucks)" (~tw :tokens "font-mono text-violet-600 hover:underline text-sm")
|
||||
"/(~essays/sx-sucks/essay-sx-sucks)")))
|
||||
(div
|
||||
(p (~tw :tokens "text-stone-500 text-xs") "An API endpoint:")
|
||||
(p (a :href "/sx/(geography.(hypermedia.(reference.(api.time))))" (~tw :tokens "font-mono text-violet-600 hover:underline text-sm")
|
||||
"/(geography.(hypermedia.(reference.(api.time))))")))
|
||||
(div
|
||||
(p (~tw :tokens "text-stone-500 text-xs") "A tool that analyzes the live system:")
|
||||
(p (a :href "/sx/(geography.(isomorphism.bundle-analyzer))" (~tw :tokens "font-mono text-violet-600 hover:underline text-sm")
|
||||
"/(geography.(isomorphism.bundle-analyzer))"))))
|
||||
(p "You do not need to explain what SX is. "
|
||||
"Show someone a URL, and they immediately understand the philosophy. "
|
||||
"The entire site is a self-hosting demonstration — "
|
||||
"the medium defines itself with itself."))))
|
||||
339
sx/sx/applications/sxtp/index.sx
Normal file
339
sx/sx/applications/sxtp/index.sx
Normal file
@@ -0,0 +1,339 @@
|
||||
(defcomp
|
||||
()
|
||||
(~docs/page
|
||||
:title "SXTP Protocol"
|
||||
(~docs/section
|
||||
:title "Overview"
|
||||
:id "overview"
|
||||
(p
|
||||
"SXTP — SX Transfer Protocol — is HTTP reimagined where the wire format "
|
||||
(em "is")
|
||||
" the language. Requests, responses, headers, cookies, status conditions, and bodies are all s-expressions. There is no text framing, no content-type negotiation, no URL query-string encoding.")
|
||||
(p "Design principles:")
|
||||
(ul
|
||||
(~tw :tokens "list-disc list-inside space-y-2 mt-2")
|
||||
(li
|
||||
(strong "SX all the way")
|
||||
" — every datum on the wire is a valid SX value")
|
||||
(li
|
||||
(strong "Open verb set")
|
||||
" — any symbol is a legal verb, not just GET/POST/PUT/DELETE")
|
||||
(li
|
||||
(strong "Structured metadata")
|
||||
" — headers and cookies are dicts, not flat strings")
|
||||
(li
|
||||
(strong "Capability-scoped")
|
||||
" — requests declare required capabilities")
|
||||
(li
|
||||
(strong "Content-addressed")
|
||||
" — responses can be cached by hash")
|
||||
(li
|
||||
(strong "Streamable")
|
||||
" — chunked responses are sequences of expressions")))
|
||||
(~docs/section
|
||||
:title "Requests"
|
||||
:id "requests"
|
||||
(p
|
||||
"A request is a list beginning with the symbol "
|
||||
(code "request")
|
||||
". All fields are keyword arguments.")
|
||||
(~docs/code
|
||||
:src (highlight "(request :verb navigate :path \"/\")" "lisp"))
|
||||
(p "Full request with all fields:")
|
||||
(~docs/code
|
||||
:src (highlight
|
||||
"(request\n :verb navigate\n :path \"/geography/capabilities\"\n :headers {:accept \"text/sx\" :language \"en\"}\n :cookies {:session \"tok_abc123\" :prefs {:theme \"dark\"}}\n :params {:page 1 :per-page 20}\n :capabilities (fetch query)\n :body nil)"
|
||||
"lisp"))
|
||||
(div
|
||||
(~tw :tokens "overflow-x-auto rounded border border-stone-200 mt-4")
|
||||
(table
|
||||
(~tw :tokens "w-full text-left text-sm")
|
||||
(thead
|
||||
(tr
|
||||
(~tw :tokens "border-b border-stone-200 bg-stone-100")
|
||||
(th (~tw :tokens "px-3 py-2 font-medium text-stone-600") "Field")
|
||||
(th (~tw :tokens "px-3 py-2 font-medium text-stone-600") "Description")))
|
||||
(tbody
|
||||
(tr
|
||||
(~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "px-3 py-2 text-stone-700 font-mono") ":verb")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-600")
|
||||
"Symbol — the action to perform (required)"))
|
||||
(tr
|
||||
(~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "px-3 py-2 text-stone-700 font-mono") ":path")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-600")
|
||||
"String — resource path (required)"))
|
||||
(tr
|
||||
(~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "px-3 py-2 text-stone-700 font-mono") ":headers")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-600")
|
||||
"Dict — structured request metadata"))
|
||||
(tr
|
||||
(~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "px-3 py-2 text-stone-700 font-mono") ":cookies")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-600")
|
||||
"Dict — client state, values can be any SX type"))
|
||||
(tr
|
||||
(~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "px-3 py-2 text-stone-700 font-mono") ":params")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-600")
|
||||
"Dict — query parameters as typed values"))
|
||||
(tr
|
||||
(~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "px-3 py-2 text-stone-700 font-mono") ":capabilities")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-600")
|
||||
"List — capabilities this request requires"))
|
||||
(tr
|
||||
(td (~tw :tokens "px-3 py-2 text-stone-700 font-mono") ":body")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-600")
|
||||
"Any SX value — request payload"))))))
|
||||
(~docs/section
|
||||
:title "Responses"
|
||||
:id "responses"
|
||||
(p
|
||||
"A response is a list beginning with the symbol "
|
||||
(code "response")
|
||||
".")
|
||||
(~docs/code
|
||||
:src (highlight
|
||||
"(response :status ok\n :headers {:content-type \"text/sx\" :cache :immutable}\n :set-cookie {:session {:value \"tok_xyz\" :max-age 3600 :path \"/\"}}\n :body (page :title \"Home\" (h1 \"Welcome\")))"
|
||||
"lisp"))
|
||||
(p
|
||||
"The body isn't serialized HTML that needs parsing — it's a live component tree the browser evaluates directly."))
|
||||
(~docs/section
|
||||
:title "Verbs"
|
||||
:id "verbs"
|
||||
(p
|
||||
"Unlike HTTP's fixed set, any symbol is a valid verb. Convention defines common verbs; domains add their own.")
|
||||
(div
|
||||
(~tw :tokens "overflow-x-auto rounded border border-stone-200 mt-4")
|
||||
(table
|
||||
(~tw :tokens "w-full text-left text-sm")
|
||||
(thead
|
||||
(tr
|
||||
(~tw :tokens "border-b border-stone-200 bg-stone-100")
|
||||
(th (~tw :tokens "px-3 py-2 font-medium text-stone-600") "Verb")
|
||||
(th (~tw :tokens "px-3 py-2 font-medium text-stone-600") "Purpose")))
|
||||
(tbody
|
||||
(tr
|
||||
(~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "px-3 py-2 text-stone-700 font-mono") "navigate")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-600")
|
||||
"Retrieve a page for display — analogous to GET for documents"))
|
||||
(tr
|
||||
(~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "px-3 py-2 text-stone-700 font-mono") "fetch")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-600")
|
||||
"Retrieve data — analogous to GET for APIs"))
|
||||
(tr
|
||||
(~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "px-3 py-2 text-stone-700 font-mono") "query")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-600")
|
||||
"Structured query — body contains a query expression"))
|
||||
(tr
|
||||
(~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "px-3 py-2 text-stone-700 font-mono") "mutate")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-600")
|
||||
"Change state — analogous to POST/PUT/PATCH"))
|
||||
(tr
|
||||
(~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "px-3 py-2 text-stone-700 font-mono") "create")
|
||||
(td (~tw :tokens "px-3 py-2 text-stone-600") "Create a new resource"))
|
||||
(tr
|
||||
(~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "px-3 py-2 text-stone-700 font-mono") "delete")
|
||||
(td (~tw :tokens "px-3 py-2 text-stone-600") "Remove a resource"))
|
||||
(tr
|
||||
(~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "px-3 py-2 text-stone-700 font-mono") "subscribe")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-600")
|
||||
"Open a streaming channel for real-time updates"))
|
||||
(tr
|
||||
(~tw :tokens "border-b border-stone-100")
|
||||
(td (~tw :tokens "px-3 py-2 text-stone-700 font-mono") "inspect")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-600")
|
||||
"Retrieve metadata about a resource (capabilities, schema)"))
|
||||
(tr
|
||||
(td (~tw :tokens "px-3 py-2 text-stone-700 font-mono") "ping")
|
||||
(td (~tw :tokens "px-3 py-2 text-stone-600") "Liveness check")))))
|
||||
(p (~tw :tokens "mt-4") "Domains define their own verbs freely:")
|
||||
(~docs/code
|
||||
:src (highlight
|
||||
"(request :verb publish :path \"/blog/draft-123\")\n(request :verb checkout :path \"/cart\")\n(request :verb render :path \"/artdag/node/abc\" :params {:format \"png\"})\n(request :verb federate :path \"/outbox\" :body (activity ...))"
|
||||
"lisp")))
|
||||
(~docs/section
|
||||
:title "What HTTP got wrong"
|
||||
:id "http-comparison"
|
||||
(div
|
||||
(~tw :tokens "overflow-x-auto rounded border border-stone-200")
|
||||
(table
|
||||
(~tw :tokens "w-full text-left text-sm")
|
||||
(thead
|
||||
(tr
|
||||
(~tw :tokens "border-b border-stone-200 bg-stone-100")
|
||||
(th (~tw :tokens "px-3 py-2 font-medium text-stone-600") "HTTP pain")
|
||||
(th (~tw :tokens "px-3 py-2 font-medium text-stone-600") "SXTP answer")))
|
||||
(tbody
|
||||
(tr
|
||||
(~tw :tokens "border-b border-stone-100")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-700")
|
||||
"Fixed verb set (GET/POST/PUT/DELETE)")
|
||||
(td (~tw :tokens "px-3 py-2 text-stone-600") "Any symbol is a verb"))
|
||||
(tr
|
||||
(~tw :tokens "border-b border-stone-100")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-700")
|
||||
"Headers are flat string pairs")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-600")
|
||||
"Headers are dicts — nested, typed"))
|
||||
(tr
|
||||
(~tw :tokens "border-b border-stone-100")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-700")
|
||||
"Cookies are encoded strings")
|
||||
(td (~tw :tokens "px-3 py-2 text-stone-600") "Cookies are SX values"))
|
||||
(tr
|
||||
(~tw :tokens "border-b border-stone-100")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-700")
|
||||
"Body requires content-type negotiation")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-600")
|
||||
"Body is always SX — rendering is the client's job"))
|
||||
(tr
|
||||
(~tw :tokens "border-b border-stone-100")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-700")
|
||||
"URL query strings (?a=1&b=2)")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-600")
|
||||
"Params are part of the request expression"))
|
||||
(tr
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-700")
|
||||
"Separate mechanisms for streaming")
|
||||
(td
|
||||
(~tw :tokens "px-3 py-2 text-stone-600")
|
||||
"Streaming is just :stream true + chunk sequences"))))))
|
||||
(~docs/section
|
||||
:title "Status and conditions"
|
||||
:id "status"
|
||||
(p
|
||||
"Status is a symbol, not a number. Conditions replace error codes with structured, informative values.")
|
||||
(~docs/code
|
||||
:src (highlight
|
||||
"(response :status not-found\n :body (condition :type resource-not-found\n :path \"/blog/nonexistent\"\n :message \"No such post\"\n :retry false))"
|
||||
"lisp"))
|
||||
(p "Conditions are extensible — domains define their own:")
|
||||
(~docs/code
|
||||
:src (highlight
|
||||
"(condition :type payment-declined\n :reason :insufficient-funds\n :provider \"sumup\")"
|
||||
"lisp")))
|
||||
(~docs/section
|
||||
:title "Streaming"
|
||||
:id "streaming"
|
||||
(p
|
||||
"A streaming response sets "
|
||||
(code ":stream true")
|
||||
". The body becomes a sequence of chunk expressions.")
|
||||
(~docs/code
|
||||
:src (highlight
|
||||
";; Ordered chunks\n(response :status ok :stream true)\n(chunk :seq 0 :body (tr (td \"Row 1\") (td \"data\")))\n(chunk :seq 1 :body (tr (td \"Row 2\") (td \"data\")))\n(chunk :done true)\n\n;; Server-sent events via subscribe\n(request :verb subscribe :path \"/events/live\")\n\n(event :type new-event :id \"evt-42\"\n :body (div :class \"event-card\" (h3 \"Jazz Night\")))\n(event :type update :id \"evt-42\"\n :body {:attendees 51})\n(event :type heartbeat :time 1711612800)"
|
||||
"lisp")))
|
||||
(~docs/section
|
||||
:title "Capabilities"
|
||||
:id "capabilities"
|
||||
(p
|
||||
"Requests declare the capabilities they need. The server checks these against the session's granted capabilities. Insufficient capabilities produce "
|
||||
(code "(response :status forbidden)")
|
||||
".")
|
||||
(~docs/code
|
||||
:src (highlight
|
||||
";; Client declares\n(request :verb query :path \"/events\"\n :capabilities (fetch db:read))\n\n;; Server grants on auth\n(response :status ok\n :set-cookie {:capabilities {:value (fetch query db:read mutate)\n :max-age 86400\n :secure true}})"
|
||||
"lisp"))
|
||||
(p "Inspect what a resource requires:")
|
||||
(~docs/code
|
||||
:src (highlight
|
||||
"(request :verb inspect :path \"/cart/checkout\")\n\n(response :status ok\n :body {:required-capabilities (mutate cart:checkout)\n :available-verbs (inspect mutate)\n :params-schema {:shipping-address \"dict\"\n :payment-method \"symbol\"}})"
|
||||
"lisp")))
|
||||
(~docs/section
|
||||
:title "Caching"
|
||||
:id "caching"
|
||||
(p
|
||||
"Content-addressed caching. The response hash "
|
||||
(em "is")
|
||||
" the cache key. No ETags, no Last-Modified — just SX content hashes.")
|
||||
(~docs/code
|
||||
:src (highlight
|
||||
";; Server provides hash\n(response :status ok\n :headers {:content-hash \"sha3-abc123...\"\n :cache :immutable}\n :body ...)\n\n;; Client validates\n(request :verb fetch :path \"/geography/capabilities\"\n :headers {:if-match \"sha3-abc123...\"})\n\n(response :status not-modified)"
|
||||
"lisp"))
|
||||
(p
|
||||
"Three cache policies: "
|
||||
(code ":immutable")
|
||||
" (content-addressed, never changes), "
|
||||
(code ":revalidate")
|
||||
" (check hash before using), "
|
||||
(code ":none")
|
||||
" (dynamic content)."))
|
||||
(~docs/section
|
||||
:title "Wire format"
|
||||
:id "wire-format"
|
||||
(p
|
||||
"On the wire, each message is a length-prefixed SX expression. Length is a decimal integer as ASCII, followed by newline. The SX expression is UTF-8 encoded.")
|
||||
(~docs/code
|
||||
:src (highlight "43\n(request :verb ping :path \"/\" :body nil)" "text"))
|
||||
(p
|
||||
"Connections are persistent — multiple request/response pairs on the same connection. Pipelining is allowed. TLS is the transport security layer: "
|
||||
(code "sxtp://")
|
||||
" is plaintext (port 5380), "
|
||||
(code "sxtps://")
|
||||
" is TLS (port 5381)."))
|
||||
(~docs/section
|
||||
:title "URI scheme"
|
||||
:id "uri"
|
||||
(p "The browser translates URIs into request expressions:")
|
||||
(~docs/code
|
||||
:src (highlight
|
||||
"sxtps://blog.rose-ash.com/geography/capabilities\n\n;; becomes\n\n(request :verb navigate\n :path \"/geography/capabilities\"\n :headers {:host \"blog.rose-ash.com\"})"
|
||||
"lisp")))
|
||||
(~docs/section
|
||||
:title "Examples"
|
||||
:id "examples"
|
||||
(p "Page navigation:")
|
||||
(~docs/code
|
||||
:src (highlight
|
||||
"(request :verb navigate :path \"/geography/capabilities\"\n :headers {:host \"sx.rose-ash.com\" :accept \"text/sx\"})\n\n(response :status ok\n :headers {:content-type \"text/sx\"\n :content-hash \"sha3-9f2a...\"}\n :body (page :title \"Capabilities\"\n (h1 \"Geography Capabilities\")\n (~capability-list :domain \"geography\")))"
|
||||
"lisp"))
|
||||
(p "Structured query:")
|
||||
(~docs/code
|
||||
:src (highlight
|
||||
"(request :verb query :path \"/events\"\n :capabilities (fetch db:read)\n :params {:after \"2026-03-01\" :limit 10}\n :body (filter (events) (fn (e) (> (:attendees e) 50))))\n\n(response :status ok\n :headers {:cache :revalidate}\n :body ((event :id \"evt-42\" :title \"Jazz Night\" :attendees 87)\n (event :id \"evt-55\" :title \"Art Walk\" :attendees 120)))"
|
||||
"lisp"))
|
||||
(p "Creating a resource:")
|
||||
(~docs/code
|
||||
:src (highlight
|
||||
"(request :verb create :path \"/blog/posts\"\n :capabilities (mutate blog:publish)\n :cookies {:session \"tok_abc123\"}\n :body {:title \"SXTP Protocol\"\n :body (article (h1 \"SXTP\") (p \"Everything is SX.\"))\n :tags (\"protocol\" \"sx\" \"web\")})\n\n(response :status created\n :headers {:location \"/blog/posts/sxtp-protocol\"\n :content-hash \"sha3-ff01...\"}\n :body {:id \"post-789\"\n :path \"/blog/posts/sxtp-protocol\"\n :created-at 1711612800})"
|
||||
"lisp")))
|
||||
(~docs/section
|
||||
:title "Specification"
|
||||
:id "spec"
|
||||
(p
|
||||
"The formal specification lives in "
|
||||
(code "applications/sxtp/spec.sx")
|
||||
" — a self-describing SX file where the field definitions are themselves SX data structures that the protocol can introspect."))))
|
||||
Reference in New Issue
Block a user