openPipeline()
The lean Vite plugin entry, configured in vite.config.ts: openPipeline({ mode, routes: { dir }, island: { dir, upgradeStrategy }, output: { outDir }, viewTransition, headExtras }). Defaults: routes app/routes, islands app/islands, components app/components, viewTransition on. headExtras is sanitized on injection against a head allowlist — only link/meta/noscript/title survive, base and meta http-equiv are stripped, and script tags are rejected outright (use inject.scripts for scripts) (#931; frozen under ADR-0122).
vite.config.ts
import { defineConfig } from 'vite';
import { openPipeline } from '@openelement/adapter-vite';
export default defineConfig({
plugins: [
openPipeline({
mode: 'ssg', // default; 'spa' produces a client-only app
routes: { dir: 'app/routes' },
island: { dir: 'app/islands', upgradeStrategy: 'visible' },
output: { outDir: 'dist' },
viewTransition: true,
}),
],
});
openElement() umbrella
Apps that need the content (blog/nav/sitemap) or i18n modules use openElement() from the same package root: it wraps openPipeline and takes the flat option names — routesDir, islandsDir, componentsDir, packageIslands, html, inject, middleware — plus content and i18n module options; omit either module to disable it.
The generated blog-data module
content: { blog: { contentDir, basePath } } compiles every markdown post into a generated module: import { posts, getPostBySlug } from '@openelement/generated/blog-data'. The module is written at build/dev time; a checked-in .d.ts stub plus an import-map entry keep deno task check green. frontmatter supports title, date, draft, tags, excerpt, type.
Named Markdown sections use content.collections. Each collection declares a
directory and optional frontmatter schema, then generates
app/data/_generated-{name}-data.ts. The blog option is a compatibility alias
over this same pipeline, so all collections share one watcher and one HTML
sanitizer allow-list.
content: {
collections: {
guide: {
contentDir: 'content/guide',
basePath: '/guide',
schema: {
fields: {
title: { type: 'string', required: true },
order: { type: 'number', required: true },
lede: 'string',
},
},
},
},
}
vite.config.ts — the blog-data module (#924)
import { defineConfig } from 'vite';
import { openElement } from '@openelement/adapter-vite';
export default defineConfig({
plugins: [
openElement({
content: {
blog: { contentDir: 'content/blog', basePath: '/blog' },
},
}),
],
});
openElement() is required (the content module is not part of openPipeline()). Every content/blog/*.md compiles to one post; draft posts are excluded from production builds.
deno.json — the .d.ts stub and import-map entry (#924)
{
"imports": {
"@openelement/generated/blog-data": "./app/data/_generated-blog-data.d.ts"
}
}
The runtime module is generated by adapter-vite during build/dev; the stub keeps deno task check type-correct before the generated file exists.
app/routes/blog/[slug].tsx — usage pattern (#924)
import { defineElement, definePage, notFound } from '@openelement/app';
import { getPostBySlug, posts } from '@openelement/generated/blog-data';
export function getStaticPaths(): Array<Record<string, string>> {
return posts.map((post) => ({ slug: post.slug }));
}
defineElement('blog-post-page', {
render(props: { slug: string }) {
const post = getPostBySlug(props.slug);
if (!post) notFound(`Post not found: ${props.slug}`);
return (
<>
<h1>{post.frontmatter.title}</h1>
{/* post.html is markdown authored in this repo — explicit trust boundary */}
<article class='post-body' innerHTML={post.html} trustedHtml></article>
</>
);
},
});
export default definePage({
route: { path: '/blog/:slug' },
renderIntent: { mode: 'static', revalidate: false },
render({ params }) {
return <blog-post-page slug={params.slug} />;
},
});
getStaticPaths() pre-renders every slug; innerHTML + trustedHtml is the explicit trust boundary for markdown HTML.
Code-block highlighting (optional)
The blog pipeline renders fenced blocks as <pre><code class="language-x"> with no token-level colors. Wire your own highlighter through the content.blog.markdown hook — the recipe below keeps the default marked behavior and adds hljs spans, which pass the sanitizer allowlist untouched. For code blocks in routes/pages, wrap them in <open-code-block> (@openelement/ui) — it highlights via a global Prism that your page must load (core + language grammars, e.g. the CDN scripts this site injects in www/vite.config.ts); without Prism you get the copy button but no token spans.
vite.config.ts — syntax highlighting recipe (optional, #930)
import { defineConfig } from 'vite';
import { openElement } from '@openelement/adapter-vite';
import { marked } from 'npm:marked@^15';
import hljs from 'npm:highlight.js@^11';
// Default marked behavior + hljs token spans. hljs output only adds class
// attributes to <code>, which the sanitizer allowlist keeps.
const markdown = (content: string) =>
marked(content, {
async: true,
renderer: {
code(code: string, lang: string | undefined) {
const language = hljs.getLanguage(lang ?? '') ? lang : 'plaintext';
const html = hljs.highlight(code, { language }).value;
return `<pre><code class="language-${language}">${html}</code></pre>`;
},
},
});
export default defineConfig({
plugins: [
openElement({
content: { blog: { contentDir: 'content/blog', markdown } },
}),
],
});
Custom renderer output still passes the same sanitizer allowlist (class attributes are kept).
middleware.use
middleware.use (ADR-0123, #858) registers fetch middleware with the WinterCG shape (request, next) => Promise<Response> — no HTTP-framework dialect. The chain is composed around the generated handler in onion order (use[0] is outermost: first to see the request, last to see the response), outside the built-in requestId/logger/cors/securityHeaders/csp middleware, and runs with identical semantics in the dev server, the start CLI, the e2e fixture server, and the Nitro production entry (locked by the request-time parity contract test). A middleware may short-circuit by returning a Response without calling next(). One constraint: middleware sources are inlined into the generated server entry (same mechanism as a function-valued corsOrigin), so each middleware must be self-contained — no closures over the vite.config.ts module scope. Route-scoped _middleware.ts files keep the Hono dialect and remain available inside the app.
vite.config.ts — middleware.use (#858)
import type { Middleware } from '@openelement/element';
// Self-contained: the source is inlined into the generated server entry,
// so it cannot close over vite.config.ts module scope.
const responseTime: Middleware = async (request, next) => {
const started = Date.now();
const response = await next();
response.headers.set('x-response-time', String(Date.now() - started));
return response;
};
const guard: Middleware = (request, next) => {
// Short-circuit: skip next() and return a Response directly.
if (new URL(request.url).pathname.startsWith('/internal')) {
return Promise.resolve(new Response('Forbidden', { status: 403 }));
}
return next();
};
export default defineConfig({
plugins: [
...openElement({
// Onion order: responseTime wraps guard wraps the app handler.
middleware: { use: [responseTime, guard] },
}),
],
});
mode: 'spa'
openPipeline({ mode: 'spa' }) produces a client-only app (no SSR). Bootstrap with defineApp({ mode: 'spa', routes }) from @openelement/app: each route is { path, tagName, loader?, action?, guard? }, paths take :id params and the :path{.+} multi-segment catch-all (Hono-style). mount(selector) attaches the client router; pages read data with useLoaderData() / useActionData().
app/main.ts — SPA bootstrap
import { defineApp, definePage, useLoaderData } from '@openelement/app';
const HomePage = definePage({
render() {
const data = useLoaderData() as { now: string } | undefined;
return <main><h1>home</h1><p>{data?.now ?? ''}</p></main>;
},
});
customElements.define('page-home', HomePage);
// register 'page-doc' the same way
const app = defineApp({
mode: 'spa',
routes: [
{
path: '/',
tagName: 'page-home',
loader: async () => ({ now: new Date().toISOString() }),
},
// multi-segment catch-all (Hono-style)
{ path: '/docs/:path{.+}', tagName: 'page-doc' },
],
});
app.mount('#app');
redirect()/notFound() still work on the SPA chain: a redirect navigates the client router, a notFound rides the page error definition; any other throw is normalized into action data.
SPA vs SSG chains
SPA loaders/actions run client-side with only { params } (actions also get formData) and signal failure by throwing; the SSG/request-time chain runs on the server with the Web-standard context and the fail()/redirect() protocol. The names are intentionally parallel, the contexts are not (ADR-0119 frozen SPA semantics).