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 +}
+
+
+
+ {+ row.title +}
+
+
+
+```
+
+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);
}