Documentation site and CI workflows

An mdBook under docs/book: a user guide (getting started, UI tour, sketching, features,
parameters, components and joints, sheet metal, plastic, files, shortcuts, MCP) and a developer
guide (CONTRIBUTING, architecture, command engine, kernel boundary, persistent naming, test
harnesses, and the existing docs/*.md pages). `cargo xtask book` generates the command reference
from `solvecraft-cli commands` and renders the screenshots with `solvecraft-cli snapshot`, then
runs mdbook. .github/workflows/pages.yml publishes the book at /solvecraft/ and the web app at
/solvecraft/app/ (skipped while the repository is private). .github/workflows/ci.yml runs
`cargo xtask ci` on pull requests. README and Help → Documentation link to the book.

Fixes #28
Fixes #8

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-09 13:25:38 +00:00
1 parent 2c3fec087e
commit 0d7e456bc1
35 files changed
+792 -5

No files matched your search

+123
View File
@@ -0,0 +1,123 @@
//! `cargo xtask book`: generate the book's command reference and screenshots, then build it.
//!
//! The command reference (`docs/book/src/reference/commands.md`) comes from
//! `solvecraft-cli commands`, so it can't drift from the registry. The screenshots in
//! `docs/book/src/generated/` are CPU renders by `solvecraft-cli snapshot` of `examples/*.json`
//! and `docs/book/scripts/*.json`, plus the app screenshot from `docs/screenshots/`. Both are
//! gitignored and attributed in ATTRIBUTION.md. The book itself is built by `mdbook` (a separate
//! tool, not a dependency) into `target/book`.
use std::path::Path;
use serde_json::Value;
use crate::cargo;
/// Toolbar tabs in toolbar order; commands not on the toolbar come last, grouped by id prefix.
const TABS: &[&str] = &["SOLID", "SKETCH", "SURFACE", "SHEET METAL", "PLASTIC"];
const SNAPSHOT_SIZE: (&str, &str) = ("960", "600");
pub fn run(root: &Path, build: bool) -> Result<(), String> {
let book = root.join("docs/book");
let mut c = cargo();
c.args(["build", "-q", "--release", "-p", "solvecraft-cli"]);
crate::run(c, "cargo build -p solvecraft-cli")?;
let out = cli().arg("commands").output().map_err(|e| format!("solvecraft-cli: {e}"))?;
if !out.status.success() {
return Err(format!("solvecraft-cli commands failed:\n{}", String::from_utf8_lossy(&out.stderr)));
}
let cmds: Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("commands JSON: {e}"))?;
let md = reference(cmds.as_array().map(Vec::as_slice).unwrap_or_default());
write(&book.join("src/reference/commands.md"), &md)?;
let gen_dir = book.join("src/generated");
std::fs::create_dir_all(&gen_dir).map_err(|e| format!("{}: {e}", gen_dir.display()))?;
let mut scripts = vec![root.join("examples/bracket.json")];
scripts.extend(json_files(&book.join("scripts"))?);
for s in &scripts {
let stem = s.file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
let png = gen_dir.join(format!("{stem}.png"));
let mut c = cli();
c.arg("snapshot").arg(s).arg("--out").arg(&png).args(["--width", SNAPSHOT_SIZE.0, "--height", SNAPSHOT_SIZE.1]);
let o = c.output().map_err(|e| format!("solvecraft-cli snapshot: {e}"))?;
if !o.status.success() {
return Err(format!("snapshot of {} failed:\n{}", s.display(), String::from_utf8_lossy(&o.stderr)));
}
println!("book: {}", png.strip_prefix(root).unwrap_or(&png).display());
}
let shot = "sample-plate.png";
std::fs::copy(root.join("docs/screenshots").join(shot), gen_dir.join(shot)).map_err(|e| format!("{shot}: {e}"))?;
if build {
let mut c = std::process::Command::new("mdbook");
c.arg("build").arg(&book);
crate::run(c, "mdbook build docs/book").map_err(|e| format!("{e}\n(install mdbook with `cargo install mdbook`)"))?;
println!("book: built into target/book");
}
Ok(())
}
/// The release solvecraft-cli just built.
fn cli() -> std::process::Command {
let mut c = cargo();
c.args(["run", "-q", "--release", "-p", "solvecraft-cli", "--"]);
c
}
fn write(path: &Path, text: &str) -> Result<(), String> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
std::fs::write(path, text).map_err(|e| format!("{}: {e}", path.display()))
}
fn json_files(dir: &Path) -> Result<Vec<std::path::PathBuf>, String> {
let rd = std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let mut v: Vec<_> = rd.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "json")).collect();
v.sort();
Ok(v)
}
/// The command reference page: one section per toolbar tab and panel, in registry order.
fn reference(cmds: &[Value]) -> String {
let s = |c: &Value, k: &str| c[k].as_str().unwrap_or_default().to_string();
let mut md = format!(
"# Command reference\n\nGenerated by `cargo xtask book` from `solvecraft-cli commands` ({} commands). Every command runs the same \
way from the toolbar, a script, `solvecraft-cli exec <id> '<json>'`, the control channel and MCP. Parameters marked `?` are \
optional; `expr` is a number or an expression with units.\n",
cmds.len()
);
let mut sections: Vec<(String, Vec<&Value>)> = Vec::new();
for tab in TABS {
for c in cmds.iter().filter(|c| s(c, "tab") == *tab) {
let title = format!("{tab} › {}", s(c, "panel"));
add(&mut sections, title, c);
}
}
for c in cmds.iter().filter(|c| !TABS.contains(&s(c, "tab").as_str())) {
let id = s(c, "id");
let prefix = id.split('.').next().unwrap_or_default().to_string();
add(&mut sections, format!("Other: {prefix}.*"), c);
}
for (title, list) in sections {
md.push_str(&format!("\n## {title}\n\n"));
for c in list {
let key = c["shortcut"].as_str().map(|k| format!(" ({k})")).unwrap_or_default();
md.push_str(&format!("- `{}` **{}**{key}", s(c, "id"), s(c, "label")));
let p = s(c, "params");
if !p.is_empty() {
md.push_str(&format!("<br>``{}``", p.replace('\n', " ")));
}
md.push('\n');
}
}
md
}
fn add<'a>(sections: &mut Vec<(String, Vec<&'a Value>)>, title: String, c: &'a Value) {
match sections.iter_mut().find(|(t, _)| *t == title) {
Some((_, v)) => v.push(c),
None => sections.push((title, vec![c])),
}
}
+5
View File
@@ -4,6 +4,7 @@
//! `std::process::Command`.
mod assets;
mod book;
mod layers;
mod parity;
@@ -24,6 +25,9 @@ commands:
step-corpus [DIR]
import every DIR/*/part.step (default plan/fusion/oracle) and compare
with measure.json; writes docs/step-import.md
book [--no-build]
generate docs/book's command reference and screenshots, then mdbook build
into target/book
wasm cargo check every library crate and the web app for wasm32-unknown-unknown
ci fmt --check, clippy -D warnings, test, assets, layers, wasm (stops at first failure)
";
@@ -37,6 +41,7 @@ fn main() -> ExitCode {
Some("parity") => parity::run(&root(), rest.contains(&"--refresh")),
Some("oracle") => cmd_oracle(),
Some("step-corpus") => cmd_step_corpus(rest.first().copied()),
Some("book") => book::run(&root(), !rest.contains(&"--no-build")),
Some("ci") => cmd_ci(),
Some("wasm") => cmd_wasm(),
Some("-h" | "--help" | "help") | None => {