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

@odla-ai/blog

v0.2.1

Published

Minimal-dependency, static-first blogging platform. Files in, site out; odla-db lights up interactivity when configured.

Downloads

1,435

Readme

@odla-ai/blog

⚠️ Early access — pre-1.0. Agents work from bounded runbooks; humans approve credentials, production changes, releases, and merges. APIs and exact package availability can change. Review the documented guarantees and limitations; this software is MIT-licensed and provided without warranty.

Minimal-dependency, static-first blogging platform. A site is a directory of markdown plus one JSON config; build() emits a fully static site that works on GitHub Pages or Cloudflare. When odla-db is configured, interactive features (sign-in, comments, likes) light up as a progressive layer — the static site never depends on it.

Three runtime dependencies, each with zero dependencies of its own: marked (markdown), yaml (frontmatter), highlight.js (build-time syntax highlighting — no client JS ships).

Ask the runbooks first. odla's operational procedures live in a database, not in this file: npx @odla-ai/cli runbook ask "<question>" returns the current steps, and unlike anything written here it cannot be out of date. Use it before searching the web or working from memory. This README and the JSDoc in the shipped .d.ts are the version-matched API reference; a runbook is the procedure. Most tasks need an answer from both.

Site layout

my-blog/
├── blog.config.json     # the only config
├── posts/2026/2026-07-03-hello.md
├── pages/about.md
├── public/              # copied verbatim
└── theme/               # optional file-by-file overrides of the chosen theme

Frontmatter: title and date required; description, tags, draft, image optional. draft: true or a future date keeps a post out of the build.

Themes

Four bundled themes, all with light + dark modes (system preference + the reading-preferences menu below):

  • juniper (default) — quiet long-form reading; Lora serif, violet/lavender
  • salt — warm cream editorial; Cormorant Garamond, moss/rust/teal
  • chalk — chalkboard on graph paper; copper/cerulean, mono chrome
  • clay — sand paper and terracotta; system fonts only, zero external requests

Pick one with "theme": "salt" in blog.config.json. Themes ship in the @odla-ai/ui dependency (the odla design system); every built site also gets ui's class-scoped component layer (assets/odla-ui.css — buttons, form fields, cards, …) theme-matched via the theme's ui.css token aliases. Override any theme file by placing a same-named file in your site's theme/ directory.

Reading preferences

The nav bar carries a preferences menu — the reader's own display controls, not the site's. Five independent axes, each persisted and each applied as one attribute on <html>:

| Axis | Values | | --- | --- | | Appearance | System · Light · Dark | | Palette | Theme · Monochrome · High contrast | | Text size | Normal · Large · Larger | | Text weight | Normal · Medium · Bold | | Motion | System · Reduced · Full |

Monochrome and high contrast layer over the theme you picked rather than replacing it. High contrast sharpens yours: it stops dimming muted text, takes hairlines to full ink, flattens tinted surfaces, drops shadows, and deepens your accents until each clears WCAG AAA — hue held, only lightness moved, so a green site stays green. Monochrome collapses hue entirely, which is the point of it.

Both reach the page, not just the component layer: the build inverts your theme's ui.css alias map per palette, narrowed to the roles that palette actually declares. That narrowing is load-bearing — inverting a role the palette leaves alone puts --ui-bg: var(--bg) and --bg: var(--ui-bg) both in force, which CSS resolves as a cycle and the colour disappears.

It is a native <details> with real radio inputs, so it opens, reads and announces itself before (and without) its client module; assets/prefs.js only adds persistence. Stored choices apply before first paint via a snippet in <head>, and the appearance axis reuses the old toggle's storage key, so readers who had already chosen dark keep it.

Sites whose text runs light should express their root rule through the tokens the size and weight axes turn:

html {
  font-size: calc(var(--ui-font-size-root) * var(--ui-font-scale));
  font-weight: var(--ui-weight-body);
  -webkit-font-smoothing: var(--ui-font-smoothing);
}

A hard-coded -webkit-font-smoothing: antialiased is the usual culprit behind body copy that reads too thin on macOS — it turns off subpixel rendering, and --ui-font-smoothing defaults to auto instead.

Keep layout measures out of rem, or the text-size axis widens your columns and header rules along with the type, which is page zoom rather than a text setting:

--measure: calc(46 * var(--font-size-root));   /* not 46rem */

If your site shadows theme/base.js, wire the control yourself with three exports — PREFS_NO_FLASH in <head>, prefsMenuHtml() wherever the old .theme-toggle button was, and prefsScriptTag(base) before </body>:

import { PREFS_NO_FLASH, prefsMenuHtml, prefsScriptTag } from "@odla-ai/blog";

The build emits assets/prefs.js and folds the axis, palette, and menu CSS into assets/styles.css either way, so a shadowed shell needs no extra links.

Writing: dev()

dev() builds the site, serves it, and rebuilds on change with live reload:

import { dev, formatSummary } from "@odla-ai/blog";

const server = await dev({ siteDir: ".", outDir: "dist-site" });
console.log(formatSummary(server.result), server.url);
// → 398 posts (39 drafts, 0 scheduled), 3 pages   http://127.0.0.1:4173/

Drafts are visible in dev (includeDrafts defaults to true) and excluded from a plain build(). The server resolves clean URLs the way a static host does, honours basePath, and — unlike an SPA-style fallback — answers a missing path with your 404.html and a real 404. A failed rebuild is reported through onBuild({ error }) and leaves the last good output being served.

Build output

Alongside the pages, every build emits:

  • 404.html — a themed not-found page. Override it with theme/notfound.js. Emitting it is not cosmetic: see Deploy below.
  • manifest.json — the machine-readable index of what was built: the resolved basePath/postsPath/slugFormat, counts (including how many drafts and scheduled posts were held back), and every post, page, and tag with both its site-relative path and absolute url.

Read it with readManifest(outDir) rather than re-deriving URLs. A post's URL depends on basePath, postsPath, and slugFormat together, so anything that assembles one by hand is really hard-coding today's config:

import { readManifest, postUrl } from "@odla-ai/blog";

const { posts } = await readManifest("dist-site");
posts[0].url; // "https://example.com/posts/hello/" — no guessing
postUrl(config, post, { absolute: true }); // same rule, if you have the config

Every entry in manifest.json is a page that was actually written, so a link checker can treat the whole list as must-be-200 without filtering.

Search

Every build emits a search index and a small client; a reader who never searches downloads neither. Measured on a 438-post, 182k-word blog the whole index is 162 KB brotli and the client is 2.9 KB — fetched once, on the first interaction with the search box.

No configuration is needed. Disable it with "search": false in blog.config.json, which emits no search bytes at all.

The index is built from post objects rather than from rendered HTML, so it carries structure a post-build crawler cannot see: matches are ranked by where they hit (title > tag > lead > body) with recency breaking ties, drafts are excluded by the same rule that excludes them from the build, and search works identically no matter which content source the posts came from. Fenced code blocks are left out — they bury prose results under syntax — while inline code is indexed, since API names are exactly what people search a technical blog for.

Post bodies are not stored, only their terms, which is what keeps the index a fraction of the corpus it searches.

If your site shadows theme/base.js, add the box and the module yourself — the packaged template is what carries them:

<div class="site-search" data-search hidden>
<input type="search" class="site-search-input" data-search-input
       placeholder="Search" aria-label="Search posts" autocomplete="off">
<div class="site-search-results" data-search-results hidden></div>
</div>
<script type="module" src="/assets/search/client.js"></script>

The box ships hidden and is revealed by the client, so a reader without JS is never shown a search field that does nothing. Styling comes from the @odla-ai/ui --ui-* tokens, so it matches every theme, light and dark, without any theme shipping search CSS.

Content sources

build() takes its posts, pages, and collections from a source. The default reads the site directory, so nothing changes if you ignore this:

await build({ siteDir: ".", outDir: "dist-site" });                  // files
await build({ siteDir: ".", outDir: "dist-site", source: mySource }); // anywhere

A source is four methods — loadPosts, loadPages, loadJsPages, loadCollections. memorySource builds from odla-db-shaped records, which is the groundwork for publishing from a database instead of a git push:

import { build, fileSource, memorySource, postToRecord } from "@odla-ai/blog";

const posts = await fileSource(".").loadPosts({ includeDrafts: true });
const records = posts.map(postToRecord);          // ← what a `blogPosts` row holds
await build({ siteDir: ".", outDir: "dist-site", source: memorySource({ posts: records }) });

BLOG_SCHEMA is the odla-db schema for that shape, already in the serialized form the schema endpoint accepts, and BLOG_RULES is a matching CEL preset. Neither pulls in @odla-ai/db — the package stays static-first.

Three rules bind any source you write:

  1. Build posts with collectPosts(). It owns slug-collision detection, title/date validation, draft and scheduled gating, and ordering. A source that re-implements them will drift, and the symptom is posts silently appearing or vanishing from a build.
  2. loadJsPages must return [] for anything remote. JS pages are import()ed and executed at build time; sourcing executable modules from a database would let anyone who can write a row run code in CI. This is a security boundary, not a gap to be closed later.
  3. Paginate internally. Callers get complete arrays, but odla-db caps a query at 1000 rows and a 1 MB response, so a db source must page rather than issue one unbounded query.

Config, themes, and public/ always come from siteDir regardless of source.

Input boundaries

Footnote identifiers are capped at 256 characters, and malformed [^ introducers are handled by a monotonic scanner so adversarial Markdown cannot force repeated whole-suffix regex scans. Markdown still permits authored raw HTML by design; it is not a sanitizer. JS pages and collection templates must use escapeHtml(String(value)) for every schema-free frontmatter value or filename-derived slug placed into HTML.

Deploy (Cloudflare)

build() emits a plain static directory; serve it from a Worker assets binding — { "assets": { "directory": "./dist-site" } } in wrangler.jsonc, build before every deploy. Add a main worker script only when you want /api/* routes next to the static site; the public platform manual describes how to combine the assets binding with odla-db and o11y.

Keep 404.html in the deployed output. Cloudflare Pages treats a site with no top-level 404.html as a single-page app and answers every unmatched path with the home page and a 200. A mistyped or stale link then looks healthy to a browser, a status check, and a link checker alike — the failure is invisible exactly where you would look for it. On Workers assets the equivalent knob is explicit: set "not_found_handling": "404-page".

Status

Early. The build pipeline, themes, dev(), the build manifest, search, and the content-source interface work; the CLI (init/new/check), a live odla-db source, deploy scaffolding, and the odla-db islands are in progress. Track supported behavior in this installed README and the public package docs at https://odla.ai/docs/packages/blog rather than relying on internal planning files.