HonoX Integration
@riebeckite/honox connects portable Core behavior to HonoX and Vite. It owns application-root/config resolution, Vite development and build integration, SSG extension mapping, generated plugin/theme style entries, and the HonoX application build workflow.
Public API
Register the integration with riebeckiteVite() from vite.config.ts. It is
the higher-level helper for a normal site: it appends the Riebeckite plugins,
applies the SSG entry and extension-map defaults, and contributes the SSR
externals the runtime needs, so the site does not restate Vite/HonoX internals.
Combine it with the site's own plugins (the HonoX plugin, a deployment build
plugin, Tailwind, and so on):
import { riebeckiteVite } from "@riebeckite/honox";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [honox({ ... }), ...riebeckiteVite(), build()],
});
riebeckiteVite accepts the same optional configRoot, appRoot, configFile,
and monorepo-only workspaceRoot as the lower-level plugin. appRoot defaults
to the Vite root, and configRoot defaults to appRoot. A config path is
imported relative to configRoot; content.directory is resolved relative to
appRoot. resolveHonoxApplication returns these roots together with the
resolved config, so CLI and Vite use the same model. workspaceRoot is only for
source-package aliases during monorepo development; installed npm consumers use
their own node_modules without it. The integration creates generated import
entries below app/.riebeckite/, and exposes the required client module as a
virtual module rather than writing it into that directory. Generated files are
integration output: do not edit them as application source.
Bootstrap modules
A generated site imports framework-owned bootstrap modules instead of keeping
resolved config and content-manager code in the application: the resolved
config is virtual:riebeckite/config, and the configured content runtime is
virtual:riebeckite/content. This is why app/config.ts, app/content.ts, and
app/constants/paths.ts are not generated. The SSG entry app/server.ts
re-exports both, and riebeckiteSsg finds the manifest through them.
A script that runs outside Vite (for example a Node script started with tsx)
can resolve the same config with resolveHonoxConfig from
@riebeckite/honox/runtime.
The lower-level pieces remain exported for callers that need full control:
riebeckite (the Vite plugin), riebeckiteSsg (static generation),
riebeckiteSsgExtensionMap, and createRiebeckiteSsg (the SSG wrapper that
fills in Riebeckite's defaults). riebeckiteSsg starts its internal Vite server
with the resolved application root and define values, so invoking
riebeckite build from a subdirectory yields the same output as invoking it
from the application root. defaultSsgEntry is the root-relative
./app/server.ts entry, and defaultSsrExternals is the SSR externals list
both helpers use. The SSG entry must re-export the resolved config and
content (export { config, content }): that is how riebeckiteSsg finds the
manifest to emit plugin-generated outputs and to run the per-page HTML
inspections. Other exports are loadRiebeckiteConfig,
resolveHonoxApplication, resolveHonoxApplicationRoot, buildHonoxApplication,
and startHonoxDevServer.
Catch-all routes need two small helpers so runtime routing and static
generation agree. contentRouteSsgParams(routePath, params) is a drop-in
replacement for ssgParams from hono/ssg: it emits params only for the
route's own enumeration request, so a shallow catch-all such as /:slug{.+}
does not capture the enumeration of a deeper sibling like /tags/:slug{.+}.
ssgEnumerableHandler(handler) keeps a route handler visible to SSG
enumeration while it still calls next() to defer to those siblings; Hono
otherwise skips middleware-shaped handlers.
When plugins provide Page Types, use resolveRiebeckiteRoute(content, path)
instead of resolveContentRoute(manifest, path) and merge
pluginPageSsgParams(content) with the content parameters. The resolver first
returns a plugin page, then falls through to content and redirects. Its page
body is intentionally a string: render it inside the site's existing document
frame and pass page.headTags to that frame. The scaffolded catch-all route
wraps this in resolveRiebeckiteContentRequest(c, content), which resolves
content, plugin pages, redirects, and not-found and sets the htmlLanguage and
headTags context, so the site only composes the returned result. The root /
is resolved by resolveRiebeckiteHomeRequest(c, content), which shares the same
mechanics. Plugin packages never need to add HonoX route files.
UI primitives
@riebeckite/honox/ui is deliberately a small structural contract, not a
component framework. Its complete public component surface is:
-
Article,ArticleLayout,ArticleHeader,ArticleContent,ArticleBody,PageBody,ArticleMeta,ArticleFooter, andContentSlotfor an article page; Sidebarfor complementary content.
The corresponding *Props types are public. ContentSlot is paired with the
pure hasSlot(slots, name) helper, and the ARTICLE_SLOT constant provides
the standard slot names. These stable styling hooks are the only classes
supplied by the contract: rb-article, rb-article-layout, rb-article-header,
rb-article-body, rb-article-content, rb-article-meta, rb-article-footer,
and rb-sidebar, in the component order above. Primitives provide semantic
HTML, those hooks, class/className composition, and the structural CSS that
makes the hooks work. That structural CSS ships in @riebeckite/honox/style.css
and reaches the site through the generated .riebeckite/framework-styles.css.
Primitives do not own article copy, metadata formatting, navigation placement,
cards, page-layout composition, islands, or site visual design and overrides.
Those belong to the site application. ArticleHeader and ArticleContent
accept either children or their HTML input prop, never both. ArticleBody
renders rendered Markdown as .rb-article-content, and Markdown typography is
scoped to that wrapper, so plugin components keep their own headings wherever
they are placed. ContentSlot looks a slot up by name in the slots map and
renders it with a data-slot attribute; a missing, empty, or whitespace-only
slot renders nothing, and class/className add site classes. It never infers
a semantic element from the slot name.
import {
Article,
ArticleBody,
ArticleContent,
ArticleLayout,
ContentSlot,
} from "@riebeckite/honox/ui";
<Article class="site-article">
<ArticleLayout>
<ContentSlot
slots={bodySlots}
name="article.aside"
class="site-article__aside"
/>
<ArticleContent>
<ContentSlot slots={bodySlots} name="article.header" />
<ContentSlot slots={bodySlots} name="article.metadata" />
<ArticleBody html={post.html ?? ""} />
</ArticleContent>
</ArticleLayout>
</Article>;
Use the primitives as composition points, then style them from the site. Pass
rendered Markdown to ArticleBody; ArticleContent html={...} remains for
backward compatibility but is deprecated. Do not import files below
@riebeckite/honox/src/ or rely on any unlisted component.
Site application contract
A Riebeckite site is a normal HonoX application. The integration supplies content and build wiring; the application owns every user-facing decision. Keep the following source directories in the site, rather than in an integration, theme, or plugin. For a walkthrough of editing them with ordinary HonoX, see Customizing Your Site.
| Directory | Site-owned responsibility |
|---|---|
app/routes/ |
URL handling, page composition, redirects, and response metadata |
app/components/ |
Reusable site presentation and composition of the public UI primitives |
app/islands/ |
Optional interactive UI and its client-side state |
app/style.css and local CSS |
Visual tokens, layout, typography, and imports of generated extension styles |
app/routes/_renderer.tsx is the site shell. It owns the document head,
navigation, page chrome, and the application client entry. A route resolves a
request with resolveRiebeckiteContentRequest(c, content) โ which also sets the
htmlLanguage and headTags context โ then chooses its own component tree.
The reference application in apps/web is one implementation, not a required
layout.
Head tag handoff
When a plugin provides document head tags, the shell still belongs to the
site. A plugin only describes meta / link / script on
ContentManifestEntry.headTags; it never renders them.
resolveRiebeckiteContentRequest / resolveRiebeckiteHomeRequest set the
resolved entry's tags into the route context, and app/routes/_renderer.tsx
decides whether to render them. A plugin does not own the <head> or the tag
order.
// app/routes/_renderer.tsx
import { PluginHeadTags } from "@riebeckite/honox/ui";
const headTags = c.get("headTags") ?? [];
<head>
<PluginHeadTags tags={headTags} />
</head>;
For example, @riebeckite/plugin-discord-embed, which aligns the Discord embed
color, supplies theme-color through this contract.
RiebeckiteHead and PluginHeadTags
@riebeckite/honox/ui provides two primitives that clarify the separation of
responsibilities between the framework and the site for head composition.
RiebeckiteHead
import { RiebeckiteHead } from "@riebeckite/honox/ui";
<RiebeckiteHead title="My Site" headTags={[]} />
The framework renders the standard head contents:
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>(the value provided by thetitleprop)<link rel="icon" href="/favicon.ico">(overridable via thefaviconHrefprop; passnullto omit)<ColorModeScript />(controlled by thecolorModeScriptprop, defaulttrue)- stylesheet entries (via the
stylesheetsprop, default["/app/style.css"]) - client script entry (via the
clientSrcprop, default"/app/client.ts";nullomits it) - conversion of
PluginHeadTagvalues (via theheadTagsprop) - child elements (via the
childrenprop) appended after the standard ones
RiebeckiteHead does not render the <head> element itself. The site retains
ownership of <head> and can add custom meta/link/script alongside it.
PluginHeadTags
import { PluginHeadTags } from "@riebeckite/honox/ui";
<PluginHeadTags tags={headTagsFromManifest} />
Converts PluginHeadTag values (meta / link / script) into JSX elements. Use this
when the site composes its own head and does not use RiebeckiteHead.
For example, a site that keeps <head> ownership and uses RiebeckiteHead:
import { RiebeckiteHead, ThemeRoot } from "@riebeckite/honox/ui";
export default jsxRenderer(({ children }, c) => (
<ThemeRoot
theme={config.theme}
lang={c.get("htmlLanguage") ?? config.site.locale}
>
<head>
<RiebeckiteHead
title={config.site.title}
headTags={c.get("headTags") ?? []}
/>
<meta name="custom-site-value" content="..." />
</head>
<body class="riebeckite-page rb-site">{children}</body>
</ThemeRoot>
));
The framework owns the standard head rendering mechanics (charset, viewport, default title, favicon wiring, color-mode bootstrap, stylesheet/client entry wiring, PluginHeadTag conversion) and theme-root attribute derivation. The site still owns <head>/<body> composition and can add custom meta/link/script. The favicon FILE (/public/favicon.ico) remains site-owned; only the default link wiring is framework-owned.
Plugin-provided head tags continue to work through the existing headTags mechanism. RiebeckiteHead and headTags can be used together.
Body slot handoff
When a plugin contributes HTML that belongs inside the note body, the site still
owns where it is rendered. A plugin only writes an HTML fragment into
ContentManifestEntry.bodySlots under a slot name; it never changes a route,
the shell, or the render order. A site route passes the slot object to its
article component, which decides whether and where to render each slot.
// app/routes/[slug{.+}].tsx
<Article
content={post}
bodySlots={route.entry.bodySlots}
/>;
The Article here is the site's own article component, not the
@riebeckite/honox/ui primitive of the same name. The scaffolded starter
renders the standard slots at fixed positions: article.aside,
article.header, article.metadata, article.before-content,
article.after-content, and article.footer. A plugin author picks one of
those, or asks the site to render a custom name; a custom slot renders nothing
until the site chooses to render it.
The site chooses which slot goes where and delegates the rendering mechanics to
the public ContentSlot primitive.
<ArticleContent>
<ContentSlot slots={bodySlots} name="article.header" />
<ContentSlot
slots={bodySlots}
name="article.metadata"
class="site-article__metadata"
/>
<ArticleBody html={post.html ?? ""} />
</ArticleContent>
ContentSlot owns the slot lookup, missing and empty handling, HTML fragment
rendering, and the data-slot attribute, so the site never writes
dangerouslySetInnerHTML for a standard slot. Order, visibility, site classes,
and custom slot names still belong to the site. The escape hatches remain:
read slots directly, wrap a slot in any element, and render the same slot more
than once.
For example, @riebeckite/plugin-properties publishes its property panel on
the properties slot when configured with render: "slot". The default
render: "html" keeps inserting the panel at the start or end of the note HTML.
A plugin never owns routes or the shell.
Article-end plugin sections share article.footer. Render that slot once in
the article component; the resolved plugin order determines the fragment
sequence, and an empty contribution does not create a DOM node.
For example, an external site can compose an article with the stable primitive contract while retaining all presentation ownership:
// app/components/article.tsx
import type { ContentBodySlots, PostContent } from "@riebeckite/core";
import {
Article,
ArticleBody,
ArticleContent,
ArticleLayout,
ContentSlot,
} from "@riebeckite/honox/ui";
export function SiteArticle({
post,
bodySlots,
}: {
post: PostContent;
bodySlots?: ContentBodySlots;
}) {
return (
<Article class="site-article">
<ArticleLayout>
<ArticleContent>
<ContentSlot slots={bodySlots} name="article.header" />
<ArticleBody html={post.html ?? ""} />
<ContentSlot slots={bodySlots} name="article.footer" />
</ArticleContent>
</ArticleLayout>
</Article>
);
}
Import generated extension styles from the site's stylesheet, but never edit the generated files themselves:
/* app/style.css */
@import "./.riebeckite/framework-styles.css";
@import "./.riebeckite/plugin-styles.css";
@import "./.riebeckite/theme-styles.css";
.site-article { max-width: 48rem; margin: 0 auto; }
Islands are also ordinary application modules. Place a HonoX island under
app/islands/, import it from the route or component that owns it, and keep
its hydration and client state local to the site. app/client.ts must continue
to initialize both createClient() and initRiebeckiteClient(); the latter
starts browser entries contributed by installed plugins and themes. A plugin
may contribute its own client entry, but it must not take ownership of a
site's routes, shell, components, islands, or CSS decisions.
The external-site E2E fixture contains this minimal arrangement: a site shell,
a local article component built from @riebeckite/honox/ui, a local island,
and site CSS. It is built from packed npm artifacts, so it is the supported
example for copying and overriding these boundaries.
Not-found and error surface
Not-found handling is HonoX's standard app/routes/_404.tsx. Riebeckite decides
that a request is not found; HonoX re-renders that response through the site's
_renderer.tsx, so the status stays 404 while the not-found screen belongs to
the site.
// app/routes/_404.tsx
import type { NotFoundHandler } from "hono";
const handler: NotFoundHandler = (c) => {
c.status(404);
return c.render(<main class="not-found">Page not found</main>);
};
export default handler;
The route resolver (resolveRiebeckiteContentRequest,
resolveRiebeckiteHomeRequest, and plugin page resolution) and the extension
guard that keeps asset-like paths out of the content route stay in
@riebeckite/honox; the site never reimplements them. Only public, routable
content resolves, so a not-found response never carries draft, future-dated, or
private metadata.
Runtime errors use Hono's own error handling. Without an app/routes/_error.tsx
the default handler logs the error and returns a plain 500 Internal Server Error; a site may add _error.tsx (an ErrorHandler) when it wants a
visitor-facing screen. Configuration, plugin, and build failures are
developer-facing and must not be turned into a successful page.
Boundary rules
Article routing resolves a request against the manifest's already-resolved public locations (byPermalink, then redirects), never by inferring a URL from a filesystem path, directory layout, or slug. A slug remains an internal content lookup key; the public URL is the resolved permalink.
Keep HonoX, Vite, Cloudflare, and route APIs in this package or apps/web; Core remains portable. A plugin can expose assets, client entries, endpoints, and renderers, but Core does not become a HonoX router. The application decides concrete route composition and islands.
Use Build system for state behavior and Architecture for package ownership.