host: extract the relate picker into a content-addressed ~relate-picker component
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 34s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 34s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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-type.sx" "$DIST/sx/"
|
||||||
cp "$ROOT/shared/sx/templates/tw.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
|
# 10. Hyperscript
|
||||||
for f in tokenizer parser compiler runtime integration htmx; do
|
for f in tokenizer parser compiler runtime integration htmx; do
|
||||||
cp "$ROOT/lib/hyperscript/$f.sx" "$DIST/sx/hs-$f.sx"
|
cp "$ROOT/lib/hyperscript/$f.sx" "$DIST/sx/hs-$f.sx"
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ const SOURCE_MAP = {
|
|||||||
'boot.sx': 'web/boot.sx',
|
'boot.sx': 'web/boot.sx',
|
||||||
'tw-layout.sx': 'web/tw-layout.sx', 'tw-type.sx': 'web/tw-type.sx', 'tw.sx': 'web/tw.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',
|
'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;
|
let synced = 0;
|
||||||
for (const [dist, src] of Object.entries(SOURCE_MAP)) {
|
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-tokenizer.sx', 'hs-parser.sx', 'hs-compiler.sx', 'hs-runtime.sx',
|
||||||
'hs-worker.sx', 'hs-prolog.sx',
|
'hs-worker.sx', 'hs-prolog.sx',
|
||||||
'hs-integration.sx', 'hs-htmx.sx',
|
'hs-integration.sx', 'hs-htmx.sx',
|
||||||
|
// Host app components — standalone defcomps, no inter-module deps.
|
||||||
|
'relate-picker.sx',
|
||||||
'boot.sx',
|
'boot.sx',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -646,6 +646,18 @@
|
|||||||
// Load entry point itself (boot.sx — not a library, just defines + init)
|
// Load entry point itself (boot.sx — not a library, just defines + init)
|
||||||
loadBytecodeFile("sx/" + entry.file) || loadSxFile("sx/" + entry.file.replace(/\.sxbc$/, '.sx'));
|
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();
|
if (K.endModuleLoad) K.endModuleLoad();
|
||||||
var count = Object.keys(_loadedLibs).length + 1; // +1 for entry
|
var count = Object.keys(_loadedLibs).length + 1; // +1 for entry
|
||||||
var dt = Math.round(performance.now() - t0);
|
var dt = Math.round(performance.now() - t0);
|
||||||
|
|||||||
@@ -592,25 +592,12 @@
|
|||||||
(button :type "submit" "remove")))))
|
(button :type "submit" "remove")))))
|
||||||
current))
|
current))
|
||||||
(quote (p :style "opacity:0.7" "None yet."))))
|
(quote (p :style "opacity:0.7" "None yet."))))
|
||||||
;; Declarative SX-htmx picker (no client JS). The form GETs relate-options
|
;; The picker is now a reusable, content-addressed SX component
|
||||||
;; serialising its inputs (kind + the filter q) into the query: on initial
|
;; (lib/host/sx/relate-picker.sx). render-page expands it server-side on a
|
||||||
;; "load" and on a debounced "input" it innerHTML-swaps the results ul.
|
;; full load; on a boosted SPA nav the body serialises to the compact
|
||||||
;; Paging is driven by the "load more" sentinel each page carries. The
|
;; (~relate-picker …) and the CLIENT expands it (the component module is
|
||||||
;; SX engine re-binds these triggers on swapped-in content, so the picker
|
;; loaded content-addressed via the manifest at boot).
|
||||||
;; works whether the edit page is a full load or a boosted SPA nav.
|
(~relate-picker :slug (unquote slug) :kind (unquote kind)))))))
|
||||||
(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")))))))
|
|
||||||
|
|
||||||
;; "Is this post a tag?" toggle — marking a post a tag is just an is-a edge to the
|
;; "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).
|
;; "tag" type-post, so it reuses the relate/unrelate routes (no new endpoint).
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ MODULES=(
|
|||||||
"lib/host/sxtp.sx"
|
"lib/host/sxtp.sx"
|
||||||
"lib/host/router.sx"
|
"lib/host/router.sx"
|
||||||
"lib/host/static.sx"
|
"lib/host/static.sx"
|
||||||
|
"lib/host/sx/relate-picker.sx"
|
||||||
"lib/host/feed.sx"
|
"lib/host/feed.sx"
|
||||||
"lib/host/relations.sx"
|
"lib/host/relations.sx"
|
||||||
"lib/host/blog.sx"
|
"lib/host/blog.sx"
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ test.describe('relate picker', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('clicking a candidate relates it (and it shows on the post page)', async ({ page }) => {
|
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 loginTo(page, `/${HOST}/edit`);
|
||||||
await waitReady(page);
|
await waitReady(page);
|
||||||
await page.fill(RELF, 'Item 07');
|
await page.fill(RELF, 'Item 07');
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ MODULES=(
|
|||||||
"lib/host/sxtp.sx"
|
"lib/host/sxtp.sx"
|
||||||
"lib/host/router.sx"
|
"lib/host/router.sx"
|
||||||
"lib/host/static.sx"
|
"lib/host/static.sx"
|
||||||
|
"lib/host/sx/relate-picker.sx"
|
||||||
"lib/host/feed.sx"
|
"lib/host/feed.sx"
|
||||||
"lib/host/relations.sx"
|
"lib/host/relations.sx"
|
||||||
"lib/host/blog.sx"
|
"lib/host/blog.sx"
|
||||||
|
|||||||
@@ -93,9 +93,16 @@
|
|||||||
|
|
||||||
;; the data-sx-manifest JSON for the shell: {"modules": {"dom.sxbc": "hash", ...}}.
|
;; the data-sx-manifest JSON for the shell: {"modules": {"dom.sxbc": "hash", ...}}.
|
||||||
;; The client's loadBytecodeFile reads manifest.modules[file] -> hash -> /sx/h/.
|
;; 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
|
(define host/static-manifest-json
|
||||||
(fn ()
|
(fn ()
|
||||||
(str "{\"v\":1,\"boot\":[],\"defs\":{},\"modules\":{"
|
(str "{\"v\":1,\"boot\":["
|
||||||
|
(join "," (map (fn (m) (str "\"" m "\"")) host/static--boot-modules))
|
||||||
|
"],\"defs\":{},\"modules\":{"
|
||||||
(join ","
|
(join ","
|
||||||
(map (fn (k) (str "\"" k "\":\"" (get host/static--file->hash k) "\""))
|
(map (fn (k) (str "\"" k "\":\"" (get host/static--file->hash k) "\""))
|
||||||
(keys host/static--file->hash)))
|
(keys host/static--file->hash)))
|
||||||
|
|||||||
39
lib/host/sx/relate-picker.sx
Normal file
39
lib/host/sx/relate-picker.sx
Normal file
@@ -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 /<slug>/relate-options serialising kind +
|
||||||
|
;; the filter q (a FORM is serialised on GET, a bare input is not), innerHTML-swapping
|
||||||
|
;; the results <ul> 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")))
|
||||||
@@ -646,6 +646,18 @@
|
|||||||
// Load entry point itself (boot.sx — not a library, just defines + init)
|
// Load entry point itself (boot.sx — not a library, just defines + init)
|
||||||
loadBytecodeFile("sx/" + entry.file) || loadSxFile("sx/" + entry.file.replace(/\.sxbc$/, '.sx'));
|
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();
|
if (K.endModuleLoad) K.endModuleLoad();
|
||||||
var count = Object.keys(_loadedLibs).length + 1; // +1 for entry
|
var count = Object.keys(_loadedLibs).length + 1; // +1 for entry
|
||||||
var dt = Math.round(performance.now() - t0);
|
var dt = Math.round(performance.now() - t0);
|
||||||
|
|||||||
39
shared/static/wasm/sx/relate-picker.sx
Normal file
39
shared/static/wasm/sx/relate-picker.sx
Normal file
@@ -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 /<slug>/relate-options serialising kind +
|
||||||
|
;; the filter q (a FORM is serialised on GET, a bare input is not), innerHTML-swapping
|
||||||
|
;; the results <ul> 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")))
|
||||||
3
shared/static/wasm/sx/relate-picker.sxbc
Normal file
3
shared/static/wasm/sx/relate-picker.sxbc
Normal file
@@ -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)))
|
||||||
Reference in New Issue
Block a user