From 79414fbb398cb18de44ea8d6bf4349981e27422f Mon Sep 17 00:00:00 2001 From: slhx agent Date: Sun, 10 May 2026 23:04:45 +0200 Subject: [PATCH] feat(runtime): lower generated runtime ids req: wire/001 req: ts/001 --- AGENTS.md | 48 +++++++++++++++---------- slhx-build/src/lib.rs | 76 +++++++++++++++++++++++++++++++++++++-- slhx-js/runtime/slhx.d.ts | 66 ++++++++++++++++++++++++++++++++++ slhx-js/runtime/slhx.js | 17 ++++++++- slhx-js/src/lib.rs | 1 + slhx-js/tests/runtime.rs | 22 ++++++++++++ 6 files changed, 208 insertions(+), 22 deletions(-) create mode 100644 slhx-js/runtime/slhx.d.ts diff --git a/AGENTS.md b/AGENTS.md index b1eb245..f7715e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,43 +2,53 @@ ## Purpose -This file tells coding agents how to work in this repository. It is hand-edited project context, not a generated requirements dump. +This file tells coding agents how to work in this repository. It is a durable operating contract, not a generated inventory. + +Keep it stable. Prefer pointers to canonical sources over copied structure, file lists, metrics, architecture maps, command inventories, or status snapshots. ## Agent workflow -- Start with requirements before implementation details. +- Start from product intent and requirements; inspect code only after the target behavior is clear. - Read `REQUIREMENTS.md` before changing behavior. - If behavior changes, update `REQUIREMENTS.md` in the same change. -- Cite relevant requirements in code, tests, or docs as `req: component/001`. +- Cite relevant requirements in code, tests, or docs as `req: /001`. +- Do not add citation-only padding to satisfy tooling; cite only where the requirement constrains the text. +- Use requirement tags consistently: stable area tags like `[parser]`, `[auth]`, `[ui]`; temporary planning tags like `[bootstrap]`, `[mvp]`, or `[milestone-1]` only while they are useful. - Run `redgate list`, `redgate refs`, and `redgate health` when requirements change. +## Git workflow + +- Commit complete, coherent slices only; do not commit broken work or temporary debug output. +- Use Conventional Commits: `type(scope): summary`. +- Keep commit subjects readable; requirement IDs do not have to be in the subject. +- Every behavior-changing or requirement-changing commit should cite relevant requirement IDs in the commit body or trailers using `req: /001`. +- Use commit history for evolution: `git log --grep 'req: parser/012'` should find the commits that changed that behavior. +- Use the current tree for state: `REQUIREMENTS.md`, citations, tests, and `redgate health` describe what is true now. +- Before committing requirement or behavior changes, run relevant tests and `redgate health --strict`. + ## Requirements-first TDD -- Write requirements as intent, not as a dump of current behavior. -- Break requirements into large error classes first: what can go wrong, and what outcome should hold. -- Use tests to pin those error classes before changing code. +- Write requirements as desired behavior, not as a snapshot of current behavior. +- First split intent into broad error classes: what can go wrong, and what outcome must hold. +- Test the largest risky classes before narrow examples. - Add adversarial tests for malformed, hostile, ambiguous, missing, duplicated, and boundary inputs. -- Avoid over-codifying existing behavior while the direction is still unclear. -- Add narrower, concrete cases only after requirements converge into a clear design. +- Do not over-codify existing behavior while direction is still moving. +- Add narrow concrete tests only after requirements converge into a stable direction. ## Redgate CLI - `redgate list` — show requirements as TSV. - `redgate refs` — show `req:` citations found in the repo. - `redgate health` — show uncited requirements, duplicate IDs, and stale citations. -- `redgate health --strict` — fail on hard errors: duplicate IDs or stale citations. -- `redgate agents` — print this starter template; review and edit before committing. +- `redgate health --strict` — fail on hard errors: empty requirements, duplicate IDs, or stale citations. +- `redgate agents` — print this starter template; review, shrink, and edit before committing. -## Project commands +## Local guidance -- `cargo check --workspace` -- `cargo test --workspace` -- `redgate health --strict` - -## Project conventions - -- Keep `AGENTS.md` concise; do not paste the requirements catalog into it. -- Requirement IDs use the current form `req: component/001`, not the legacy `req:_component/001` form. +- Add only durable style, ownership, gotchas, and at most a few stable commands agents should actually run. +- Prefer links or pointers to canonical sources over copied lists. +- Avoid project trees, architecture maps, generated inventories, current file sizes, issue lists, TODO inventories, and other snapshots that will rot. +- Stable commands: `cargo check --workspace`, `cargo test --workspace`, `redgate health --strict`. - slhx core stays small: effects, typed ids, registries, and wire schema only. - Routing, auth, sessions, transport, transitions, sync, and storage belong in integration/user crates. - Public examples and beginner APIs should use generated resources and `IntoEffect`, not raw ids or runtime opcodes. diff --git a/slhx-build/src/lib.rs b/slhx-build/src/lib.rs index 13ed9bb..d1f8961 100644 --- a/slhx-build/src/lib.rs +++ b/slhx-build/src/lib.rs @@ -120,6 +120,7 @@ impl Resources { self.insert_handle(canonical, name.clone(), component.clone())?; if tag == "form" { + let form_name = static_attr(&node.attrs, "data-slhx-form").unwrap_or_else(|| name.clone()); let controls = surface .forms .iter() @@ -135,7 +136,7 @@ impl Resources { .collect() }) .unwrap_or_default(); - self.insert_form(canonical_symbol(root, path, &name), name, component.clone(), controls)?; + self.insert_form(canonical_symbol(root, path, &form_name), form_name, component.clone(), controls)?; } } } @@ -192,14 +193,17 @@ impl Resources { // req: build/001 self.push_resource_modules(&mut out, None, 0); + self.push_lowering_api(&mut out, None, 0); for component in self.component_names() { out.push_str("\n#[allow(non_upper_case_globals)]\n"); out.push_str(&format!("pub mod {component} {{\n")); self.push_resource_modules(&mut out, Some(&component), 1); + self.push_lowering_api(&mut out, Some(&component), 1); out.push_str("}\n"); } + self.push_lowering_helpers(&mut out); out } @@ -279,6 +283,68 @@ impl Resources { out.push_str(&format!("{pad}}}\n")); } + fn push_lowering_api(&self, out: &mut String, component: Option<&str>, indent: usize) { + let pad = " ".repeat(indent); + let table_name = match component { + Some(component) => format!("__SLHX_LOWERING_TABLE_{}", component.to_ascii_uppercase()), + None => "__SLHX_LOWERING_TABLE".to_string(), + }; + out.push_str(&format!("\n{pad}pub fn lower_html(html: impl ::std::convert::AsRef) -> ::std::string::String {{\n")); + out.push_str(&format!("{pad} ")); + if component.is_some() { + out.push_str("super::"); + } + out.push_str(&format!("__slhx_lower_html(html.as_ref(), &{table_name})\n")); + out.push_str(&format!("{pad}}}\n\n")); + self.push_lowering_table(out, component, indent, &table_name); + } + + fn push_lowering_table(&self, out: &mut String, component: Option<&str>, indent: usize, name: &str) { + let pad = " ".repeat(indent); + out.push_str(&format!("{pad}const {name}: &[(&str, &str, u32)] = &[\n")); + for res in self.slots.values().filter(|res| component_matches(res, component)) { + out.push_str(&format!("{pad} (\"data-slhx-slot\", {}, {}),\n", rust_str(&res.ident), res.id)); + } + for res in self.handles.values().filter(|res| component_matches(res, component)) { + out.push_str(&format!("{pad} (\"data-slhx-handle\", {}, {}),\n", rust_str(&res.ident), res.id)); + } + for form in self.forms.values().filter(|form| component_matches(&form.resource, component)) { + let res = &form.resource; + out.push_str(&format!("{pad} (\"data-slhx-form\", {}, {}),\n", rust_str(&res.ident), res.id)); + } + for res in self.atoms.values().filter(|res| component_matches(res, component)) { + out.push_str(&format!("{pad} (\"data-slhx-atom\", {}, {}),\n", rust_str(&res.ident), res.id)); + } + out.push_str(&format!("{pad}];\n")); + } + + fn push_lowering_helpers(&self, out: &mut String) { + out.push_str(r#" +fn __slhx_lower_html(html: &str, table: &[(&str, &str, u32)]) -> ::std::string::String { + let mut out = html.to_owned(); + for (attr, name, id) in table { + let runtime_attr = match *attr { + "data-slhx-slot" => "data-sid", + "data-slhx-handle" => "data-hid", + "data-slhx-form" => "data-fid", + "data-slhx-atom" => "data-aid", + _ => continue, + }; + out = __slhx_replace_attr(out, attr, name, runtime_attr, *id); + } + out +} + +fn __slhx_replace_attr(mut html: ::std::string::String, attr: &str, name: &str, runtime_attr: &str, id: u32) -> ::std::string::String { + let replacement = ::std::format!("{runtime_attr}=\"{id}\""); + let double = ::std::format!("{attr}=\"{name}\""); + html = html.replace(&double, &replacement); + let single = ::std::format!("{attr}='{name}'"); + html.replace(&single, &replacement) +} +"#); + } + fn component_names(&self) -> Vec { let mut components = Vec::new(); for component in self @@ -572,7 +638,7 @@ mod tests { std::fs::create_dir_all(&templates).unwrap(); std::fs::write( templates.join("todo.heml"), - r#"
    "#, + r#"
      "#, ) .unwrap(); @@ -586,8 +652,14 @@ mod tests { assert!(generated.contains("pub mod forms")); assert!(generated.contains("pub mod atoms")); assert!(generated.contains("pub const filter")); + assert!(generated.contains("pub const new_todo")); assert!(generated.contains("pub mod todo")); assert!(generated.contains("pub const ALL_IDS")); + assert!(generated.contains("pub fn lower_html")); + assert!(generated.contains("data-slhx-slot")); + assert!(generated.contains("data-slhx-form")); + assert!(generated.contains("data-sid")); + assert!(generated.contains("data-fid")); let syms = std::fs::read_to_string(out.join("slhx.syms")).unwrap(); assert!(syms.contains("atom\t")); diff --git a/slhx-js/runtime/slhx.d.ts b/slhx-js/runtime/slhx.d.ts new file mode 100644 index 0000000..e8e474b --- /dev/null +++ b/slhx-js/runtime/slhx.d.ts @@ -0,0 +1,66 @@ +// req: ts/001 +export type ResourceKind = "slot" | "atom" | "handle" | "form"; + +export interface ResourceId { + kind: ResourceKind; + id: number; +} + +export type ScopeKey = + | { kind: "key"; value: string } + | { kind: "field"; value: string }; + +export interface ResourceRef { + resource: ResourceId; + scope: ScopeKey | null; +} + +export type Payload = + | { kind: "text"; value: string } + | { kind: "html"; value: string }; + +export type ScrollBehavior = + | "preserve" + | "top" + | { kind: "element"; target: ResourceRef }; + +export type Effect = + | { kind: "put"; target: ResourceRef; payload: Payload } + | { kind: "insert"; target: ResourceRef; key: string; payload: Payload } + | { kind: "prepend"; target: ResourceRef; key: string; payload: Payload } + | { kind: "remove"; target: ResourceRef; key: string | null } + | { kind: "move"; target: ResourceRef; key: string; before: string | null } + | { kind: "focus"; target: ResourceRef } + | { kind: "navigate"; url: string; mode: "push" | "replace" | "redirect"; scroll: ScrollBehavior; title: string | null } + | { kind: "emit"; name: string; payload: string }; + +export interface EffectBatch { + abiVersion: number; + fingerprint: bigint; + ops: Effect[]; +} + +export interface AtomSnapshot { + id: number; + bytes: Uint8Array; +} + +export interface SlhxRuntime { + readonly runtimeAbiVersion: number; + roots(): Element[]; + rootOf(node: Element | null): Element | null; + applyHtml(html: string, root?: ParentNode | null, title?: string | null): boolean; + applyBatch(buffer: ArrayBuffer, root?: ParentNode | null): void; + decodeBatch(buffer: ArrayBuffer): EffectBatch; + atomValue(root: Element | ParentNode | null | undefined, id: number): Uint8Array | undefined; + decodeAtomState(encoded: string): AtomSnapshot[]; +} + +declare global { + interface Window { + slhx: SlhxRuntime; + } +} + +declare const slhx: SlhxRuntime; +export default slhx; diff --git a/slhx-js/runtime/slhx.js b/slhx-js/runtime/slhx.js index 9cc3b2f..b665492 100644 --- a/slhx-js/runtime/slhx.js +++ b/slhx-js/runtime/slhx.js @@ -341,7 +341,9 @@ const scope = root || document; const doc = new DOMParser().parseFromString(html, "text/html"); const template = doc.querySelector("template[data-slhx]"); - if (!replaceSlot(scope, doc, "content", template ? template.innerHTML : html)) { + const lowered = replaceLoweredSlots(scope, doc); + const named = replaceSlot(scope, doc, "content", lowered ? undefined : (template ? template.innerHTML : html)); + if (!lowered && !named) { emit(scope, "slhx:missing-content-slot", null); return false; } @@ -351,6 +353,19 @@ return true; } + function replaceLoweredSlots(scope, doc) { + let changed = false; + doc.querySelectorAll("[data-sid], [data-slot-id]").forEach((source) => { + const id = source.getAttribute("data-sid") || source.getAttribute("data-slot-id"); + const target = scope.querySelector(`[data-sid="${cssEscape(id)}"], [data-slot-id="${cssEscape(id)}"]`); + if (target) { + target.innerHTML = source.innerHTML; + changed = true; + } + }); + return changed; + } + function replaceSlot(scope, doc, name, fallback) { const target = scope.querySelector(`[data-slhx-slot="${name}"], [data-slot="${name}"]`); if (!target) return false; diff --git a/slhx-js/src/lib.rs b/slhx-js/src/lib.rs index 0f24848..3bbfcca 100644 --- a/slhx-js/src/lib.rs +++ b/slhx-js/src/lib.rs @@ -1,2 +1,3 @@ pub const RUNTIME_ABI_VERSION: u32 = 1; pub const RUNTIME_JS: &str = include_str!("../runtime/slhx.js"); +pub const RUNTIME_D_TS: &str = include_str!("../runtime/slhx.d.ts"); diff --git a/slhx-js/tests/runtime.rs b/slhx-js/tests/runtime.rs index 80db231..7877739 100644 --- a/slhx-js/tests/runtime.rs +++ b/slhx-js/tests/runtime.rs @@ -40,6 +40,16 @@ fn runtime_exposes_page_swap_hooks() { assert!(source.contains("location.href = href")); } +#[test] +fn runtime_page_swaps_lowered_slot_ids() { + // req: wire/001 + let source = slhx_js::RUNTIME_JS; + + assert!(source.contains("function replaceLoweredSlots(scope, doc)")); + assert!(source.contains("doc.querySelectorAll(\"[data-sid], [data-slot-id]\")")); + assert!(source.contains("target.innerHTML = source.innerHTML")); +} + #[test] fn runtime_keeps_form_field_targets_separate_from_error_targets() { let source = slhx_js::RUNTIME_JS; @@ -58,3 +68,15 @@ fn runtime_preflights_batches_before_applying_ops() { assert!(source.contains("function canApplyOp(scope, op)")); assert!(source.contains("for (const op of batch.ops) applyOp(scope, op)")); } + +#[test] +fn runtime_ships_typescript_definitions() { + let source = slhx_js::RUNTIME_D_TS; + + assert!(source.contains("req: ts/001")); + assert!(source.contains("export interface EffectBatch")); + assert!(source.contains("fingerprint: bigint")); + assert!(source.contains("export type Effect =")); + assert!(source.contains("decodeBatch(buffer: ArrayBuffer): EffectBatch")); + assert!(source.contains("interface Window")); +}