Blog Markdown

Kasus pemakaian situs statis yang paling khas: berkas markdown dalam sebuah folder, satu halaman statis per pos, umpan RSS, dan nol JavaScript sisi klien. Sumber lengkap di examples/blog.

Yang Anda dapat

Tata letak proyek

my-blog/
  sitelo.config.js
  content/
    hello-world.md       # posts: frontmatter + markdown
    why-static.md
  src/
    lib/
      posts.js           # baca content/, urai frontmatter, render markdown
    index.ht.js          # / — daftar tulisan
    blog/
      [slug].ht.js       # /blog/:slug — satu halaman per tulisan
    css/
      styles.css
export default {
  site: 'https://example.com',
  rss: {
    site: 'https://example.com',
    title: 'Blog saya',
    description: 'Tulisan terbaru',
    routePrefix: '/blog', // setiap halaman /blog/* menjadi item feed
  },
}

1. Tulis pos sebagai markdown

Pos tinggal di content/ — di luar src/, jadi sitelo tidak pernah menganggapnya halaman atau aset. Frontmatter berupa baris key: value biasa:

---
title: Halo, dunia
date: 2026-08-01
description: Tulisan pertama yang wajib ada.
---

Blog ini adalah folder berisi berkas markdown yang dirender ke HTML statis.

2. Baca dan render di Node

Modul kecil khusus server membaca foldernya, mengurai frontmatter, dan merender markdown dengan marked. Karena tidak ada di HTML yang merujuk modul ini, ia tidak pernah sampai ke peramban.

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

// Tulisan berada di luar src/ sehingga tidak pernah dianggap halaman atau aset.
const CONTENT_DIR = new URL('../../content/', import.meta.url)

/** Pengurai frontmatter mungil — baris `kunci: nilai` di antara pagar ---. */
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) }
}

/** Semua tulisan, terbaru dulu. Berjalan di Node hanya saat build/dev. */
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. Daftarkan pos di beranda

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: 'id' },
    head(
      title('Blog saya'),
      link({ rel: 'stylesheet', href: '/styles.css' }),
      link({
        rel: 'alternate',
        type: 'application/rss+xml',
        title: 'Blog saya',
        href: '/rss.xml',
      }),
    ),
    body(
      h1('Blog saya'),
      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' }, 'Feed RSS')),
    ),
  )

4. Satu halaman statis per pos

generateStaticParams mengembalikan setiap slug saat build; data() memuat pos yang cocok untuk tiap halaman.

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(`Tulisan tidak ditemukan: ${params.slug}`)
  return { post }
}

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

  return html({ lang: 'id' },
    head(
      title(`${post.title} — Blog saya`),
      meta({ name: 'description', content: post.description }),
      link({ rel: 'stylesheet', href: '/styles.css' }),
    ),
    body(
      article(
        p(a({ href: '/' }, '← Semua tulisan')),
        h1(post.title),
        time({ datetime: post.date }, post.date),
        post.html,
      ),
    ),
  )
}

5. RSS secara cuma-cuma

Dengan konfigurasi rss di atas, sitelo build menghasilkan dist/rss.xml dengan satu item untuk setiap halaman di bawah /blog — tanpa kode tambahan.

Dokumentasi perutean · Dokumentasi pemuatan data · Dokumentasi konfigurasi