@ciromaciel/blog
v1.0.1
Published
Pluggable blog framework for Bun + Elysia. Multi-source posts (FS + DB), built-in BlogPosting JSON-LD, FAQ schema, sitemap integration, configurable layout.
Downloads
163
Maintainers
Readme
@ciromaciel/blog
A mountable blog engine for Bun + Elysia — a self-contained sub-app
that ships its own routes, views, data sources, and SEO machinery,
and plugs into a host Elysia app via app.use(blog.router).
Drops into any app that wants /blog and /blog/:slug without
re-implementing markdown parsing, frontmatter, multi-source merging,
BlogPosting JSON-LD, FAQ schema, sitemap integration, and view rendering.
Fully decoupled from any host's domain — lift into another Bun/Elysia app with no edits.
Quick start
import { Elysia } from "elysia"
import { createBlog } from "@ciromaciel/blog"
const blog = createBlog({ fsDir: "./blog/posts" })
new Elysia().use(blog.router).listen(3000)That's it. /blog lists posts; /blog/:slug renders one. A built-in
HTML5 layout handles <head> (title, description, canonical, OG,
JSON-LD) so the result is indexable from the first request.
Concepts
A blog is composed from four pieces. Each is optional, each has a default, each is replaceable in isolation.
| Piece | Purpose | Default |
|---|---|---|
| Sources | Where posts live (FS, DB, anything else) | none — empty list renders fine |
| Theme | Page chrome (<head> + outer HTML) + visible copy | minimal HTML5 layout |
| Plugins | Per-slug FAQs, custom related-posts, view hooks | all undefined |
| Slots | Inline HTML injected into list/post views (CTA, empty-state) | none |
Configuration
Everything is optional. createBlog() with no args is valid.
createBlog({
// Mounting
prefix: "/blog", // default
canonicalBase: "https://yoursite.com", // default: process.env.APP_URL || http://localhost:3000
defaultAuthor: "Author", // fallback when frontmatter/DB row omits it
// Sources — pick any combination. Explicit `sources` overrides shortcuts.
fsDir: "./blog/posts", // shortcut → FsSource
db: {
instance: sqlite, // bun:sqlite Database
table: "generated_posts", // default
viewCounterColumn: "view_count", // optional — increments on each get()
columns: { /* DbColumnMap override */ },
},
sources: [/* custom BlogSource[] — bypasses shortcuts if set */],
// Theme
theme: {
listTitle: "My Blog",
listDescription: "Subtitle on /blog and default <meta description>",
publisher: { name: "Acme Co", url: "https://acme.co" },
layout: (inner, meta, { headers }) => "<html>...</html>",
cta: (post) => `<aside>Buy our product</aside>`,
emptyState: () => `<p>No posts yet</p>`,
},
// Plugins
plugins: {
faqFor: (slug) => ({ html: "...", jsonLd: { "@type": "FAQPage" } }) ?? null,
relatedFor: (post, all) => all.slice(0, 2),
onPostView: (slug) => incrementViewCount(slug),
},
})Sources
A source is anything that exposes list() and get(slug). The
engine merges multiple sources, deduping by slug — sources earlier
in the sources array win on collision.
FsSource — markdown on disk
import { FsSource } from "@ciromaciel/blog"
new FsSource({
dir: "./blog/posts",
defaultAuthor: "Editorial Team",
id: "curated", // surfaces in PostMeta.source for filtering
})Expects *.md files with YAML-ish frontmatter. Files lacking slug
or title are silently skipped (drafts).
Frontmatter fields
---
slug: my-post # required — used as URL segment
title: My Post # required
description: One-line summary # used in meta description
date: 2026-05-10 # YYYY-MM-DD
dateModified: 2026-05-15 # optional — SEO override, falls back to `date`
author: Jane Doe # optional — falls back to defaultAuthor
tags: seo, growth, tactics # comma-separated
readingTime: 6 # minutes, default 5
---
# Markdown body here
Standard markdown rendered with `marked` (GFM enabled, `breaks: false`).The parser is intentionally minimal — no nested YAML, no arrays, no multi-line strings. If you need richer metadata, write a custom source.
DbSource — rows in a SQLite table
import { DbSource } from "@ciromaciel/blog"
import { Database } from "bun:sqlite"
new DbSource({
db: myDb,
table: "articles",
id: "cms",
viewCounterColumn: "view_count", // optional
})Default expected schema:
CREATE TABLE generated_posts (
slug TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL,
content_html TEXT NOT NULL,
tags_csv TEXT NOT NULL DEFAULT '',
reading_time INTEGER NOT NULL DEFAULT 5,
published_at TEXT NOT NULL
);Custom column names via columns:
new DbSource({
db: myDb,
table: "articles",
columns: {
slug: "key",
title: "headline",
description: "summary",
contentHtml: "body_html",
tagsCsv: "labels",
readingTime: "mins",
publishedAt: "issued",
dateModified: "updated", // optional column — omit to skip
author: "byline", // optional column — omit to use defaultAuthor
},
})viewCounterColumn fires UPDATE … SET col = col + 1 WHERE slug = ?
on every get(). Errors (missing column, type mismatch) are swallowed
so a broken counter never blocks rendering.
Source precedence
createBlog({
fsDir: "./posts", // first
db: { instance: sqlite, table: "drafts" }, // second
})If both contain slug: hello, the FS version wins. This is how the VA
Growth Suite project lets a curated markdown file override an
auto-generated draft from the writer agent.
Custom sources
Implement the BlogSource interface:
import type { BlogSource, Post, PostMeta } from "@ciromaciel/blog"
class NotionSource implements BlogSource {
readonly id = "notion"
async list(): Promise<PostMeta[]> { /* ... */ }
async get(slug: string): Promise<Post | null> { /* ... */ }
}
createBlog({ sources: [new NotionSource()] })Pass it as sources: [new NotionSource()] — shortcuts are ignored
when explicit sources are provided.
Layout
The layout wraps the engine's rendered inner HTML (list cards or post body) with the host's site chrome.
Default layout
If you don't provide theme.layout, the engine ships a minimal
HTML5 layout: charset, viewport, <title>, <meta description>,
canonical, OG tags, JSON-LD <script>, basic typography, dark mode.
Indexable, readable, no external dependencies. Good for prototypes;
real apps should provide their own.
Custom layout
theme: {
layout: async (inner, meta, { headers }) => {
const user = await getUser(headers)
return `<!DOCTYPE html>
<html><head>
<title>${meta.title}</title>
<meta name="description" content="${meta.description}">
<link rel="canonical" href="${meta.canonical}">
${meta.structuredData ? `<script type="application/ld+json">${JSON.stringify(meta.structuredData)}</script>` : ""}
</head><body>${inner}</body></html>`
},
}PageMeta shape:
{
title: string // post title OR theme.listTitle
description: string // post description OR theme.listDescription
canonical: string // absolute URL, ready to drop into <link rel=canonical>
isArticle: boolean // true on /blog/:slug, false on /blog
prefix: string // your configured prefix
slug?: string // present only on /blog/:slug
structuredData?: object | object[] // BlogPosting + optional FAQPage
}The layout receives request headers — handy for auth, locale, feature-flag, A/B variant lookups.
Plugins
faqFor(slug)
Returns an FAQ for a specific post, or null. The engine injects
html into the post body (after prose, before CTA) and merges
jsonLd into the post's structured data array.
plugins: {
faqFor: (slug) => {
const entries = lookupFaq(slug)
if (!entries) return null
return {
html: `<section><h2>FAQ</h2>${entries.map(/*...*/)}</section>`,
jsonLd: { "@context": "https://schema.org", "@type": "FAQPage", "mainEntity": entries },
}
},
}relatedFor(post, all)
Overrides the default related-posts strategy (any two other posts). Return up to N posts; the post view caps display at 2.
plugins: {
relatedFor: (post, all) =>
all
.filter(p => p.slug !== post.slug)
.filter(p => p.tags.some(t => post.tags.includes(t)))
.slice(0, 5),
}onPostView(slug)
Fires on every successful GET. Use for view counters, analytics events, last-seen timestamps. Errors are swallowed.
plugins: {
onPostView: (slug) => {
posthog.capture("post_viewed", { slug })
},
}Slots
Two HTML injection points in the views. Both optional.
theme: {
// Appears at the bottom of every post, inside <article>, after FAQ
cta: (post) => `<aside class="cta">Read more on ${post.tags[0]}</aside>`,
// Replaces the default "No posts yet" message on the list page
emptyState: () => `<div>Posts coming soon — subscribe below.</div>`,
}Slot output is injected verbatim — no escaping. Don't interpolate untrusted strings without escaping yourself.
Sitemap
The engine can produce sitemap entries for the host's central sitemap:
const blog = createBlog({ /* ... */ })
const entries = await blog.sitemapEntries()
// [
// { loc: "https://site.com/blog" },
// { loc: "https://site.com/blog/post-a", lastmod: "2026-05-10" },
// ...
// ]lastmod prefers dateModified over date, omitted when both are
empty. The first entry is always the list URL.
If your sitemap needs relative paths (because it prepends APP_URL
itself), use blog.service.list() instead and build entries inline:
const posts = await blog.service.list()
posts.map(p => ({ loc: `/blog/${p.slug}`, lastmod: p.dateModified || p.date }))Public API
import {
createBlog,
FsSource, DbSource,
defaultLayout,
parseFrontmatter, renderMarkdown, esc,
type BlogSource, type Post, type PostMeta, type PageMeta,
type BlogTheme, type BlogPlugins,
type FsSourceOptions, type DbSourceOptions, type DbColumnMap,
type SitemapEntry,
} from "@ciromaciel/blog"
const blog = createBlog({ /* ... */ })
blog.service // { list(), get(slug), slugExists(slug) }
blog.router // Elysia router — .use(blog.router)
blog.sitemapEntries // () => Promise<SitemapEntry[]>
blog.prefix // normalized (no trailing slash)
blog.canonicalBase // normalized (no trailing slash)Generated JSON-LD
Every /blog/:slug page receives a BlogPosting JSON-LD object
(passed to theme.layout via meta.structuredData). Shape:
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "...",
"description": "...",
"datePublished": "2026-05-10",
"dateModified": "2026-05-15",
"author": { "@type": "Organization", "name": "..." },
"publisher": { "@type": "Organization", "name": "...", "url": "..." },
"mainEntityOfPage": { "@type": "WebPage", "@id": "..." },
"url": "...",
"keywords": "tag1, tag2"
}If plugins.faqFor returns a jsonLd object, structuredData becomes
an array [BlogPosting, FAQPage]. The default layout emits both; a
custom layout can choose to emit only one.
File layout
Standard publishable-package layout: README at the root, code under
src/. The root index.ts is a thin re-export so consumers import
from @ciromaciel/blog regardless of internal structure.
src/engines/blog/
├── README.md this file
├── package.json @ciromaciel/blog manifest
├── index.ts public entrypoint (re-exports ./src)
└── src/
├── index.ts createBlog factory + public exports
├── types.ts BlogConfig, BlogSource, PageMeta, etc.
├── router.ts buildService + buildRouter (Elysia)
├── escape.ts HTML escape helper
├── frontmatter.ts minimal YAML-ish parser
├── markdown.ts marked wrapper
├── sources/
│ ├── fs.ts FsSource — markdown + frontmatter
│ └── db.ts DbSource — bun:sqlite, configurable schema
└── views/
├── default-layout.ts fallback HTML5 layout
├── list.ts list page (Tailwind/DaisyUI classes)
└── post.ts post page (Tailwind/DaisyUI classes)Each *.ts file pairs with a colocated *.test.ts.
The list/post views use Tailwind utility classes by convention. They render fine without Tailwind — classes become inert — and look plain. Hosts using a different CSS framework should provide a layout that includes their own styles; the inner HTML structure is intentionally semantic.
Tests
Run the engine's test suite:
bun test src/engines/blog68 tests cover frontmatter parsing, escape edge cases, FS and DB
sources, source merging/precedence, router endpoints, plugins,
default layout, and the createBlog factory (defaults, shortcuts,
explicit-overrides-shortcuts).
Each test file pairs with one source file — read the tests for working code samples.
Extending
| Need | How |
|---|---|
| New data source (Notion, MDX, REST API) | Implement BlogSource, pass via sources: [] |
| Different markdown engine | Replace inside a custom source — renderMarkdown is internal to FsSource |
| Custom URL pattern (e.g. /articles/:year/:slug) | Out of scope — build a wrapper around blog.service |
| Multi-locale | Instantiate one createBlog per locale with different prefix and sources |
| Pagination | Not built in — handle in a custom layout using blog.service.list() and slice |
The engine deliberately stays small. Anything beyond list + single
post is the host's job: pagination, search, RSS, comments, drafts,
admin editing. The exported blog.service gives you the data; build
on it.
Non-goals
- Admin / authoring UI. Writing posts happens elsewhere (file system, CMS, agent).
- Comments, reactions, social. Out of scope.
- Search. Use SQLite FTS5 or an external index against
blog.service.list(). - Pagination. The default views render every post. Override the list view via the layout if you need cursors.
- Multi-locale switching. The engine has no concept of locale — host instantiates one blog per locale.
