Content & collections
Markdown articles with locale fallback, and sharded JSON collections read on demand.
How content works
The content plugin turns Markdown files into Article objects. It does not read files itself:
a provider does. On Node you compose fileSystemContent, which reads
content/<slug>/<locale>.md, parses the frontmatter and renders the body to HTML with Shiki
highlighting. The plugin adds locale fallback, draft filtering, date sorting and a cache on top.
Routes reach it in build-only loaders with ctx.require(contentPlugin). Nothing of the Markdown
pipeline ships to the browser.
Article field | Holds |
|---|---|
frontmatter | title, date, description, tags, language, draft?, author? |
computed | slug, readingTime (minutes, at least 1), contentId, status, wordCount |
html | The rendered body |
locale, isFallback | The requested locale, and whether the default-locale file was used |
url | The article URL in this locale, e.g. /uk/second-post/ |
For data that is not an article and that the browser loads on demand, such as a search index,
the collection plugin writes JSON shards at build and reads them in the browser.
Example
content/
hello-world/
en.md # frontmatter: title, date, description, tags, language
uk.md # same slug, another locale
images/
cover.png # co-located, copied to dist/hello-world/images/
second-post/
en.md # no uk.md: /uk/second-post/ falls back to Englishimport { buildPlugin, contentPlugin, createApp, fileSystemContent } from "@moku-labs/web";
import { routes } from "./routes";
export const app = createApp({
plugins: [contentPlugin, buildPlugin],
pluginConfigs: {
site: { name: "My Blog", url: "https://blog.dev", author: "Ada", description: "Notes." },
content: {
providers: [
fileSystemContent({
contentDir: "content",
shikiTheme: "github-dark",
defaultAuthor: "Ada"
})
]
},
i18n: { locales: ["en", "uk"], defaultLocale: "en" },
router: { routes }
}
});- A bundled Shiki theme name, or your own theme object.
- Used when an article's frontmatter has no
author.
import { contentPlugin, defineRoutes, route } from "@moku-labs/web/browser";
export const routes = defineRoutes({
post: route("/{lang:?}/{slug}/")
.generate(async ctx => {
const byLocale = await ctx.require(contentPlugin).loadAll();
return (byLocale.get(ctx.locale) ?? []).map(article => ({
lang: ctx.locale,
slug: article.computed.slug
}));
})
.load(ctx => ctx.require(contentPlugin).load(ctx.params.slug, ctx.locale))
.render(ctx => (
<article>
{ctx.data.isFallback ? <p>This post is not translated yet.</p> : null}
<h1>{ctx.data.frontmatter.title}</h1>
<div dangerouslySetInnerHTML={{ __html: ctx.data.html }} />
</article>
))
.head(ctx => ({ title: ctx.data.frontmatter.title, description: ctx.data.frontmatter.description }))
});- Cached: every page of every route can call it, the files are read once.
Locale fallback and drafts
load(slug, locale) reads <slug>/<locale>.md. When that file is missing it reads the default
locale's file and sets isFallback: true. locale and url stay the requested locale, so the
page is written at /uk/second-post/. loadAll() does the same for every slug and every
locale, then sorts each locale's list by date, newest first.
An article with draft: true is left out when the global stage is "production", the
default. In "development" and "test" it loads normally. In production a draft passed to
load() throws the same not-found error as a missing file, so drafts cannot be probed.
The Markdown pipeline
The framework pipeline already includes GitHub-flavoured Markdown, the generic directive syntax,
Shiki and, last, rehype-sanitize.
Option of fileSystemContent | Default | What it does |
|---|---|---|
contentDir | required | The article root. |
trustedContent | false | false runs rehype-sanitize as the last step. Set true only when you write every file yourself. |
shikiTheme | "github-dark" | A bundled theme name or a ThemeRegistration object. |
defaultAuthor | none | Author for articles without one. |
extraRemarkPlugins | [] | Added after the framework's remark plugins. They never replace them. |
extraRehypePlugins | [] | Added after the framework transforms, before Shiki and sanitize. |
mermaid | off | Renders mermaid fences to inline SVG at build. Needs trustedContent and the optional peer mermaid-isomorphic. |
embed | off | ::embed{src="…" title="…"} becomes a click-to-load iframe facade. Needs trustedContent. |
gallery | off | ::gallery{src="./images/dir/" caption="…"} becomes an image set from a co-located folder. Needs trustedContent. |
::embed needs the lazyEmbed island in the browser to swap the facade for the real iframe:
import { createApp, defineRoutes, lazyEmbed, route } from "@moku-labs/web/browser";
const routes = defineRoutes({ home: route("/").render(() => <h1>My Blog</h1>) });
const app = createApp({
pluginConfigs: {
site: { name: "My Blog", url: "https://blog.dev", author: "Ada", description: "Notes." },
router: { routes },
spa: { islands: [lazyEmbed] }
}
});
await app.start();- Activates every
[data-island="lazy-embed"]facade on click.
Collections
A collection is a set of JSON files the build writes and the browser reads on demand. You name
the collection and the shard; the plugin owns the file layout:
<outDir>/<collection>/<shard>.json, fetched from <baseUrl><collection>/<shard>.json.
import { collectionPlugin, contentPlugin, createApp, fileSystemContent } from "@moku-labs/web";
const app = createApp({
plugins: [contentPlugin, collectionPlugin],
pluginConfigs: {
site: { name: "My Blog", url: "https://blog.dev", author: "Ada", description: "Notes." },
i18n: { locales: ["en", "uk"], defaultLocale: "en" },
content: { providers: [fileSystemContent({ contentDir: "content" })] }
}
});
const byLocale = await app.content.loadAll();
const shards = [...byLocale].map(([locale, articles]) => ({
collection: "search",
shard: locale,
data: articles.map(article => app.content.articleToCard(article))
}));
const summary = await app.collection.write(shards, { outDir: "dist" });
console.log(summary.files);- A shard key may contain
/, e.g."en/animals", which becomes a folder.
import { loadCollectionShard } from "@moku-labs/web/browser";
import type { Content } from "@moku-labs/web/browser";
export async function searchTitles(query: string): Promise<Content.ArticleCard[]> {
const cards = await loadCollectionShard<Content.ArticleCard[]>("/", "search", "en");
const needle = query.toLowerCase();
return cards.filter(card => card.title.toLowerCase().includes(needle));
}- Fetches
/search/en.jsonand throws on a non-OK response.
bun scripts/write-index.ts [ "search/en.json", "search/uk.json" ] head -c 120 dist/search/uk.json [{"contentId":"uk:0000:second-post","status":"published","title":"Second post","date":"2026-02-01",
collectionPlugin is exported from @moku-labs/web only. In the browser bundle, read shards
with loadCollectionShard or build the URL with collectionUrl from @moku-labs/web/browser.
contentneeds a provider. The defaultproviders: []failscreateAppwith[web] content: no provider composed.- Five frontmatter fields are required.
title,date,description,tagsandlanguage. A missing one fails the build with the field name. load()throws on a missing article.[web] content article "nope" not found for locale "en".Build page lists fromloadAll(), not from guessed slugs.loadAll()returns a cached object. Copy before you sort or change it.trustedContent: falsestrips more than scripts. Inlinestyleand Shiki's per-token colours go too. Usetrueonly for Markdown you write yourself.- Write collections after the build.
build.run()cleansoutDirfirst, so a shard written before it is deleted.
Related
- content plugin: the full API and every option.
- collection plugin:
write,at,urlFor,fileFor. - Routing:
generateandloadwithctx.require. - i18n: locales and the fallback chain.