@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.tsare 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 themeFrontmatter: 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 withtheme/notfound.js. Emitting it is not cosmetic: see Deploy below.manifest.json— the machine-readable index of what was built: the resolvedbasePath/postsPath/slugFormat, counts (including how many drafts and scheduled posts were held back), and every post, page, and tag with both its site-relativepathand absoluteurl.
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 configEvery 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 }); // anywhereA 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:
- 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. loadJsPagesmust return[]for anything remote. JS pages areimport()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.- 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.
