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

content

Node build. Markdown articles to sanitized HTML, with frontmatter, reading time, locale fallback and drafts.

content.mdllms.txt

What it does

Node build. content gives you a locale-keyed model of Markdown articles. Each content/<slug>/<locale>.md becomes an Article with its frontmatter, computed fields and rendered HTML. Routes read it in build-only loaders with ctx.require(contentPlugin).

The plugin itself imports no Node code and is exported from both entries, so a route file can ctx.require(contentPlugin) in either app. The reading and rendering happen in a provider. fileSystemContent, the Node provider, is exported from @moku-labs/web only.

How it works

The plugin is a shell around providers, the same way env works with env providers:

The shell ownsThe provider owns
Locale fallbackFinding slugs
Draft filtering by stageReading <slug>/<locale>.md
Date sort, newest firstFrontmatter parsing
contentId assignmentThe Markdown pipeline and Shiki
The article cacheThe rendered HTML
content:ready and content:invalidated

With several providers, slugs() are merged and the first provider that has a slug and locale wins.

The fileSystemContent pipeline: frontmatter, then remark (GitHub-flavoured Markdown, the directive syntax, your extraRemarkPlugins), then rehype (raw HTML, the framework transforms, your extraRehypePlugins), then Shiki, then rehype-sanitize when trustedContent is false. The processor is built on the first render and kept inside the provider.

Computed fields:

FieldValue
slugThe folder name.
readingTimeMinutes, rounded up, at least 1.
wordCountWords in the source body.
status"draft" when the frontmatter has draft: true, else "published".
contentIdThe slug on a single load. After loadAll sorts: <locale>:<index>:<slug>, e.g. uk:0000:second-post.

Frontmatter dates become ISO YYYY-MM-DD strings. Relative image paths such as ./images/cover.png become /<slug>/images/cover.png, so they work from every locale page.

Config

pluginConfigs.content.

OptionTypeDefaultDescription
providersContentProvider[][]Content sources, in order. At least one is required: the empty default fails createApp.

fileSystemContent(options):

OptionTypeDefaultDescription
contentDirstringrequiredThe article root, absolute or relative to the project.
trustedContentbooleanfalsefalse sanitizes the HTML last. true skips sanitize. Use it only for Markdown you write yourself.
shikiThemeBundledTheme | ThemeRegistrationAny"github-dark"Passed to @shikijs/rehype.
defaultAuthorstringnoneAuthor for articles without one.
extraRemarkPluginsreadonly Pluggable[][]Added after the framework remark plugins.
extraRehypePluginsreadonly Pluggable[][]Added after the framework transforms, before Shiki and sanitize.
mermaidboolean | MermaidDiagramOptionsoffmermaid fences to inline SVG at build. Needs trustedContent and mermaid-isomorphic. A diagram that fails fails the build.
embedboolean | { facade? }off::embed{src title width? height?} to a click-to-load facade. Needs trustedContent and the lazyEmbed island.
galleryboolean | { component? }off::gallery{src caption?} to an image set from a co-located folder. Needs trustedContent.

The extra* options add to the framework plugins and never replace them. A full plugin array in config would be replaced by the shallow config merge, which is why there is none.

API

Mounted at app.content, reachable with ctx.require(contentPlugin).

MethodSignatureDescription
loadAll(options?: { reuse?: boolean }) => Promise<Map<string, Article[]>>Every article of every locale, newest first, with fallback. Cached: repeated calls return the same object. { reuse: false } reloads everything. Emits content:ready.
load(slug: string, locale: string) => Promise<Article>One article with fallback. Throws [web] content article "…" not found for locale "…".
renderMarkdown(md: string) => Promise<string>Renders a Markdown string through the provider pipeline.
invalidate(paths: readonly string[]) => voidMarks files stale for the next dev rebuild. Emits content:invalidated.
articleToCard(article: Article) => ArticleCardA light projection without HTML, for lists.
contentDir() => stringThe first provider's folder. The build copies co-located assets from it.

ArticleCard is { contentId, status, title, date, description, tags, readingTime, url }.

Events

Emits

EventPayloadWhen
content:ready{ locales: readonly string[]; articleCount: number }At the end of every loadAll() that loaded.
content:invalidated{ paths: readonly string[] }On invalidate(paths), with the accepted, non-empty paths.

Listens to

None.

Depends on

Nothing in depends. i18n is read when present: its locales decide what loadAll iterates and its default locale is the fallback. Without it there is one locale, "en".

Example

scripts/stats.ts
import { contentPlugin, createApp, createPlugin, fileSystemContent } from "@moku-labs/web";

const statsPlugin = createPlugin("stats", {
  depends: [contentPlugin],
  hooks: ctx => ({
    "content:ready": ({ locales, articleCount }) => ctx.log.info("content:stats", { locales, articleCount })
  })
});

const app = createApp({
  plugins: [contentPlugin, statsPlugin],
  config: { stage: "development" },
  pluginConfigs: {
    site: { name: "My Blog", url: "https://blog.dev", author: "Ada", description: "Notes." },
    i18n: { locales: ["en", "uk"], defaultLocale: "en" },
    content: {
      providers: [fileSystemContent({ contentDir: "content", shikiTheme: "github-light", defaultAuthor: "Ada" })]
    }
  }
});

const byLocale = await app.content.loadAll();
console.log([...byLocale].map(([locale, articles]) => `${locale}: ${articles.length}`));

const article = await app.content.load("second-post", "uk");
console.log(article.isFallback, article.url); // true "/uk/second-post/"

const html = await app.content.renderMarkdown("Inline `code` and **bold**.");
console.log(html); // "<p>Inline <code>code</code> and <strong>bold</strong>.</p>"
  1. Drafts load in development and test. In production, the default, they are left out.
  2. Emits content:ready, which the stats plugin logs.
run
bun scripts/stats.ts
{ level: "info", event: "content:stats", data: { locales: [ "en", "uk" ], articleCount: 4 } }
[ "en: 2", "uk: 2" ]
true /uk/second-post/
<p>Inline <code>code</code> and <strong>bold</strong>.</p>

Two articles in two locales count as four: second-post has no uk.md, and its fallback copy in the uk list counts too.

  • No provider, no app. providers: [] fails createApp with [web] content: no provider composed.
  • mermaid, embed and gallery need trustedContent: true. fileSystemContent throws at construction otherwise: their output is raw HTML the sanitizer would strip.
  • Five frontmatter fields are required. title, date, description, tags, language. A missing one throws [web] content frontmatter is missing required field "…".
  • Drafts depend on the global stage. stage: "production", the default, hides them everywhere, and load() of a draft throws the not-found error.
  • The loadAll() result is shared. It is the cached object. Copy before you sort or filter in place.
  • fileSystemContent is not in @moku-labs/web/browser. The browser entry has the shell only, for typing and ctx.require.