Color mode

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.

Diagram source
text
flowchart TD
    A["Riebeckite core<br/>content / manifest / plugin"]
    B["@riebeckite/honox<br/>build / routing integration"]
    C["Site application"]
 
    C --> D["app/routes/<br/>URL / page composition"]
    C --> E["app/components/<br/>Site UI"]
    C --> F["app/islands/<br/>Interactive UI"]
    C --> G["app/style.css<br/>Visual design"]
 
    A --> B
    B --> C
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) from @riebeckite/honox/server — which also sets the htmlLanguage and headTags context — then chooses its own component tree. When a route needs only route resolution, without context assignment or loading the processed content, use the lower-level resolveRiebeckiteRoute(content, c.req.path). The reference application in apps/web is one implementation, not a required layout.

Plugins provide information and UI fragments; the site decides where they are rendered:

Diagram source
text
flowchart LR
    A["Plugin"]
    B["Manifest"]
    C["Site route"]
    D["Site shell / component"]
 
    A -->|"headTags / bodySlots / page"| B
    B --> C
    C -->|"placement"| D

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>;

The flow is:

text
Plugin
  ↓ provides headTags
Framework resolver
  ↓ sets them on the context
_renderer.tsx
  ↓
renders into <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.

History

1 changesCollapseExpand
1 + ---
2 + title: Site application contract
3 + sidebar:
4 + label: Site application contract
5 + order: 20
6 + ---
7 +
8 + # Site application contract
9 +
10 + A Riebeckite site is a normal HonoX application. The integration supplies
11 + content and build wiring; the application owns every user-facing decision.
12 + Keep the following source directories in the site, rather than in an
13 + integration, theme, or plugin. For a walkthrough of editing them with ordinary
14 + HonoX, see [Customizing Your Site](../../guides/customizing-your-site.md).
15 +
16 + ```mermaid
17 + flowchart TD
18 + A["Riebeckite core<br/>content / manifest / plugin"]
19 + B["@riebeckite/honox<br/>build / routing integration"]
20 + C["Site application"]
21 +
22 + C --> D["app/routes/<br/>URL / page composition"]
23 + C --> E["app/components/<br/>Site UI"]
24 + C --> F["app/islands/<br/>Interactive UI"]
25 + C --> G["app/style.css<br/>Visual design"]
26 +
27 + A --> B
28 + B --> C
29 + ```
30 +
31 + | Directory | Site-owned responsibility |
32 + | --- | --- |
33 + | `app/routes/` | URL handling, page composition, redirects, and response metadata |
34 + | `app/components/` | Reusable site presentation and composition of the public UI primitives |
35 + | `app/islands/` | Optional interactive UI and its client-side state |
36 + | `app/style.css` and local CSS | Visual tokens, layout, typography, and imports of generated extension styles |
37 +
38 + `app/routes/_renderer.tsx` is the site shell. It owns the document head,
39 + navigation, page chrome, and the application client entry. A route resolves a
40 + request with `resolveRiebeckiteContentRequest(c, content)` from
41 + `@riebeckite/honox/server` — which also sets the `htmlLanguage` and `headTags`
42 + context — then chooses its own component tree. When a route needs only route
43 + resolution, without context assignment or loading the processed content, use the
44 + lower-level `resolveRiebeckiteRoute(content, c.req.path)`. The reference
45 + application in `apps/web` is one implementation, not a required layout.
46 +
47 + Plugins provide information and UI fragments; the site decides where they are
48 + rendered:
49 +
50 + ```mermaid
51 + flowchart LR
52 + A["Plugin"]
53 + B["Manifest"]
54 + C["Site route"]
55 + D["Site shell / component"]
56 +
57 + A -->|"headTags / bodySlots / page"| B
58 + B --> C
59 + C -->|"placement"| D
60 + ```
61 +
62 + ## Head tag handoff
63 +
64 + When a plugin provides document head tags, the shell still belongs to the
65 + site. A plugin only describes `meta` / `link` / `script` on
66 + `ContentManifestEntry.headTags`; it never renders them.
67 + `resolveRiebeckiteContentRequest` / `resolveRiebeckiteHomeRequest` set the
68 + resolved entry's tags into the route context, and `app/routes/_renderer.tsx`
69 + decides whether to render them. A plugin does not own the `<head>` or the tag
70 + order.
71 +
72 + ```tsx
73 + // app/routes/_renderer.tsx
74 + import { PluginHeadTags } from "@riebeckite/honox/ui";
75 +
76 + const headTags = c.get("headTags") ?? [];
77 +
78 + <head>
79 + <PluginHeadTags tags={headTags} />
80 + </head>;
81 + ```
82 +
83 + The flow is:
84 +
85 + ```text
86 + Plugin
87 + ↓ provides headTags
88 + Framework resolver
89 + ↓ sets them on the context
90 + _renderer.tsx
91 + ↓
92 + renders into <head>
93 + ```
94 +
95 + For example, `@riebeckite/plugin-discord-embed`, which aligns the Discord embed
96 + color, supplies `theme-color` through this contract.
97 +
98 + ## RiebeckiteHead and PluginHeadTags
99 +
100 + `@riebeckite/honox/ui` provides two primitives that clarify the separation of
101 + responsibilities between the framework and the site for head composition.
102 +
103 + ### `RiebeckiteHead`
104 +
105 + ```tsx
106 + import { RiebeckiteHead } from "@riebeckite/honox/ui";
107 +
108 + <RiebeckiteHead title="My Site" headTags={[]} />
109 + ```
110 +
111 + The framework renders the standard head contents:
112 +
113 + - `<meta charset="utf-8">`
114 + - `<meta name="viewport" content="width=device-width, initial-scale=1.0">`
115 + - `<title>` (the value provided by the `title` prop)
116 + - `<link rel="icon" href="/favicon.ico">` (overridable via the `faviconHref` prop; pass `null` to omit)
117 + - `<ColorModeScript />` (controlled by the `colorModeScript` prop, default `true`)
118 + - stylesheet entries (via the `stylesheets` prop, default `["/app/style.css"]`)
119 + - client script entry (via the `clientSrc` prop, default `"/app/client.ts"`; `null` omits it)
120 + - conversion of `PluginHeadTag` values (via the `headTags` prop)
121 + - child elements (via the `children` prop) appended after the standard ones
122 +
123 + `RiebeckiteHead` does **not** render the `<head>` element itself. The site retains
124 + ownership of `<head>` and can add custom meta/link/script alongside it.
125 +
126 + ### `PluginHeadTags`
127 +
128 + ```tsx
129 + import { PluginHeadTags } from "@riebeckite/honox/ui";
130 +
131 + <PluginHeadTags tags={headTagsFromManifest} />
132 + ```
133 +
134 + Converts `PluginHeadTag` values (meta / link / script) into JSX elements. Use this
135 + when the site composes its own head and does not use `RiebeckiteHead`.
136 +
137 + For example, a site that keeps `<head>` ownership and uses `RiebeckiteHead`:
138 +
139 + ```tsx
140 + import { RiebeckiteHead, ThemeRoot } from "@riebeckite/honox/ui";
141 +
142 + export default jsxRenderer(({ children }, c) => (
143 + <ThemeRoot
144 + theme={config.theme}
145 + lang={c.get("htmlLanguage") ?? config.site.locale}
146 + >
147 + <head>
148 + <RiebeckiteHead
149 + title={config.site.title}
150 + headTags={c.get("headTags") ?? []}
151 + />
152 + <meta name="custom-site-value" content="..." />
153 + </head>
154 + <body class="riebeckite-page rb-site">{children}</body>
155 + </ThemeRoot>
156 + ));
157 + ```
158 +
159 + 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.
160 +
161 + Plugin-provided head tags continue to work through the existing `headTags` mechanism. `RiebeckiteHead` and `headTags` can be used together.
162 +
163 + ## Body slot handoff
164 +
165 + When a plugin contributes HTML that belongs inside the note body, the site still
166 + owns where it is rendered. A plugin only writes an HTML fragment into
167 + `ContentManifestEntry.bodySlots` under a slot name; it never changes a route,
168 + the shell, or the render order. A site route passes the slot object to its
169 + article component, which decides whether and where to render each slot.
170 +
171 + ```tsx
172 + // app/routes/[slug{.+}].tsx
173 + <Article
174 + content={post}
175 + bodySlots={route.entry.bodySlots}
176 + />;
177 + ```
178 +
179 + The `Article` here is the site's own article component, not the
180 + `@riebeckite/honox/ui` primitive of the same name. The scaffolded starter
181 + renders the standard slots at fixed positions: `article.aside`,
182 + `article.header`, `article.metadata`, `article.before-content`,
183 + `article.after-content`, and `article.footer`. A plugin author picks one of
184 + those, or asks the site to render a custom name; a custom slot renders nothing
185 + until the site chooses to render it.
186 +
187 + The site chooses which slot goes where and delegates the rendering mechanics to
188 + the public `ContentSlot` primitive.
189 +
190 + ```tsx
191 + <ArticleContent>
192 + <ContentSlot slots={bodySlots} name="article.header" />
193 + <ContentSlot
194 + slots={bodySlots}
195 + name="article.metadata"
196 + class="site-article__metadata"
197 + />
198 + <ArticleBody html={post.html ?? ""} />
199 + </ArticleContent>
200 + ```
201 +
202 + `ContentSlot` owns the slot lookup, missing and empty handling, HTML fragment
203 + rendering, and the `data-slot` attribute, so the site never writes
204 + `dangerouslySetInnerHTML` for a standard slot. Order, visibility, site classes,
205 + and custom slot names still belong to the site. The escape hatches remain:
206 + read `slots` directly, wrap a slot in any element, and render the same slot more
207 + than once.
208 +
209 + For example, `@riebeckite/plugin-properties` publishes its property panel on
210 + the `properties` slot when configured with `render: "slot"`. The default
211 + `render: "html"` keeps inserting the panel at the start or end of the note HTML.
212 + A plugin never owns routes or the shell.
213 +
214 + Article-end plugin sections share `article.footer`. Render that slot once in
215 + the article component; the resolved plugin `order` determines the fragment
216 + sequence, and an empty contribution does not create a DOM node.
217 +
218 + For example, an external site can compose an article with the stable primitive
219 + contract while retaining all presentation ownership:
220 +
221 + ```tsx
222 + // app/components/article.tsx
223 + import type { ContentBodySlots, PostContent } from "@riebeckite/core";
224 + import {
225 + Article,
226 + ArticleBody,
227 + ArticleContent,
228 + ArticleLayout,
229 + ContentSlot,
230 + } from "@riebeckite/honox/ui";
231 +
232 + export function SiteArticle({
233 + post,
234 + bodySlots,
235 + }: {
236 + post: PostContent;
237 + bodySlots?: ContentBodySlots;
238 + }) {
239 + return (
240 + <Article class="site-article">
241 + <ArticleLayout>
242 + <ArticleContent>
243 + <ContentSlot slots={bodySlots} name="article.header" />
244 + <ArticleBody html={post.html ?? ""} />
245 + <ContentSlot slots={bodySlots} name="article.footer" />
246 + </ArticleContent>
247 + </ArticleLayout>
248 + </Article>
249 + );
250 + }
251 + ```
252 +
253 + Import generated extension styles from the site's stylesheet, but never edit
254 + the generated files themselves:
255 +
256 + ```css
257 + /* app/style.css */
258 + @import "./.riebeckite/framework-styles.css";
259 + @import "./.riebeckite/plugin-styles.css";
260 + @import "./.riebeckite/theme-styles.css";
261 +
262 + .site-article { max-width: 48rem; margin: 0 auto; }
263 + ```
264 +
265 + Islands are also ordinary application modules. Place a HonoX island under
266 + `app/islands/`, import it from the route or component that owns it, and keep
267 + its hydration and client state local to the site. `app/client.ts` must continue
268 + to initialize both `createClient()` and `initRiebeckiteClient()`; the latter
269 + starts browser entries contributed by installed plugins and themes. A plugin
270 + may contribute its own client entry, but it must not take ownership of a
271 + site's routes, shell, components, islands, or CSS decisions.
272 +
273 + The external-site E2E fixture contains this minimal arrangement: a site shell,
274 + a local article component built from `@riebeckite/honox/ui`, a local island,
275 + and site CSS. It is built from packed npm artifacts, so it is the supported
276 + example for copying and overriding these boundaries.
277 + ### Not-found and error surface
278 +
279 + Not-found handling is HonoX's standard `app/routes/_404.tsx`. Riebeckite decides
280 + that a request is not found; HonoX re-renders that response through the site's
281 + `_renderer.tsx`, so the status stays `404` while the not-found screen belongs to
282 + the site.
283 +
284 + ```tsx
285 + // app/routes/_404.tsx
286 + import type { NotFoundHandler } from "hono";
287 +
288 + const handler: NotFoundHandler = (c) => {
289 + c.status(404);
290 + return c.render(<main class="not-found">Page not found</main>);
291 + };
292 +
293 + export default handler;
294 + ```
295 +
296 + The route resolver (`resolveRiebeckiteContentRequest`,
297 + `resolveRiebeckiteHomeRequest`, and plugin page resolution) and the extension
298 + guard that keeps asset-like paths out of the content route stay in
299 + `@riebeckite/honox`; the site never reimplements them. Only public, routable
300 + content resolves, so a not-found response never carries draft, future-dated, or
301 + private metadata.
302 +
303 + Runtime errors use Hono's own error handling. Without an `app/routes/_error.tsx`
304 + the default handler logs the error and returns a plain `500 Internal Server
305 + Error`; a site may add `_error.tsx` (an `ErrorHandler`) when it wants a
306 + visitor-facing screen. Configuration, plugin, and build failures are
307 + developer-facing and must not be turned into a successful page.
308 +