plates-cli 0.7.0

Command-line static site generator over a prov archive: build, watch, serve and clean.
---
title: plates-cli
part_of: '[plates](/README.md)'
audience: public
---

# plates-cli

The `plates` command: a static site generator over a
[`prov`](https://github.com/diaryx-org/prov) archive.

```
cargo install plates-cli
```

installs `plates`, which finds the archive by walking up from the current
directory the same way `prov` does. The crate is `plates-cli`; the installed
command is `plates`.

This is the application layer, and the only one in
[the workspace](https://github.com/diaryx-org/plates) allowed to have the
opinions a library must not: **where a build lands, when to build, and how loudly
to say what went wrong**. What a site *is* lives in
[`plates`](https://github.com/diaryx-org/plates/tree/main/plates); what a page
*looks like* lives in
[`plates-render`](https://github.com/diaryx-org/plates/tree/main/plates-render).
Another application over the same archive format replaces this crate and keeps
the other two.

## The four verbs

| | |
|---|---|
| `plates build` | Render every site into `_site`. |
| `plates watch` | The same, then again on every change. |
| `plates serve` | A dev server on `http://127.0.0.1:4321`, each site under its own name, reloading when the archive moves. |
| `plates clean` | Remove what a build wrote. |

They are four views of one build. `build` writes it to a directory, `watch`
keeps writing it, `serve` hands it to a browser, and `clean` takes back exactly
what `build` put down — with one collector and one renderer underneath all four,
which is the property that stops a preview and a deploy drifting apart.

| Flag | Verbs | |
|---|---|---|
| `-C`, `--root DIR` | all | Run as if `plates` had started in `DIR` — the `git -C` model, entered once before anything resolves. Also `PLATES_ROOT`. |
| `-o`, `--out DIR` | `build`, `watch`, `clean` | Where the site lands. Defaults to `_site`; also `PLATES_OUT`. |
| `--site NAME` | `build`, `watch`, `serve` | Render one site. `build`/`watch` write it *at* the destination root and `serve` answers it at `/`, because someone who named one site asked for one site. |
| `--base-url URL` | `build`, `watch`, `serve` | The absolute URL the finished site will live at. |
| `--follow[=DEPTH]` | `build`, `watch`, `serve` | Follow foreign references into peer workspaces and mount their sites — see below. DEPTH counts boundaries crossed; prov's default without one. |
| `--peers FILE` | with `--follow` | The peer map to follow with, instead of this device's (`prov peer list` prints where that is). Also `PROV_PEERS`. |
| `--unverified` | with `--follow` | Also follow a peer whose name could not be confirmed. |
| `--force` | `build`, `watch`, `clean` | Write into (or empty) a directory holding files no build of ours wrote. |
| `--host`, `-p`, `--port` | `serve` | Defaults `127.0.0.1` and the first free port from 4321, so a second archive served alongside the first just works. |
| `--open` | `serve` | Open the site in a browser once it is up. |

`--base-url https://example.org` is what canonical links, the sitemap,
`robots.txt` and the feeds are written against. Without one they are skipped,
which is the right default for a preview whose address is `localhost`.

`_site` is underscore-prefixed by the static-site convention, which is not
decoration: it sorts away from the archive's own directories, and the hosts that
auto-publish a repository skip it, so a build committed by accident does not
become a second copy of the site.

## Mounting a peer

`--follow` makes one site out of several archives. A published page's foreign
reference — `[fig](id:fig/b9j9zgk)`, drawn in its `contents:` — into a
workspace this device's peer map names (`prov peer add fig ~/src/fig`) mounts
that workspace's export for the *same audience* at `/fig/`: its front page at
`/fig/index.html`, its pages and attachments below, its links to the origin
and the origin's links to it resolved across the boundary, and its outline hung
in the nav where the edge was drawn. `plates build` inside `fig` still builds
fig's site at `/`; the mounting site is the union.

Which export is mounted is the one in the peer's own config whose gate names
the origin site's field and value — one sharing the origin site's *name* if
several do, else the only one. A peer with none, an unknown peer, or one that
calls itself something else is a warning naming it, and the edge stays what an
unfollowed edge is: a link to a page this site does not publish. The origin's
shell frames every page; a peer's theme is not mounted.

A `watch` re-fingerprints the origin archive only. An edit inside a peer is
picked up on the next rebuild the origin triggers.

The rule is argued in
[`docs/proposals/mounting-a-peer.md`](https://github.com/diaryx-org/plates/blob/main/docs/proposals/mounting-a-peer.md).

## Declaring a site

**A site is an export.** prov's `exports.<name>` is already a named, gated set of
documents that may leave the archive, which is the whole of what a site needs to
exist. The render-facing half it cannot carry — a front page, a shell, a
stylesheet, a language, extra grammars — is written on the **term node** of the
gate field's vocabulary, which is where a value that is a document keeps what is
true of it.

So plates declares no config vocabulary of its own. What a site is, this archive
answers.

```yaml
# The config document, or the root's own `prov:` block.
exports:
  docs:
    label: plates                 # what a reader sees; defaults to the name, humanized
    gate:
      field: audience             # the document field the gate judges
      value: public               # the value that admits a document
    hold: draft                   # a page declaring `draft: true` waits; absent, nothing waits
    view: daily                   # a prov view, for the arrangement; default is containment
fields:
  audience:
    values: closed                # an unknown value is a `prov check` finding
    vocabulary: '[Audiences](/vocab/audiences.md)'
    reify: true                   # each term is a node, not a row
```

| Export key | |
|---|---|
| `gate` | **Required**, both halves. `field` is the document field judged and `value` is what admits a document; prov offers no default for either. A gate on `clearance` is not a special case, it is the gate. |
| `hold` | A document field the site reads a *not yet* out of. A page the gate admits that declares `true` under it stays off the site and is reported as held; absent, nothing is held. |
| `label` | What a person calls the site. Defaults to the name, humanized. |
| `view` | A prov view, by its key under `views:`. Its arrangement becomes the site's; absent, the gate's whole set arranged by containment. |

The entry's *name* is the site's path segment in every published URL —
deliberately not the gate value, since the honest name for a readership is
routinely one its members should never read off a URL.

### The term node

`reify: true` makes the vocabulary an index node whose `contents:` are the terms,
each an ordinary document with a body, a stable id and backlinks. The term whose
`term:` (or, absent that, `title:`) is the gate's value is the one plates reads:

```yaml
# vocab/public.md
---
title: Public
term: public
part_of: '[Audiences](/vocab/audiences.md)'
front_page: '[Home](id:7f3a91c)'
site:
  shell: .config/sites/docs/shell.html
  stylesheet: .config/sites/docs/style.css
  header: .config/sites/docs/header.md
  footer: .config/sites/docs/footer.md
  lang: en
  syntaxes:                       # grammars for languages the built-in 213 miss
    - .config/sites/docs/wat.sublime-syntax
---

Anyone; safe to publish. Everything here has left the archive on purpose.
```

| Key | |
|---|---|
| `front_page` | The page that greets a reader, as a link resolved **relative to the term node** — through prov's link layer, so it survives a rename, a move and a retitle. Absent, an index is synthesized from the site's entries. |
| `site.shell` | An HTML file with named slots, as an archive-relative path. `.config/sites/<name>/` is the recommended home, not a requirement. |
| `site.stylesheet` | A CSS file that *replaces* the built-in sheet rather than layering over it. |
| `site.header` | A document — Markdown, Djot or HTML — rendered above every page's content, through the same pipeline a body is: templated against the page's context, `:vis`-filtered for the audience, links rewritten to the page's depth. Not an entry: it never publishes as a page. |
| `site.footer` | The same, below every page's content and before the attribution line. `© :val[site.title] · [Source](https://…)` is a whole footer. |
| `site.lang` | BCP 47, for every page's `<html lang="…">`. Defaults to `en`, and a page carrying its own `lang:` overrides it for that page. |
| `site.syntaxes` | `.sublime-syntax` files for languages the built-in grammars do not cover, as archive-relative paths. A list, or a bare path for the one-item case. |

Every one of them has a defensible default, and the defaults are
`plates::SiteSpec`'s. A key inside `site:` that plates does not read costs the
site one setting and is reported; refusing to build the other four sites over it
would be a worse answer.

The render keys are nested under `site:` rather than written bare because a term
node's frontmatter is the author's: a top-level `shell:` is claimed by convention
only, and collides with a field the archive already had or with the next tool
that wants one. `front_page:` is exempt because it is not payload — it is a
relation, and a declared relation is its own registration.

A term node declaring no `audience:` of its own is published to nobody, which is
the default and the right one: an archive that wants its audiences described on
its own site opts in per term, deliberately.

An archive that declares no vocabulary at all keeps working throughout — every
render key takes its default and the front page is synthesized from the site's
entries.

### The deprecated `sites:` block

Until plates 0.2 a site was declared in a top-level `sites:` block, on the
argument that a site's spelling is a dialect a different host could replace
wholesale:

```yaml
sites:
  blog:
    label: Field notes
    audience: public
    gate_field: clearance        # the field `audience` is compared against
    view: daily
    index: '[Home](id:7f3a91c)'
    shell: .config/sites/blog/shell.html
    stylesheet: .config/sites/blog/style.css
    lang: en
    syntaxes:
      - .config/sites/blog/wat.sublime-syntax
```

It is read from the same two surfaces prov reads its own config from, in the same
order: the root document's frontmatter first, then the config document the root
links to, which wins. Where it exists it still wins outright over `exports:`,
block for block — an archive that has not migrated builds exactly what it built
before — and every site it declares is named in a warning alongside the export
entry and the term node that replace it. **plates 0.3 removes it.**

Migrating one site is two moves: `audience`/`gate_field` become
`exports.<name>.gate.value`/`.field`, and `index`/`shell`/`stylesheet`/`lang`/
`syntaxes` become `front_page:` and `site:` on the term node. `label` and `view`
keep their names and meanings under `exports.<name>`. `hold` has no `sites:`
spelling and will not get one — the block is frozen at what it read the day it
was deprecated, so a site that wants to hold its drafts back migrates.

## What a build remembers

A build records every path it wrote in `.plates-build` at the destination root.

Two problems, one answer. A rebuild after a document is deleted, renamed or taken
off a site leaves the old `.html` sitting in the destination, and it will be
deployed alongside the new ones: a page that is no longer part of the site, still
reachable, still in the sitemap of whatever indexed it. And `clean` given a
directory has no way to tell the site it built from a directory somebody typed
one character wrong.

So the next build removes what the last one wrote and this one did not, and
`clean` removes exactly the listed set. **Nothing ever deletes a file no build of
ours recorded writing** — which is what makes it safe to point `--out` at a
directory that also holds something else, and why `clean` refuses a destination
with no record rather than guessing. `--out` typed one character wrong is not a
way to delete somebody's work.

It is a dotfile because it ships with the site: a build directory *is* the
deployable artifact, and a memory kept somewhere else would be gone at exactly
the moment the next build needed it. Every static host and every web server
already declines to serve dot-prefixed files, so the record of the site is not
part of the site.

## `plates serve`

Three threads' worth of machinery, and no more. A **builder** thread owns the
archive — every prov future is `!Send`, so the workspace never crosses a thread
boundary; what crosses is the finished bytes. It re-opens the archive for each
build rather than reusing one, because a rebuild has to see files that did not
exist when the server started, including a changed config document, which is
where views and sites are declared. The **accept loop** hands each connection to
a short-lived thread serving from the current snapshot, so serving never blocks
on a build and a build never blocks a request; a request mid-rebuild is answered
from the previous snapshot, which is what a static host would do.

**Change detection** is a stat walk compared against the last one — no
filesystem-watch dependency, since the platform APIs disagree about what an event
is and need a fallback poll anyway for the network and synced volumes an archive
often lives on, and the whole of what is wanted here is one bit. It runs only
while a browser is actually watching, so an unattended `plates serve` costs
nothing, which matters when the archive is tens of thousands of files rather than
a blog.

Served HTML carries one thing a built page does not: a small script that polls a
build number and reloads when it moves. It is injected at serve time and never
written into a build, so `plates build` output is byte-identical to what the
server renders.

Attachments are never pulled through memory to render a page of text: collection
is told they are already accounted for and carries each one's path, length and
MIME type forward, so `build` copies them and `serve` reads one when a browser
asks for it.

## Features

| Feature | |
|---|---|
| `yaml` *(default)* | `---` frontmatter, `registry.yaml` |
| `json`, `toml`, `fig-lang` | the other metadata dialects |

Forwarded to `plates` and `plates-render`, which forward to `prov`, which
forwards to `fig`. With a format off, its parser is left out of the build and
`prov` stops recognizing it, so at least one must be on. `plates-render` is taken
*with* its `templating` feature here: running the render pipeline is the caller's
job, and this is the caller — without it, a body's template directives would be
published as literal text.

`prov` is deliberately not a dependency of this crate. Workspace discovery, the
config document and the id registry are all reached through `plates::prov`, which
`plates` re-exports for exactly this reason: one prov in the tree, and no way for
this crate to resolve a different version of the `Workspace` it hands to
`collect_site`.

## License

MIT or Apache-2.0, at your option.