From b21ae05e8fd582804ac35830c73252686d1e3da5 Mon Sep 17 00:00:00 2001 From: giles Date: Mon, 29 Jun 2026 15:17:30 +0000 Subject: [PATCH] host: extract the relate picker into a content-addressed ~relate-picker component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The declarative picker markup is now a reusable SX component (lib/host/sx/relate-picker.sx, defcomp ~relate-picker &key slug kind) instead of inline markup in the editor. It is a CONTENT-ADDRESSED, CLIENT-EXPANDED component: - Server: on a full page load render-page expands ~relate-picker server-side (SEO / no-JS), exactly as before. - Client: on a boosted SPA nav the edit body serialises to the compact (~relate-picker :slug … :kind …), and the CLIENT expands it. The component module is compiled to a content-addressed .sxbc, served immutably from /sx/h/{hash}, and listed in the page's data-sx-manifest "boot" array so the client eager-loads it after the web stack — registering its defcomp before any boosted fragment references it. Wiring: - lib/host/sx/relate-picker.sx — the component. - lib/host/blog.sx — editor emits (~relate-picker :slug s :kind k); the inline form markup is gone. - lib/host/static.sx — host/static-manifest-json emits boot:["relate-picker.sxbc"] (the previously-empty boot array, now used as designed). - hosts/ocaml/browser/sx-platform.js — loadWebStack eager-loads the page manifest's boot[] modules (content-addressed) after the web stack. - bundle.sh + compile-modules.js — copy/compile the component to .sxbc. - serve.sh + conformance.sh — load the component module server-side. This gives the host an app-component system: app defcomps shipped to the client by hash, the same machinery as the kernel modules — the picker is the first, and it's the model for publishing components externally. Tests: conformance 272/272 (server expansion); relate-picker.spec.js 6/6 incl. the boosted-nav populate (proves client-side component load + expansion) and the error/retry case. WASM stack rebuilt (relate-picker.sxbc @ 6818110a). Co-Authored-By: Claude Opus 4.8 --- hosts/ocaml/browser/bundle.sh | 5 +++ hosts/ocaml/browser/compile-modules.js | 4 +++ hosts/ocaml/browser/sx-platform.js | 12 +++++++ lib/host/blog.sx | 25 ++++----------- lib/host/conformance.sh | 1 + lib/host/playwright/relate-picker.spec.js | 3 ++ lib/host/serve.sh | 1 + lib/host/static.sx | 9 +++++- lib/host/sx/relate-picker.sx | 39 +++++++++++++++++++++++ shared/static/wasm/sx-platform.js | 12 +++++++ shared/static/wasm/sx/relate-picker.sx | 39 +++++++++++++++++++++++ shared/static/wasm/sx/relate-picker.sxbc | 3 ++ 12 files changed, 133 insertions(+), 20 deletions(-) create mode 100644 lib/host/sx/relate-picker.sx create mode 100644 shared/static/wasm/sx/relate-picker.sx create mode 100644 shared/static/wasm/sx/relate-picker.sxbc diff --git a/hosts/ocaml/browser/bundle.sh b/hosts/ocaml/browser/bundle.sh index 5e833b20..6409992a 100755 --- a/hosts/ocaml/browser/bundle.sh +++ b/hosts/ocaml/browser/bundle.sh @@ -71,6 +71,11 @@ cp "$ROOT/shared/sx/templates/tw-layout.sx" "$DIST/sx/" cp "$ROOT/shared/sx/templates/tw-type.sx" "$DIST/sx/" cp "$ROOT/shared/sx/templates/tw.sx" "$DIST/sx/" +# 9b. Host app components (content-addressed, client-expanded on boosted nav). +# Listed in the host's data-sx-manifest "boot" array so the client eager-loads +# them after the web stack — see lib/host/static.sx + sx-platform.js loadWebStack. +cp "$ROOT/lib/host/sx/relate-picker.sx" "$DIST/sx/" + # 10. Hyperscript for f in tokenizer parser compiler runtime integration htmx; do cp "$ROOT/lib/hyperscript/$f.sx" "$DIST/sx/hs-$f.sx" diff --git a/hosts/ocaml/browser/compile-modules.js b/hosts/ocaml/browser/compile-modules.js index 11c64058..f813bdd0 100644 --- a/hosts/ocaml/browser/compile-modules.js +++ b/hosts/ocaml/browser/compile-modules.js @@ -48,6 +48,8 @@ const SOURCE_MAP = { 'boot.sx': 'web/boot.sx', 'tw-layout.sx': 'web/tw-layout.sx', 'tw-type.sx': 'web/tw-type.sx', 'tw.sx': 'web/tw.sx', 'text-layout.sx': 'lib/text-layout.sx', + // Host app components (content-addressed, client-expanded on boosted nav). + 'relate-picker.sx': 'lib/host/sx/relate-picker.sx', }; let synced = 0; for (const [dist, src] of Object.entries(SOURCE_MAP)) { @@ -87,6 +89,8 @@ const FILES = [ 'hs-tokenizer.sx', 'hs-parser.sx', 'hs-compiler.sx', 'hs-runtime.sx', 'hs-worker.sx', 'hs-prolog.sx', 'hs-integration.sx', 'hs-htmx.sx', + // Host app components — standalone defcomps, no inter-module deps. + 'relate-picker.sx', 'boot.sx', ]; diff --git a/hosts/ocaml/browser/sx-platform.js b/hosts/ocaml/browser/sx-platform.js index 1b873404..69217bdc 100644 --- a/hosts/ocaml/browser/sx-platform.js +++ b/hosts/ocaml/browser/sx-platform.js @@ -646,6 +646,18 @@ // Load entry point itself (boot.sx — not a library, just defines + init) loadBytecodeFile("sx/" + entry.file) || loadSxFile("sx/" + entry.file.replace(/\.sxbc$/, '.sx')); + // App components: the page's data-sx-manifest "boot" array lists app-specific + // modules (e.g. ~relate-picker) to eager-load after the web stack, so their + // defcomps are registered before a boosted fragment references them. Loaded + // content-addressed, the same as any module. + var pageM = loadPageManifest(); + if (pageM && pageM.boot && pageM.boot.length) { + for (var b = 0; b < pageM.boot.length; b++) { + var bf = pageM.boot[b]; + loadBytecodeFile("sx/" + bf) || loadSxFile("sx/" + bf.replace(/\.sxbc$/, '.sx')); + } + } + if (K.endModuleLoad) K.endModuleLoad(); var count = Object.keys(_loadedLibs).length + 1; // +1 for entry var dt = Math.round(performance.now() - t0); diff --git a/lib/host/blog.sx b/lib/host/blog.sx index e88cbbf4..24908416 100644 --- a/lib/host/blog.sx +++ b/lib/host/blog.sx @@ -592,25 +592,12 @@ (button :type "submit" "remove"))))) current)) (quote (p :style "opacity:0.7" "None yet.")))) - ;; Declarative SX-htmx picker (no client JS). The form GETs relate-options - ;; serialising its inputs (kind + the filter q) into the query: on initial - ;; "load" and on a debounced "input" it innerHTML-swaps the results ul. - ;; Paging is driven by the "load more" sentinel each page carries. The - ;; SX engine re-binds these triggers on swapped-in content, so the picker - ;; works whether the edit page is a full load or a boosted SPA nav. - (form :class "relate-picker" :data-slug (unquote slug) :data-kind (unquote kind) - :sx-get (unquote (str "/" slug "/relate-options")) - :sx-trigger "input delay:200ms, load" - :sx-target (unquote (str "#rp-" kind "-results")) - :sx-swap "innerHTML" - ;; a failed initial/filter fetch retries with backoff (1s→30s) - :sx-retry "exponential:1000:30000" - :style "margin:0" - (input :type "hidden" :name "kind" :value (unquote kind)) - (input :type "text" :name "q" :class "rp-filter" :placeholder "filter…" :autocomplete "off" - :style "width:100%;padding:0.4em;box-sizing:border-box") - (ul :id (unquote (str "rp-" kind "-results")) :class "rp-results" - :style "list-style:none;padding:0;margin:0.5em 0;border:1px solid #ddd"))))))) + ;; The picker is now a reusable, content-addressed SX component + ;; (lib/host/sx/relate-picker.sx). render-page expands it server-side on a + ;; full load; on a boosted SPA nav the body serialises to the compact + ;; (~relate-picker …) and the CLIENT expands it (the component module is + ;; loaded content-addressed via the manifest at boot). + (~relate-picker :slug (unquote slug) :kind (unquote kind))))))) ;; "Is this post a tag?" toggle — marking a post a tag is just an is-a edge to the ;; "tag" type-post, so it reuses the relate/unrelate routes (no new endpoint). diff --git a/lib/host/conformance.sh b/lib/host/conformance.sh index 8a80d03d..f4fbc60c 100755 --- a/lib/host/conformance.sh +++ b/lib/host/conformance.sh @@ -77,6 +77,7 @@ MODULES=( "lib/host/sxtp.sx" "lib/host/router.sx" "lib/host/static.sx" + "lib/host/sx/relate-picker.sx" "lib/host/feed.sx" "lib/host/relations.sx" "lib/host/blog.sx" diff --git a/lib/host/playwright/relate-picker.spec.js b/lib/host/playwright/relate-picker.spec.js index 0c301a75..0611c01a 100644 --- a/lib/host/playwright/relate-picker.spec.js +++ b/lib/host/playwright/relate-picker.spec.js @@ -79,6 +79,9 @@ test.describe('relate picker', () => { }); test('clicking a candidate relates it (and it shows on the post page)', async ({ page }) => { + // heavier than the others: a full-reload relate (sx-disable) then a goto to the + // post page — two WASM kernel boots — so it needs more than the 30s default. + test.setTimeout(75000); await loginTo(page, `/${HOST}/edit`); await waitReady(page); await page.fill(RELF, 'Item 07'); diff --git a/lib/host/serve.sh b/lib/host/serve.sh index 1ac8a399..97abe7a6 100755 --- a/lib/host/serve.sh +++ b/lib/host/serve.sh @@ -82,6 +82,7 @@ MODULES=( "lib/host/sxtp.sx" "lib/host/router.sx" "lib/host/static.sx" + "lib/host/sx/relate-picker.sx" "lib/host/feed.sx" "lib/host/relations.sx" "lib/host/blog.sx" diff --git a/lib/host/static.sx b/lib/host/static.sx index ed4c78db..0bf9e11d 100644 --- a/lib/host/static.sx +++ b/lib/host/static.sx @@ -93,9 +93,16 @@ ;; the data-sx-manifest JSON for the shell: {"modules": {"dom.sxbc": "hash", ...}}. ;; The client's loadBytecodeFile reads manifest.modules[file] -> hash -> /sx/h/. +;; App components the client must eager-load (after the web stack) so their +;; defcomps are registered before a boosted fragment references them. Loaded +;; content-addressed via the modules map below, the same as any web-stack module. +(define host/static--boot-modules (list "relate-picker.sxbc")) + (define host/static-manifest-json (fn () - (str "{\"v\":1,\"boot\":[],\"defs\":{},\"modules\":{" + (str "{\"v\":1,\"boot\":[" + (join "," (map (fn (m) (str "\"" m "\"")) host/static--boot-modules)) + "],\"defs\":{},\"modules\":{" (join "," (map (fn (k) (str "\"" k "\":\"" (get host/static--file->hash k) "\"")) (keys host/static--file->hash))) diff --git a/lib/host/sx/relate-picker.sx b/lib/host/sx/relate-picker.sx new file mode 100644 index 00000000..e9125702 --- /dev/null +++ b/lib/host/sx/relate-picker.sx @@ -0,0 +1,39 @@ +;; lib/host/sx/relate-picker.sx — the relate picker as a reusable, content-addressed +;; SX component. On a FULL load render-page expands it server-side (SEO / no-JS); on a +;; boosted SPA nav the edit body is serialized as `(~relate-picker :slug … :kind …)` +;; and the CLIENT expands it — the component module is loaded content-addressed via +;; the data-sx-manifest at boot, so its defcomp is registered before any fragment +;; referencing it arrives. +;; +;; Pure markup, no client JS: the form GETs //relate-options serialising kind + +;; the filter q (a FORM is serialised on GET, a bare input is not), innerHTML-swapping +;; the results
    on "load" and on a debounced "input". Paging is server-driven — +;; each full page carries a "load more" sentinel (sx-trigger revealed) the endpoint +;; emits. sx-retry makes a dropped/offline fetch self-heal; the engine's .sx-error +;; class (styled by the host shell) surfaces a stuck retry. The engine re-binds these +;; triggers on swapped-in content, so it works on full load AND boosted nav. +(defcomp + ~relate-picker + (&key slug kind) + (form + :class "relate-picker" + :data-slug slug + :data-kind kind + :sx-get (str "/" slug "/relate-options") + :sx-trigger "input delay:200ms, load" + :sx-target (str "#rp-" kind "-results") + :sx-swap "innerHTML" + :sx-retry "exponential:1000:30000" + :style "margin:0" + (input :type "hidden" :name "kind" :value kind) + (input + :type "text" + :name "q" + :class "rp-filter" + :placeholder "filter…" + :autocomplete "off" + :style "width:100%;padding:0.4em;box-sizing:border-box") + (ul + :id (str "rp-" kind "-results") + :class "rp-results" + :style "list-style:none;padding:0;margin:0.5em 0;border:1px solid #ddd"))) diff --git a/shared/static/wasm/sx-platform.js b/shared/static/wasm/sx-platform.js index 1b873404..69217bdc 100644 --- a/shared/static/wasm/sx-platform.js +++ b/shared/static/wasm/sx-platform.js @@ -646,6 +646,18 @@ // Load entry point itself (boot.sx — not a library, just defines + init) loadBytecodeFile("sx/" + entry.file) || loadSxFile("sx/" + entry.file.replace(/\.sxbc$/, '.sx')); + // App components: the page's data-sx-manifest "boot" array lists app-specific + // modules (e.g. ~relate-picker) to eager-load after the web stack, so their + // defcomps are registered before a boosted fragment references them. Loaded + // content-addressed, the same as any module. + var pageM = loadPageManifest(); + if (pageM && pageM.boot && pageM.boot.length) { + for (var b = 0; b < pageM.boot.length; b++) { + var bf = pageM.boot[b]; + loadBytecodeFile("sx/" + bf) || loadSxFile("sx/" + bf.replace(/\.sxbc$/, '.sx')); + } + } + if (K.endModuleLoad) K.endModuleLoad(); var count = Object.keys(_loadedLibs).length + 1; // +1 for entry var dt = Math.round(performance.now() - t0); diff --git a/shared/static/wasm/sx/relate-picker.sx b/shared/static/wasm/sx/relate-picker.sx new file mode 100644 index 00000000..e9125702 --- /dev/null +++ b/shared/static/wasm/sx/relate-picker.sx @@ -0,0 +1,39 @@ +;; lib/host/sx/relate-picker.sx — the relate picker as a reusable, content-addressed +;; SX component. On a FULL load render-page expands it server-side (SEO / no-JS); on a +;; boosted SPA nav the edit body is serialized as `(~relate-picker :slug … :kind …)` +;; and the CLIENT expands it — the component module is loaded content-addressed via +;; the data-sx-manifest at boot, so its defcomp is registered before any fragment +;; referencing it arrives. +;; +;; Pure markup, no client JS: the form GETs //relate-options serialising kind + +;; the filter q (a FORM is serialised on GET, a bare input is not), innerHTML-swapping +;; the results
      on "load" and on a debounced "input". Paging is server-driven — +;; each full page carries a "load more" sentinel (sx-trigger revealed) the endpoint +;; emits. sx-retry makes a dropped/offline fetch self-heal; the engine's .sx-error +;; class (styled by the host shell) surfaces a stuck retry. The engine re-binds these +;; triggers on swapped-in content, so it works on full load AND boosted nav. +(defcomp + ~relate-picker + (&key slug kind) + (form + :class "relate-picker" + :data-slug slug + :data-kind kind + :sx-get (str "/" slug "/relate-options") + :sx-trigger "input delay:200ms, load" + :sx-target (str "#rp-" kind "-results") + :sx-swap "innerHTML" + :sx-retry "exponential:1000:30000" + :style "margin:0" + (input :type "hidden" :name "kind" :value kind) + (input + :type "text" + :name "q" + :class "rp-filter" + :placeholder "filter…" + :autocomplete "off" + :style "width:100%;padding:0.4em;box-sizing:border-box") + (ul + :id (str "rp-" kind "-results") + :class "rp-results" + :style "list-style:none;padding:0;margin:0.5em 0;border:1px solid #ddd"))) diff --git a/shared/static/wasm/sx/relate-picker.sxbc b/shared/static/wasm/sx/relate-picker.sxbc new file mode 100644 index 00000000..43a5fc47 --- /dev/null +++ b/shared/static/wasm/sx/relate-picker.sxbc @@ -0,0 +1,3 @@ +(sxbc 1 "6818110a50d36c46" + (code + :constants ("eval-defcomp" (defcomp ~relate-picker (&key slug kind) (form :class "relate-picker" :data-slug slug :data-kind kind :sx-get (str "/" slug "/relate-options") :sx-trigger "input delay:200ms, load" :sx-target (str "#rp-" kind "-results") :sx-swap "innerHTML" :sx-retry "exponential:1000:30000" :style "margin:0" (input :type "hidden" :name "kind" :value kind) (input :type "text" :name "q" :class "rp-filter" :placeholder "filter…" :autocomplete "off" :style "width:100%;padding:0.4em;box-sizing:border-box") (ul :id (str "rp-" kind "-results") :class "rp-results" :style "list-style:none;padding:0;margin:0.5em 0;border:1px solid #ddd")))) :bytecode (20 0 0 1 1 0 48 1 50)))