A minimal, fast static site generator for my personal blog. It was inspired by Hugo, built with TypeScript (run via tsx) and Tailwind CSS v4: no framework, just template functions writing HTML files.
- Markdown posts with YAML frontmatter
- Atom feed:
feed.xmlwith auto-discovery (<link rel="alternate">), drafts excluded - SEO basics:
sitemap.xml(posts, tags, categories) androbots.txtgenerated at build time - Rich social + search metadata: complete OpenGraph/Twitter tags (
og:type="article",article:*, Twitter cards) and JSON-LDBlogPostingstructured data on every post - Generated social cards: each post gets a 1200x630 title card rendered at build time (SVG via sharp), used as its share preview
- Syntax highlighting via highlight.js (GitHub Dark Dimmed theme)
- Image pipeline: raster images get optimized WebP variants at build time (sharp); post HTML is rewritten automatically, originals pruned from the output
- Tailwind CSS v4 with
@tailwindcss/typographyfor pretty prose - Auto-extracted metadata: title, date, tags, author, description, reading time
- Categories: automatically generated from posts' metadata
- Tags: automatically generated from posts' metadata
- Related posts: up to three, scored by shared category and tags
- GitHub Pages ready: includes a CI/CD workflow
my-blog/
|-- content/
| `-- posts/ <- blog markdowns
|-- src/
| |-- scripts/
| | |-- types.ts <- shared TS types
| | |-- parser.ts <- markdown + frontmatter parser
| | |-- renderer.ts <- HTML template functions
| | `-- build.ts <- build orchestrator (dev + prod)
| |-- utils/
| | `-- lib.ts <- site config + shared helpers
| |-- tests/
| | |-- parser.test.ts
| | `-- lib.test.ts
| |-- styles/
| | `-- main.css <- tailwind entry + all custom CSS (mostly not written by me)
|-- public/ <- static assets copied as-is to dist/
|-- dist/ <- generated output (not meant to be edited manually)
|-- .github/
| `-- workflows/
| `-- deploy.yml <- GitHub Actions CI/CD
|-- .oxlintrc.json <- oxlint configuration
|-- .prettierrc.json <- Prettier configuration
|-- package.json <- dependencies (managed with pnpm)
`-- tsconfig.json <- TS configuration
Requires pnpm (corepack enable works too):
pnpm installpnpm devThis starts three concurrent processes:
- build watcher: rebuilds HTML when markdown or templates change
- CSS watcher: rebuilds Tailwind CSS on source change
- local server: serves
dist/at http://localhost:3000
pnpm buildGenerates optimized output in dist/. Preview it locally:
pnpm previewCreate a new .md file in content/posts/:
---
title: "My New Post"
date: "2026-01-31"
description: "A short summary shown in listings."
category: "category-name"
tags: ["tag-one", "tag-two"]
author: "Your Name"
draft: false
---
Your post content here...The filename becomes the URL slug. my-new-post.md -> /posts/my-new-post/.
| Field | Required | Description |
|---|---|---|
title |
yes | Post title |
date |
yes | ISO date string (YYYY-MM-DD) |
description |
no | Excerpt for listings and meta tags |
tags |
no | Array of strings |
category |
no | Defaults to "uncategorized" |
author |
no | Defaults to "Anonymous" |
lang |
no | BCP 47 tag (e.g. ja); defaults to "en" |
draft |
no | true hides post in prod builds |
Fenced code blocks with a language identifier get syntax-highlighted:
```typescript
const greeting = (name: string) => `Hello, ${name}!`;
```Edit getSiteConfig() in src/utils/lib.ts:
export function getSiteConfig(): SiteConfig {
return {
title: "My Blog",
description: "A personal blog about code and ideas.",
author: "Your Name",
baseUrl: process.env["BASE_URL"] ?? "",
siteUrl: process.env["SITE_URL"] ?? "https://your-site.example.com",
year: new Date().getFullYear(),
socials: {
github: "https://github.com/you/",
linkedin: "https://www.linkedin.com/in/you/",
email: "you@example.com",
},
};
}baseUrl prefixes internal links (leave empty for root-relative URLs); siteUrl is the absolute origin used for metadata like <link rel="canonical">.
Drop images in public/<post-slug>/ and reference them as usual (). At build time every .png/.jpg under public/ gets a WebP sibling generated with sharp (max width 1920px, quality 80), image tags in posts are rewritten to use it, and the original is removed from dist/; an original is only kept if its WebP encoding failed. Variants are cached by mtime, so unchanged images aren't re-encoded on rebuilds. Keep in mind direct links to old .png/.jpg URLs will no longer resolve.
Comments are powered by giscus (GitHub Discussions) and appear at the bottom of every post. To enable them:
- Make sure your repo is public and has Discussions enabled (Settings > General > Features)
- Install the giscus app
- Pick a Discussion category:
Announcementsis recommended so only you can open threads - Fill in the IDs from giscus.app in
getSiteConfig()(src/utils/lib.ts):repoIdandcategoryId
Until both IDs are set, the comments section stays hidden.
All styling lives in src/styles/main.css. The design tokens (colors, fonts, spacing) are defined as CSS custom properties in the @theme block at the top of the file. Change them to theme the whole site.
To add a new static page (e.g. /uses/):
- Add a
renderUses(config: SiteConfig): stringfunction insrc/scripts/renderer.ts - Call it in
src/scripts/build.tsinside thebuild()function:
writeFile(path.join(OUT_DIR, "uses", "index.html"), renderUses(config));- Add a nav link in the
navbar()function inrenderer.ts
| Command | Description |
|---|---|
pnpm dev |
Dev server + watchers at localhost:3000 |
pnpm build |
Production build to dist/ |
pnpm preview |
Serve dist/ locally |
pnpm type-check |
TypeScript type checking |
pnpm lint |
Lint with oxlint (pnpm lint:fix auto-fixes) |
pnpm format |
Format with Prettier (pnpm format:check verifies) |
pnpm test |
Run tests with vitest (pnpm test:watch for watch) |
- pnpm: package manager, pinned via the
packageManagerfield inpackage.json(Corepack picks it up automatically) - oxlint: linter, configured in
.oxlintrc.json - Prettier: formatter, configured in
.prettierrc.json; ignoresdist/,public/, and post markdown (see.prettierignore)