UI, assets, and runtime
UI components
Plugins may export a PluginComponent<Props> and a PluginDataHelper<Input, Model> in addition to their factory. Data helpers receive narrow inputs such as PublicContentView and return typed models; components accept readonly typed props and return rendered output. The Site composes these components in its own TSX, choosing whether, where, and how often to render each one.
Typical plugin export shape:
// index.ts
export function example() {
return definePlugin({
name: "example",
// ... factory hooks, assets, clientEntries, etc.
});
}
export { MyComponent, getMyData };
Or more conventionally, a plugin may place its component and helper under ./components/:
packages/plugins/example/
├─ index.ts
├─ components/
│ ├─ my-component.tsx // exports <MyComponent /> and getMyData
│ └─ types.ts
├─ style.css
└─ client.ts
The Site then imports and renders:
import { getMyData } from "@riebeckite/plugin-example";
import { MyComponent } from "@riebeckite/plugin-example/components";
const model = getMyData({ view, frontmatter: entry.frontmatter });
<MyComponent model={model} />
Components may also optionally export internal CSS and a client entry, registered through the integration as usual. Existing asset and client-entry registration remains factory-owned; importing a component alone neither registers CSS/client runtime nor enables a plugin capability.
Placement during SPA navigation
A component renders for the server request; it does not own its placement or
choose when it is replaced in the browser. The Site places it in
main#main-content when it belongs to page content. When shell output depends
on the current URL, language, or page data, the Site wraps it in a stable,
unique data-spa-region on every route that can navigate between it:
<aside data-spa-region="docs-navigation">
<DocsSidebar />
</aside>
Client behavior follows DOM lifetime, not component ownership. A client entry
for content in main uses the default page scope. A client entry for a
replaceable shell region uses app scope and reinitializes after
spa:navigation, or uses event delegation. Plugins must not assume that
their component is placed in either location.
HonoX primitives such as Article, ArticleHeader, ArticleContent, ArticleBody, ArticleFooter, Sidebar, and ThemeRoot remain as semantic primitives for article structure and layout. Components belong in plugin packages and are exported from their public entry points with their model types and helpers. Site TSX belongs under the application's app/. HonoX owns rendering primitives only; it does not collect plugin components or select their order.
// Minimal UI composition in a site route
import { getBacklinks } from "@riebeckite/plugin-backlinks";
import { MyComponent } from "@riebeckite/plugin-example";
const view = createPublicContentView(manifest);
const entry = view.findRoutableBySlug(slug);
const backlinks = await getBacklinks({ view, slug });
<article>
<MyComponent backlinks={backlinks} />
</article>
Assets and client entries
Plugin CSS remains in the plugin package and is declared through
assets. Browser initialization is declared through clientEntries
only when browser JavaScript is genuinely required. Do not copy plugin
CSS into website or expose /node_modules directly.
Use the Core helpers to register the plugin name and the package exports together. The module specifier is explicit, so the same API works for official, third-party, and site-local packages:
import {
createClientEntry,
createStyleAsset,
definePlugin,
} from "@riebeckite/core";
export function example() {
return definePlugin({
name: "example",
assets: [createStyleAsset("example", "@riebeckite/plugin-example/style.css")],
clientEntries: [
createClientEntry(
"example",
"@riebeckite/plugin-example/client",
"initExample",
{ selector: ".example" },
),
],
});
}
The same declarations written out explicitly:
assets: [
{
pluginName: "example",
kind: "style",
moduleSpecifier: "@riebeckite/plugin-example/style.css",
},
]
Diagram source
flowchart LR
Plugin["Plugin package"]
CSS["style.css"]
Asset["assets"]
Integration["Integration"]
Browser["Browser"]
Plugin --> CSS
CSS --> Asset
Asset --> Integration
Integration --> Browser
The module specifier is resolved by the host bundler. It can be a subpath exposed
by the plugin package's exports map, a site-local absolute path (e.g.
/extensions/plugin.css), or a path the bundler can resolve. For example, a
third-party package can use createStyleAsset("example", "@example/plugin/style.css")
and createClientEntry("example", "@example/plugin/client", ...).
Omit assets or clientEntries when the plugin does not need them. The client
entry's export name is optional and defaults to the module default export. Its
third argument is an optional export name passed to that initializer. Its
fourth argument is an optional JSON value passed to that initializer. It is the
only plugin configuration exposed to browser code (and recorded in the
manifest for static hosts); options are never copied to the client. Register
only deliberately public values—never tokens, credentials, or private service
URLs. An initializer without public config continues to receive no arguments.
A client entry can also be declared explicitly:
clientEntries: [
{
pluginName: "example",
moduleSpecifier: "@riebeckite/plugin-example/client",
exportName: "initExample",
publicConfig: { selector: ".example" },
},
]
Diagram source
flowchart LR
Plugin["Plugin"]
Entry["Client entry"]
Build["Integration"]
Browser["Browser initializer"]
Plugin --> Entry
Entry --> Build
Build --> Browser
Do not add client JavaScript to a plugin that completes entirely at SSR or build time.
CSS hooks
Plugin CSS stays in the plugin package and reaches the browser through
assets. When a plugin renders a distinct, reusable feature, put a stable
root hook on its outermost element:
-
Name plugin/feature hooks
rr-<feature>(rr-search,rr-callout,rr-query,rr-code, ...). Use BEM structure under the root:rr-<feature>,rr-<feature>__element,rr-<feature>--modifier. -
Emit only
rr-*hooks; plugin CSS and site themes should target therr-hook. -
Do not put plugin output in the
rb-namespace.rb-*classes and--rb-*tokens belong to framework structural hooks and semantic design tokens. Plugin-local tokens use--rr-*and may fall back to--rb-*. -
rr-<feature>__*andrr-<feature>--*are internal implementation details. Document any descendant a theme is expected to target.
| Namespace | Use |
|---|---|
rb-* |
Framework structural hooks |
--rb-* |
Framework semantic design tokens |
rr-* |
Plugin / feature hooks |
--rr-* |
Plugin-local tokens |
Themes target these root hooks. Plugin default CSS loads before theme CSS, so a theme restyles a feature without editing the plugin. See Theme System.
For dark mode, consume the semantic --rb-* tokens: they resolve correctly in
light, explicit dark, and system dark. Only when a plugin must branch on the
mode itself (for example to invert a build-time asset) should it scope the
override to the theme root so it works both at the document root and inside an
embedded .rb-theme-root:
:is(:root, .rb-theme-root)[data-rb-mode="dark"] <hook> and, for system dark,
@media (prefers-color-scheme: dark) { :is(:root, .rb-theme-root):not([data-rb-mode]) <hook> { ... } }.
Do not key a dark override on html[data-rb-mode="dark"] or
:root:not([data-rb-mode]) alone; those miss embedded theme roots. The
framework never adds a .dark class, so do not depend on one.
Endpoints and SEO
endpoints lets an Integration connect reusable plugin HTTP behavior to
the host router without embedding HonoX-specific routing in Core. seo
lets plugins participate in metadata/feed-related behavior through the
framework contract.
Diagram source
flowchart LR
Plugin["Plugin"]
Contract["Endpoint contract"]
Integration["Integration"]
Router["Host router"]
Plugin --> Contract
Contract --> Integration
Integration --> Router
Do not embed a specific router implementation (such as HonoX) in the plugin itself; the integration connects the endpoint contract to the host router. Do not reimplement plugin-specific SEO logic in application routes.
Use defineEndpoint to declare an endpoint. Pass cacheControl only when the
response is safe to cache; the helper applies the header without duplicating
response plumbing.
import { defineEndpoint } from "@riebeckite/core";
const searchEndpoint = defineEndpoint(
"/search-data.json",
({ config, manifest }) => ({ json: buildSearchItems({ config, manifest }) }),
{ cacheControl: "public, max-age=300" },
);
Diagnostics
Return structured diagnostics rather than printing ad-hoc CLI messages. Use the injected Logger for operational logging.
addDiagnostics(context) {
return [
{
// Diagnostic contract
},
];
}
Return diagnostics as structured data rather than calling console.log(...)
for CLI messages; use diagnostics or the logger.
Plugin Cache
context.cache is a plugin-scoped, regenerable build-time cache.
- Store only regenerable, JSON-serializable values.
- Reference only your own plugin namespace.
- Use
cacheVersionwhen compatibility changes. - Treat a corrupt cache as a safe miss.
- Writes are atomic.
Diagram source
flowchart TD
Plugin["Plugin"]
Cache["Plugin cache"]
Valid{"Usable?"}
Plugin --> Cache
Cache --> Valid
Valid -->|"yes"| Reuse["Reuse"]
Valid -->|"no"| Generate["Regenerate"]
It is not a database or Cloudflare Workers runtime storage.
Observability (Logger / Tracer)
Use context.logger and context.tracer:
context.logger.info("...");
await context.tracer.span("plugin.example.work", { plugin: "example" }, async () => {
// work
});
The Profiler consumes structured tracing, so plugins do not need their own timing/reporting system.