Quill docs
CLI: scaffolding & commands
This page is the reference for the quill CLI: every command, how quill new validates a name and turns embedded templates into a real app, exactly what gets generated, and how the serve and build commands work end to end. Everything here is transcribed from the CLI source and the templates it ships, so the requests, responses, and generated code match what you will actually see.
Command surface#
The quill binary parses its own arguments (no third-party arg crate). The first argument selects the command:
| Command | Effect |
|---|---|
| quill new <name> | Scaffold a new Quill app in ./<name>. |
| quill build [outdir] | Export the current app to a static dist/ (default: dist). Run from inside an app dir. |
| quill --help / -h / help / (no args) | Print the usage banner; exit success. |
| quill --version / -V | Print `quill <version>`; exit success. |
An unknown command prints an error plus the help banner and exits with failure:
Request
quill frobnicateResponse
error: unknown command `frobnicate`
quill 0.1.0 — the Qirava Quill scaffolder
... (full usage banner) ...Output
Exit code is FAILURE for an unknown command.quill new: name validation#
quill new first validates the app name, because the name becomes a Cargo package name and a directory. The rules (from validate_name): non-empty; at most 64 characters; must start with an ASCII letter; may contain only ASCII letters, digits, _, and -; and may not be one of the reserved names ., .., src, or target.
| Name | Valid? | Reason |
|---|---|---|
| myapp | yes | letters only |
| my_app2 | yes | letters, digit, underscore; starts with a letter |
| my-app | yes | hyphen allowed |
| 2cool | no | must start with an ASCII letter |
| my app | no | space is not allowed |
| src | no | reserved name |
| (empty) | no | name is empty |
An invalid name fails before anything is written:
Request
quill new 2coolResponse
error: invalid app name `2cool`: must start with an ASCII letterOutput
Exit code FAILURE. No directory is created.If the target directory already exists, quill new refuses rather than overwrite:
Request
quill new myappResponse
error: `myapp` already exists — refusing to overwriteTemplate substitution#
The starter's files are embedded into the quill binary at compile time with include_str!, so the CLI ships no template directory. When you run quill new, each template is written to disk with exactly two tokens substituted:
- {{APP_NAME}}
- The app/crate name you passed to
quill new. Substituted into Cargo.toml'snameand[[bin]], the README, andmain.rs(the serving banner and usage text). - {{QIRAVA_ROOT}}
- The absolute path to your Qirava checkout, computed at CLI build time from
CARGO_MANIFEST_DIR(the CLI is at<root>/qquill/qquill-cli, so root is two directories up). Substituted into every path dependency in Cargo.toml.
For example, the generated Cargo.toml wires the name and the dependency paths like this (with a root of, say, /opt/qirava and a name of myapp):
[package]
name = "myapp"
...
[dependencies]
qdms = { path = "/opt/qirava/qdms" }
qquill-view = { path = "/opt/qirava/qquill/qquill-view" }
...
[[bin]]
name = "myapp"
path = "src/main.rs"Generated layout#
The templates are written in this order, creating parent directories as needed. This is the complete starter — there are no stubs to fill in:
<name>/
├── Cargo.toml # path deps + [[bin]] (zero third-party)
├── README.md
├── .gitignore # /target, Cargo.lock, /dist
├── public/
│ └── favicon.svg # static asset; gives the export's copy step input
└── src/
├── main.rs # PAGES list + serve/build modes
└── app/
├── mod.rs # document() + respond_html() (the island rule)
├── theme.rs # tokens + base CSS via style!{}
└── routes/
├── mod.rs # pub mod counter; pub mod index;
├── index.rs # GET / — static, zero-JS page
└── counter.rs # GET /counter — one hydrating islandOn success, the CLI prints the created-app banner with copy-paste next steps:
Request
quill new myappResponse
Created Quill app `myapp`.
Next steps:
cd myapp && cargo run # serve at http://127.0.0.1:7179
cargo run -- build # export a static dist/ (no server)
open http://127.0.0.1:7179The PAGES contract#
Both serve and build are driven by one array in src/main.rs. A Page is an id, a URL path, and a handler function. Adding or removing a route is a single edit here — there is no separate router config — and because both modes walk the same list, the served site and the exported site can never drift.
struct Page {
id: &'static str,
path: &'static str,
handler: fn(&[u8]) -> FunctionResponse,
}
const PAGES: &[Page] = &[
Page { id: "index", path: "/", handler: app::routes::index::respond },
Page { id: "counter", path: "/counter", handler: app::routes::counter::respond },
];To add a page: (1) create src/app/routes/about.rs exporting pub fn respond(_input: &[u8]) -> qexec::FunctionResponse; (2) add pub mod about; to src/app/routes/mod.rs; (3) add Page { id: "about", path: "/about", handler: app::routes::about::respond } to PAGES. Restart cargo run and /about is live. A minimal static page handler looks like this:
use qexec::FunctionResponse;
use qquill_view::{view, Node};
use crate::app::{document, respond_html};
fn page() -> Node {
view! {
main {
h1 { "About" }
p .lead { "Added by hand in three steps." }
}
}
}
pub fn respond(_input: &[u8]) -> FunctionResponse {
let tree = document("About", Node::Fragment(vec![]), page());
respond_html(&tree)
}serve: how a route goes live#
cargo run (or cargo run -- serve) runs cmd_serve. It mirrors the DMS launcher in four steps: open an in-memory database and ensure the system tables, then restore the catalog; for each page, register its render handler under the key qq.render.<id> (via register_dynamic, with a resource budget of 128 KiB / 1024 units); declare each page as a _sys_routes row (GET <path> → qq.render.<id>, output Html), idempotently so repeated boots do not duplicate rows; then build the system worker, attach the loaded routes, and serve on QUILL_ADDR (default 127.0.0.1:7179).
PAGES ─┬─► register handler qq.render.<id> (register_dynamic)
├─► insert route row GET <path> -> qq.render.<id>, Html (idempotent)
└─► load routes -> attach to system worker -> serve_blocking_reactorThe handler-key mapping is deterministic: handler_key(id) = "qq.render." + id. The same function is the single source of truth for both the emitted _sys_routes row and the registered handler, so they meet at the key and cannot drift. The emitted route row has the exact shape:
Request
sys_routes_insert(&RouteDef::page("index", "/"), "qq.web")Response
INSERT INTO _sys_routes { id: "index", worker: "qq.web", method: "GET", path: "/", handler: "qq.render.index", output: "Html" }Output
Route ids and paths are escaped before interpolation (backslash and quote are escaped, control chars dropped), so the statement is always well-formed.At serve time the app prints the URLs it is serving, one per page in PAGES:
Request
cargo runResponse
myapp serving:
http://127.0.0.1:7179/
http://127.0.0.1:7179/counterbuild: how the static export works#
quill build [outdir] (the CLI) is a thin wrapper that runs cargo run --release -- build [outdir] in the current directory — it first checks that a Cargo.toml is present. That drives the app binary's cmd_build, which needs no database and no socket. For each page in PAGES it calls the handler with an empty request ((page.handler)(&[])), strips the __headers response frame with take_response_headers to recover the bare HTML bytes and the page's Cache-Control, and hands an ExportPage to export_static. Dynamic routes (/users/:id, /files/*rest) are skipped, since a file tree has no params.
Request
quill buildResponse
Exported 2 page(s) to dist/
dist/index.html
dist/counter/index.html
Copied 1 asset(s) from public/
Deploy dist/ to Cloudflare Pages — it serves with no DMS running.Output
If a dynamic route were present it would print, e.g., ` skipped dynamic route /users/:id (no static file)`.Routes map to pretty URLs via route_to_file: / → index.html, /counter → counter/index.html, /blog/post → blog/post/index.html. Each file is the complete document and is written byte-for-byte identical to what the live server would send. The export also copies the whole public/ tree verbatim (skipping symlinks for safety) and writes the Cloudflare Pages control files:
dist/
├── index.html # GET /
├── counter/
│ └── index.html # GET /counter (pretty URL)
├── favicon.svg # everything under public/ copied verbatim
├── _headers # per-page Cache-Control + immutable /assets/*
├── _redirects # documented placeholder (pure SSG needs none)
└── 404.html # served for unmatched paths by Cloudflare PagesThe generated _headers carries one stanza per page with its Cache-Control (defaulting to public, max-age=60 if the handler set none), plus a year-long immutable rule for content-hashed assets under /assets/*:
/
Cache-Control: public, max-age=60
/counter
Cache-Control: public, max-age=60
/assets/*
Cache-Control: public, max-age=31536000, immutable