openPipeline()
The lean Vite plugin entry, configured in vite.config.ts: openPipeline({ routes: { dir }, island: { dir, upgradeStrategy }, output: { outDir }, viewTransition, headExtras }). Defaults: routes app/routes, islands app/islands, viewTransition on. headExtras is developer-trusted input passed through verbatim at the trustedHtml trust level — the framework does not sanitize it. <script> tags are rejected outright (use inject.scripts for scripts), and <style> blocks must not carry executable CSS; anything else, including base and meta http-equiv, is the fragment author's responsibility.
vite.config.ts
import { defineConfig } from 'vite';
import { openPipeline } from '@openelement/router/vite';
export default defineConfig({
plugins: [
openPipeline({
routes: { dir: 'app/routes' },
island: { dir: 'app/islands', upgradeStrategy: 'visible' },
output: { outDir: 'dist' },
viewTransition: true,
}),
],
});openElement() umbrella
openElement() wraps openPipeline with the unified SSG and island plugin set. In an application it takes no arguments: plugins: [...openElement()]. Framework options have exactly one home, openelement.config.ts (see below), and passing them inline while that file carries options is a hard error. The low-level openPipeline() from @openelement/router/vite keeps its own explicit config shape for callers that build a pipeline directly.
openelement.config.ts
The optional, near-empty config file: export default defineConfig({ ... }) from @openelement/router. Every option it omits comes from a file convention, and the conventions follow the dirs block:
| Option | Convention when omitted |
|---|---|
dirs |
routes app/routes, islands app/islands, components app/components |
| design tokens | <shared base>/styles/tokens.css (inlined into every document <head>) |
| layout | <shared base>/layout.tsx (auto-registered; deleting the file is supported) |
| document head content | <shared base>/head.tsx |
| site title | the name field of package.json |
The shared base is the three roots' longest common directory: dirs: { routes: 'src/routes', islands: 'src/islands', components: 'src/components' } moves the tokens file to src/styles/tokens.css, the layout to src/layout.tsx and the head module to src/head.tsx. A partial dirs has no shared segment left (src/pages against the default app/*), so it moves only what it names and the conventions stay at app.
tokens.css or theme.css
The two sheets serve different roles:
tokens.cssis the generic token convention above: a plain stylesheet the framework inlines into every document<head>, no build step and no Tailwind involved. Its presence is what triggers it — create the file and it is inlined on every page;styles: { tokens }points the convention at a different file.theme.cssis the starter's Tailwind sheet. It is not a framework convention — the scaffold names it explicitly throughtailwind: { theme: ['app/styles/theme.css'] }inopenelement.config.ts. That key is independent of the convention above: dev compiles the declared sources and serves the compiled sheet through a head link, and the build emits the same compile as a content-addressed asset underclient/assets/(/client/assets/<hash>.css, the name read from the build's own manifest — never a hand-written file name). Roles land in the Tailwindthemelayer rather than as a raw inlined block.
A starter project keeps its tokens in the theme.css sheet it declares, so there is one token source rather than two — but that is a project choice, not a rule the framework enforces. What it does enforce is one home for framework options: passing options inline to openElement() while openelement.config.ts carries them is a hard error. If you keep both a tokens.css convention file and a tailwind.theme sheet, both take effect and you own keeping them consistent.
One policy, two stylesheet channels
The styling surface is two deliveries, and which one a sheet takes is decided by its ROLE rather than by the file it lives in:
| channel | what it carries | how it is delivered |
|---|---|---|
| document | the token/role table, the engine's preflight layers, and the utilities the light DOM uses | a compiled, content-addressed stylesheet linked from every document's <head> (the engine's own compile, driven by the selected provider) |
| component | the utilities a SHADOW ROOT's admitted classes need | the same provider's compile of the shadow candidate set, delivered through the framework's style-asset protocol — declared by each DSD template, adopted as one shared CSSStyleSheet per realm |
The split is not a packaging detail: a reset or a token table inside a component's shadow tree would change how every existing component renders, so the component sheet carries utilities only and the document sheet owns everything global. A .css file the app authors follows its own import path; a sheet the ENGINE compiles is placed by the provider the plan selected.
tailwind decides whether the pipeline exists at all, and a package can declare that need itself. With the key omitted, Tailwind compiles only when an admitted package declares it: an openElement.tailwind block (OpenElementExtensions, the framework's namespaced extension to the standard package manifest — not a second metadata format, and not custom-elements.json: the Custom Elements Manifest remains the generic interchange authority for tags, attributes, members, events, slots and parts) in an admitted package — declared explicitly or derived from the app's real imports, see packageIslands below — either requires the compile (required: true — disabling the entry then fails the build with a coded diagnostic instead of shipping unstyled components) or offers the package-owned sources that compile with it (sources: ['./theme.css'], package-relative, resolved inside the installed package). A consumer never repeats an owner's source: declared sources compile first, the application's theme entries after them, and a source reached twice under different spellings compiles once (deduplicated by the file it resolves to). Local application classes never turn the pipeline on by themselves — an application that authors its own utilities declares tailwind itself. true compiles the declared sources with none of the application's; false switches the pipeline off entirely (no peer is loaded, nothing is compiled, no stylesheet is emitted) and fails closed when an admitted component requires it.
Accepted keys — anything else fails the build with this list:
renderer—'native'(default, the compiled Part Program serializer) or'lit'.dirs—{ routes, islands, components }.layout—falseto opt out, or{ import, props }. The tag name is derived from the import's basename, so it is not configurable.packageIslands— how component packages are admitted. Omitted or'auto'(the default): the build derives the admitted set at scan time — every package-owned element module your routes, pages, layouts, islands and components really import (bundler-resolved) is attributed to its owner package, and a package with a valid OpenElement manifest is admitted with no configuration. An array adds explicit entries on top of the derivation (e.g.['@openelement/ui']); an explicit entry outranks a derived claim when two packages declare the same tag.'manual'turns the derivation off: only listed packages are admitted, and an imported-but-unlisted component package is reported with the explicit fix. A package whose element modules the app imports but which carries no valid manifest is never admitted (there is nothing to admit) and is reported — the one true mistake class. The derivation also folds every admitted package into the SSR externalization list (an admitted package is bundled, not imported at run time), which is why there is nossr.noExternalkey.head— the structured document-head channel:title,description,lang,favicon,ogImage,stylesheets: string[]andscripts: { src, defer?, crossOrigin?, integrity? }[]. Scripts and stylesheets serialize through the framework's own link/script writer, so a config entry and an inlineinjectentry emit identical bytes.injectitself — the framework's raw-HTML channel — deliberately has no home here.langsets<html lang>and defaults toen; wheni18nis configured, the locale derived from the request path overrides it (ADR-0172).styles—{ tokens }to point the token convention at another file.i18n—{ locales, defaultLocale }; the build expands every route under each additional locale prefix.viewTransition/speculation— booleans. The object forms are a possible future widening of these two keys. ThisviewTransitionis the BUILD-side switch (whether client navigation carries the View Transition meta). It is a different knob from the client router'sviewTransitionsoption (createRouter, default off) — the switch that wraps each navigation's render indocument.startViewTransitionand stays opt-in for the input-suppression reasons its JSDoc records.build—{ manifestBudget }, an advisory per-entry manifest budget in KB.middleware—{ corsOrigin }static allowlist data; the origin callback lives in a module (middleware.use/middleware.corsOriginModule), never in the config file.
app/head.tsx
Structural head content a URL list cannot express — site meta tags, font preloads, icon and feed links, inline critical CSS — belongs in the head convention module, not in the config file. It is compiled into the app's module graph, so it may import CSS by URL and pull constants from local modules; it must not use host APIs, because its output is a build artifact rather than a runtime read.
// app/head.tsx — entries are data; the framework serializes them.
export default [
{ meta: { property: 'og:site_name', content: 'My App' } },
{ link: { rel: 'preload', href: '/assets/inter.woff2', as: 'font', crossorigin: 'anonymous' } },
{ style: 'html{visibility:visible!important}' },
];Each entry is a { meta } record, a { link } record (rel and href required) or a { style } CSS string, emitted in the order written. Attribute names, URL protocols and inline CSS all pass the same fail-closed checks as every other head fragment: an unsafe attribute name, a javascript: URL, an @import or a </style> that closes the block early fails the build instead of being dropped. Import CSS with ?raw when the file must reach the document byte-for-byte (?inline runs it through Vite's CSS pipeline).
Content collections are site-owned
The 1.0 router ships routing, locale/render context, the SSG descriptor and Document ownership — not a CMS or content database. A site owns its Markdown pipeline. This repository's reference site validates frontmatter against declarative schemas, renders with marked, treats the rendered HTML as first-party trusted content (trustCollectionHtml in www/lib/content.ts, trustedHtml trust level — untrusted sources must be sanitized at your own boundary first), defines collections in www/lib/blog.ts, and writes typed data modules with www/tools/generate-site-content-data.ts:
pnpm --dir www run generate:content # site:build runs this before the router buildGenerated modules are consumed through the site's own package import — the imports field of the site's package.json — there is no framework virtual module:
{
"imports": {
"#generated/blog-data": "./app/data/_generated-blog-data.ts"
}
}Every content/blog/*.md compiles to one post; draft posts are excluded from production builds. frontmatter supports title, date, draft, tags, excerpt, type, lang.
A collection declares its directory, base path and schema:
import type { CollectionOptions } from '../lib/content.ts';
export const blogCollection: CollectionOptions = {
contentDir: 'content/blog',
basePath: '/blog',
schema: {
fields: {
title: 'string',
date: 'string',
draft: 'boolean',
tags: 'string[]',
},
},
};app/routes/blog/[slug].tsx — usage pattern
// app/components/page-blog-post.tsx — compiled by the open:compiled-element transform
import { element, OpenElement, property, trustedHtml, type TrustedHtml } from '@openelement/element';
@element('blog-post-page', { root: 'shadow-open' })
export default class BlogPostPage extends OpenElement {
@property({ reflect: false, attribute: false })
title = '';
@property({ type: Object, reflect: false, attribute: false })
html: TrustedHtml = trustedHtml('');
render() {
return (
<main>
<h1>{this.title}</h1>
{/* post.html is markdown authored in this repo — explicit trust boundary */}
<article class='post-body' innerHTML={this.html} trustedHtml></article>
</main>
);
}
}// app/routes/blog/[slug].tsx — the route module the scanner discovers
import { definePage, notFound } from '@openelement/router';
import { getPostBySlug, posts } from '#generated/blog-data';
import BlogPostPage from '../../components/page-blog-post.tsx';
export function getStaticPaths(): Array<Record<string, string>> {
return posts.map((post) => ({ slug: post.slug }));
}
export default definePage(BlogPostPage, {
renderIntent: { mode: 'static' },
props({ params }) {
const post = getPostBySlug(params.slug);
if (!post) notFound(`Post not found: ${params.slug}`);
return { title: post.frontmatter.title, html: post.html };
},
});getStaticPaths() pre-renders every slug; innerHTML + trustedHtml is the explicit trust boundary for markdown HTML.
Code-block highlighting (build-time)
The site-owned collection loader compiles fenced blocks to Shiki token spans during the generate step, on the build machine — the highlighter is a devDependency and never ships to the client, so pages carry static token markup with no highlight script and no highlight flash. Token colors are var(--shiki-token-*) references resolved by one palette table in the site stylesheet, so both palettes flip with data-theme like every other site color. A collection's markdown option replaces the renderer; its output is still first-party trusted content. For code blocks in routes/pages, wrap them in <open-code-block> (@openelement/ui) — it owns the copy button only; highlighting is whatever the build compiled into the slot's light DOM, and a block without token spans still renders (and copies) as plain text.
lib/blog.ts — syntax highlighting recipe
import { marked } from 'marked';
import {
createCssVariablesTheme,
createHighlighter,
createJavaScriptRegexEngine,
} from 'shiki';
import type { CollectionOptions } from '../lib/content.ts';
// Build-time highlighting: token colors are CSS variables resolved by the
// site stylesheet; the strict JavaScript regex engine loads no WASM and
// fails the build on an incompatible grammar. Languages outside the loaded
// set fall through to marked's plain <pre><code> path.
const theme = createCssVariablesTheme({ variablePrefix: '--shiki-' });
const highlighter = await createHighlighter({
themes: [theme],
langs: ['typescript', 'tsx', 'javascript', 'json', 'bash', 'css', 'html'],
engine: createJavaScriptRegexEngine(),
});
const langs = new Set(['typescript', 'tsx', 'javascript', 'json', 'bash', 'css', 'html']);
const markdown = (content: string) =>
marked(content, {
renderer: {
code(code: string, lang: string | undefined) {
const grammar = ({ ts: 'typescript', js: 'javascript', sh: 'bash' })[lang ?? ''] ?? lang;
return grammar && langs.has(grammar)
? highlighter.codeToHtml(code, { lang: grammar, theme })
: false;
},
},
});
export const blogCollection: CollectionOptions = {
contentDir: 'content/blog',
basePath: '/blog',
markdown,
};Custom renderer output stays within the same first-party trust boundary.
middleware.use
middleware.use 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. Middleware semantics are request-time only: a static GET/HEAD is served straight from the built artifacts (tryStatic) and never passes through the middleware.use chain or the built-in middleware, so do not rely on middleware to guard prerendered pages. On the request-time dispatch path (dynamic routes, POSTs, and non-static fallbacks) the same chain runs 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(). Each entry is a module path (resolved like layout.import): the module default-exports the middleware, and the generated server entry imports it — so middleware may close over module scope and import local helpers and third-party packages. Route-scoped _middleware.ts files use the same WinterCG shape: a root or nested _middleware.ts default-exports (request, next) => Promise<Response>, applied to its route subtree.
vite.config.ts — middleware.use
middleware.use is the one framework option that stays inline: the config file's middleware block carries corsOrigin only, so a project that needs a middleware chain passes it through openElement(...) and therefore keeps that call as its framework-options home (a non-empty openelement.config.ts next to inline options is a hard error). Use the inline form or the file, never both.
import { defineConfig } from 'vite';
import { openElement } from '@openelement/router/vite';
export default defineConfig({
plugins: [
...openElement({
// Onion order: responseTime wraps guard wraps the app handler.
middleware: {
use: ['./app/middleware/response-time.ts', './app/middleware/guard.ts'],
},
}),
],
});app/middleware/response-time.ts
import type { Middleware } from '@openelement/element';
// A real module: close over module scope, import helpers and packages.
const started = () => performance.now();
const responseTime: Middleware = async (_request, next) => {
const begin = started();
const response = await next();
response.headers.set('x-response-time', String(started() - begin));
return response;
};
export default responseTime;app/middleware/guard.ts
import type { Middleware } from '@openelement/element';
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 guard;middleware.corsOrigin / middleware.corsOriginModule
middleware.corsOrigin takes static allowlist data (string | string[]), serialized into the generated entry as JSON. When the allowlist needs logic, point middleware.corsOriginModule at a module default-exporting (origin: string) => string | undefined — the entry imports the module and references the callback, so it can import dependencies and close over module scope just like a middleware.use module. The two options are mutually exclusive (configuring both is a build-time config error).
See also
- Deployment — how the configured output is emitted and served.
- Security — the middleware, CORS and CSP options in context.
- Routing and Data — routes, rendering modes and data boundaries.