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:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user