Freeframe

Documentation

Notebook

v1.0.0Updated Oct 10, 2026A personal developer blog with series, tags and code that copies

Overview#

Notebook is a personal developer blog. The demo belongs to Imke Vos, a fictional backend engineer in Rotterdam who writes long posts about databases, build tools and debugging. It has a home page with the latest posts and featured series, a post layout made for technical writing (highlighted code with copy buttons, heading links, an outline that follows your reading, a progress bar and series navigation), an archive by year, a tag browser, instant search, a series page, projects, an about and now page and a subscribe page. The design is a sheet of graph paper with a red margin line and a yellow highlighter, in light and dark mode. This version is built with Astro 7, TypeScript and Tailwind CSS v4 and builds to static files. This guide covers version 1.0.0.

Live demo: freeframe.dev/templates/notebook

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/notebook into demo/ (only used for the demo on freeframe.dev)

Pages#

Route File Purpose and notable sections
/ src/pages/index.astro Introduction with the "now" line, newest post, five latest posts, series, tags, newsletter
/post src/pages/post.astro The example post: series box, outline, reading time and progress, highlighted code with copy buttons, callouts, heading links, older/newer posts
/archive src/pages/archive.astro Every post grouped by year, with links to each year
/tags src/pages/tags.astro Tag buttons with counts and the posts of the chosen tag (/tags?tag=postgres)
/series src/pages/series.astro Each series with its description and parts, in order
/search src/pages/search.astro Instant search with highlighted matches (/search?q=vacuum)
/about src/pages/about.astro Introduction and portrait, now, uses, contact
/projects src/pages/projects.astro Project cards with status, language and links, talks
/subscribe src/pages/subscribe.astro Email sign-up form, RSS address with a copy button, recent emails
404 src/pages/404.astro Search box and links back (built as 404.html)

Project structure#

notebook/
  astro.config.mjs          site URL, base path, file-style output, Tailwind plugin
  src/
    pages/                  one .astro file per page (see above)
    layouts/Layout.astro    <html>, <head> metadata, no-flash theme script, header, footer, scripts
    components/
      SiteHeader.astro      logo, main menu, search link, theme and menu buttons, mobile menu
      SiteFooter.astro      footer columns and copyright
      PostRow.astro         one post in a list (home page)
      NewsletterForm.astro  the compact newsletter form
    data/
      posts.ts              the post index and the series (SERIES, POSTS) and helpers
      nav.ts                header and footer links
      projects.ts           projects and talks
    lib/
      site.ts               site name, theme colours and the url() helper
      icons.ts              inline SVG icons
    scripts/
      main.ts               all interactive features
      back-to-top.ts        the back-to-top button
    styles/global.css       Tailwind: colour tokens, fonts and component classes
  public/img/               logo, favicon, illustrations, portrait and social image
  README.md, DOCS.md, CHANGELOG.md

Customising#

Brand and colours#

All colours are CSS custom properties at the top of src/styles/global.css, once for light mode (:root) and once for dark mode (.dark). Tailwind reads them through @theme inline, so classes such as bg-paper, text-muted, border-line, text-pen and bg-marker follow the tokens.

Token Used for
--paper Page background
--surface Cards ("sheets"), inputs
--ink / --muted Text and secondary text
--line Borders and dashed rules
--grid The graph-paper lines behind the page
--pen / --pen-soft Red pen: margin lines, // labels, series badges, active outline entry
--marker Highlighter: link underlines, <mark>, the current nav item
--shadow Hard offset shadow under cards and buttons
--code-bg, --code-ink, --code-line, --tok-* Code blocks and the syntax colours

To make the pen blue instead of red:

css
:root { --pen: #1f4fb8; --pen-soft: #e2e9f8; }
.dark { --pen: #8fb3ff; --pen-soft: #1c2740; }

To remove the graph paper, delete the background-image and background-size lines from body in src/styles/global.css. The browser's theme colour is themeColor in src/lib/site.ts.

Typography#

Three Google Fonts are loaded by the <link> in src/layouts/Layout.astro: Martian Mono for headings and labels (slightly condensed with font-stretch: 87.5% in the .display, .kicker and .post-title classes), Atkinson Hyperlegible Next for text and Atkinson Hyperlegible Mono for code. The stacks are --font-display, --font-sans and --font-mono in src/styles/global.css. To swap a font, change the Google Fonts link and the matching variable.

Content#

The post index. src/data/posts.ts lists every post once, newest first, in POSTS, and the series in SERIES. The home page's latest posts, series cards and tags, the series page, and (in the browser) the archive, the tag browser, search, the series box and the older/newer links on a post are all built from it, so adding a post there updates all of them:

ts
{ slug: "my-new-post", title: "My new post", excerpt: "One sentence about it.", date: "2026-11-02",
  tags: ["postgres"], series: "", part: 0, minutes: 6, url: "/my-new-post" },
Field Meaning
slug Unique id; a post page names itself with data-post={post.slug}
title, excerpt Shown in lists and searched
date YYYY-MM-DD, shown exactly as written (never converted to a time zone)
tags Lower-case tag names
series, part A series slug from SERIES and the part number, or "" and 0
minutes Reading time shown in lists
url The post's page as a site path; it goes through url(). In the demo every post links to /post

A series has a slug, a name and a description (shown on the home and series pages). The Post and Series interfaces type every entry, so a missing field fails pnpm check.

Writing a post. Copy src/pages/post.astro to a new file (for example my-new-post.astro), change the slug in POSTS.find(...) to your post's, and replace the text inside <div class="prose-nb mt-10" data-post-body>. The header (series badge, title, summary, date, tags), the page title and description come from the post's entry in posts.ts. Headings (<h2>, <h3>) in the text become the outline automatically. If you prefer Markdown, move the text into a .md file and render it inside the same data-post-body element; the scripts only need the element.

Code blocks. Write a <pre> with a lang-<name> class; the script adds the frame, the language label and the copy button:

html
<pre class="lang-sql"><code><span class="tok-k">SELECT</span> id <span class="tok-k">FROM</span> users;</code></pre>

Highlighting is plain <span>s with five classes: tok-k (keywords), tok-s (strings), tok-n (numbers), tok-c (comments) and tok-f (functions and commands). Their colours are the --tok-* tokens, one set for each theme. Known languages get a friendly label (sql → SQL, bash → Shell, text → Output; the list is LANGS in src/scripts/main.ts); any other name is shown as written. Inside .astro markup, write a literal { or } in code as {"{"} and {"}"}.

Callouts. <aside class="callout"> (red pen) or <aside class="callout callout-note"> (highlighter).

Projects and talks are PROJECTS and TALKS in src/data/projects.ts. A project's color is its language dot; add a matching entry to DOT_CLASS for a new colour (Tailwind needs the class written out). The about page (now, uses) and the subscribe page are plain markup in their .astro files.

Images and artwork#

Everything in public/img/ is an original SVG: logo.svg (also favicon.svg), notebook-page.svg (home illustration, 560 × 440), imke.svg (portrait, square) and og-image.svg (social image source, 1200 × 630). Replace the portrait with a square photo of any size and keep width/height on the <img>. og-image.png is the social image that sites actually display; export a new one at 1200 × 630 when you change the SVG. Write image paths with url("/img/...").

Header links are mainNav in src/data/nav.ts (desktop and mobile menus); a page highlights its link by passing the matching key as current to <Layout> (<Layout current="tags" …>). The footer columns are footerNav in the same file. The search link, theme button and Subscribe button are in src/components/SiteHeader.astro.

Dark mode#

The theme button toggles the dark class on <html> and stores "dark" or "light" under notebook-theme in localStorage. The <script is:inline> in the <head> of Layout.astro applies it before the page paints; without a stored choice the page follows the system setting. To default to dark for everyone, change that script's condition to t !== "light". To remove dark mode, delete the theme button and the inline script.

Features#

All features are in src/scripts/main.ts, imported once by the layout.

Code blocks and copy buttons#

Every <pre> inside [data-post-body] is wrapped in a .code-block frame with a header showing the language and a Copy button. Copying uses the Clipboard API with a fallback; the button reads "Copied" for two seconds and a toast announces it. The code is focusable (tabindex="0") so keyboard users can scroll wide lines, and named ("SQL code") for screen readers. The light and dark syntax themes switch with the site theme.

Each <h2> and <h3> in the post gets an id (from its text, made unique) and a # link that appears on hover or focus. Following it also copies the section's address. Both [data-toc] lists (a sidebar on wide screens, a collapsible "On this page" on phones) are filled from the same headings; <h3>s are indented. The entry of the section you are reading is highlighted and marked aria-current="location": it is the last heading above the top quarter of the screen. The bar at the very top ([data-progress], shown when a page passes progress to the layout) grows as you read the text, and [data-reading-time] is counted from the text at 230 words a minute.

Series box and older/newer posts#

[data-series-nav] and [data-post-nav] read their post's data-post slug, find it in POSTS and render the series (with the current part highlighted) and links to the next older and newer posts. A post with no series shows no box; the newest post shows only the "Older post" card.

Archive by year#

[data-archive] groups every post by the year of its date and [data-archive-years] lists links to each year (#year-2025). [data-post-count] shows the total.

Tag browser#

/tags builds one button per tag (most used first) plus "All", each a toggle with aria-pressed. Choosing one lists its posts, updates the heading and the live count and rewrites the address to ?tag=<name> with history.replaceState, so the view can be shared. On load ?tag= is matched regardless of case; a value that isn't a tag is ignored and a short notice says so.

/search searches as you type. Every word you type must appear in a post's title, summary, tags or series name; matches are highlighted with <mark> and the count is announced politely. The query is kept in ?q= (up to 100 characters), the form still works with Enter, and "Clear the search" returns focus to the box. Press / on any page to jump to search (or into the box on the search page); it is ignored while you type in a field.

Copy the feed address#

On /subscribe the button with data-copy-target="feed-url" copies the value of the #feed-url input. Change the address in that input.

Scroll reveals#

Elements with the class reveal fade in when they scroll into view, and so does everything above them, so jumping to the end never leaves hidden content. With reduced motion they are visible straight away.

Mobile menu#

Below 1024 px the menu button opens #mobile-menu. It updates aria-expanded and its label, closes with Escape (returning focus to the button), when a link is followed, and when the window gets wider.

Back to top button#

src/scripts/back-to-top.ts adds a button after you scroll about one screen. It returns to the top (instantly with reduced motion), focuses the main content and lifts itself above toasts. Delete the file, its import in Layout.astro and the .back-to-top styles to drop it.

Forms#

Form Page Fields
Newsletter index.astro (NewsletterForm) email
Email subscription subscribe.astro name (optional), email, frequency, series
Search search.astro, 404.astro q (a GET form to /search)

Forms with data-validate check each named text field: the email address is required and must look like name@example.com. Errors appear in the element with id <field id>-error, the field gets aria-invalid="true" and the first invalid field is focused. When everything is valid the form resets and the [data-form-success] message is shown and focused.

Nothing is sent yet. The site is static, so use a form service. To connect a form to Formspree (or a newsletter provider's form endpoint):

  1. Add the endpoint to the form:

    html
    <form action="https://formspree.io/f/your-id" method="post" data-validate novalidate aria-label="Newsletter sign-up">
  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");
      form.reset();
      if (success) {
        success.hidden = false;
        success.focus();
      }
    });
  3. Update the success message text.

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
notebook-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 (THEME_KEY) and in the inline script in Layout.astro. Reset with localStorage.removeItem("notebook-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 post) 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://imkevos.example pnpm build

The 404 page passes noindex, which outputs <meta name="robots" content="noindex"> and no canonical. For a sitemap, add the official @astrojs/sitemap integration; for an RSS feed at the address on the subscribe page, @astrojs/rss can read POSTS. An Article schema on post pages is a good next step for structured data.

Accessibility#

  • Skip link, banner/main/contentinfo landmarks, labelled navigation and asides, one <h1> per page and visible focus outlines in the pen colour.
  • The current page is marked with aria-current="page"; toggle buttons (theme, tags) use aria-pressed.
  • Counts, copy confirmations and form messages are announced (aria-live / role="status").
  • Code blocks are focusable and labelled; heading links are named after their section.
  • Colours meet WCAG AA in both themes, including the syntax colours.
  • Reveals and smooth scrolling 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=/notebook pnpm build. It becomes Astro's base, and every link written with url() (including the lists 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#

  • A new post doesn't appear in the archive, tags or search. Add it to POSTS in src/data/posts.ts; check the date is YYYY-MM-DD and tags is an array (entries that aren't are skipped).
  • The series box or older/newer links are missing on a post. data-post must match the post's slug.
  • 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.
  • 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.

License#

MIT, see LICENSE.txt in the download. The illustrations, portrait and social image are original artwork included under the same license. Fonts are from Google Fonts under the SIL Open Font License.

Search documentation

Search the guides and every template's documentation