Custom host lifecycle#
Use oxContentCustomHost() when the site owns layout and publication policy,
but Ox Content should own the Vite lifecycle.
// vite.config.ts
import { defineConfig } from "vite";
import { oxContentCustomHost, planCollectionAssets } from "@ox-content/vite-plugin";
export default defineConfig({
appType: "custom",
plugins: [
oxContentCustomHost({
host: "./src/site-host.ts",
oxContent: {
srcDir: "content",
redirects: { provider: "netlify" },
ssg: {
markdownSource: true,
siteUrl: "https://example.com",
siteName: "Example",
},
},
themeTokens: {
theme: colorScheme,
include: (name) => name.startsWith("syntax-"),
},
collectionAssets: {
manifest: (ctx) =>
planCollectionAssets({
root: ctx.root,
assets: [
{
sourcePath: "content/projects/cover.jpg",
publicPath: "/projects/cover.jpg",
},
],
}),
watch: [{ path: "content/projects", kind: "directory" }],
ownedPrefixes: ["/assets/content"],
},
dev: {
routeDependencies: [{ path: "content/projects", kind: "directory" }],
},
}),
],
});
The factory registers oxContent({ ssg: { enabled: false } }) and the custom
host plugin together. A lower-level createOxContentCustomHostPlugin() is also
available for hosts that already install oxContent() themselves.
Host module#
The host module exports routes. Routes render ordinary Response objects or a
plain object with html, text, contentType, metadata, and dependencies.
// src/site-host.ts
export default {
routes: [
{
path: "/",
inputPath: "content/index.md",
source: "# Home\n",
aliases: ["/old-home"],
dependencies: ["src/data.ts"],
async render(ctx) {
const data = await ctx.loadModule("/src/data.ts");
const assets = ctx.assets.document({
head: "<title>" + data.title + "</title>",
sharedStyles: [ctx.assets.themeTokens?.href].filter(Boolean),
clientEntries: ["src/main.ts"],
crossorigin: true,
});
return {
html: `<!doctype html><html><head>${assets.headHtml}</head><body>${data.html}</body></html>`,
title: data.title,
};
},
},
{
path: "/feed.xml",
render: () =>
new Response("<feed />", {
headers: { "content-type": "application/xml; charset=utf-8" },
}),
},
],
async outputs(ctx) {
const content = await ctx.memo("content", () => ctx.loadModule("/src/content.ts"));
return {
siteDescription: content.siteDescription,
collectionNames: ["blog", "media"],
collections: {
blog: content.blogFeedItems,
media: content.mediaFeedItems,
},
};
},
notFound() {
return { text: "Not Found", status: 404, contentType: "text/plain" };
},
};
In development, Ox Content SSR-loads the host through Vite, dispatches matching
routes, preserves status and content type, applies transformIndexHtml() only
to HTML, and falls through when no route or custom 404 handles the request.
Route responses are cached as promises. Declared dependencies invalidate only
the affected responses, reloads are debounced, failed renders retry, and an old
in-flight render cannot delete a newer cache entry. Requests carrying
Cookie or Authorization are rendered without storing their response, so
request identities are not shared through the development cache.
dependencies accepts file paths or { path, kind } descriptors where kind
is "file", "directory", or "glob". dev.dependencies invalidates every
response, while
dev.routeDependencies also clears and reloads the route catalogue.
Modules loaded through ctx.loadModule() and CSS returned by
ctx.assets.stylesheets() are tracked automatically in development.
Set dev.feedOutputs: true to serve configured RSS, Atom, and JSON feed files
from outputs(ctx) in development. Explicit host routes win when they use the
same path. Missing feed paths fall through to the host notFound() handler or
Vite, and failed feed renders are not cached so the next matching request can
retry after the source or module error is fixed.
In production, the plugin runs once from closeBundle, after Vite has emitted
client assets and .vite/manifest.json. It opens a temporary middleware-mode
Vite server only to SSR-load the host and site modules, passes ctx.loadModule
instead of the raw server, and closes the temporary server in finally.
outputs(ctx) is called only when feeds is enabled. It is build-only unless
the development server opts into dev.feedOutputs.
ctx.memo(key, load) shares one expensive route-catalogue load inside a build
or development route-catalogue pass. Use the same key from routes(ctx) and
outputs(ctx) to reuse a host-owned content artifact without module-global
mutable state. Rejected loaders are evicted so the next call can retry.
Markdown renderer#
Every custom-host context exposes ctx.markdown.render() for hosts that read
Markdown or MDX source themselves but want Ox Content to own the configured
rendering pipeline. Pass the raw source and the real documentPath.
const page = await ctx.markdown.render<{
clientModules: Array<{ moduleId: string }>;
}>({
source,
documentPath,
async renderHtml(markdown) {
const solid = await markdown.loadModule("/src/solid-renderer.ts");
return solid.render(markdown.html, {
documentPath: markdown.documentPath,
imports: markdown.transform.imports,
root: markdown.root,
srcDir: markdown.srcDir,
contentRoot: markdown.contentRoot,
});
},
});
const styles = ctx.assets.stylesheets({
modules: page.metadata?.clientModules.map((module) => module.moduleId) ?? [],
});
The supported order is:
- Markdown and MDX syntax transforms from the resolved Ox Content options.
- Built-in HTML/embed transforms, with project-relative provider cache and media output paths resolved from the Vite root.
- Legacy
<Island>HTML transform. - Optional
renderHtml(markdown)framework integration. - Reader-chrome HTML transformation from
oxContent.ssg.readerChrome, unless the call overridesreaderChrome.
The result includes final html, the pre-framework markdownHtml, the full
TransformResult, flattened frontmatter, toc, MDX imports, MDX exports,
components, optional framework metadata, and dependencies. The input
document path is included in dependencies; merge the returned dependencies
into the route result so development responses invalidate when the source
changes. Modules loaded with markdown.loadModule() and stylesheets resolved
inside the renderer callback are tracked by the same development lifecycle as
ctx.loadModule() and ctx.assets.stylesheets(), so a failed or changed
renderer can recover without a process-global cache.
The framework integration remains host-owned. The framework-neutral island
contract is available from @ox-content/vite-plugin/html-host for adapters that
own server rendering, head output, slots, browser hydration, mounting, and
disposal. Solid and Svelte custom hosts can keep using
createSolidHtmlHostRenderer() or createSvelteHtmlHostRenderer(); those
helpers wrap the shared contract and return the rendered html, headHtml,
clientModules, and diagnostics metadata the host needs for assets and
hydration. renderHtmlHostMarkdown() from
@ox-content/vite-plugin/html-host can be used inside renderHtml(markdown)
when the host wants the shared Markdown HTML, island metadata, and document
asset composition in one step. The host still owns route selection, publication
rules, layout, URL policy, and global styles.
Island head output is appended in document island order before client entries
when the returned documentAssets.headHtml is placed in the document <head>.
Exact serialized head fragments are emitted once, so two islands that return the
same scoped <style> do not duplicate it; semantic conflicts such as two
different <title> values remain host/framework policy.
Coordinated outputs#
Host-rendered HTML routes are connected to the same public output writers as the default SSG:
writeResourceFiles()for resource fingerprinting and rewritten HTML.writeSelfHostedAssets()for fonts and Iconify CSS.writeCollectionAssets()fromcollectionAssets.manifest.writeMarkdownCompanions()from routesource.writeRedirectOutputs()from routealiases/redirect.writeFeedFiles()andwriteSiteMapFiles()from selected route metadata.
Route metadata belongs on the route or render result: title, description,
source, aliases, redirect, lastUpdatedPaths, and publish-state fields
describe that HTML page. Site-output data that is not itself a page belongs in
outputs(ctx): programmatic default items, named feed collections,
collectionNames, and siteDescription. Ox Content passes that data through
planSsgOutputs() and the existing feed writers, so publish-state filtering,
validation, output paths, and diagnostics stay shared with the built-in SSG.
With dev.feedOutputs: true, the same feed channels and outputs(ctx) data
are rendered on demand without writing files. The development server still does
not render every page to serve one feed; hosts should return feed collections or
programmatic items from outputs(ctx) for feed-only data.
Set ssg.minifyHtml: true for built-in SSG pages or
build.minifyHtml: true on the custom-host plugin to minify production HTML.
When the custom-host build option is omitted, it follows oxContent.ssg.minifyHtml.
Minification runs after transformIndexHtml() and resource URL rewriting,
immediately before HTML files are written. It does not transform XML feeds,
JSON feeds, Markdown companions, text outputs, or binary assets.
Use lastUpdatedPaths on a route or render result when sitemap freshness should
consider shared metadata files or source directories in addition to inputPath.
It is only used for git lastmod; dev invalidation still uses explicit
dependencies.
Duplicate route output paths fail the build with the conflicting owners. The host still owns publication selection; Ox Content only writes the routes the host returns.
Collection assets#
Use collectionAssets when the host owns files attached to collections, such
as project images or downloadable artifacts, and wants Ox Content to serve and
write them with the same manifest.
oxContentCustomHost({
host: "./src/site-host.ts",
collectionAssets: {
manifest: async (ctx) =>
planCollectionAssets({
root: ctx.root,
assets: [
{
sourcePath: "content/projects/cover.jpg",
publicPath: "/projects/cover.jpg",
},
],
}),
watch: [{ path: "content/projects", kind: "directory" }],
ownedPrefixes: ["/assets/content"],
},
});
In development, the middleware serves aliases and content-addressed targets
before route rendering. A missing URL under an owned prefix returns 404 instead
of falling through to the host router. When watched files or manifest source
files change, Ox Content re-plans the manifest, clears cached route responses,
and reloads after a successful plan. A failed re-plan is retryable and does not
replace the last successful manifest. In production, collection assets are
written with the coordinated output files unless collectionAssets.write is
false.
Route, render, and output hooks can read the same configured snapshot through
ctx.assets.collectionManifest(). Use it when the host needs the manifest for
HTML rewriting or structured page-data aliases; the writer and development
middleware reuse that resolved manifest instead of calling
collectionAssets.manifest again.
const manifest = await ctx.assets.collectionManifest();
const body = manifest
? rewriteCollectionAssetUrls({
html,
pagePath: ctx.url.pathname,
manifest,
}).html
: html;
When the host already knows the public Markdown or MDX documents, use
planCollectionAssetsFromDocuments() instead of globbing media extensions. It
discovers relative Markdown image/link URLs plus literal HTML and MDX JSX
href, src, and poster attributes from only those selected documents,
resolves files against the real document path, and returns the same
CollectionAssetManifest.
collectionAssets: {
manifest: async (ctx) => {
const result = await planCollectionAssetsFromDocuments({
root: ctx.root,
contentRoot: "content",
documents: [{ documentPath: "content/posts/hello.mdx", pagePath: "/posts/hello" }],
extraAssets: [{ sourcePath: "content/posts/og.png", publicPath: "/og/hello.png" }],
publicPath: (reference) => [reference.publicPath, `/legacy${reference.publicPath}`],
});
if (result.diagnostics.length > 0) {
throw new Error(result.diagnostics.map((diagnostic) => diagnostic.message).join("\n"));
}
return result.manifest;
},
}
External, root-absolute, data/blob, fragment-only, and other non-local URLs are
ignored. Missing files and references outside contentRoot are reported as
document-scoped diagnostics, while query strings and fragments stay on the
reported references and are preserved later by rewriteCollectionAssetUrls().
HTML-string hosts can generate their browser island registry from that same
selected route/document set. Use createHtmlHostIslandRegistry() from
@ox-content/vite-plugin/html-host for a framework-neutral virtual module, or
the Solid and Svelte wrappers when the host wants their stable framework
virtual ids. The generated module contains only the selected island
dynamic-import roots; Vite still keeps their transitive dependencies.
SSR stylesheets#
When route pages or layouts import blocking CSS from server-rendered modules, configure the small set of SSR stylesheet roots up front and resolve the route-selected roots during render:
// vite.config.ts
oxContentCustomHost({
host: "./src/host.ts",
ssrStylesheets: {
modules: ["src/layout.tsx", "src/pages/**/page.tsx"],
},
});
// host.ts
const routes = [{ path: "/about", pageModule: "/src/pages/about/page.tsx" }];
export default {
routes: routes.map((route) => ({
path: route.path,
async render(ctx) {
const layout = await ctx.loadModule("/src/layout.tsx");
const page = await ctx.loadModule(route.pageModule);
const ssrStyles = ctx.assets.ssrStylesheets({
modules: ["/src/layout.tsx", route.pageModule],
});
const assets = ctx.assets.document({
pageStyles: ssrStyles.stylesheets,
clientEntries: ["src/main.ts"],
});
return {
html: `<!doctype html><html><head>${assets.headHtml}</head><body>${layout.render(page)}</body></html>`,
dependencies: ssrStyles.dependencies,
};
},
})),
};
The host supplies module identity explicitly because rendered HTML strings do
not carry their source module ids. Route descriptors can keep page module ids
next to path, inputPath, and other route metadata; pass only the selected
layout/page roots for the current route. Do not derive a page's styles from a
shared route catalogue import, because that would include sibling routes loaded
only to build the catalogue.
In development, call ctx.loadModule() for each selected SSR root before
ctx.assets.ssrStylesheets(). The resolver then uses Vite's module graph to
return blocking stylesheet hrefs plus dependency files that invalidate the
cached route. ssrStylesheets.modules is also watched, so new or deleted page
module files matching a configured glob can refresh the route catalogue.
In production builds, the plugin statically walks configured local JavaScript
and TypeScript SSR roots, follows local static imports, and writes hashed
custom-host CSS artifacts for directly imported plain CSS files. External
package JavaScript is not traversed. Local dynamic imports, missing roots,
unresolved local imports, and CSS resolved outside the Vite root are reported
as diagnostics instead of being silently dropped. Ordering follows the source
import order and then the route's modules order; pass the returned styles
into ctx.assets.document() so overlap with other returned stylesheet
descriptors is deduplicated by the document asset API. Preprocessed stylesheets
that need Vite plugin transforms should stay on explicit browser/CSS entries
and use ctx.assets.stylesheets().
Island stylesheets#
When a rendered route knows the browser module ids used by SSR-visible islands,
resolve their blocking CSS through ctx.assets.stylesheets().
const islandStyles = ctx.assets.stylesheets({
modules: rendered.clientModules.map((module) => module.moduleId),
});
const assets = ctx.assets.document({
islandStyles: islandStyles.stylesheets,
clientEntries: ["src/main.ts"],
});
return {
html: `<!doctype html><html><head>${assets.headHtml}</head><body>${rendered.html}</body></html>`,
dependencies: islandStyles.dependencies,
};
In development, after the host renders islands through ctx.loadModule(), the
resolver walks the internal Vite module graph for each rendered browser module
id, includes direct and transitive CSS in dependency order, preserves CSS query
strings, and returns source file dependencies that can invalidate the cached
route. In build, it reads the Vite manifest and returns the same module
identities with emitted hashed stylesheet hrefs. Missing entries are reported
as diagnostics instead of silently dropping styles. Pass the returned styles
into ctx.assets.document() so document-level dedupe, nonce, base, shared CSS,
and page CSS composition all stay in one place.
For Markdown-first HTML-string hosts, the shared helper wires the same pieces through the custom-host Markdown lifecycle:
const page = await ctx.markdown.render({
source,
documentPath: "content/posts/hello.mdx",
renderHtml(markdown) {
return renderHtmlHostMarkdown({
context: markdown,
renderIslands,
documentAssets: { clientEntries: ["src/client.ts"] },
});
},
});
return {
html: `<!doctype html><html><head>${page.metadata?.documentAssets?.headHtml ?? ""}</head><body>${page.html}</body></html>`,
dependencies: page.dependencies,
};
Build hosts that deliberately inline critical CSS can read the emitted bytes for the same resolved descriptors without deriving filesystem paths from public hrefs:
const islandStyles = ctx.assets.stylesheets({
modules: rendered.clientModules.map((module) => module.moduleId),
});
const critical = await ctx.assets.stylesheetContent({
stylesheets: islandStyles.stylesheets,
});
const assets = ctx.assets.document({
inlineStyles: critical.stylesheets.map((style) => ({
key: `critical:${style.href}`,
content: style.content,
})),
});
stylesheetContent() is build-only for Vite-emitted stylesheets. In
development it returns unavailable diagnostics, and in build it reports
missing-artifact diagnostics when a descriptor has no recorded artifact or the
file cannot be read. Ordering follows the input descriptors, so hosts can keep
their own policy for home-only inlining, linked stylesheets, and dedupe.
Theme token stylesheet#
themeTokens writes and serves a small stylesheet, defaulting to
/__ox_theme_tokens__/theme-tokens.css. Include ctx.assets.themeTokens.href
in ctx.assets.document() to reuse syntax token CSS without a local Vite
transform.
Related#
- Document assets
- SSG output primitives
- Theme
- Tracking: #1281