Freeframe

Documentation

Notebook

v1.0.0Updated Nov 12, 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 Paducah 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 is the Nuxt version: Nuxt 4, Vue 3 single-file components, TypeScript and Tailwind CSS v4, generated as a static site. This guide covers version 1.0.0.

Live demo: freeframe.dev/templates/notebook

Quick start#

Requirements: Node.js 24 or newer and pnpm (corepack enable pnpm).

sh
pnpm install
pnpm dev          # http://localhost:3000
Command What it does
pnpm dev Development server with hot reload
pnpm generate Static site in .output/public/
pnpm build Node.js server build in .output/ (pages still prerendered)
pnpm preview Serve the last build locally
pnpm typecheck nuxt prepare, then type-check with vue-tsc
pnpm lint ESLint (flat config from @nuxt/eslint)
pnpm build:demo Build for /demos/nuxt/notebook into demo/ (only used by freeframe.dev; you can delete it)

pnpm install runs nuxt prepare, which writes the .nuxt/ folder that TypeScript and ESLint read.

Pages#

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

One example post#

The demo has one full post at /post, and every post in lists links there (each post's href in app/data/posts.ts is "/post"). See Adding a post to give each post its own page.

Project structure#

notebook/
  app/
    app.vue                 <NuxtLayout><NuxtPage /></NuxtLayout>, with the page key (see same-page.client.ts)
    layouts/default.vue     skip link, header, reading progress (post page), footer, toast, back-to-top
    pages/                  one file per page (index.vue, post.vue, archive.vue...)
    error.vue               404 page
    components/
      SiteHeader.vue        logo, menu, search link, theme switch, mobile menu
      SiteFooter.vue        footer columns, copyright year
      PostRow.vue           a post in a list (MarkedText.vue highlights search matches)
      TagLink.vue           a tag as a link to the tag browser
      ReadingABTreePageByHand.vue   the example post's text
      PostHeading.vue       post heading with id and "#" link
      CodeBlock.vue         code block with language label and copy button
      PostOutline.vue       "On this page" outline with scroll spy
      ReadingProgress.vue   progress bar at the top of the post page
      SeriesNav.vue         series box of a post
      PostNav.vue           older/newer posts
      NewsletterForm.vue    the home page's sign-up form
      BackToTop.vue         back-to-top button
    composables/
      useFormCheck.ts       form validation
      usePageSeo.ts         title, description, canonical and Open Graph tags
      useCurrentPage.ts     aria-current for menu links
      useAsset.ts           files in public/ with the base path
    data/
      posts.ts              the post index and series, plus helpers
      projects.ts           projects and talks
      site.ts               name, email, feed address, menu and footer links
    utils/
      highlight.ts          SQL and shell highlighting for code blocks
      marks.ts              splits text around search matches
      clipboard.ts, toast.ts, theme.ts, safeStorage.ts
    plugins/
      reveal.client.ts          scroll reveals
      search-shortcut.client.ts "/" jumps to search
      same-page.client.ts       links to the page already showing start it afresh, at the top
      keep-url.client.ts        keeps the address exactly as opened (?tag=…, #section)
      stable-build.server.ts    byte-identical builds
    router.options.ts       keeps query strings as written (?q=build%20graph)
    assets/css/main.css     color tokens, fonts, component classes (Tailwind CSS v4)
    assets/fonts/           self-hosted woff2 files and their licenses
  public/img/               logo, favicon, illustrations, portrait, social image
  nuxt.config.ts            base path, head defaults, theme script, SITE_URL, prerendered routes
  template.json, README.md, DOCS.md, CHANGELOG.md

Customizing#

Brand and colors#

All colors are CSS custom properties near the top of app/assets/css/main.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 menu item
--shadow Hard offset shadow under cards and buttons
--code-bg, --code-ink, --code-line, --tok-* Code blocks and the syntax colors

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 main.css. If you change --paper, also update the two theme-color entries in nuxt.config.ts.

Typography#

Three fonts are self-hosted with @font-face rules at the top of main.css (files and SIL Open Font Licenses in app/assets/fonts/): 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. They are the files Google Fonts serves, so nothing is loaded from Google. @theme maps them to --font-display, --font-sans and --font-mono. To swap a font, replace its woff2 file and @font-face rule, and the family name in @theme.

Content#

The post index. app/data/posts.ts lists every post once, newest first, in POSTS, and the series in SERIES. The home page, archive, tag browser, search, series page, the subscribe page's recent emails and each post's series box and older/newer links are all built from it:

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,
  href: "/post",
},
Field Meaning
slug Unique id; the post page passes it to <SeriesNav> and <PostNav>
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
href The post's route

A series is { slug, name, description }; its parts, part count and total reading time are worked out from POSTS (partsOf(), minutesOf()). The home page shows the newest post large and the next five as a list; the tag list counts tags with tagCounts().

app/data/site.ts holds the name, email address, feed address, menu (NAV) and footer columns (FOOTER). app/data/projects.ts holds PROJECTS (name, status, language with its dot color class, stars, description, links), STATUS_CLASS and TALKS. The about page's text, now section and uses list are in app/pages/about.vue.

Writing a post#

A post's text is a component (the example is app/components/ReadingABTreePageByHand.vue), made of plain elements plus two helpers:

html
<p class="lede">The first paragraph, a little larger.</p>
<PostHeading text="Setting the table" />
<PostHeading text="What the high key is for" :level="3" />
<CodeBlock lang="sql" :code="CODE[1]" />
<aside class="callout">A note in red pen.</aside>
<aside class="callout callout-note">A note in highlighter.</aside>

The component has several root elements on purpose: .prose-nb spaces its direct children, so don't wrap them in a <div>. PostHeading gives the heading an id (from its text) and a # link; <h2>s and <h3>s become the outline. CodeBlock adds the language label and the copy button; the code itself is a string (the example keeps them in a CODE list in the component's script). SQL and shell (bash, sh) are highlighted by app/utils/highlight.ts with five classes (tok-k keywords, tok-s strings, tok-n numbers, tok-c comments, tok-f functions and commands) colored by the --tok-* tokens; other languages (text for output) are shown as they are. Add keywords to SQL_KEYWORDS, or a function for another language.

Adding a post#

  1. Write its text in a component, for example app/components/posts/MyNewPost.vue.
  2. Add it at the top of POSTS with href: "/posts/my-new-post".
  3. Turn app/pages/post.vue into app/pages/posts/[slug].vue: find the post with POSTS.find((p) => p.slug === useRoute().params.slug), and render its text component (for example from a { [slug]: Component } map). The header, <SeriesNav :slug>, <PostNav :slug> and the outline work for any post. List every post's route in routes in nuxt.config.ts so pnpm generate writes them, and change route.path === '/post' in app/layouts/default.vue so the progress bar shows on posts.

Images and artwork#

Everything in public/img/ is an original SVG: logo.svg, 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. og-image.png is the social image that sites actually display; export a new one at 1200 × 630 when you change the SVG. Files in public/ are referenced with asset("img/…") (app/composables/useAsset.ts) so they work under a base path.

The menu is NAV in app/data/site.ts, used by both the desktop and mobile menus in app/components/SiteHeader.vue, which marks the current page with aria-current="page" (useCurrentPage()). Footer columns are FOOTER in the same file, rendered by SiteFooter.vue; links that start with / become NuxtLinks, anything else (#, mailto:) a plain link.

Dark mode#

The theme button in SiteHeader.vue toggles the dark class on <html> and stores "dark" or "light" under notebook-theme (THEME_KEY in app/utils/theme.ts). The themeScript in nuxt.config.ts applies it before the page paints; without a stored choice the page follows the system setting. To default to dark, change the script's condition to t!=='light'. To remove dark mode, delete the button and the script's try { … } part.

Features#

Code blocks and copy buttons#

CodeBlock.vue renders a .code-block frame with the language (LANGS maps sql → SQL, bash → Shell, text → Output; other names are shown as written) and a Copy button. Copying uses the Clipboard API with a fallback (app/utils/clipboard.ts); 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 syntax colors switch with the site theme.

PostHeading.vue links each section with #; following the link also copies the section's address. PostOutline.vue (used twice: a sidebar on wide screens, a collapsible "On this page" on phones) reads the post's <h2>s and <h3>s from [data-post-body] once the page has mounted, and highlights the section you are reading, marked aria-current="location": the last heading above the top quarter of the screen. Following a link in the phone version closes it. ReadingProgress.vue fills the bar at the top as you read the text, and app/pages/post.vue counts the visible words at 230 a minute for the reading time.

Series box and older/newer posts#

SeriesNav.vue shows the post's series with every part (the current one highlighted and marked aria-current="page"); posts without a series show nothing. PostNav.vue links to the next older and newer posts in POSTS; the newest post has only "Older post".

Archive by year#

/archive groups POSTS by the year of their date, with jump links to each year (#year-2025).

Tag browser#

app/pages/tags.vue shows "All" and one toggle button (aria-pressed) per tag, most used first. The chosen tag is read from ?tag= once the page has mounted, and choosing one rewrites the address with history.replaceState, so the view can be shared. ?tag= is matched in any case; a value that isn't a tag shows every post and a short notice. A tag link to the page you are on starts it afresh with that tag (app/plugins/same-page.client.ts and the page key in app/app.vue).

app/pages/search.vue searches as you type: every word must appear in a post's title, summary, tags or series name. Matches are wrapped in <mark> and the count is announced politely. The query is kept in ?q= (read up to 100 characters), Enter works too, and "Clear the search" returns focus to the box. app/plugins/search-shortcut.client.ts makes <kbd>/</kbd> jump to search from any page, or into the box on the search page; it is ignored while you type in a field.

Copy the feed address#

The Copy button on /subscribe copies the value of the #feed-url input (the feed address in app/data/site.ts).

Scroll reveals, mobile menu and back to top#

app/plugins/reveal.client.ts fades in .reveal elements as they scroll into view (and everything above them), straight away with reduced motion. Don't put .reveal on an element with a :class binding: Vue would remove the is-visible class the plugin adds. Below 1024 px the menu button opens the mobile menu; Escape closes it and returns focus, and it closes after following a link. BackToTop.vue appears after about one screen, scrolls up (instantly with reduced motion) and focuses the main content; remove it from app/layouts/default.vue to drop it.

Forms#

Form File Validates
Newsletter (home) app/components/NewsletterForm.vue Email
Email subscription app/pages/subscribe.vue Email; name is optional, frequency and series announcements are choices
404 search app/error.vue Nothing: it opens /search?q=…

Both forms use useFormCheck() from app/composables/useFormCheck.ts: the browser's own constraints with friendly messages ("Please enter your email address."). A field is checked when it loses focus (once it has a value) and again as you type while it's invalid; each has an error line (<field id>-error) linked with aria-describedby, and aria-invalid once checked. Submitting focuses the first problem, or resets the form and shows and focuses the success panel. The success texts tell the reader to look out for a confirmation email, as most newsletter services send one to confirm a sign-up; change them if yours doesn't.

Nothing is sent until you connect the forms, even though the success texts read as if it had been. To send sign-ups to a service such as Buttondown or Formspree, fill in the onValid function next to the form, for example in app/pages/subscribe.vue:

ts
function onValid(form: HTMLFormElement) {
  fetch("https://formspree.io/f/your-form-id", {
    method: "POST",
    body: new FormData(form),
    headers: { Accept: "application/json" },
  });
}

onValid runs before the form resets. With pnpm build (a Node.js server) you can instead post to a Nitro server route such as server/api/subscribe.post.ts.

Saved data (localStorage)#

Key Value Validation
notebook-theme "dark" or "light" Any other value is ignored and the system setting is used

Nothing else is stored. Storage goes through app/utils/safeStorage.ts, which keeps the choice in memory for the page view when the browser blocks storage. Clear it in the browser's site data, or run localStorage.removeItem("notebook-theme").

SEO and sharing#

  • Each page calls usePageSeo({ title, description }) (app/composables/usePageSeo.ts) with its title and description; the post passes type: "article" and the 404 page noindex: true. It sets Open Graph and Twitter tags and a canonical link.
  • Canonical URLs, og:url and og:image are absolute: SITE_URL plus the base path. Set it when you build:
sh
SITE_URL=https://www.example.com pnpm generate

Without it, runtimeConfig.public.siteUrl in nuxt.config.ts falls back to https://imkevos.example. Consider adding a sitemap, a robots.txt in public/ and an RSS feed at the address in site.feed, built from POSTS (for example a Nitro server route that is prerendered).

Accessibility#

  • A skip link, landmarks (header, labeled navs, main, aside, footer) and one <h1> per page.
  • Visible focus outlines in the pen color; the current page is marked with aria-current="page".
  • Toggle buttons (aria-pressed) for theme and tags, live regions for counts, copy and form messages.
  • Focusable, labeled code blocks; heading links named after their section.
  • Colors meet WCAG AA in both themes, including the syntax colors.
  • Reduced motion turns off reveals and smooth scrolling.

When editing, keep one <h1> per page, give every new input a <label>, and keep headings in order.

Building and deploying#

sh
pnpm generate   # static site in .output/public/
pnpm preview    # preview it locally

Every page is prerendered to its own file (archive.html, not archive/index.html) and linked as /archive.

  • Netlify or Cloudflare Pages: build command pnpm generate, publish directory .output/public.
  • Vercel: import the repository; Nuxt is detected automatically.
  • Any static host: upload .output/public/. If the host doesn't serve /archive from archive.html, set autoSubfolderIndex: true in nitro.prerender in nuxt.config.ts.
  • Sub-folder hosting: build with DEMO_BASE_PATH=/notes pnpm generate; it becomes app.baseURL, which every NuxtLink, script and stylesheet uses.

demo/ is the build used for the freeframe.dev demo; you can delete it.

TypeScript setup: typescript is an alias of @typescript/typescript6, the TypeScript 6 API that vue-tsc and typescript-eslint need.

Troubleshooting#

Problem Fix
A new post doesn't appear in lists, tags or search Add it to POSTS in app/data/posts.ts; check the date is YYYY-MM-DD
The series box or older/newer links are missing The slug passed to <SeriesNav>/<PostNav> must match the post's slug
Post paragraphs lose their spacing Keep the post's elements as direct children of [data-post-body]; don't wrap them in a <div>
A heading is missing from the outline Only <h2> and <h3> inside [data-post-body] are listed; use <PostHeading>
A Tailwind class built from data has no effect Write complete class names in the data (before:bg-[#dea584]), not pieces
pnpm lint can't find .nuxt/eslint.config.mjs Run pnpm install (or pnpm typecheck) first; it runs nuxt prepare
A hydration warning after editing a page Read window, Date and localStorage only in onMounted() or event handlers
The newsletter form shows its confirmation but nothing arrives It sends nothing until you connect it; see Forms
Copy buttons don't copy on plain http:// The Clipboard API needs https or localhost; the fallback covers most browsers

License#

Freeframe Commercial License: use it in unlimited websites and apps for yourself and your clients; it may not be resold or redistributed as a template. The full terms, with your licensee details, are in LICENSE.txt and at https://freeframe.dev/license#commercial. Third-party components keep their own licenses. Martian Mono, Atkinson Hyperlegible Next and Atkinson Hyperlegible Mono are under the SIL Open Font License (app/assets/fonts/*-OFL.txt). Imke Vos, Havenlogic and the posts' examples are fictional.

Search documentation

Search the guides and every template's documentation