Skip to content
Moku LabsDocs
@moku-labs/web pages

Content & collections

Markdown articles with locale fallback, and sharded JSON collections read on demand.

content.mdllms.txt

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 fieldHolds
frontmattertitle, date, description, tags, language, draft?, author?
computedslug, readingTime (minutes, at least 1), contentId, status, wordCount
htmlThe rendered body
locale, isFallbackThe requested locale, and whether the default-locale file was used
urlThe 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/
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 English
src/app.ts
import { 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 }
  }
});
  1. A bundled Shiki theme name, or your own theme object.
  2. Used when an article's frontmatter has no author.
src/routes.tsx
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 }))
});
  1. 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 fileSystemContentDefaultWhat it does
contentDirrequiredThe article root.
trustedContentfalsefalse 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.
defaultAuthornoneAuthor 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.
mermaidoffRenders mermaid fences to inline SVG at build. Needs trustedContent and the optional peer mermaid-isomorphic.
embedoff::embed{src="…" title="…"} becomes a click-to-load iframe facade. Needs trustedContent.
galleryoff::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:

src/client.tsx
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();
  1. 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.

scripts/write-index.ts
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);
  1. A shard key may contain /, e.g. "en/animals", which becomes a folder.
src/search.ts
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));
}
  1. Fetches /search/en.json and throws on a non-OK response.
write the index
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.

  • content needs a provider. The default providers: [] fails createApp with [web] content: no provider composed.
  • Five frontmatter fields are required. title, date, description, tags and language. 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 from loadAll(), not from guessed slugs.
  • loadAll() returns a cached object. Copy before you sort or change it.
  • trustedContent: false strips more than scripts. Inline style and Shiki's per-token colours go too. Use true only for Markdown you write yourself.
  • Write collections after the build. build.run() cleans outDir first, so a shard written before it is deleted.