Freeframe

Documentation

Ledger

v1.0.3Updated Oct 9, 2026An editorial magazine for long-form writing

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).

sh
pnpm install
pnpm dev          # http://localhost:4321

With 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 classes

Customising#

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
css
: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.

json
{
  "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():

ts
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-hidden elements 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). Add data-toc-skip to 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) and tok-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.

The header, top strip and mobile menu are src/components/SiteHeader.astro; both menus are rendered from mainNav in src/data/nav.ts:

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:

  1. 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">
  2. 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:

    ts
    fetch(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();
    });
  3. 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:

sh
SITE_URL=https://ledger.example.com pnpm build

The 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/contentinfo landmarks, 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 use aria-pressed; the current table-of-contents link gets aria-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#

sh
pnpm build        # static site in dist/
pnpm preview      # preview dist/ locally

dist/ 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 directory dist.
  • Vercel: import the repository; it detects Astro and static output needs no adapter. Add a vercel.json with { "cleanUrls": true } so /archive serves archive.html.
  • GitHub Pages: use the official Astro GitHub Action. For a project site (username.github.io/repo), build with DEMO_BASE_PATH=/repo.
  • Any static host: upload the contents of dist/. If the host doesn't map /archive to archive.html, change build.format to "directory" in astro.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 posts schema error. An entry in src/content/posts.json is missing a field, has a date that isn't YYYY-MM-DD, or uses a topic that isn't in src/data/topics.ts. The message names the entry and field.
  • Unknown post id when building. A pick() list on the front page or article names an id that isn't in posts.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 with url() from src/lib/site.ts rather than plain "/..." strings.
  • /archive shows a 404 on my host. The host doesn't map extensionless URLs to .html files. Set build.format: "directory" in astro.config.mjs and rebuild.
  • Social previews show the wrong domain or no image. Set SITE_URL to your domain at build time.
  • The theme flashes on load. Keep the <script is:inline> in the layout's <head>. Without is:inline, Astro would bundle it and run it too late.
  • pnpm install skips a build script. pnpm-workspace.yaml lists the packages allowed to run install scripts (esbuild, sharp, @tailwindcss/oxide); add any new one there.
  • pnpm typecheck reports missing astro:* types. Run it through the script, which runs astro sync first 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.

Search documentation

Search the guides and every template's documentation