A free, open-source Astro theme for a personal site with four sections:
- Home: a short intro, three posts, and six notes.
- Posts: long-form writing, each with a one-sentence summary.
- Notes: short entries, listed as tight date-and-title rows.
- Work: bio, experience, selected projects, contact links, and a call to action.
Everything you publish is a markdown file in its own folder, next to its
images, video, and anything else it needs. npm run build turns that into a
static site you can upload to any host.
Features: dark by default with a light toggle · four color palettes · pinning (per section and to Home) · collapsible code blocks · Mermaid diagrams rendered at build time · Chart.js charts · colocated video/audio · YouTube embeds · static search · tags and series · RSS.
npm create astro@latest -- --template bilarikan/astro-theme-kanpages
cd your-project-name
npm install
npm run devRequires Node 22.12 or newer. npm install also downloads a headless Chromium
(via Playwright), which is used to render Mermaid diagrams at build time.
Then:
- Edit
kanpages.config.json: site title, your name, the Home intro, and the Work page. - Replace the example entries in
src/content/posts/andsrc/content/notes/. - Run
npm run build. The finished site is indist/.
src/content/
posts/
my-first-post/
index.md ← the post
diagram.png ← images it uses
walkthrough.mp4 ← video it uses
notes/
quick-thought.md ← a note with no media can be a single file
another-note/
index.md
The folder (or file) name becomes the URL: /posts/my-first-post/. Use .mdx
instead of .md if you want to use components in an entry.
---
title: "My first post"
date: 2026-09-29
summary: "One sentence saying what this post is about." # required for posts
tags: [writing, tools]
series: "Getting Started" # posts only; behaves like a tag
pin: true # top of its own section
pinHome: true # on Home, ahead of newer entries
draft: true # visible in dev, left out of builds
---| Field | Posts | Notes | Notes on use |
|---|---|---|---|
title |
required | required | |
date |
required | required | YYYY-MM-DD. Every date on the site is shown this way. |
summary |
required | optional | One sentence. Shown under post titles and used in meta. |
tags |
optional | optional | Each tag gets a page at /tags/<tag>/. |
series |
optional | — | Listed on /tags/<series-name>/ alongside tags. |
pin |
optional | optional | Default false. |
pinHome |
optional | optional | Default false. |
draft |
optional | optional | Default false. |
Two independent flags, both on posts and notes:
pin: truefloats an entry to the top of/posts/or/notes/, with a pin marker. There's no limit.pinHome: trueputs an entry on Home ahead of the most recent ones. Home showshome.postsposts andhome.notesnotes (3 and 6 by default). If more entries are pinned than fit, the newest pinned ones win and the build logs a warning.
Pinned entries sort newest-first among themselves. When nothing is pinned, Home shows the most recent entries.
/posts/ and /notes/ show pagination.posts (default 10) and
pagination.notes (default 30) entries per page. Older entries continue on
/posts/page/2/, /posts/page/3/, and so on, with Newer/Older links and page
numbers at the bottom. Pinned entries lead page 1 and count toward its size.
Normal markdown with a relative path. Images are optimized at build time.
Use the same image syntax. An optional quoted title becomes a caption.


Local video and audio files (.mp4 .webm .mov .m4v .ogv .mp3 .m4a .ogg .wav)
are copied next to the built page (dist/posts/my-first-post/walkthrough.mp4)
and served from your own host. So are other non-image files in an entry's
folder, such as PDFs you link to. YouTube links become a privacy-enhanced
(youtube-nocookie.com) embed.
Mark the lines that should start out collapsed on the code fence itself:
```ts collapse={5-30}
// lines 5–30 are hidden behind "Show 26 lines"
```Several ranges work too: collapse={1-4, 20-32}. Once expanded, the section
shows a "Hide" bar so readers can collapse it again. Blocks without collapse
render in full. Code blocks also get syntax highlighting, a copy button, and
optional file titles (```ts title="src/app.ts").
```mermaid caption="Optional caption"
flowchart LR
A[Write] --> B[Build] --> C[Publish]
```Diagrams are rendered to SVG at build time, so readers download no JavaScript for them. A light and a dark version are both generated and swap with the site's mode. Diagrams wider than the column are scaled down to fit, and get a View full size button that opens them at their natural size in an overlay (scrollable if needed); closing it returns the reader to the post. The button only appears when a diagram is actually shrunk, and its small script only ships on pages with a diagram.
A ```chart block holds a Chart.js
configuration as JSON:
```chart caption="Optional caption"
{
"type": "bar",
"data": {
"labels": ["Q1", "Q2", "Q3"],
"datasets": [{ "label": "Posts", "data": [3, 5, 4] }]
}
}
```Series colors come from the active palette unless you set backgroundColor or
borderColor yourself. Chart.js is loaded only on pages that contain a chart.
Invalid JSON fails the build with the file name.
Everything outside the content folders lives in kanpages.config.json. It is
validated at build time, so a typo fails the build with a specific message
instead of shipping a broken page.
When support.show is true, a "Pay what you like" pill links out from under
every post and note title. Turn it on while you preview a draft of your site,
and back off until there's an account to link to.
The site loads in defaultMode and the header's sun/moon button switches
modes. The visitor's choice is remembered and applied before first paint, so
the page never flashes the wrong background.
| Palette | Look |
|---|---|
midnight |
Deep navy with a purple accent. (default) |
mono |
Black and white, no accent color. |
moss |
Dark green-grey with a mint accent. |
ember |
Warm brown-black with an amber accent. |
Every palette has a dark and a light variant. Palettes only change color: the layout, type, and spacing are identical across all four.
To add a palette, add its name to src/lib/palettes.ts and a dark and light
block for it in src/styles/palettes.css. Code blocks, charts, and every
component read the same --kp-* variables, so nothing else needs to change.
Search is built into the header. Press / or ⌘K to open
it. Pagefind indexes your posts and notes right after
astro build, and the index ships as plain static files: there's no server,
no external service, and nothing to maintain.
Search only works on a built site, so use npm run build && npm run preview
to try it locally. npm run dev shows a short explanation instead.
npm run build writes a fully static site to dist/.
Shared hosting (cPanel, Apache): upload the contents of dist/ into
public_html/, not the dist folder itself. The theme includes a
public/.htaccess with ErrorDocument 404 /404.html, so the custom 404 page
works without extra setup. Remember to upload .htaccess too; some FTP
clients hide dotfiles by default.
Netlify, Vercel, Cloudflare Pages: build command npm run build, output
directory dist. The build needs Playwright's Chromium for Mermaid, which
npm install fetches automatically. On hosts that skip install scripts, add
npx playwright install --only-shell chromium before the build.
Under a sub-path (e.g. example.com/blog/): set Astro's base in
astro.config.mjs and pass the same value to the media plugin:
[remarkMedia, { base: "/blog/" }].
kanpages.config.jsonis read and validated once, insrc/lib/config.ts. Components import the typed config from there.src/content.config.tsdefines thepostsandnotescollections and their frontmatter.src/lib/content.tsholds the queries: sorting, drafts,splitPinned,homeSlice, and tag grouping.- Rich content is handled by three remark plugins in
src/plugins/(remark-media,remark-mermaid,remark-chart), which run before Expressive Code highlights the remaining code blocks. Settings for code blocks live inec.config.mjs. src/integrations/colocated-media.mjscopies non-image files from entry folders intodist/and serves them in dev.- Colors are CSS custom properties (
--kp-*) set per palette and mode insrc/styles/palettes.css. Components never hardcode a color.
MIT. See LICENSE.

{ "site": { "title": "Your Name", "description": "Used for meta tags and RSS.", "url": "https://example.com", "lang": "en", // optional "favicon": "/favicon.svg", // optional "ogImage": "/og.png" // optional, for link previews }, "author": { "name": "Your Name" }, "nav": [ // the header links, in order { "label": "Home", "href": "/" }, { "label": "Posts", "href": "/posts/" }, { "label": "Notes", "href": "/notes/" }, { "label": "Work", "href": "/work/" } ], "theme": { "palette": "midnight", // midnight | mono | moss | ember "defaultMode": "dark", // dark | light | system "paletteSwitcher": false // true shows palette swatches in the footer }, "home": { "intro": "Raw HTML allowed.", "posts": 3, "notes": 6 }, "pagination": { // entries per page on /posts/ and /notes/ "posts": 10, // default 10 (the demo uses 5 so page 2 shows) "notes": 30 // default 30 (the demo uses 8) }, "work": { "intro": ["Paragraph one.", "Paragraph two."], "experience": [ { "role": "Role", "org": "Company", "start": "2020", "end": "Present", "summary": "Optional." } ], "projects": [ { "title": "Project", "description": "One or two sentences.", "href": "/optional-link/" } ], "links": [ { "label": "Email", "text": "you@example.com", "href": "mailto:you@example.com" } ], "cta": { // optional "heading": "Work with me", "body": "What you offer and who it's for.", "label": "Get in touch", "href": "mailto:you@example.com" } }, "support": { "show": false, // keep false until you have a real account "label": "Pay what you like", "href": "https://ko-fi.com/yourname" }, "footer": { "note": "Open to consulting — <a href=\"/work/\">see Work</a>.", // raw HTML, "" to remove "credit": "Built with Kanpages." // raw HTML, "" to remove } }