Customization
Masthead is an EmDash site: content, navigation, and site identity live in the database, not in code. You mostly edit through the admin UI (/_emdash/admin); the files below are for what the admin UI doesn’t cover.
Site identity and navigation
Admin UI → Settings: site title, tagline, logo, favicon. Read in code with getSiteSettings().
Admin UI → Menus: header links, footer links, the podcast RSS link. Read with getMenu().
Section rails on the home page come from the category taxonomy. If you install without sample content, create your sections in the admin UI first — the home page hides any section with no published terms, so an empty install shows no rails until you add them.
Words and language
Every word the theme prints is in src/utils/ui.ts: the skip link, the header and footer labels, the archive pager, bylines and reading time, the article footer, search, the 404 page, and in Pro the paywall, the sign-in form and email, the members page and the podcast pages. The site’s title and tagline stay in the admin, the show’s details in src/utils/podcast.ts.
To publish in another language, set locale at the top of ui.ts (it sets <html lang>, the feeds’ <language>, dates and name lists) and translate ui. Counts are functions, so plurals stay right. Strings marked HTML carry a link and are printed as markup: keep the tags when you translate them.
Colors, rules, and type
src/styles/theme.css overrides the tokens defined in src/styles/tokens.css (don’t edit that file directly). The palette is one accent color on ink and paper — don’t add a second accent or colored section backgrounds. Preview changes at /styleguide (dev only, 404s in production).
After any color change, run pnpm check:contrast — it checks every text token against every background token, light and dark, for WCAG AA (4.5:1).
Webfonts
Declared under fonts: in astro.config.mjs, bound to --font-body, --font-heading, and --font-display CSS variables. All three are served from Fontsource (no external font CDN links). Swapping a font is a matter of changing the name and matching CSS variable in theme.css; check licensing before adding a non-OFL font (see THIRD-PARTY-NOTICES.md).
Publication line (Vol./No. and date)
src/utils/edition.ts, the FOUNDED constant — set it to your first issue date. The footer line (“Friday, September 18, 2026 · Vol. 1, No. 257”) computes the volume (turns over every year since FOUNDED) and issue number (one per day since FOUNDED, the way daily papers count) from that single date, in UTC.
Caching
Public pages are cached at the edge by Workers Caching ("cache": { "enabled": true } in wrangler.jsonc, the cacheCloudflare() provider in astro.config.mjs). A page opts in by calling Astro.cache.set(...) with its query’s cacheHint and PAGE_CACHE from src/utils/cache.ts (an hour, then served stale for up to a day while it refreshes in the background). Editing content purges the pages tagged with it, so the TTL mainly limits how long the footer’s edition date can lag.
Everything else is marked Cache-Control: private, no-store in src/worker.ts, because Workers Caching also stores responses that carry no cache headers. Keep it that way: a cached page is served to every visitor, so a page that opts in must not depend on who is signed in.
EmDash’s editor toolbar follows the same rule. toolbar: "client" in astro.config.mjs keeps public HTML identical for everyone; browsers signed in to the admin get a small Edit pill that reloads the page with an _edit parameter, which is never cached and carries the full toolbar. With the default "server" mode EmDash keeps toolbar pages out of the cache itself, but editors then see the toolbar only on pages that were not already cached.
Schema, taxonomies, and sample content
seed/seed.json — collections, fields, taxonomies, bylines, menus, and demo content. It’s applied once, at setup, not on every deploy. To change the schema after setup you need a fresh database (pnpm reset locally); in production that means a new D1 database. A field removed from the seed drops its column and every value in it. Field types can only be changed among string, text, and slug — anything else is a content migration. New fields must be optional so existing rows stay valid.
Podcast
Pro only. src/utils/podcast.ts, the PODCAST constant — one show per site: title, description, author, ownerEmail (directories send here, so use an address you check), Apple Podcasts category/subcategory, and cover.
Set cover explicitly. Left empty, the feed falls back to the newest episode’s cover image — but uploaded images are converted to WebP, which Apple Podcasts rejects. cover must be a JPEG or PNG, 1400 to 3000 px square, as a URL or a path under public/.
Episode audio plays from audio_url when set (externally hosted), otherwise from the uploaded file via /audio/<key>, which answers byte-range requests (required for the Apple Podcasts player and for scrubbing). That route serves every byte through the Worker; if a show gets enough traffic for that to matter, switch to serving the R2 bucket from a custom domain instead (see the ponytail: comment in src/pages/audio/[...key].ts).
Membership and paywall
Pro only. Sign-up and sign-in are both a single email-link flow at /members — no password, no separate registration step. Any signed-in account can read access: members content; there are no membership tiers. An article’s free_blocks field controls how many content blocks show before the paywall (default 3, set per article in the admin UI). Episode pages hide the player and transcript instead of truncating. Lists (home page, archives, podcast, related stories) mark these items “Members only” in the meta line; the label is the members prop of src/components/PostCard.astro.
Sending member emails (magic links) requires the Cloudflare Email Sending plugin — see deploy.md.
Forms and sections
Pro only. Forms come from the official forms plugin (@emdash-cms/plugin-forms): build them in Admin > Forms, then place one in any article or page with the editor’s Form block. Submissions are listed and exported in Admin > Forms > Submissions; notification emails go out through the same Cloudflare Email Sending setup as member sign-in. The theme styles the forms in src/layouts/Base.astro (search for .ec-form). Forms submit with JavaScript; with scripts off, the form shows but does not send.
Sections are reusable blocks editors insert with /section. The seed ships two: “Newsletter signup” (heading, one line, and the newsletter form) and “Editor’s note”. Add your own in Admin > Sections. The two sample forms are created by seed/forms.sql — see deploy.md.
Page layouts
Pro only. Each page has a Layout field in the admin UI:
- Standard — the reading column. The seed’s corrections policy uses it.
- Cover — a front-page title in the display face over a double rule, and the opening paragraph as a drop-capped lede. The seed’s About page uses it.
- Sign-up — the page text as the pitch, with a member sign-up box beside it (above it on phones). The seed’s “Read the whole paper” page (
/pages/join, linked from the footer) uses it.
All three are in src/pages/pages/[slug].astro. To add one, add an option to the pages template field (in seed/base.json for new sites, in the Pages collection settings in the admin for an existing one), then a .page--<option> rule in that file.
OG images
Pro only. src/pages/og/[slug].png.ts generates a 1200×630 image per article at request time with Satori. Satori is pinned to 0.32.0 — newer versions fail on Cloudflare Workers.