feat(xtask): scaffold generic hemx apps

Add cargo run -p hemx-xtask -- app new PATH for the generic checked-hypermedia starter: v0 page/form/keyed-row/notice handlers and tests with generated helpers instead of raw ids, opcodes, selector UI JavaScript, or manual registry plumbing.

req: ceremony/005

req: canonical_authoring/003
This commit is contained in:
slhx agent
2026-06-12 15:13:33 +02:00
parent cac23578e2
commit 274f44f91d
4 changed files with 132 additions and 5 deletions
+1
View File
@@ -54,6 +54,7 @@ Keep it stable. Prefer pointers to canonical sources over copied structure, file
- hemx core stays small: effects, typed ids, registries, and wire schema only. - hemx 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.
- Use `cargo run -p hemx-xtask -- app new PATH` for the generic page/form/keyed-row/notice starter; use `workout new PATH` only for the phone-first Workout product starter. req: ceremony/005
- 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). - 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).
- 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.
+4 -1
View File
@@ -131,7 +131,10 @@ See `docs/versioning.md`.
- `examples/v0`: canonical beginner path covering counter, typed todo CRUD, - `examples/v0`: canonical beginner path covering counter, typed todo CRUD,
form wizard, auth action, page swaps, SSE notifications, and keyed list form wizard, auth action, page swaps, SSE notifications, and keyed list
updates. Start here. updates. Create the generic starter with `cargo run -p hemx-xtask -- app new
PATH`; it includes a page, form, keyed row partial, notice slot, handlers,
tests, and generated append/replace/remove/dynamic-batch updates. Start here.
req: ceremony/005
- `examples/saas`: compile-tested v1 tutorial app covering auth/session, - `examples/saas`: compile-tested v1 tutorial app covering auth/session,
CSRF-safe mutation, local persistence, generated swaps, page/push shape, plain CSRF-safe mutation, local persistence, generated swaps, page/push shape, plain
CSS, and one explicit island without provider-heavy platform scope. Read the CSS, and one explicit island without provider-heavy platform scope. Read the
+3
View File
@@ -130,6 +130,9 @@ client app state framework.
### req: ceremony/004 ### req: ceremony/004
004 No API may require users to write numeric ids, raw ResourceIds, raw opcodes, or serialized payloads in normal code. 004 No API may require users to write numeric ids, raw ResourceIds, raw opcodes, or serialized payloads in normal code.
### req: ceremony/005
005 `cargo run -p hemx-xtask -- app new PATH` creates a generic checked-hypermedia scaffold with a page, form, keyed row partial, notice slot, Rust handlers, and tests using generated helpers instead of raw ids, opcodes, selector UI JavaScript, or manual registry plumbing. [north_star]
--- ---
## progressive_disclosure ## progressive_disclosure
+124 -4
View File
@@ -14,6 +14,11 @@ fn main() -> ExitCode {
let operand = args.next(); let operand = args.next();
run_workout(subcommand.as_deref(), operand.as_deref()) run_workout(subcommand.as_deref(), operand.as_deref())
} }
Some("app") => {
let subcommand = args.next();
let operand = args.next();
run_app(subcommand.as_deref(), operand.as_deref())
}
Some("workout-mobile") => run_workout_mobile(args.next().as_deref()), Some("workout-mobile") => run_workout_mobile(args.next().as_deref()),
Some("help") | Some("--help") | Some("-h") => { Some("help") | Some("--help") | Some("-h") => {
print_help(); print_help();
@@ -29,10 +34,56 @@ fn main() -> ExitCode {
fn print_help() { fn print_help() {
println!( println!(
"hemx-ci — resource-aware project checks\n\n cargo run -p hemx-xtask -- test\n cargo run -p hemx-xtask -- bench\n cargo run -p hemx-xtask -- workout new PATH\n cargo run -p hemx-xtask -- workout dev\n cargo run -p hemx-xtask -- workout test\n cargo run -p hemx-xtask -- workout build\n cargo run -p hemx-xtask -- workout mobile-release\n cargo run -p hemx-xtask -- workout mobile-verify\n cargo run -p hemx-xtask -- workout doctor\n\nEnvironment overrides:\n HEMX_CI_JOBS=N compile jobs, capped by detected resources\n HEMX_CI_TEST_THREADS=N Rust test threads, capped by detected resources\n HEMX_CI_SKIP_BROWSER=1 skip browser E2E\n HEMX_WORKOUT_ORIGIN=https://app.example.com\n HEMX_WORKOUT_MOBILE_OUT=target/hemx-mobile/workout" "hemx-ci — resource-aware project checks\n\n cargo run -p hemx-xtask -- test\n cargo run -p hemx-xtask -- bench\n cargo run -p hemx-xtask -- app new PATH\n cargo run -p hemx-xtask -- workout new PATH\n cargo run -p hemx-xtask -- workout dev\n cargo run -p hemx-xtask -- workout test\n cargo run -p hemx-xtask -- workout build\n cargo run -p hemx-xtask -- workout mobile-release\n cargo run -p hemx-xtask -- workout mobile-verify\n cargo run -p hemx-xtask -- workout doctor\n\nEnvironment overrides:\n HEMX_CI_JOBS=N compile jobs, capped by detected resources\n HEMX_CI_TEST_THREADS=N Rust test threads, capped by detected resources\n HEMX_CI_SKIP_BROWSER=1 skip browser E2E\n HEMX_WORKOUT_ORIGIN=https://app.example.com\n HEMX_WORKOUT_MOBILE_OUT=target/hemx-mobile/workout"
); );
} }
fn run_app(command: Option<&str>, operand: Option<&str>) -> ExitCode {
// req: ceremony/005 req: canonical_authoring/003
match command.unwrap_or("help") {
"new" | "create" => run_app_new(operand),
"help" | "--help" | "-h" => {
print_help();
ExitCode::SUCCESS
}
other => {
eprintln!("unknown app command `{other}`\n");
print_help();
ExitCode::from(2)
}
}
}
fn run_app_new(destination: Option<&str>) -> ExitCode {
// req: ceremony/005 req: canonical_authoring/003
let Some(destination) = destination else {
eprintln!("usage: cargo run -p hemx-xtask -- app new PATH");
return ExitCode::from(2);
};
let destination = PathBuf::from(destination);
if destination.exists() {
eprintln!(
"{} already exists; choose an empty path",
destination.display()
);
return ExitCode::from(2);
}
match create_app_scaffold(&destination) {
Ok(()) => {
println!("app-new\tpath={}", destination.display());
println!(
"next\tcargo test --manifest-path {}/Cargo.toml",
destination.display()
);
ExitCode::SUCCESS
}
Err(err) => {
eprintln!("failed to create app at {}: {err}", destination.display());
ExitCode::FAILURE
}
}
}
fn run_workout(command: Option<&str>, operand: Option<&str>) -> ExitCode { fn run_workout(command: Option<&str>, operand: Option<&str>) -> ExitCode {
// req: examples/001 req: examples/006 // req: examples/001 req: examples/006
match command.unwrap_or("dev") { match command.unwrap_or("dev") {
@@ -102,6 +153,47 @@ fn run_workout_new(destination: Option<&str>) -> ExitCode {
} }
} }
fn create_app_scaffold(destination: &Path) -> std::io::Result<()> {
let root = repo_root()?;
copy_dir(&root.join("examples/v0"), destination)?;
let cargo_toml = destination.join("Cargo.toml");
let manifest = fs::read_to_string(&cargo_toml)?;
fs::write(
&cargo_toml,
manifest
.replace("name = \"hemx-v0-examples\"", "name = \"hemx-app\"")
.replace("version.workspace = true", "version = \"0.1.0\"")
.replace("edition.workspace = true", "edition = \"2021\"")
.replace(
"path = \"../../../hemplate/hemplate\"",
&format!("path = \"{}\"", root.join("../hemplate/hemplate").display()),
)
.replace(
"path = \"../../hemx\"",
&format!("path = \"{}\"", root.join("hemx").display()),
)
.replace(
"path = \"../../hemx-axum\"",
&format!("path = \"{}\"", root.join("hemx-axum").display()),
)
.replace(
"path = \"../../hemx-test\"",
&format!("path = \"{}\"", root.join("hemx-test").display()),
)
.replace(
"path = \"../../hemx-build\"",
&format!("path = \"{}\"", root.join("hemx-build").display()),
),
)?;
replace_in_tree(destination, "hemx_v0_examples", "hemx_app")?;
replace_in_tree(destination, "hemx-v0-examples", "hemx-app")?;
fs::write(
destination.join("CREATED.md"),
"# Created hemx app\n\nThis scaffold is the generic checked-hypermedia starting point: one page, form, keyed row partial, notice slot, Rust handlers, and tests using generated helpers instead of raw ids, opcodes, selector UI JavaScript, or manual registry plumbing.\n\n```sh\ncargo test\ncargo run\n```\n\nThe reusable todo row partial is rendered in the initial page and updated through generated append/replace/remove/dynamic-batch effects. req: canonical_authoring/003 req: ceremony/005\n",
)?;
Ok(())
}
fn create_workout_app(destination: &Path) -> std::io::Result<()> { fn create_workout_app(destination: &Path) -> std::io::Result<()> {
let root = repo_root()?; let root = repo_root()?;
copy_dir(&root.join("examples/workout"), destination)?; copy_dir(&root.join("examples/workout"), destination)?;
@@ -1053,9 +1145,9 @@ fn is_executable(path: impl AsRef<Path>) -> bool {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
android_twa_release_json, create_workout_app, mobile_external_blockers, origin_host, android_twa_release_json, create_app_scaffold, create_workout_app,
verify_workout_mobile_release, workout_mobile_manifest, write_workout_mobile_release, mobile_external_blockers, origin_host, verify_workout_mobile_release,
Budget, WorkoutMobileConfig, workout_mobile_manifest, write_workout_mobile_release, Budget, WorkoutMobileConfig,
}; };
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
@@ -1091,6 +1183,34 @@ mod tests {
assert_eq!(super::bench_values(6), vec![1, 2, 4, 6]); assert_eq!(super::bench_values(6), vec![1, 2, 4, 6]);
} }
#[test]
fn app_new_creates_generated_helper_scaffold() {
// req: ceremony/005 req: canonical_authoring/003
let destination = PathBuf::from("target/test-hemx-app-new");
let _ = fs::remove_dir_all(&destination);
create_app_scaffold(&destination).expect("create app scaffold");
let manifest = fs::read_to_string(destination.join("Cargo.toml")).expect("manifest");
let main_rs = fs::read_to_string(destination.join("src/main.rs")).expect("main rs");
let template =
fs::read_to_string(destination.join("templates/todos.heml")).expect("template");
let created = fs::read_to_string(destination.join("CREATED.md")).expect("created docs");
assert!(manifest.contains("name = \"hemx-app\""));
assert!(manifest.contains("edition = \"2021\""));
assert!(manifest.contains("hemx-build"));
assert!(main_rs.contains("hemx_app::ui"));
assert!(!main_rs.contains("hemx_v0_examples"));
assert!(template.contains("data-hemx-form=\"new_todo\""));
assert!(template.contains("data-hemx-slot=\"notice\""));
assert!(template.contains("data-hemx-slot=\"todo_row\""));
assert!(template.contains("h-key=\"row.id\""));
assert!(created.contains("keyed row partial"));
assert!(created.contains("generated append/replace/remove/dynamic-batch effects"));
assert!(destination.join("src/lib.rs").exists());
let _ = fs::remove_dir_all(&destination);
}
#[test] #[test]
fn workout_new_creates_standalone_app_manifest() { fn workout_new_creates_standalone_app_manifest() {
// req: examples/001 req: examples/006 // req: examples/001 req: examples/006