diff --git a/AGENTS.md b/AGENTS.md index 136d5c5..75c3cef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. - 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. +- 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. - 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. diff --git a/README.md b/README.md index c7e992d..f0892e8 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,10 @@ See `docs/versioning.md`. - `examples/v0`: canonical beginner path covering counter, typed todo CRUD, 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, CSRF-safe mutation, local persistence, generated swaps, page/push shape, plain CSS, and one explicit island without provider-heavy platform scope. Read the diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 357105f..d58af0b 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -130,6 +130,9 @@ client app state framework. ### req: ceremony/004 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 diff --git a/hemx-xtask/src/main.rs b/hemx-xtask/src/main.rs index 8468899..b6db791 100644 --- a/hemx-xtask/src/main.rs +++ b/hemx-xtask/src/main.rs @@ -14,6 +14,11 @@ fn main() -> ExitCode { let operand = args.next(); 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("help") | Some("--help") | Some("-h") => { print_help(); @@ -29,10 +34,56 @@ fn main() -> ExitCode { fn print_help() { 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 { // req: examples/001 req: examples/006 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<()> { let root = repo_root()?; copy_dir(&root.join("examples/workout"), destination)?; @@ -1053,9 +1145,9 @@ fn is_executable(path: impl AsRef) -> bool { #[cfg(test)] mod tests { use super::{ - android_twa_release_json, create_workout_app, mobile_external_blockers, origin_host, - verify_workout_mobile_release, workout_mobile_manifest, write_workout_mobile_release, - Budget, WorkoutMobileConfig, + android_twa_release_json, create_app_scaffold, create_workout_app, + mobile_external_blockers, origin_host, verify_workout_mobile_release, + workout_mobile_manifest, write_workout_mobile_release, Budget, WorkoutMobileConfig, }; use std::fs; use std::path::PathBuf; @@ -1091,6 +1183,34 @@ mod tests { 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] fn workout_new_creates_standalone_app_manifest() { // req: examples/001 req: examples/006