eratic — operating manual
This file is for whoever is doing the work: usually an LLM, occasionally a
human. Read it before touching anything. It is deliberately concrete — exact
paths, exact frontmatter, exact commands — because the whole point of this
setup is that publishing does not require remembering how it works.
What this is
Four static sites, one repository, one small server.
| Site |
Address |
What goes there |
| home |
eratic.ro |
Front door. Shows the latest few things from the other three. Nothing is authored here. |
| blog |
blog.eratic.ro |
Essays and photo sets, in one feed. |
| fav |
fav.eratic.ro |
Objects, books, films, music — with notes. |
| io |
io.eratic.ro |
One issue a week of things read/watched/heard. |
Astro 7, no JavaScript shipped unless an article asks for it, no database, no
CMS, no third-party anything. Content is Markdown files in git. Deploying is
npm run deploy.
The five commands
npm run dev:blog # local preview at :4321 (also dev:home, dev:fav, dev:io)
npm run build # build all four, after checking photos for GPS
npm run check # TypeScript + Astro diagnostics; must be clean
npm run deploy # build, upload, go live, verify
./deploy/rollback.sh # back to the previous release, in seconds
Drafts and future-dated entries appear in dev and are excluded from build.
That is the whole staging story — there is no staging server.
Adding things
A blog post
sites/blog/src/content/posts/<year>/<slug>.md → blog.eratic.ro/<year>/<slug>/
---
title: On making a place instead of a profile
summary: >-
One or two sentences. Required. Used as the meta description, the listing
blurb, and the text feed readers get when the full body cannot travel.
pubDate: 2026-08-05
tags: [meta, design]
---
Body in Markdown.
Optional: updatedDate, draft: true, cover: ./images/x.jpg + coverAlt,
canvas, theme (see Designing a single article).
Use .md unless you need components. Plain Markdown is rendered to HTML at
build time, so the whole post travels in the RSS and JSON feeds. An .mdx post
compiles to a component instead, so subscribers get the summary and a link.
That is a real trade-off, not a technicality — most posts should be .md.
A photo set
sites/blog/src/content/photos/<year>/<slug>.mdx, images in
sites/blog/src/content/photos/<year>/images/.
---
title: Harbour, early
summary: Three frames from a morning at the water.
pubDate: 2026-08-03
place: Constanța # a place name, never coordinates
columns: 2 # 1, 2 or 3
exif: true # show camera settings under each frame
photos:
- src: ./images/harbour-01.jpg
alt: A wide view of still water with moored boats # required
caption: The water had not decided what colour it was yet.
---
Optional prose after the grid.
Photo sets share the blog's feed and URL space. Camera settings are read from
the files at build time; the served images carry no metadata at all.
A fav
sites/fav/src/content/favs/<year>/<slug>.md
---
title: A Pattern Language
summary: Required, as everywhere.
pubDate: 2026-08-02
kind: book # object | book | film | music | other
creator: [redacted]topher Alexander et al.
year: 1977 # when the thing was made
rating: 5 # 1–5, optional — omit and nothing renders
link: https://… # optional, never an affiliate link
---
The review.
kind drives the filter pages (/books/, /films/, …), which appear
automatically once a kind has something in it.
An io issue
sites/io/src/content/issues/<year>/<number>.md
---
issue: 31
summary: Required.
pubDate: 2026-08-03
links:
- title: Practical Typography
url: https://practicaltypography.com/
kind: read # read | watched | listened | other
via: a friend # optional, credit costs nothing
note: Why it is here.
---
Optional intro.
title is optional here and nowhere else — without one it renders as
"Issue 31". The links are frontmatter rather than prose because io's feed
carries the full list, rendered; the web page is the copy.
Designing a single article
The design system is a floor, not a ceiling. Two escape hatches, both
frontmatter:
theme — re-declare any token from
packages/core/src/styles/tokens.css for this entry only:
theme:
accent: 'oklch(64% 0.19 42)'
font-prose: "'Fraunces Variable', Georgia, serif"
measure: '40rem'
surface: 'oklch(97% 0.02 85)'
Write keys without the leading --. They are applied as inline custom
properties on <html>, so everything inside re-tunes coherently.
canvas — how much of the site to keep:
article (default) — reading column, header, nav, footer
wide — same, but the body starts in the 52 rem track
bare — an empty page. Tokens and fonts loaded, nothing else: no header,
no footer, no prose styles, no measure. For pieces where the article is
the design.
Components inside .mdx
Available without any import: <Figure>, <PhotoGrid>, <Video>,
<Bleed>, <Aside>. Explicit imports still work and take precedence — that
is how you use a component built for one article.
<Figure src={frontmatter.cover} alt="…" width="wide" exif />
<Aside label="Note">A note in the margin on wide screens.</Aside>
<Bleed width="full">…anything that needs the whole viewport…</Bleed>
width is content | wide | full on all of them.
Interactive elements
Astro <script> tags are bundled to external files, so they work under the
site's Content-Security-Policy. Inline <script> will be blocked — this is
deliberate, and it is what stops a third-party tracker from ever being added by
accident. Inline <style> and style= attributes are fine.
Rules that are enforced, not just requested
- No GPS in photographs.
npm run build runs scripts/photos-check.mjs
first and fails, naming the files and their coordinates, if any source image
carries location data. Fix at export time in your photo editor, or run
npm run photos:strip for files already committed. See docs/06-privacy.md.
- Alt text is required on every photo. The schema rejects entries without
it.
summary is required on every entry, everywhere.
- No third-party requests. The CSP in
deploy/eratic.caddy blocks them, so
an accidental external font or script fails visibly instead of quietly
tracking people.
- Video never goes in git. See
docs/05-media.md.
Things that will waste your afternoon
Learned the hard way while building this. All are fixed; do not undo them.
- Astro 7 uses Sätteri, not remark/rehype, as its Markdown processor.
Plugin config from older Astro guides does not apply.
@fontsource-variable/<face>/index.css is the weight axis only.
Newsreader's optical-size axis is in opsz.css, and font-optical-sizing
does nothing without it — headings silently render as inflated body text.
prose.css must be imported in BaseHead.astro. It is scoped under
.prose; loading it per-layout is how articles end up in the interface sans
with no paragraph spacing.
- Never bind-mount a single config file into a container. Docker pins it to
an inode; rsync replaces the file, and the container keeps serving the old
contents while the host shows the new ones. Mount the directory. This applies
to
/opt/web/caddy/etc.
<Image> falls back to the original file; <Picture> does not. The
original is the one copy that still has EXIF, which is why Figure.astro
uses <Picture fallbackFormat="webp"> and why the build prunes orphaned
originals from dist/.
- sharp names EXIF blocks positionally —
IFD0, IFD2 (exposure),
IFD3 (GPS) — and uses exiv2 tag names, so ISO is ISOSpeedRatings.
Writing ExifIFD or ISO succeeds and silently stores nothing.
- Caddy's global options block must be first in the file, before any
comment, or every reload warns that the input is not formatted.
- Astro's content store lives in
sites/<site>/node_modules/.astro, not in
sites/<site>/.astro. Delete an entry and the build keeps failing with
ImageNotFound for a photo that no longer exists, because the store still
has it. npm run clean covers both locations; deleting .astro alone does
nothing.
- Vite inlines assets under 4 kB as
data: URIs, which the CSP's
font-src 'self' then blocks. packages/core/astro.mjs forces fonts to stay
real files. Without it exactly one small subset breaks, silently.
Showing and hiding a whole section
Each site in packages/core/src/config.ts has a listed flag. false means
the section is still built, still deployed and still reachable at its own
address — it is simply not linked from the header, the home page or the
footer. That is how fav and io are currently parked: no dead ends, no
"coming soon" page, and one line to flip when there is something there.
What to write next
IDEAS.md — essay ideas distilled from the note archive, each with a thesis, a
suggested design treatment and a rough length. Read it before starting a post.
Layout
packages/core/ the design system — tokens, layouts, components, feeds
src/config.ts identity, the four sites, media resolution ← start here
src/styles/tokens.css every value the design depends on
sites/{home,blog,fav,io} one Astro project each
deploy/ bootstrap.sh, deploy.sh, rollback.sh, eratic.caddy
scripts/ photos-check, photos-strip, prune-originals
docs/ the runbooks
Identity, domains and the media base live in packages/core/src/config.ts.
Change them there and nowhere else.
Infrastructure, briefly
A dedicated VPS at 178.104.116.235 (ird tier 0x), Ubuntu 24.04, firewall
open only on 22/80/443. Caddy owns 80/443 and gets TLS automatically. Releases
are directories under /opt/web/sites/eratic/releases/, and current is a
relative symlink; deploying flips it atomically, rolling back flips it back.
It is not the server the Agora API runs on, and adding a second domain
later means one file in /opt/web/caddy/etc/conf.d/ plus a DNS record.
Full detail in docs/03-deploy.md. DNS in docs/04-dns-and-tls.md.