Open navigation

Configuration

Configuration stays close to the route, build or package surface it affects.

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)

// app/components/page-blog-post.tsx — compiled by the open:compiled-element transform
import { element, OpenElement, property } from '@openelement/element';

@element('blog-post-page', { root: 'shadow-open' })
export default class BlogPostPage extends OpenElement {
  @property({ reflect: false, attribute: false })
  title = '';

  @property({ reflect: false, attribute: false })
  html = '';

  render() {
    return (
      <>
        <h1>{this.title}</h1>
        {/* post.html is markdown authored in this repo — explicit trust boundary */}
        <article class='post-body' innerHTML={this.html} trustedHtml></article>
      </>
    );
  }
}
// app/routes/blog/[slug].tsx — the route module the scanner discovers
import { definePage, notFound } from '@openelement/app';
import { getPostBySlug, posts } from '@openelement/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, {
  route: { path: '/blog/:slug' },
  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 (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. Page classes are compiled @element classes whose @property fields carry the loader data; the bootstrap imports each page module so its class is registered before mount.

app/main.ts — SPA bootstrap

// app/components/page-home.tsx — compiled by the open:compiled-element transform
import { element, OpenElement, property } from '@openelement/element';

@element('page-home', { root: 'shadow-open' })
export default class HomePage extends OpenElement {
  @property({ reflect: false, attribute: false })
  now = '';

  render() {
    return <main><h1>home</h1><p>{this.now}</p></main>;
  }
}
// app/main.ts
import { defineApp } from '@openelement/app';
import './components/page-home.tsx';
// import '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 projector; 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).