Skip to content

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:

CommandEffect
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 / -VPrint `quill <version>`; exit success.

An unknown command prints an error plus the help banner and exits with failure:

Request

quill frobnicate

Response

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.

NameValid?Reason
myappyesletters only
my_app2yesletters, digit, underscore; starts with a letter
my-appyeshyphen allowed
2coolnomust start with an ASCII letter
my appnospace is not allowed
srcnoreserved name
(empty)noname is empty

An invalid name fails before anything is written:

Request

quill new 2cool

Response

error: invalid app name `2cool`: must start with an ASCII letter

Output

Exit code FAILURE. No directory is created.

If the target directory already exists, quill new refuses rather than overwrite:

Request

quill new myapp

Response

error: `myapp` already exists — refusing to overwrite

Template 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's name and [[bin]], the README, and main.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):

toml
[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 island
Files written by quill new <name>

On success, the CLI prints the created-app banner with copy-paste next steps:

Request

quill new myapp

Response

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:7179

The 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.

rust
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:

rust
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_reactor
Serve boot sequence (driven by PAGES)

The 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 run

Response

myapp serving:
  http://127.0.0.1:7179/
  http://127.0.0.1:7179/counter

build: 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 build

Response

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, /countercounter/index.html, /blog/postblog/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 Pages
Contents of dist/ after a build

The 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/*:

text
/
  Cache-Control: public, max-age=60

/counter
  Cache-Control: public, max-age=60

/assets/*
  Cache-Control: public, max-age=31536000, immutable