Color mode

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):

ts
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, and ContentSlot for an article page;
  • Sidebar for 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.

tsx
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.

tsx
// 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

tsx
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 the title prop)
  • <link rel="icon" href="/favicon.ico"> (overridable via the faviconHref prop; pass null to omit)
  • <ColorModeScript /> (controlled by the colorModeScript prop, default true)
  • stylesheet entries (via the stylesheets prop, default ["/app/style.css"])
  • client script entry (via the clientSrc prop, default "/app/client.ts"; null omits it)
  • conversion of PluginHeadTag values (via the headTags prop)
  • child elements (via the children prop) 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

tsx
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:

tsx
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.

tsx
// 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.

tsx
<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:

tsx
// 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:

css
/* 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.

tsx
// 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.

History

1 changesCollapseExpand
1 + # HonoX Integration
2 +
3 + `@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.
4 +
5 + ## Public API
6 +
7 + Register the integration with `riebeckiteVite()` from `vite.config.ts`. It is
8 + the higher-level helper for a normal site: it appends the Riebeckite plugins,
9 + applies the SSG entry and extension-map defaults, and contributes the SSR
10 + externals the runtime needs, so the site does not restate Vite/HonoX internals.
11 + Combine it with the site's own plugins (the HonoX plugin, a deployment build
12 + plugin, Tailwind, and so on):
13 +
14 + ```ts
15 + import { riebeckiteVite } from "@riebeckite/honox";
16 + import { defineConfig } from "vite";
17 +
18 + export default defineConfig({
19 + plugins: [honox({ ... }), ...riebeckiteVite(), build()],
20 + });
21 + ```
22 +
23 + `riebeckiteVite` accepts the same optional `configRoot`, `appRoot`, `configFile`,
24 + and monorepo-only `workspaceRoot` as the lower-level plugin. `appRoot` defaults
25 + to the Vite root, and `configRoot` defaults to `appRoot`. A config path is
26 + imported relative to `configRoot`; `content.directory` is resolved relative to
27 + `appRoot`. `resolveHonoxApplication` returns these roots together with the
28 + resolved config, so CLI and Vite use the same model. `workspaceRoot` is only for
29 + source-package aliases during monorepo development; installed npm consumers use
30 + their own `node_modules` without it. The integration creates generated import
31 + entries below `app/.riebeckite/`, and exposes the required client module as a
32 + virtual module rather than writing it into that directory. Generated files are
33 + integration output: do not edit them as application source.
34 +
35 + ## Bootstrap modules
36 +
37 + A generated site imports framework-owned bootstrap modules instead of keeping
38 + resolved config and content-manager code in the application: the resolved
39 + config is `virtual:riebeckite/config`, and the configured content runtime is
40 + `virtual:riebeckite/content`. This is why `app/config.ts`, `app/content.ts`, and
41 + `app/constants/paths.ts` are not generated. The SSG entry `app/server.ts`
42 + re-exports both, and `riebeckiteSsg` finds the manifest through them.
43 +
44 + A script that runs outside Vite (for example a Node script started with `tsx`)
45 + can resolve the same config with `resolveHonoxConfig` from
46 + `@riebeckite/honox/runtime`.
47 +
48 + The lower-level pieces remain exported for callers that need full control:
49 + `riebeckite` (the Vite plugin), `riebeckiteSsg` (static generation),
50 + `riebeckiteSsgExtensionMap`, and `createRiebeckiteSsg` (the SSG wrapper that
51 + fills in Riebeckite's defaults). `riebeckiteSsg` starts its internal Vite server
52 + with the resolved application root and define values, so invoking
53 + `riebeckite build` from a subdirectory yields the same output as invoking it
54 + from the application root. `defaultSsgEntry` is the root-relative
55 + `./app/server.ts` entry, and `defaultSsrExternals` is the SSR externals list
56 + both helpers use. The SSG entry must re-export the resolved `config` and
57 + `content` (`export { config, content }`): that is how `riebeckiteSsg` finds the
58 + manifest to emit plugin-generated outputs and to run the per-page HTML
59 + inspections. Other exports are `loadRiebeckiteConfig`,
60 + `resolveHonoxApplication`, `resolveHonoxApplicationRoot`, `buildHonoxApplication`,
61 + and `startHonoxDevServer`.
62 +
63 + Catch-all routes need two small helpers so runtime routing and static
64 + generation agree. `contentRouteSsgParams(routePath, params)` is a drop-in
65 + replacement for `ssgParams` from `hono/ssg`: it emits params only for the
66 + route's own enumeration request, so a shallow catch-all such as `/:slug{.+}`
67 + does not capture the enumeration of a deeper sibling like `/tags/:slug{.+}`.
68 + `ssgEnumerableHandler(handler)` keeps a route handler visible to SSG
69 + enumeration while it still calls `next()` to defer to those siblings; Hono
70 + otherwise skips middleware-shaped handlers.
71 +
72 + When plugins provide Page Types, use `resolveRiebeckiteRoute(content, path)`
73 + instead of `resolveContentRoute(manifest, path)` and merge
74 + `pluginPageSsgParams(content)` with the content parameters. The resolver first
75 + returns a plugin page, then falls through to content and redirects. Its page
76 + body is intentionally a string: render it inside the site's existing document
77 + frame and pass `page.headTags` to that frame. The scaffolded catch-all route
78 + wraps this in `resolveRiebeckiteContentRequest(c, content)`, which resolves
79 + content, plugin pages, redirects, and not-found and sets the `htmlLanguage` and
80 + `headTags` context, so the site only composes the returned result. The root `/`
81 + is resolved by `resolveRiebeckiteHomeRequest(c, content)`, which shares the same
82 + mechanics. Plugin packages never need to add HonoX route files.
83 +
84 + ## UI primitives
85 +
86 + `@riebeckite/honox/ui` is deliberately a small structural contract, not a
87 + component framework. Its complete public component surface is:
88 +
89 + - `Article`, `ArticleLayout`, `ArticleHeader`, `ArticleContent`,
90 + `ArticleBody`, `PageBody`, `ArticleMeta`, `ArticleFooter`, and `ContentSlot`
91 + for an article page;
92 + - `Sidebar` for complementary content.
93 +
94 + The corresponding `*Props` types are public. `ContentSlot` is paired with the
95 + pure `hasSlot(slots, name)` helper, and the `ARTICLE_SLOT` constant provides
96 + the standard slot names. These stable styling hooks are the only classes
97 + supplied by the contract: `rb-article`, `rb-article-layout`, `rb-article-header`,
98 + `rb-article-body`, `rb-article-content`, `rb-article-meta`, `rb-article-footer`,
99 + and `rb-sidebar`, in the component order above. Primitives provide semantic
100 + HTML, those hooks, `class`/`className` composition, and the structural CSS that
101 + makes the hooks work. That structural CSS ships in `@riebeckite/honox/style.css`
102 + and reaches the site through the generated `.riebeckite/framework-styles.css`.
103 + Primitives do not own article copy, metadata formatting, navigation placement,
104 + cards, page-layout composition, islands, or site visual design and overrides.
105 + Those belong to the site application. `ArticleHeader` and `ArticleContent`
106 + accept either children or their HTML input prop, never both. `ArticleBody`
107 + renders rendered Markdown as `.rb-article-content`, and Markdown typography is
108 + scoped to that wrapper, so plugin components keep their own headings wherever
109 + they are placed. `ContentSlot` looks a slot up by name in the `slots` map and
110 + renders it with a `data-slot` attribute; a missing, empty, or whitespace-only
111 + slot renders nothing, and `class`/`className` add site classes. It never infers
112 + a semantic element from the slot name.
113 +
114 + ```tsx
115 + import {
116 + Article,
117 + ArticleBody,
118 + ArticleContent,
119 + ArticleLayout,
120 + ContentSlot,
121 + } from "@riebeckite/honox/ui";
122 +
123 + <Article class="site-article">
124 + <ArticleLayout>
125 + <ContentSlot
126 + slots={bodySlots}
127 + name="article.aside"
128 + class="site-article__aside"
129 + />
130 + <ArticleContent>
131 + <ContentSlot slots={bodySlots} name="article.header" />
132 + <ContentSlot slots={bodySlots} name="article.metadata" />
133 + <ArticleBody html={post.html ?? ""} />
134 + </ArticleContent>
135 + </ArticleLayout>
136 + </Article>;
137 + ```
138 +
139 + Use the primitives as composition points, then style them from the site. Pass
140 + rendered Markdown to `ArticleBody`; `ArticleContent html={...}` remains for
141 + backward compatibility but is deprecated. Do not import files below
142 + `@riebeckite/honox/src/` or rely on any unlisted component.
143 +
144 + ## Site application contract
145 +
146 + A Riebeckite site is a normal HonoX application. The integration supplies
147 + content and build wiring; the application owns every user-facing decision.
148 + Keep the following source directories in the site, rather than in an
149 + integration, theme, or plugin. For a walkthrough of editing them with ordinary
150 + HonoX, see [Customizing Your Site](../guides/customizing-your-site.en.md).
151 +
152 + | Directory | Site-owned responsibility |
153 + | --- | --- |
154 + | `app/routes/` | URL handling, page composition, redirects, and response metadata |
155 + | `app/components/` | Reusable site presentation and composition of the public UI primitives |
156 + | `app/islands/` | Optional interactive UI and its client-side state |
157 + | `app/style.css` and local CSS | Visual tokens, layout, typography, and imports of generated extension styles |
158 +
159 + `app/routes/_renderer.tsx` is the site shell. It owns the document head,
160 + navigation, page chrome, and the application client entry. A route resolves a
161 + request with `resolveRiebeckiteContentRequest(c, content)` โ€” which also sets the
162 + `htmlLanguage` and `headTags` context โ€” then chooses its own component tree.
163 + The reference application in `apps/web` is one implementation, not a required
164 + layout.
165 +
166 + ### Head tag handoff
167 +
168 + When a plugin provides document head tags, the shell still belongs to the
169 + site. A plugin only describes `meta` / `link` / `script` on
170 + `ContentManifestEntry.headTags`; it never renders them.
171 + `resolveRiebeckiteContentRequest` / `resolveRiebeckiteHomeRequest` set the
172 + resolved entry's tags into the route context, and `app/routes/_renderer.tsx`
173 + decides whether to render them. A plugin does not own the `<head>` or the tag
174 + order.
175 +
176 + ```tsx
177 + // app/routes/_renderer.tsx
178 + import { PluginHeadTags } from "@riebeckite/honox/ui";
179 +
180 + const headTags = c.get("headTags") ?? [];
181 +
182 + <head>
183 + <PluginHeadTags tags={headTags} />
184 + </head>;
185 + ```
186 +
187 + For example, `@riebeckite/plugin-discord-embed`, which aligns the Discord embed
188 + color, supplies `theme-color` through this contract.
189 +
190 + ### RiebeckiteHead and PluginHeadTags
191 +
192 + `@riebeckite/honox/ui` provides two primitives that clarify the separation of
193 + responsibilities between the framework and the site for head composition.
194 +
195 + #### `RiebeckiteHead`
196 +
197 + ```tsx
198 + import { RiebeckiteHead } from "@riebeckite/honox/ui";
199 +
200 + <RiebeckiteHead title="My Site" headTags={[]} />
201 + ```
202 +
203 + The framework renders the standard head contents:
204 +
205 + - `<meta charset="utf-8">`
206 + - `<meta name="viewport" content="width=device-width, initial-scale=1.0">`
207 + - `<title>` (the value provided by the `title` prop)
208 + - `<link rel="icon" href="/favicon.ico">` (overridable via the `faviconHref` prop; pass `null` to omit)
209 + - `<ColorModeScript />` (controlled by the `colorModeScript` prop, default `true`)
210 + - stylesheet entries (via the `stylesheets` prop, default `["/app/style.css"]`)
211 + - client script entry (via the `clientSrc` prop, default `"/app/client.ts"`; `null` omits it)
212 + - conversion of `PluginHeadTag` values (via the `headTags` prop)
213 + - child elements (via the `children` prop) appended after the standard ones
214 +
215 + `RiebeckiteHead` does **not** render the `<head>` element itself. The site retains
216 + ownership of `<head>` and can add custom meta/link/script alongside it.
217 +
218 + #### `PluginHeadTags`
219 +
220 + ```tsx
221 + import { PluginHeadTags } from "@riebeckite/honox/ui";
222 +
223 + <PluginHeadTags tags={headTagsFromManifest} />
224 + ```
225 +
226 + Converts `PluginHeadTag` values (meta / link / script) into JSX elements. Use this
227 + when the site composes its own head and does not use `RiebeckiteHead`.
228 +
229 + For example, a site that keeps `<head>` ownership and uses `RiebeckiteHead`:
230 +
231 + ```tsx
232 + import { RiebeckiteHead, ThemeRoot } from "@riebeckite/honox/ui";
233 +
234 + export default jsxRenderer(({ children }, c) => (
235 + <ThemeRoot
236 + theme={config.theme}
237 + lang={c.get("htmlLanguage") ?? config.site.locale}
238 + >
239 + <head>
240 + <RiebeckiteHead
241 + title={config.site.title}
242 + headTags={c.get("headTags") ?? []}
243 + />
244 + <meta name="custom-site-value" content="..." />
245 + </head>
246 + <body class="riebeckite-page rb-site">{children}</body>
247 + </ThemeRoot>
248 + ));
249 + ```
250 +
251 + 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.
252 +
253 + Plugin-provided head tags continue to work through the existing `headTags` mechanism. `RiebeckiteHead` and `headTags` can be used together.
254 +
255 + ### Body slot handoff
256 +
257 + When a plugin contributes HTML that belongs inside the note body, the site still
258 + owns where it is rendered. A plugin only writes an HTML fragment into
259 + `ContentManifestEntry.bodySlots` under a slot name; it never changes a route,
260 + the shell, or the render order. A site route passes the slot object to its
261 + article component, which decides whether and where to render each slot.
262 +
263 + ```tsx
264 + // app/routes/[slug{.+}].tsx
265 + <Article
266 + content={post}
267 + bodySlots={route.entry.bodySlots}
268 + />;
269 + ```
270 +
271 + The `Article` here is the site's own article component, not the
272 + `@riebeckite/honox/ui` primitive of the same name. The scaffolded starter
273 + renders the standard slots at fixed positions: `article.aside`,
274 + `article.header`, `article.metadata`, `article.before-content`,
275 + `article.after-content`, and `article.footer`. A plugin author picks one of
276 + those, or asks the site to render a custom name; a custom slot renders nothing
277 + until the site chooses to render it.
278 +
279 + The site chooses which slot goes where and delegates the rendering mechanics to
280 + the public `ContentSlot` primitive.
281 +
282 + ```tsx
283 + <ArticleContent>
284 + <ContentSlot slots={bodySlots} name="article.header" />
285 + <ContentSlot
286 + slots={bodySlots}
287 + name="article.metadata"
288 + class="site-article__metadata"
289 + />
290 + <ArticleBody html={post.html ?? ""} />
291 + </ArticleContent>
292 + ```
293 +
294 + `ContentSlot` owns the slot lookup, missing and empty handling, HTML fragment
295 + rendering, and the `data-slot` attribute, so the site never writes
296 + `dangerouslySetInnerHTML` for a standard slot. Order, visibility, site classes,
297 + and custom slot names still belong to the site. The escape hatches remain:
298 + read `slots` directly, wrap a slot in any element, and render the same slot more
299 + than once.
300 +
301 + For example, `@riebeckite/plugin-properties` publishes its property panel on
302 + the `properties` slot when configured with `render: "slot"`. The default
303 + `render: "html"` keeps inserting the panel at the start or end of the note HTML.
304 + A plugin never owns routes or the shell.
305 +
306 + Article-end plugin sections share `article.footer`. Render that slot once in
307 + the article component; the resolved plugin `order` determines the fragment
308 + sequence, and an empty contribution does not create a DOM node.
309 +
310 + For example, an external site can compose an article with the stable primitive
311 + contract while retaining all presentation ownership:
312 +
313 + ```tsx
314 + // app/components/article.tsx
315 + import type { ContentBodySlots, PostContent } from "@riebeckite/core";
316 + import {
317 + Article,
318 + ArticleBody,
319 + ArticleContent,
320 + ArticleLayout,
321 + ContentSlot,
322 + } from "@riebeckite/honox/ui";
323 +
324 + export function SiteArticle({
325 + post,
326 + bodySlots,
327 + }: {
328 + post: PostContent;
329 + bodySlots?: ContentBodySlots;
330 + }) {
331 + return (
332 + <Article class="site-article">
333 + <ArticleLayout>
334 + <ArticleContent>
335 + <ContentSlot slots={bodySlots} name="article.header" />
336 + <ArticleBody html={post.html ?? ""} />
337 + <ContentSlot slots={bodySlots} name="article.footer" />
338 + </ArticleContent>
339 + </ArticleLayout>
340 + </Article>
341 + );
342 + }
343 + ```
344 +
345 + Import generated extension styles from the site's stylesheet, but never edit
346 + the generated files themselves:
347 +
348 + ```css
349 + /* app/style.css */
350 + @import "./.riebeckite/framework-styles.css";
351 + @import "./.riebeckite/plugin-styles.css";
352 + @import "./.riebeckite/theme-styles.css";
353 +
354 + .site-article { max-width: 48rem; margin: 0 auto; }
355 + ```
356 +
357 + Islands are also ordinary application modules. Place a HonoX island under
358 + `app/islands/`, import it from the route or component that owns it, and keep
359 + its hydration and client state local to the site. `app/client.ts` must continue
360 + to initialize both `createClient()` and `initRiebeckiteClient()`; the latter
361 + starts browser entries contributed by installed plugins and themes. A plugin
362 + may contribute its own client entry, but it must not take ownership of a
363 + site's routes, shell, components, islands, or CSS decisions.
364 +
365 + The external-site E2E fixture contains this minimal arrangement: a site shell,
366 + a local article component built from `@riebeckite/honox/ui`, a local island,
367 + and site CSS. It is built from packed npm artifacts, so it is the supported
368 + example for copying and overriding these boundaries.
369 +
370 + ### Not-found and error surface
371 +
372 + Not-found handling is HonoX's standard `app/routes/_404.tsx`. Riebeckite decides
373 + that a request is not found; HonoX re-renders that response through the site's
374 + `_renderer.tsx`, so the status stays `404` while the not-found screen belongs to
375 + the site.
376 +
377 + ```tsx
378 + // app/routes/_404.tsx
379 + import type { NotFoundHandler } from "hono";
380 +
381 + const handler: NotFoundHandler = (c) => {
382 + c.status(404);
383 + return c.render(<main class="not-found">Page not found</main>);
384 + };
385 +
386 + export default handler;
387 + ```
388 +
389 + The route resolver (`resolveRiebeckiteContentRequest`,
390 + `resolveRiebeckiteHomeRequest`, and plugin page resolution) and the extension
391 + guard that keeps asset-like paths out of the content route stay in
392 + `@riebeckite/honox`; the site never reimplements them. Only public, routable
393 + content resolves, so a not-found response never carries draft, future-dated, or
394 + private metadata.
395 +
396 + Runtime errors use Hono's own error handling. Without an `app/routes/_error.tsx`
397 + the default handler logs the error and returns a plain `500 Internal Server
398 + Error`; a site may add `_error.tsx` (an `ErrorHandler`) when it wants a
399 + visitor-facing screen. Configuration, plugin, and build failures are
400 + developer-facing and must not be turned into a successful page.
401 +
402 + ## Boundary rules
403 +
404 + 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`.
405 +
406 + 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.
407 +
408 + Use [Build system](build-system.en.md) for state behavior and [Architecture](architecture.en.md) for package ownership.
409 +