blog
A tiny blog. Three inputs feed one pipeline, posts are normalised and tagged in the middle, and one SQLite table holds
them. A static site in out/site is rebuilt from that table on every post.
| Input | via | How to post |
|---|---|---|
files | watch | Drop or edit a markdown file in posts/ (front matter: title, author, date, tags, slug, draft) |
api | http | POST /in/blog/posts with {title, body, author?, tags?, slug?, date?, draft?}; no title or body is a 422 |
manual | push | pipo push blog --input manual --data '{…}' |
parse(fn.fromMarkdown) turns a watched file into post fields; the slug defaults to the file name.normalize(fn.normalize) gives posts from every input one shape: a slug, trimmed title, excerpt, reading time, ISO dates andsource(the input it came through). Tags are lower-cased, folded onto one spelling (ts→typescript,sqlite3→sqlite), and topics found in the title or body are added (blog.fn.ts,TOPICS).site(tap: exec) runsbuild-site.ts, which reads every post fromdata/blog.db, merges the incoming one by slug (the tap runs before the write), and writesout/site. Drafts are stored but left off the site. Withconcurrency: 1, each build sees every earlier post. A failed build is logged and doesn't hold the post back.- The output upserts into
data/blog.dbbyslug, so editing a file updates its post in place, anddeliveredchecks that the row holds this version (slugandupdated_at).
bun pipo check examples/blog
bun pipo start examples/blog/blog.pipo --listen 8792
cp examples/blog/samples/*.md examples/blog/posts/
curl -X POST localhost:8792/in/blog/posts -H 'content-type: application/json' \
-d '{"title": "Hello over HTTP", "author": "Ada", "body": "Posted with **curl**.", "tags": ["JS"]}'
bun pipo push blog --input manual --data '{"title": "Pushed by hand", "body": "Fixing a typo.", "tags": ["Meta"]}'
open examples/blog/out/site/index.html # or xdg-open; plain files, no server needed
sqlite3 examples/blog/data/blog.db 'select slug, tags, source from posts'
bun pipo test examples/blog
Files already in posts/ when the pipeline first starts are its baseline and aren't imported (spec D19), so copy the
samples in after starting it. To rebuild the site from the database alone: bun examples/blog/build-site.ts < /dev/null.
blog.pipo#
# A tiny blog. Posts arrive three ways: a markdown file dropped in ./posts, a POST to /in/blog/posts, or `pipo push`.
# Each is normalised and tagged the same way (blog.fn.ts), the static site in ./out/site is rebuilt from SQLite plus
# the new post (build-site.ts), and the post is upserted into ./data/blog.db by slug (docs/spec.md §3.3.1).
# bun pipo start examples/blog/blog.pipo --listen 8792
# cp examples/blog/samples/*.md examples/blog/posts/
# curl -X POST localhost:8792/in/blog/posts -H 'content-type: application/json' \
# -d '{"title": "Hello over HTTP", "author": "Ada", "body": "Posted with **curl**."}'
# bun pipo push blog --input manual --data '{"title": "Pushed by hand", "body": "Fixing a typo.", "tags": ["Meta"]}'
pipo: 1
name: blog
description: Blog posts from markdown files, an HTTP API and pipo push, tagged and stored in SQLite.
fn: ./blog.fn.ts
# One at a time, so each site build sees every post written before it.
concurrency: 1
errors:
retry: 2
backoff: exponential
delay: 500ms
then: dead_letter
inputs:
files:
via: watch
with:
path: ./posts/*.md
events: [create, change]
read: content
api:
via: http
with: {path: /posts, method: POST}
format: json
validate:
- type(data.title) == "string" && len(trim(data.title)) > 0
- type(data.body) == "string" && len(trim(data.body)) > 0
on_invalid:
respond: 422
message: "Rejected: rule '${error.rule}' failed. A post needs a title and a body."
manual:
via: push
validate:
- exists(data.title) && exists(data.body)
nodes:
parse:
label: Parse markdown
from: files
transform: fn.fromMarkdown
normalize:
label: Normalise and tag
from: [parse, api, manual]
transform: fn.normalize
site:
label: Rebuild site
from: normalize
tap: exec
with:
command: bun
args: [./build-site.ts]
stdin: "${json(data)}"
timeout: 1m
# A failed build never holds a post back; the next post's build catches the site up.
on_error: {retry: 1, delay: 1s, then: continue}
output:
from: site
to: sqlite
with:
path: ./data/blog.db
table: posts
create: true
mode: upsert
key: slug
columns:
slug: "${data.slug}"
title: "${data.title}"
author: "${data.author}"
body: "${data.body}"
excerpt: "${data.excerpt}"
tags: "${json(data.tags)}"
draft: "${data.draft ? 1 : 0}"
reading_minutes: "${data.reading_minutes}"
published_at: "${data.published_at}"
updated_at: "${data.updated_at}"
source: "${data.source}"
# Delivered once the row holds this version of the post, not just some row with its slug.
delivered:
check: record_exists
with:
where: {slug: "${data.slug}", updated_at: "${data.updated_at}"}
Source: examples/blog.