From b8c83fee250fe6f80ae8cfd4da20a205bb760659 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Fri, 12 Jun 2026 18:11:39 +0200 Subject: [PATCH] feat(build): surface structured template diagnostics Add public .heml syntax authority and refactor the unkeyed generated-target error into structured diagnostic data while preserving the human compiler error. req: diagnostics/001 req: diagnostics/002 --- AGENTS.md | 2 +- README.md | 7 ++- docs/diagnostics.md | 5 +- docs/hemplate-syntax.md | 81 +++++++++++++++++++++++++++++++ hemx-build/src/lib.rs | 103 ++++++++++++++++++++++++++++++++++++---- 5 files changed, 187 insertions(+), 11 deletions(-) create mode 100644 docs/hemplate-syntax.md diff --git a/AGENTS.md b/AGENTS.md index 47927a0..6c81923 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file - Public examples and beginner APIs should use generated resources and `IntoEffect`, not raw ids or runtime opcodes. - Use `cargo run -p hemx-xtask -- app new PATH` for the generic page/form/keyed-row/notice starter, and `cargo run -p hemx-xtask -- app new --mobile PATH` for the phone-first starter with host capabilities, recovery truth, and release-kit commands. req: ceremony/005 req: ceremony/006 - The public component-reuse explanation lives in `docs/recipes/reusable-partials.md`; do not grow a client component framework to explain partial composition. -- Hemlate examples must use real hemplate syntax, not Vue/Handlebars sketches: `{+ expr +}` for escaped text, `{+= expr =+}` only for trusted/rendered HTML, `+attr="expr"` for dynamic attributes, and Rust-shaped `h-if`, `h-for`, `h-match`, `h-case` directives (`h-case="_"` is the default arm). +- The stable public `.heml` authoring surface lives in `docs/hemplate-syntax.md`; Hemlate examples must use that real hemplate syntax, not Vue/Handlebars sketches. - JS runtime changes must preserve root-scoped lookup and avoid selectors, VDOM, expressions, and per-node listeners. - Host capability adapters must stay at the `hemx-host` boundary: they may call host APIs and return host events, but they must not mutate DOM or own app/domain state. req: host/002 - Local/offline app behavior should be commands/events/projections; do not add `hemx-local`, stored DOM patches, or stored `EffectBatch` truth without a proven reusable contract. req: local/001 req: local/002 diff --git a/README.md b/README.md index e42827d..41f5bfb 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,10 @@ SaaS tutorial app, an advanced Kanban milestone sketch, and a full techdemo. The v1 story now has tutorial, recipe, diagnostics, and stability docs; see `docs/v1-readiness.md` for the remaining close-gap audit. +Template authoring: `.heml` is HTML plus a small hemplate overlay for escaped +text, trusted HTML, dynamic attributes, Rust-shaped control directives, generated +slots/forms/handles, and keyed partial targets. See `docs/hemplate-syntax.md`. + Local checkout note: until the hemplate crates are published, this repository expects `hemplate` checked out next to `hemx` as `../hemplate/hemplate`. The app scaffolder fails with that exact path if the prerequisite is missing, instead of @@ -21,7 +25,8 @@ For beginner and production-shaped app code, stay on this path. req: public_api/ 1. **Templates declare the surface.** `.heml` files declare roots, slots, forms, handles, keys, page targets, optional pending states, and explicit - leaf islands with `data-hemx-*` attributes. + leaf islands with `data-hemx-*` attributes. The stable syntax surface lives in + `docs/hemplate-syntax.md`. 2. **Build generates typed helpers.** `hemx_build::app().run()` consumes the hemplate Surface and emits generated Rust helpers for slots, forms, handles, page targets, classes, and events. req: ceremony/003 req: build/001 diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 790ebd4..0f36302 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -6,7 +6,10 @@ raw ids, selector targeting, runtime opcodes, or Cargo internals in the normal path. req: diagnostics/001 req: diagnostics/002 req: diagnostics/003 Use this guide as the v1 checklist for common mistakes in beginner and -production-shaped apps. +production-shaped apps. Structured `hemx-build` diagnostics expose a file path, +directive, target, expected template fact, and repair action so an optional +editor overlay can share compiler authority without becoming a custom editor +framework. ## Where errors happen diff --git a/docs/hemplate-syntax.md b/docs/hemplate-syntax.md new file mode 100644 index 0000000..22d6f88 --- /dev/null +++ b/docs/hemplate-syntax.md @@ -0,0 +1,81 @@ +# `.heml` syntax surface + +`.heml` files are ordinary HTML plus the small hemplate surface below. Use normal +HTML tooling first; hemx/hemplate adds checks for the few template facts that +Rust code generation needs. req: diagnostics/001 req: diagnostics/002 + +## Text and HTML + +- `{+ expr +}` inserts escaped text. +- `{+= expr =+}` inserts trusted/rendered HTML. Use it only for values already + represented as trusted HTML in Rust. + +```html +

{+ self.title +}

+
{+= self.body_html =+}
+``` + +## Dynamic attributes + +Prefix an HTML attribute with `+` when its value is a Rust expression. + +```html +{+ self.label +} + +``` + +Dynamic attributes render HTML. They do not replace template facts such as +`h-key` on a loop or `data-hemx-slot` names used by generated helpers. + +## Control flow + +```html +
Welcome back
+ +
  • + {+ todo.title +} +
  • + +
    +

    Loading

    +

    Ready

    +

    Unknown

    +
    +``` + +`h-key` is required when generated targets live inside `h-for`; it must be the +stable template fact on the loop that owns the repeated target. `+data-key` on a +child is just rendered HTML and is not enough for generated keyed helpers. + +## Generated hemx targets + +Generated targets are named in templates and used from Rust through generated +helpers. Do not target them with CSS selectors or raw ids in normal app code. + +```html +
    +
    + +
    + +

    {+ self.notice +}

    + + +
    +``` + +Rust handlers then use generated helpers such as +`ui::notice.set("Saved")`, `ui::todo_row.replace(row)`, and composed +`IntoEffect` batches. The template owns target names; Rust owns state, commands, +events, and projections. + +## Boundary + +This file defines the stable public authoring surface for hemx examples and +beginner docs. It does not introduce a client component framework, custom editor +framework, JavaScript expression language, selector targeting model, or stored DOM +truth. diff --git a/hemx-build/src/lib.rs b/hemx-build/src/lib.rs index e77d80a..50de607 100644 --- a/hemx-build/src/lib.rs +++ b/hemx-build/src/lib.rs @@ -9,6 +9,47 @@ use std::io; use std::path::{Path, PathBuf}; use std::sync::Arc; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DiagnosticSeverity { + Error, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DiagnosticCode { + UnkeyedGeneratedTarget, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Diagnostic { + pub code: DiagnosticCode, + pub severity: DiagnosticSeverity, + pub file: PathBuf, + pub directive: String, + pub target: String, + pub message: String, + pub expected: String, + pub repair: String, +} + +impl Diagnostic { + pub fn to_io_error(&self) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, self.to_string()) + } +} + +impl std::fmt::Display for Diagnostic { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}: {}; expected {}; repair: {}", + self.file.display(), + self.message, + self.expected, + self.repair + ) + } +} + #[derive(Clone, Debug)] pub struct AppBuilder { out_dir: Option, @@ -1549,13 +1590,9 @@ fn reject_unkeyed_loop( key_expr: None, } = ¤t.kind { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - format!( - "{}: data-hemx-{kind}=\"{name}\" is inside h-for=\"{pattern} in {expr}\" without h-key; add a stable h-key=\"{pattern}.id\" to that h-for so generated keyed helpers such as ui::{name}.replace(row) can target this partial. Dynamic +data-key on the child is rendered HTML, not the template fact hemx uses for generated targets.", - path.display() - ), - )); + return Err( + unkeyed_generated_target_diagnostic(path, kind, name, pattern, expr).to_io_error(), + ); } let Some(parent) = current.parent else { return Ok(()); @@ -1564,6 +1601,31 @@ fn reject_unkeyed_loop( } } +fn unkeyed_generated_target_diagnostic( + path: &Path, + kind: &str, + name: &str, + pattern: &str, + expr: &str, +) -> Diagnostic { + Diagnostic { + code: DiagnosticCode::UnkeyedGeneratedTarget, + severity: DiagnosticSeverity::Error, + file: path.to_path_buf(), + directive: format!("data-hemx-{kind}"), + target: name.to_owned(), + message: format!( + "data-hemx-{kind}=\"{name}\" is inside h-for=\"{pattern} in {expr}\" without h-key" + ), + expected: format!( + "a stable template h-key on h-for=\"{pattern} in {expr}\" so generated keyed helpers such as ui::{name}.replace(row) can target this partial" + ), + repair: format!( + "add h-key=\"{pattern}.id\" to that h-for; dynamic +data-key on the child is rendered HTML, not the template fact hemx uses for generated targets" + ), + } +} + fn canonical_symbol(root: &Path, path: &Path, name: &str) -> String { let rel = path.strip_prefix(root).unwrap_or(path); format!("{}::{name}", rel.to_string_lossy().replace('\\', "/")) @@ -2055,6 +2117,31 @@ fn main() {{ let _ = std::fs::remove_dir_all(&base); } + #[test] + fn unkeyed_generated_target_diagnostic_is_structured() { + // req: diagnostics/002 + let diagnostic = unkeyed_generated_target_diagnostic( + Path::new("templates/todo.heml"), + "slot", + "todo_row", + "todo", + "&self.todos", + ); + + assert_eq!(diagnostic.code, DiagnosticCode::UnkeyedGeneratedTarget); + assert_eq!(diagnostic.severity, DiagnosticSeverity::Error); + assert_eq!(diagnostic.file, PathBuf::from("templates/todo.heml")); + assert_eq!(diagnostic.directive, "data-hemx-slot"); + assert_eq!(diagnostic.target, "todo_row"); + assert!(diagnostic + .message + .contains("h-for=\"todo in &self.todos\" without h-key")); + assert!(diagnostic.expected.contains("ui::todo_row.replace(row)")); + assert!(diagnostic.repair.contains("h-key=\"todo.id\"")); + assert!(diagnostic.repair.contains("dynamic +data-key on the child")); + assert!(diagnostic.to_string().contains("templates/todo.heml")); + } + #[test] fn rejects_hemx_resources_inside_unkeyed_for() { // req: scope/001 req: diagnostics/002 @@ -2096,7 +2183,7 @@ fn main() {{ assert!(err.contains("h-key=\"todo.id\"")); assert!(err.contains("generated keyed helpers")); assert!(err.contains(helper)); - assert!(err.contains("Dynamic +data-key on the child")); + assert!(err.contains("dynamic +data-key on the child")); let _ = std::fs::remove_dir_all(&base); }