Configuration
Configuration stays close to the route, build or package surface it affects.
Configuration stays close to the route, build or package surface it affects.
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; 0.42 line, unfrozen).
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.
content: { blog: { contentDir, basePath } } compiles every markdown post into a virtual module: import { posts, getPostBySlug } from '@openelement/generated/blog-data'. The runtime module is generated 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.
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.
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.
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().
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).
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,
}),
],
});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] },
}),
],
});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.
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.
{
"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.
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.
import { defineConfig } from 'vite';
import { openElement } from '@openelement/adapter-vite';
import { marked } from 'marked';
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).