Markdown 博客

静态站点最经典的用法:文件夹里的 markdown 文件、每篇文章一个静态页面、一份 RSS 订阅,以及零客户端 JavaScript。完整源码见 examples/blog

你会得到什么

项目结构

my-blog/
  sitelo.config.js
  content/
    hello-world.md       # 文章:frontmatter + markdown
    why-static.md
  src/
    lib/
      posts.js           # 读取 content/,解析 frontmatter,渲染 markdown
    index.ht.js          # / —— 文章列表
    blog/
      [slug].ht.js       # /blog/:slug —— 每篇文章一个页面
    css/
      styles.css
export default {
  site: 'https://example.com',
  rss: {
    site: 'https://example.com',
    title: '我的博客',
    description: '最新文章',
    routePrefix: '/blog', // 每个 /blog/* 页面都会成为一条订阅条目
  },
}

1. 用 markdown 写文章

文章放在 content/ 里 —— 在 src/ 之外,因此 sitelo 绝不会把它们当成页面或资源。frontmatter 就是简单的 key: value 行:

---
title: 你好,世界
date: 2026-08-01
description: 照例的第一篇文章。
---

这个博客就是一个装满 markdown 文件的文件夹,它们被渲染成静态 HTML。

2. 在 Node 中读取并渲染

一个仅在服务端运行的小模块读取该文件夹、解析 frontmatter,并用 marked 渲染 markdown。由于 HTML 中没有任何东西引用这个模块,它永远不会进入浏览器。

import { readdir, readFile } from 'node:fs/promises'
import { marked } from 'marked'

// 文章放在 src/ 之外,因此永远不会被当作页面或资源。
const CONTENT_DIR = new URL('../../content/', import.meta.url)

/** 极简 frontmatter 解析器 —— --- 围栏之间的 `key: value` 行。 */
function parseFrontmatter(raw) {
  const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw)
  if (!match) return { meta: {}, body: raw }

  const meta = {}
  for (const line of match[1].split('\n')) {
    const colon = line.indexOf(':')
    if (colon === -1) continue
    meta[line.slice(0, colon).trim()] = line.slice(colon + 1).trim()
  }

  return { meta, body: raw.slice(match[0].length) }
}

/** 所有文章,最新在前。仅在 Node 中于构建或开发时运行。 */
export async function getPosts() {
  const files = (await readdir(CONTENT_DIR)).filter((file) =>
    file.endsWith('.md'),
  )

  const posts = await Promise.all(
    files.map(async (file) => {
      const raw = await readFile(new URL(file, CONTENT_DIR), 'utf8')
      const { meta, body } = parseFrontmatter(raw)

      return {
        slug: file.replace(/\.md$/, ''),
        title: meta.title ?? file,
        date: meta.date ?? '1970-01-01',
        description: meta.description ?? '',
        html: marked.parse(body),
      }
    }),
  )

  return posts.sort((a, b) => b.date.localeCompare(a.date))
}

export async function getPost(slug) {
  const posts = await getPosts()
  return posts.find((post) => post.slug === slug) ?? null
}

3. 在首页列出文章

import {
  html, head, title, link, body, h1, ul, li, a, time, p,
} from 'javascript-to-html'
import { getPosts } from './lib/posts.js'

export async function data() {
  return { posts: await getPosts() }
}

export default ({ data }) =>
  html({ lang: 'zh' },
    head(
      title('我的博客'),
      link({ rel: 'stylesheet', href: '/styles.css' }),
      link({
        rel: 'alternate',
        type: 'application/rss+xml',
        title: '我的博客',
        href: '/rss.xml',
      }),
    ),
    body(
      h1('我的博客'),
      ul({ class: 'posts' },
        ...data.posts.map((post) =>
          li(
            a({ href: `/blog/${post.slug}` }, post.title),
            time({ datetime: post.date }, post.date),
            p(post.description),
          ),
        ),
      ),
      p(a({ href: '/rss.xml' }, 'RSS 订阅')),
    ),
  )

4. 每篇文章一个静态页面

generateStaticParams 在构建时返回全部 slug;data() 为每个页面加载对应的文章。

import {
  html, head, title, meta, link, body, article, p, a, h1, time,
} from 'javascript-to-html'
import { getPost, getPosts } from '../lib/posts.js'

export async function generateStaticParams() {
  const posts = await getPosts()
  return posts.map((post) => ({ slug: post.slug }))
}

export async function data({ params }) {
  const post = await getPost(params.slug)
  if (!post) throw new Error(`找不到文章: ${params.slug}`)
  return { post }
}

export default ({ data }) => {
  const { post } = data

  return html({ lang: 'zh' },
    head(
      title(`${post.title} — 我的博客`),
      meta({ name: 'description', content: post.description }),
      link({ rel: 'stylesheet', href: '/styles.css' }),
    ),
    body(
      article(
        p(a({ href: '/' }, '← 全部文章')),
        h1(post.title),
        time({ datetime: post.date }, post.date),
        post.html,
      ),
    ),
  )
}

5. RSS 白送

配上上面的 rss 配置,sitelo build 就会产出 dist/rss.xml,为 /blog 下的每个页面各生成一条 —— 不需要额外代码。

路由文档 · 数据加载文档 · 配置文档