npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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/blog

68 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.