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
This commit is contained in:
@@ -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.
|
- 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
|
- 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.
|
- 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.
|
- 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
|
- 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
|
- 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
|
||||||
|
|||||||
@@ -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
|
The v1 story now has tutorial, recipe, diagnostics, and stability docs; see
|
||||||
`docs/v1-readiness.md` for the remaining close-gap audit.
|
`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
|
Local checkout note: until the hemplate crates are published, this repository
|
||||||
expects `hemplate` checked out next to `hemx` as `../hemplate/hemplate`. The app
|
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
|
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,
|
1. **Templates declare the surface.** `.heml` files declare roots, slots,
|
||||||
forms, handles, keys, page targets, optional pending states, and explicit
|
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
|
2. **Build generates typed helpers.** `hemx_build::app().run()` consumes the
|
||||||
hemplate Surface and emits generated Rust helpers for slots, forms, handles,
|
hemplate Surface and emits generated Rust helpers for slots, forms, handles,
|
||||||
page targets, classes, and events. req: ceremony/003 req: build/001
|
page targets, classes, and events. req: ceremony/003 req: build/001
|
||||||
|
|||||||
+4
-1
@@ -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
|
path. req: diagnostics/001 req: diagnostics/002 req: diagnostics/003
|
||||||
|
|
||||||
Use this guide as the v1 checklist for common mistakes in beginner and
|
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
|
## Where errors happen
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
<h1>{+ self.title +}</h1>
|
||||||
|
<div>{+= self.body_html =+}</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dynamic attributes
|
||||||
|
|
||||||
|
Prefix an HTML attribute with `+` when its value is a Rust expression.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<a +href="self.url">{+ self.label +}</a>
|
||||||
|
<button +disabled="self.saving">Save</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
<section h-if="self.logged_in">Welcome back</section>
|
||||||
|
|
||||||
|
<li h-for="todo in &self.todos" h-key="todo.id">
|
||||||
|
{+ todo.title +}
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<div h-match="self.state">
|
||||||
|
<p h-case="State::Loading">Loading</p>
|
||||||
|
<p h-case="State::Ready">Ready</p>
|
||||||
|
<p h-case="_">Unknown</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
`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
|
||||||
|
<main data-hemx-root="todos">
|
||||||
|
<form data-hemx-form="new_todo" data-hemx-handle="add_todo">
|
||||||
|
<input name="title" required>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p data-hemx-slot="notice">{+ self.notice +}</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li h-for="row in &self.rows" h-key="row.id" data-hemx-slot="todo_row">
|
||||||
|
{+ row.title +}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</main>
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
+95
-8
@@ -9,6 +9,47 @@ use std::io;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
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)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct AppBuilder {
|
pub struct AppBuilder {
|
||||||
out_dir: Option<PathBuf>,
|
out_dir: Option<PathBuf>,
|
||||||
@@ -1549,13 +1590,9 @@ fn reject_unkeyed_loop(
|
|||||||
key_expr: None,
|
key_expr: None,
|
||||||
} = ¤t.kind
|
} = ¤t.kind
|
||||||
{
|
{
|
||||||
return Err(io::Error::new(
|
return Err(
|
||||||
io::ErrorKind::InvalidData,
|
unkeyed_generated_target_diagnostic(path, kind, name, pattern, expr).to_io_error(),
|
||||||
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()
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
let Some(parent) = current.parent else {
|
let Some(parent) = current.parent else {
|
||||||
return Ok(());
|
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 {
|
fn canonical_symbol(root: &Path, path: &Path, name: &str) -> String {
|
||||||
let rel = path.strip_prefix(root).unwrap_or(path);
|
let rel = path.strip_prefix(root).unwrap_or(path);
|
||||||
format!("{}::{name}", rel.to_string_lossy().replace('\\', "/"))
|
format!("{}::{name}", rel.to_string_lossy().replace('\\', "/"))
|
||||||
@@ -2055,6 +2117,31 @@ fn main() {{
|
|||||||
let _ = std::fs::remove_dir_all(&base);
|
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]
|
#[test]
|
||||||
fn rejects_hemx_resources_inside_unkeyed_for() {
|
fn rejects_hemx_resources_inside_unkeyed_for() {
|
||||||
// req: scope/001 req: diagnostics/002
|
// req: scope/001 req: diagnostics/002
|
||||||
@@ -2096,7 +2183,7 @@ fn main() {{
|
|||||||
assert!(err.contains("h-key=\"todo.id\""));
|
assert!(err.contains("h-key=\"todo.id\""));
|
||||||
assert!(err.contains("generated keyed helpers"));
|
assert!(err.contains("generated keyed helpers"));
|
||||||
assert!(err.contains(helper));
|
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);
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user