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).
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/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.mdCustomising#
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:
: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:
{ 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:
<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/...").
Navigation, header and footer#
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.
Heading links, outline and progress#
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#
/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):
-
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"> -
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"); form.reset(); if (success) { success.hidden = false; success.focus(); } }); -
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:
SITE_URL=https://imkevos.example pnpm buildThe 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/contentinfolandmarks, 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) usearia-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#
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=/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
POSTSinsrc/data/posts.ts; check the date isYYYY-MM-DDandtagsis an array (entries that aren't are skipped). - The series box or older/newer links are missing on a post.
data-postmust match the post'sslug. - 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.- 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.
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.