Publishing

Two commands: site check audits what the build produced, and site push sends it. push runs the audit itself, so most days the first one is only for when you want to look.

Checking

site check

Four rules, all of them reading the built output:

RuleFinds
linksinternal links that resolve to nothing
secretsemails, phones, addresses, account numbers, keys in the HTML
SEOmissing descriptions, overlong titles, alt-less images
externaloff-site links that answer 4xx or 5xx (opt-in)

Reading emitted HTML is a deliberate exception to how the rest of this tool works. It is right here for one reason: an audit’s whole job is to see what a browser will see. The distinction that matters is that nothing in it writes. A pass that reads the output to fix the output is the post-build rewrite this design exists to avoid; a pass that reads the output to complain about it is a different thing entirely.

Warnings print and pass. Errors print and exit 1, so it drops into CI unchanged.

Tuning it

The link sweep has no severity, because a broken internal link is always a defect. The rest are configurable:

[check]
seo            = "warning"   # error | warning | off
external_links = "off"

[check.secrets]
email = "public"             # error | warning | public
phone = "public"

public is the interesting one. A regex cannot know that the address on your contact page is deliberate — public is how you tell it, and it is better than turning the rule off.

External links

site check --external

Sweeps every off-site link, eight at a time, and caches the answers for a day in .site/. Add that directory to your .gitignore.

Feeds and sitemaps

Both are written automatically, and both need [site] url — their entries are absolute URLs by specification, and there is nothing to build one from if the site doesn’t know its own address. Without that key neither is written at all, which is the honest outcome: a feed of relative links is worse than no feed.

FileWhat it is
/articles/feed.xmlan Atom feed of the articles section
/sitemap.xmlevery published, listed page

The feed is written by the same pipeline step that renders the article index, so it sees exactly the items the index does — already filtered, already sorted. A feed assembled from a second pass over the output is a feed that can disagree with the site.

Every page links the feed from its <head>, so a reader’s client finds it from anywhere rather than only from the page that mentions it.

The sitemap is registered last, because it renders from the list of pages the pipeline has generated and can only see steps that ran before it. It lists HTML pages only — generatedPages includes the feed too, and a sitemap advertising a feed as a page of the site is a crawler error nobody notices until it happens.

Unlisted sections

[content]
unlisted = ["book.okf"]

Published, but not advertised. The URL works for anyone who has it; the site simply doesn’t hand it to anyone who doesn’t:

  • no tab in the navigation,
  • no entries in the search index,
  • no rows in the sitemap,
  • and <meta name="robots" content="noindex"> on every page — the one signpost the site doesn’t own, so it has to be asked for rather than removed.

noindex alone, not noindex, nofollow: the links inside an unlisted book point at other pages of the same unlisted book, every one of which carries the same tag.

This is discretion, not a permission. Nothing authenticates anything, and an unlisted URL that gets shared is public. A page that must not be read by a stranger belongs in withhold, which doesn’t publish it at all.

site check warns about an entry naming a folder no section claims. Unlisting is answered by the section that owns the folder, so an entry nothing answers for is inert — and its silence looks exactly like success.

Publishing

site push

Reads [deploy], and does everything knowable before the build first:

  1. resolve the transport and pre-flight it
  2. build (unless --no-build)
  3. refuse to send an empty output
  4. audit (unless --no-check)
  5. send

Step 1 before step 2 is the whole point. Learning about an unreachable remote after a full build is just a slower way to learn it — and with delete = true, a half-configured push is how a live site gets replaced by nothing.

Step 4 is the last moment a broken link or a leaked key is still cheap. Afterwards it is on the internet, and taking it down is not the same as never having published it. --no-check exists for the deploy you are making because something is broken.

--dry-run shows the transfer without performing it.

Transports

[deploy]
method = "rsync"
target = "user@host:/var/www/site"
delete = true
[deploy]
method = "github-pages"
branch = "gh-pages"
cname  = "example.com"
[deploy]
method       = "s3"
bucket       = "example-site"
region       = "us-west-2"
profile      = "personal"
distribution = "E123ABC"     # CloudFront to invalidate

Each transport owns its own validation, so a bad manifest is one message rather than a failure discovered mid-transfer. site pull mirrors the live site back down — rsync and s3 only; a published Pages branch already lives in git, which has a better answer (git clone -b).

S3 uploads in cache classes: content-hashed files get a year, other assets a day, and anything a reader navigates to must revalidate. It also warns before uploading if the bucket has no website configuration and no CloudFront distribution, because S3’s REST endpoint doesn’t map /about/ to /about/index.html — every page below the root would 404, and only after the upload.

Credentials

Never in Site.toml. That file is committed, and a key that reaches it is a key to rotate — so push refuses a manifest containing anything shaped like one.

They go beside it, in a git-ignored Site.secrets.toml:

[env]
AWS_ACCESS_KEY_ID     = "AKIA…"
AWS_SECRET_ACCESS_KEY = { keychain = "site-aws" }
DEPLOY_TOKEN          = { file = "~/.config/site/token" }

Everything under [env] is set in the environment of the child processes a deploy spawns — aws, rsync, git — and nowhere else. Not on a command line, where ps would show it to every user on the box.

The indirections exist so the file itself can hold no literal either. keychain is the good one on macOS: the value lives in the login keychain, the repo holds a name, and a stolen backup holds neither.

security add-generic-password -s site-aws -a "$USER" -w

If git turns out to be tracking that file, push stops rather than continuing — a secret already in the history is a secret to rotate, not one to keep using. check warns about the softer version: a secrets file that exists and no .gitignore rule covers.

The best case is still an empty [env]. profile names an AWS profile and lets the CLI resolve the credential through its own chain, so this tool never handles the secret at all.