Overview#
Ledger is an editorial magazine for long-form writing, built around a fictional independent publication, The Ledger, that has published essays about software, design, craft and attention since 2019. It has a featured front page, a long-form article layout with a table of contents and reading progress, a searchable archive, topic pages, an about page with the masthead, a contact page and a 404 page, in light and dark themes. This is the Astro version (1.0.3): Astro 7, TypeScript and Tailwind CSS v4, with no UI framework. Essays live in a typed content collection, and the site builds to static HTML that looks and behaves like the HTML version. Live demo: https://freeframe.dev/templates/ledger
Quick start#
Requirements: Node.js 24 or newer and pnpm (npm works too).
pnpm install
pnpm dev # http://localhost:4321With npm: npm install, then npm run dev.
| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:4321 with hot reload |
pnpm build |
Static site in dist/ |
pnpm preview |
Serve dist/ locally after a build |
pnpm check |
astro check: type-checks .astro files and TypeScript |
pnpm typecheck |
Generate Astro's types, then type-check with tsc (TypeScript 7) |
pnpm lint |
ESLint (TypeScript and Astro rules) |
pnpm build:demo |
Build for /demos/astro/ledger into demo/ (only used for the demo on freeframe.dev) |
Pages#
| File | URL | Purpose and notable sections |
|---|---|---|
src/pages/index.astro |
/ |
Featured essay, topic chips with counts, "Latest" grid, pull quote, "Short notes", "Most read", newsletter sign-up |
src/pages/article.astro |
/article |
One long-form essay: reading progress, table of contents, share buttons, pull quote, chart, footnotes, author box, "Keep reading", newsletter sign-up |
src/pages/archive.astro |
/archive |
Every essay grouped by year, live search, topic filters, shareable URLs |
src/pages/topics.astro |
/topics |
One card per topic with its essay count and latest essay |
src/pages/about.astro |
/about |
Mission, writers, "What we believe", the masthead and a call for writers |
src/pages/contact.astro |
/contact |
Contact and pitch form, pitch guidelines, email addresses, newsletter sign-up |
src/pages/404.astro |
any unknown URL | Not-found page with an archive search (not indexed by search engines) |
Project structure#
ledger/
astro.config.mjs site URL, base path, static output as archive.html (not archive/index.html)
package.json scripts and dependencies
pnpm-workspace.yaml keeps this folder a standalone pnpm project; allowed install scripts
tsconfig.json Astro's strict TypeScript preset
eslint.config.mjs ESLint flat config (typescript-eslint + eslint-plugin-astro)
template.json gallery metadata (name, version, pages)
public/img/ cover art, writer avatars, favicon.svg, og-image.svg and og-image.png
src/
content.config.ts the "posts" content collection and its schema
content/posts.json every essay: title, dek, topic, author, date, reading time, cover
data/nav.ts header links
data/topics.ts the five topics and their descriptions
lib/site.ts site name, theme colours and the url() helper
lib/posts.ts getPosts(), pick() and date/avatar helpers
layouts/Layout.astro <html>, <head> metadata, theme script, header, <main>, footer, toast, scripts
components/
SiteHeader.astro top strip, logo, navigation, theme toggle, subscribe button, mobile menu
SiteFooter.astro about blurb, topic links, publication and follow links
PostCard.astro an essay card with cover art
pages/*.astro one file per page (see Pages)
scripts/main.ts theme, menu, reveals, toast, article tools, archive, form validation
scripts/back-to-top.ts the back-to-top button
styles/global.css Tailwind import, colour variables, @theme tokens and component classesCustomising#
Brand and colours#
Colours are CSS variables at the top of src/styles/global.css: :root for light mode and .dark for
dark mode. @theme inline maps them to Tailwind utilities (bg-paper, bg-surface, text-ink,
text-muted, border-line, text-accent, bg-accent-soft).
| Variable | Used for |
|---|---|
--paper |
Page background |
--surface |
Cards, inputs, chart panel |
--ink, --muted |
Text and secondary text |
--line |
Rules and borders |
--accent, --accent-soft |
Kickers, links, focus rings, success messages |
:root { --accent: #1d4ed8; --accent-soft: #e0e8fb; }
.dark { --accent: #7aa2ff; --accent-soft: #1a2240; }If you change --paper, update themeColor in src/lib/site.ts (it fills the two theme-color meta
tags).
Typography#
Headlines and article text use Newsreader (--font-serif), interface text uses Inter
(--font-sans) and code uses JetBrains Mono (--font-mono), loaded from Google Fonts by the
<link rel="stylesheet"> in src/layouts/Layout.astro. Replace that link and the tokens in @theme to
change them. Article typography is the .prose-ledger component in global.css.
Content#
Essays are entries in src/content/posts.json, newest first or in any order (they are sorted by
date). src/content.config.ts validates every entry at build time, so a typo in a topic or a missing
field stops the build with a clear message.
{
"id": "defaults-are-decisions",
"title": "Defaults are decisions",
"dek": "Every setting you ship unchanged is a choice made on behalf of millions of people.",
"topic": "Design",
"author": "Jonah Reyes",
"date": "2026-08-12",
"minutes": 9,
"cover": "/img/cover-blocks.svg"
}topic must be one of the names in src/data/topics.ts; cover is optional and points at a file in
public/. Pages read the collection through getPosts() in src/lib/posts.ts:
| Where | What it shows |
|---|---|
| Front page | The newest essay as the featured essay, the next six under "Latest", topic counts, and the "Short notes" and "Most read" lists picked by id in the page's frontmatter |
| Archive | Every essay, grouped by year |
| Topics page | Each topic's count and newest essay |
| Article | "Keep reading", picked by id in the page's frontmatter |
To change a hand-picked list, edit the ids passed to pick():
const mostRead = pick(posts, ["the-quiet-economics-of-open-source", "defaults-are-decisions", "in-praise-of-boring-technology-revisited", "writing-documentation-people-actually-read"]);The featured essay's portrait comes from its author: "Jonah Reyes" uses /img/avatar-jonah-reyes.svg
(see avatar() in src/lib/posts.ts).
Topics are in src/data/topics.ts (name and description). They drive the front page chips, the topic
cards and the footer links; the archive's filter chips are built from the topics its essays use.
The article. src/pages/article.astro is the layout for one essay. The text inside
<div class="prose-ledger" data-article> drives:
- Reading time: the words a reader reads ÷ 230, written into every
[data-reading-time]. Words are counted per text node (so words in neighbouring tags never run together), leaving out SVG text (the chart's title, description and labels), scripts, styles,hidden/aria-hiddenelements and the notes; captions and code count. - Table of contents: built from the article's
<h2>headings (ids are generated from the text if missing). Adddata-toc-skipto an<h2>to leave it out, as the "Notes" heading does. - Footnotes: a reference
<sup><a href="#fn1" id="ref1" aria-describedby="notes-label">1</a></sup>and a note<li id="fn1">... <a href="#ref1" aria-label="Back to reference 1">↩</a></li>in the Notes list. - Code: hand-coloured with
tok-c(comment),tok-k(keyword),tok-s(string) andtok-f(function) spans; there's no automatic highlighter.
Every essay links to /article. To give each essay its own page, add a dynamic route such as
src/pages/essays/[id].astro with getStaticPaths() over getPosts(), move the article markup into it,
and change the url("/article") links in PostCard.astro, the pages and src/scripts/main.ts.
Other pages (about, contact, the article body) are plain markup in their .astro files.
Images and artwork#
Cover art is SVG in public/img/cover-*.svg (3:2) and writer avatars are public/img/avatar-*.svg
(square). Replace them with images of the same ratio; covers are set per essay by cover. The chart in
the article is inline SVG with a <title> and <desc>; edit its bars and both text descriptions
together. The favicon is public/img/favicon.svg; the social image is public/img/og-image.png
(1200×630), exported from og-image.svg.
Navigation, header and footer#
The header, top strip and mobile menu are src/components/SiteHeader.astro; both menus are rendered
from mainNav in src/data/nav.ts:
export const mainNav = [
{ key: "home", href: "/", label: "Home" },
{ key: "archive", href: "/archive", label: "Archive" },
// ...
] as const;A page highlights its link by passing the matching key to the layout (current="archive"); the mobile
link also turns accent-coloured. To add a page, create src/pages/<name>.astro with a <Layout> and add
it to mainNav. The footer is src/components/SiteFooter.astro.
Dark mode#
An inline script in the <head> of src/layouts/Layout.astro (<script is:inline>) adds the dark
class to <html> before the first paint, from the saved choice or, if there is none, the system
setting. The toggle (data-theme-toggle, with aria-pressed) switches it and saves the choice (see
Saved data). Dark styles use the .dark colour variables and the dark:
variant. To default to dark, change the condition in the inline script to t !== "light".
Features#
All behaviour is in src/scripts/main.ts (and src/scripts/back-to-top.ts), imported once by the
layout.
Reading progress and table of contents#
On the article, the bar at the top ([data-progress], added by the layout's progress prop) grows as
you read through the article. The table of contents is filled into every <ul data-toc> (a sticky
sidebar on wide screens, a collapsible <details> on small ones, which closes after you pick a heading)
and highlights the section you're reading with aria-current.
Share buttons#
Links with data-share="x", "linkedin" or "email" get share URLs for the current page and title.
data-copy-link copies the page address (without the #hash) and shows a "Link copied to clipboard"
toast ([data-toast], announced politely).
Archive search and filters#
src/pages/archive.astro renders every essay at build time and also writes them as JSON into
<script type="application/json" id="posts-data">. In the browser, typing in [data-archive-search]
matches titles, deks, authors and topics; the topic chips (aria-pressed) narrow by topic; results are
grouped by year with a count ([data-archive-count]) and an empty state with a reset button
(data-archive-reset). The URL keeps the state, so /archive?topic=Design&q=notes can be shared (the
topic is matched case-insensitively; unknown topics fall back to "All"). Press / to jump to the
search box. The search box on the 404 page submits to /archive?q=....
Scroll reveals#
Elements with reveal fade up when they scroll into view. They are only hidden while JavaScript is
running (.js .reveal) and show immediately with reduced motion.
Mobile menu#
The menu button (data-menu-toggle) opens the menu named in its aria-controls, swaps its icon and
label, and closes with Esc (returning focus to the button), a link click or on wide screens.
Back to top button#
src/scripts/back-to-top.ts adds a button in the bottom-right corner, styled by the .back-to-top
rules at the end of global.css. It appears after about one screen of scrolling, scrolls to the top
(instantly with reduced motion), moves focus to the main content, lifts above the copy-link toast and
steps aside while an in-page "Back to top" link is on screen. Add data-back-to-top-avoid to anything
you pin to the bottom of the screen, or data-back-to-top-hide to a sticky element it must never cover.
To remove it, delete the file, its import in Layout.astro and the CSS rules.
Forms#
| Form | Page | Fields |
|---|---|---|
| Contact and pitch | contact.astro |
name (2+ characters), email, topic, message (30+ characters), consent |
| Newsletter | index.astro, contact.astro |
email |
| Archive search | 404.astro |
q (sent to /archive) |
Forms with data-validate check required fields, email format and minimum lengths. Each field's message
goes into the element with id <field id>-error, using the field's data-label (for example "Please
enter your email address."), and the first problem is focused. On success the form's
[data-form-success] message is shown and focused, and the form resets.
Nothing is sent yet. The site is static, so use a form service. To connect a form to Formspree:
-
Add the endpoint to the form in the page:
html<form action="https://formspree.io/f/your-id" method="post" class="space-y-5" data-validate novalidate aria-labelledby="form-title"> -
In the "Form validation" section of
src/scripts/main.ts, find the comment "Send the data to your form endpoint here" and post the data before showing the success message:tsfetch(form.action, { method: "POST", body: new FormData(form), headers: { Accept: "application/json" } }).then((res) => { if (!res.ok) throw new Error("Request failed"); const success = $("[data-form-success]", form); if (success) { success.hidden = false; success.focus(); } form.reset(); }); -
Update the success message text in the page. For the newsletter, use your email provider's form endpoint instead.
If you deploy with an Astro adapter instead, you can post to your own endpoint in src/pages/api/.
Saved data (localStorage)#
| Key | Value | Purpose |
|---|---|---|
ledger-theme |
"dark" or "light" |
The reader's theme choice |
Any other value is ignored and the site follows the system setting; the next click on the toggle
overwrites it. If storage is blocked, the toggle still works for the current page. Rename the key in
src/scripts/main.ts and in the inline script in Layout.astro. Reset with
localStorage.removeItem("ledger-theme").
SEO and sharing#
Each page passes title and description to <Layout>; the layout writes the <title>, meta
description, canonical URL, og:* tags (og:type is article on the article page) and
twitter:card. Canonical and og:url use the clean URL (/archive, not /archive.html), and
og:image is the absolute URL of /img/og-image.png. Both are built from site in astro.config.mjs,
which reads SITE_URL:
SITE_URL=https://ledger.example.com pnpm buildThe 404 page passes noindex, which outputs <meta name="robots" content="noindex"> and no canonical.
The archive's list is in the HTML, so search engines can follow it. For a sitemap, add the official
@astrojs/sitemap integration; for an RSS feed, @astrojs/rss can read the same posts collection. An
Article schema on the article page is a good next step for structured data.
Accessibility#
- Skip link,
banner/main/contentinfolandmarks, a labelled region for the top strip, one<h1>per page and visible focus rings. - Text colours meet WCAG AA contrast in both themes.
- The mobile menu uses
aria-expanded/aria-controls; the theme toggle and topic chips usearia-pressed; the current table-of-contents link getsaria-current. - Archive counts, form success messages and the toast are announced (
aria-live/role="status"). - Form errors are linked with
aria-describedby; the first invalid field is focused. - The article's chart has a text alternative, footnotes link both ways, and the code sample can be scrolled with the keyboard.
- Reveals, the progress bar and transitions respect
prefers-reduced-motion.
When editing, keep one <h1> per page, give new inputs a <label> and an error element, and keep
aria-current coming from current rather than hard-coding it.
Building and deploying#
pnpm build # static site in dist/
pnpm preview # preview dist/ locallydist/ is plain HTML, CSS and JavaScript. Pages are written as archive.html rather than
archive/index.html (build.format: "file" in astro.config.mjs), and links point to /archive
without a trailing slash. Netlify, Cloudflare Pages and GitHub Pages serve /archive from
archive.html automatically; Vercel does with cleanUrls (below).
- Netlify / Cloudflare Pages: build command
pnpm build, publish directorydist. - Vercel: import the repository; it detects Astro and static output needs no adapter. Add a
vercel.jsonwith{ "cleanUrls": true }so/archiveservesarchive.html. - GitHub Pages: use the official
Astro GitHub Action. For a project site
(
username.github.io/repo), build withDEMO_BASE_PATH=/repo. - Any static host: upload the contents of
dist/. If the host doesn't map/archivetoarchive.html, changebuild.formatto"directory"inastro.config.mjs.
To serve the site from a sub-folder, set DEMO_BASE_PATH when building, e.g.
DEMO_BASE_PATH=/ledger pnpm build. It becomes Astro's base, and every link written with url()
(including the archive rows built in the browser) includes it.
demo/ (if present) is the build used for the freeframe.dev demo; you can delete it.
TypeScript 7 setup#
package.json installs two TypeScript packages on purpose. @typescript/native is TypeScript 7 and
provides the tsc that pnpm typecheck runs. typescript is an alias for @typescript/typescript6, the
TypeScript 6 API that astro check and typescript-eslint load. When those tools support TypeScript 7,
replace both with a single typescript@7. tsconfig.json extends Astro's strict preset.
Troubleshooting#
- The build fails with a
postsschema error. An entry insrc/content/posts.jsonis missing a field, has a date that isn'tYYYY-MM-DD, or uses a topic that isn't insrc/data/topics.ts. The message names the entry and field. Unknown post idwhen building. Apick()list on the front page or article names anidthat isn't inposts.json; fix the id or add the essay.- Links or images break when served from a sub-folder. Build with
DEMO_BASE_PATH, and write internal links and asset paths withurl()fromsrc/lib/site.tsrather than plain"/..."strings. /archiveshows a 404 on my host. The host doesn't map extensionless URLs to.htmlfiles. Setbuild.format: "directory"inastro.config.mjsand rebuild.- Social previews show the wrong domain or no image. Set
SITE_URLto your domain at build time. - The theme flashes on load. Keep the
<script is:inline>in the layout's<head>. Withoutis:inline, Astro would bundle it and run it too late. pnpm installskips a build script.pnpm-workspace.yamllists the packages allowed to run install scripts (esbuild,sharp,@tailwindcss/oxide); add any new one there.pnpm typecheckreports missingastro:*types. Run it through the script, which runsastro syncfirst to generate.astro/types.d.ts(including the content collection's types).
License#
MIT. The code and all included artwork (cover art, writer avatars, favicon and social image) are free
for personal and commercial use; the download includes the license text in LICENSE.txt. Newsreader,
Inter and JetBrains Mono are licensed under the SIL Open Font License.