feat(runtime): lower generated runtime ids
req: wire/001 req: ts/001
This commit is contained in:
@@ -2,43 +2,53 @@
|
|||||||
|
|
||||||
## Purpose
|
## 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
|
## 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.
|
- Read `REQUIREMENTS.md` before changing behavior.
|
||||||
- If behavior changes, update `REQUIREMENTS.md` in the same change.
|
- 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: <component>/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.
|
- 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: <component>/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
|
## Requirements-first TDD
|
||||||
|
|
||||||
- Write requirements as intent, not as a dump of current behavior.
|
- Write requirements as desired behavior, not as a snapshot of current behavior.
|
||||||
- Break requirements into large error classes first: what can go wrong, and what outcome should hold.
|
- First split intent into broad error classes: what can go wrong, and what outcome must hold.
|
||||||
- Use tests to pin those error classes before changing code.
|
- Test the largest risky classes before narrow examples.
|
||||||
- Add adversarial tests for malformed, hostile, ambiguous, missing, duplicated, and boundary inputs.
|
- Add adversarial tests for malformed, hostile, ambiguous, missing, duplicated, and boundary inputs.
|
||||||
- Avoid over-codifying existing behavior while the direction is still unclear.
|
- Do not over-codify existing behavior while direction is still moving.
|
||||||
- Add narrower, concrete cases only after requirements converge into a clear design.
|
- Add narrow concrete tests only after requirements converge into a stable direction.
|
||||||
|
|
||||||
## Redgate CLI
|
## Redgate CLI
|
||||||
|
|
||||||
- `redgate list` — show requirements as TSV.
|
- `redgate list` — show requirements as TSV.
|
||||||
- `redgate refs` — show `req:` citations found in the repo.
|
- `redgate refs` — show `req:` citations found in the repo.
|
||||||
- `redgate health` — show uncited requirements, duplicate IDs, and stale citations.
|
- `redgate health` — show uncited requirements, duplicate IDs, and stale citations.
|
||||||
- `redgate health --strict` — fail on hard errors: duplicate IDs or stale citations.
|
- `redgate health --strict` — fail on hard errors: empty requirements, duplicate IDs, or stale citations.
|
||||||
- `redgate agents` — print this starter template; review and edit before committing.
|
- `redgate agents` — print this starter template; review, shrink, and edit before committing.
|
||||||
|
|
||||||
## Project commands
|
## Local guidance
|
||||||
|
|
||||||
- `cargo check --workspace`
|
- Add only durable style, ownership, gotchas, and at most a few stable commands agents should actually run.
|
||||||
- `cargo test --workspace`
|
- Prefer links or pointers to canonical sources over copied lists.
|
||||||
- `redgate health --strict`
|
- 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`.
|
||||||
## 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.
|
|
||||||
- slhx core stays small: effects, typed ids, registries, and wire schema only.
|
- 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.
|
- 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.
|
- Public examples and beginner APIs should use generated resources and `IntoEffect`, not raw ids or runtime opcodes.
|
||||||
|
|||||||
+74
-2
@@ -120,6 +120,7 @@ impl Resources {
|
|||||||
self.insert_handle(canonical, name.clone(), component.clone())?;
|
self.insert_handle(canonical, name.clone(), component.clone())?;
|
||||||
|
|
||||||
if tag == "form" {
|
if tag == "form" {
|
||||||
|
let form_name = static_attr(&node.attrs, "data-slhx-form").unwrap_or_else(|| name.clone());
|
||||||
let controls = surface
|
let controls = surface
|
||||||
.forms
|
.forms
|
||||||
.iter()
|
.iter()
|
||||||
@@ -135,7 +136,7 @@ impl Resources {
|
|||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.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
|
// req: build/001
|
||||||
self.push_resource_modules(&mut out, None, 0);
|
self.push_resource_modules(&mut out, None, 0);
|
||||||
|
self.push_lowering_api(&mut out, None, 0);
|
||||||
|
|
||||||
for component in self.component_names() {
|
for component in self.component_names() {
|
||||||
out.push_str("\n#[allow(non_upper_case_globals)]\n");
|
out.push_str("\n#[allow(non_upper_case_globals)]\n");
|
||||||
out.push_str(&format!("pub mod {component} {{\n"));
|
out.push_str(&format!("pub mod {component} {{\n"));
|
||||||
self.push_resource_modules(&mut out, Some(&component), 1);
|
self.push_resource_modules(&mut out, Some(&component), 1);
|
||||||
|
self.push_lowering_api(&mut out, Some(&component), 1);
|
||||||
out.push_str("}\n");
|
out.push_str("}\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.push_lowering_helpers(&mut out);
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +283,68 @@ impl Resources {
|
|||||||
out.push_str(&format!("{pad}}}\n"));
|
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<str>) -> ::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<String> {
|
fn component_names(&self) -> Vec<String> {
|
||||||
let mut components = Vec::new();
|
let mut components = Vec::new();
|
||||||
for component in self
|
for component in self
|
||||||
@@ -572,7 +638,7 @@ mod tests {
|
|||||||
std::fs::create_dir_all(&templates).unwrap();
|
std::fs::create_dir_all(&templates).unwrap();
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
templates.join("todo.heml"),
|
templates.join("todo.heml"),
|
||||||
r#"<form data-slhx-handle="create"><input name="title"></form><ul data-slhx-slot="todos"></ul><section data-slhx-atom="filter"></section>"#,
|
r#"<form data-slhx-handle="create" data-slhx-form="new_todo"><input name="title"></form><ul data-slhx-slot="todos"></ul><section data-slhx-atom="filter"></section>"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -586,8 +652,14 @@ mod tests {
|
|||||||
assert!(generated.contains("pub mod forms"));
|
assert!(generated.contains("pub mod forms"));
|
||||||
assert!(generated.contains("pub mod atoms"));
|
assert!(generated.contains("pub mod atoms"));
|
||||||
assert!(generated.contains("pub const filter"));
|
assert!(generated.contains("pub const filter"));
|
||||||
|
assert!(generated.contains("pub const new_todo"));
|
||||||
assert!(generated.contains("pub mod todo"));
|
assert!(generated.contains("pub mod todo"));
|
||||||
assert!(generated.contains("pub const ALL_IDS"));
|
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();
|
let syms = std::fs::read_to_string(out.join("slhx.syms")).unwrap();
|
||||||
assert!(syms.contains("atom\t"));
|
assert!(syms.contains("atom\t"));
|
||||||
|
|||||||
Vendored
+66
@@ -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;
|
||||||
+16
-1
@@ -341,7 +341,9 @@
|
|||||||
const scope = root || document;
|
const scope = root || document;
|
||||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||||
const template = doc.querySelector("template[data-slhx]");
|
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);
|
emit(scope, "slhx:missing-content-slot", null);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -351,6 +353,19 @@
|
|||||||
return true;
|
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) {
|
function replaceSlot(scope, doc, name, fallback) {
|
||||||
const target = scope.querySelector(`[data-slhx-slot="${name}"], [data-slot="${name}"]`);
|
const target = scope.querySelector(`[data-slhx-slot="${name}"], [data-slot="${name}"]`);
|
||||||
if (!target) return false;
|
if (!target) return false;
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
pub const RUNTIME_ABI_VERSION: u32 = 1;
|
pub const RUNTIME_ABI_VERSION: u32 = 1;
|
||||||
pub const RUNTIME_JS: &str = include_str!("../runtime/slhx.js");
|
pub const RUNTIME_JS: &str = include_str!("../runtime/slhx.js");
|
||||||
|
pub const RUNTIME_D_TS: &str = include_str!("../runtime/slhx.d.ts");
|
||||||
|
|||||||
@@ -40,6 +40,16 @@ fn runtime_exposes_page_swap_hooks() {
|
|||||||
assert!(source.contains("location.href = href"));
|
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]
|
#[test]
|
||||||
fn runtime_keeps_form_field_targets_separate_from_error_targets() {
|
fn runtime_keeps_form_field_targets_separate_from_error_targets() {
|
||||||
let source = slhx_js::RUNTIME_JS;
|
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("function canApplyOp(scope, op)"));
|
||||||
assert!(source.contains("for (const op of batch.ops) applyOp(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"));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user