plates-cli 0.5.0

Command-line static site generator over a prov archive: build, watch, serve and clean.
plates-cli-0.5.0 is not a library.

title: plates-cli part_of: 'plates' audience: public

plates-cli

The plates command: a static site generator over a 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 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; what a page looks like lives in 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.
--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.

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.

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

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

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.