2.1 KiB
| title | description | date | tags | draft | |||
|---|---|---|---|---|---|---|---|
| how this blog works | markdown in a git repo, a webhook, and no build step. here is the whole thing. | 2026-09-08 |
|
false |
This post is a file. It lives in a git repo, it has YAML frontmatter, and pushing
it is the entire publishing pipeline. No CMS, no rebuild, no npm run deploy.
the shape of a post
Everything above the second --- is frontmatter:
---
title: how this blog works # required-ish; falls back to the filename
description: one line, used in the # optional; falls back to the first paragraph
list view and the og tags
date: 2026-09-08 # optional; falls back to a YYYY-MM-DD filename prefix
updated: 2026-09-09 # optional
tags: [meta, nuxt, gitea] # optional; also accepts "meta, nuxt"
slug: how-this-blog-works # optional; falls back to the filename
draft: false # true hides it in production
---
The filename becomes the slug, with a leading date stripped:
2026-09-08-hello.md → /blog/hello.
how it gets here
- You push to the content repo.
- Gitea fires a webhook at
/api/webhook/contentwith an HMAC signature. - The server checks the signature, drops its cache, and re-reads the tree.
- The next request renders from the fresh copy.
If the webhook never fires — network hiccup, misconfigured secret — the cache
still expires on its own after CONTENT_TTL seconds. And if the repo is down
when a refresh is due, the last good copy keeps being served instead of a 502.
code and images
Fenced blocks are highlighted server-side by Shiki, in both themes at once:
fn main() {
let payload = std::fs::read_to_string("post.md").expect("a post");
println!("{} bytes, zero javascript", payload.len());
}
Relative image paths resolve against the post's own directory in the repo, so
 works if you commit the png next to the markdown.
Blockquotes, tables, and lists all render. Anything markdown-it understands.
That's it. Write a file, push, done.