openPipeline()
精简的 Vite 插件入口,在 vite.config.ts 中配置:openPipeline({ mode, routes: { dir }, island: { dir, upgradeStrategy }, output: { outDir }, viewTransition, headExtras })。默认值:routes 为 app/routes,islands 为 app/islands,components 为 app/components,viewTransition 开启。headExtras 是开发者可信输入,以 trustedHtml 信任级别原样透传——框架不做消毒。script 标签直接拒绝(脚本请走 inject.scripts),style 块不得携带可执行 CSS;其余内容(含 base 与 meta http-equiv)由片段作者负责。
vite.config.ts
import { defineConfig } from 'vite';
import { openPipeline } from '@openelement/router/vite';
export default defineConfig({
plugins: [
openPipeline({
mode: 'ssg', // default
routes: { dir: 'app/routes' },
island: { dir: 'app/islands', upgradeStrategy: 'visible' },
output: { outDir: 'dist' },
viewTransition: true,
}),
],
});openElement() 伞入口
openElement() 用统一的 SSG 与 island 插件集包装 openPipeline。在应用里它不接受参数:plugins: [...openElement()]。框架选项只有一个家,即 openelement.config.ts(见下);当该文件已带选项时再内联传参是硬错误。需要自行搭管线的调用方仍可直接使用 @openelement/router/vite 的 openPipeline(),它有自己显式的配置形状。
openelement.config.ts
可选且近乎为空的配置文件:export default defineConfig({ ... }),defineConfig 从 @openelement/router 导入。它省略的每个选项都由文件约定提供,而约定会跟着 dirs 一起移动:
| 选项 | 省略时的约定 |
|---|---|
dirs |
routes app/routes、islands app/islands、components app/components |
| 设计 token | <共享基目录>/styles/tokens.css(内联进每个文档 <head>) |
| app shell | <共享基目录>/islands/app-shell.tsx(自动注册;删除该文件是受支持的) |
| 文档头内容 | <共享基目录>/head.tsx |
| 站点标题 | package.json 的 name 字段 |
共享基目录是三个根的公共前缀:dirs: { routes: 'src/routes', islands: 'src/islands', components: 'src/components' } 会把 token 文件移到 src/styles/tokens.css、shell 移到 src/islands/app-shell.tsx、head 模块移到 src/head.tsx。部分覆盖的 dirs 不再与默认的 app/* 共享前缀(例如只写 src/pages),因此它只移动自己点名的根,约定仍留在 app。
tokens.css 与 theme.css
两张样式表职责不同:
tokens.css就是上表中的通用 token 约定:一张普通样式表,框架把它内联进每个文档<head>,不经构建步骤、与 Tailwind 无关。触发它的是文件是否存在——建了这张表,它就会被内联进每个页面;styles: { tokens }可把约定指向别的文件。theme.css是 starter 的 Tailwind 样式表。它不是框架约定——脚手架通过openelement.config.ts里的tailwind: { theme: ['app/styles/theme.css'] }显式点名它。该键与上面的约定相互独立:dev 编译声明的源并在文档头以 link 交付编译产物,build 把同一份编译结果作为/assets/open-tailwind.css输出并链接。角色落在 Tailwind 的theme层,而不是一段原样内联的样式。
脚手架项目把 token 放在它声明的 theme.css 里,于是只有一个 token 来源而不是两个——但这是项目选择,不是框架强制的规则。框架真正强制的是「框架选项只有一个家」:openelement.config.ts 已带选项时再往 openElement() 内联传选项是硬错误。如果你同时保留 tokens.css 约定文件与 tailwind.theme 样式表,两者都会生效(都会被内联/编译),保持一致的责任在使用方。
可接受的键——其余任何键都会以这份列表报错并让构建失败:
renderer——'native'(默认,编译后的 Part Program 序列化器)或'lit'。dirs——{ routes, islands, components }。appShell——false表示退出约定,或{ import, props }。标签名由 import 的文件名推导,因此不可配置。packageIslands——构建额外接纳其 island 模块的包名,例如['@openelement/ui']。加载器会把该列表并入 SSR 外部化列表(列出的包被打包而非运行时导入),因此没有ssr.noExternal键。head——结构化文档头通道:title、description、lang、favicon、ogImage、stylesheets: string[]与scripts: { src, defer?, crossOrigin?, integrity? }[]。脚本与样式表都经框架自己的 link/script 序列化器写出,因此配置条目与内联inject条目产出的字节完全一致。inject本身——框架的原始 HTML 通道——刻意在这里没有家。styles——{ tokens },把 token 约定指向另一个文件。i18n——{ locales, defaultLocale };构建会在每个额外 locale 前缀下展开所有路由。viewTransition/speculation——布尔值。对象形式是这两个键将来可能的扩展方向。 这里的viewTransition是构建侧开关(客户端导航是否携带 View Transition 元信息)。它与客户端路由的viewTransitions选项(createRouter,默认关)是两个不同的旋钮——后者把每次导航的渲染包进document.startViewTransition,因输入抑制问题保持 opt-in(其 JSDoc 记录了原因)。build——{ manifestBudget },以 KB 为单位的建议性 manifest 预算。middleware——{ corsOrigin }静态白名单数据;来源回调放在模块里(middleware.use/middleware.corsOriginModule),永远不进配置文件。
app/head.tsx
URL 列表无法表达的结构性 head 内容——站点 meta 标签、字体预加载、图标与 feed 链接、内联关键 CSS——属于 head 约定模块,而不是配置文件。它会被编译进应用的模块图,因此可以按 URL 导入 CSS、从本地模块取常量;它不得使用宿主 API,因为它的产物是构建产物而不是运行时读取。
// app/head.tsx —— 条目都是数据;由框架序列化。
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}' },
];每个条目是 { meta } 记录、{ link } 记录(rel 与 href 必填)或 { style } CSS 字符串,按书写顺序输出。属性名、URL 协议与内联 CSS 都经过与其他 head 片段完全相同的 fail-closed 检查:不安全的属性名、javascript: URL、@import 或提前闭合的 </style> 会让构建失败,而不是被静默丢弃。需要文件字节原样进入文档时用 ?raw 导入 CSS(?inline 会让它过一遍 Vite 的 CSS 管线)。
内容 collection 归站点所有
1.0 的 router 提供路由、locale/渲染上下文、SSG descriptor 与 Document 归属——不是 CMS,也不是内容数据库。站点的 Markdown 管线由站点自己拥有。本仓库的参考站点用声明式 schema 校验 frontmatter、用 marked 渲染、把渲染结果视为第一方可信内容(www/lib/content.ts 的 trustCollectionHtml,trustedHtml 信任级别——非可信来源请先在你自己的边界消毒),在 www/lib/blog.ts 中定义 collection,并用 www/tools/generate-site-content-data.ts 写出带类型的数据模块:
pnpm --dir www run generate:content # site:build 会在 router 构建前先运行生成模块通过站点自己的 package import 消费——即站点 package.json 的 imports 字段,不存在框架虚拟模块:
{
"imports": {
"#generated/blog-data": "./app/data/_generated-blog-data.ts"
}
}每篇 content/blog/*.md 编译为一个 post;draft 文章在 production 构建中被排除。frontmatter 支持 title、date、draft、tags、excerpt、type、lang。
collection 声明自己的目录、base path 与 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 —— 使用模式
// app/components/page-blog-post.tsx —— 由 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 —— scanner 发现的路由模块
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() 预渲染每个 slug;innerHTML + trustedHtml 是渲染 markdown HTML 的显式信任边界。
代码块语法高亮(构建期)
站点自有的 collection loader 在构建机的 generate 步骤里把围栏代码块编译为 Shiki token span——高亮器是 devDependency,永不进入客户端 bundle,页面携带的是静态 token 标记:没有高亮 script,也没有高亮闪烁。token 颜色是 var(--shiki-token-*) 引用,由站点样式表里唯一的调色板表解析,两套配色随 data-theme 与站点其他颜色一起切换。collection 的 markdown 选项可以替换 renderer;其输出仍是第一方可信内容。路由/页面里的代码块用 <open-code-block>(@openelement/ui)包裹——它只拥有 copy 按钮;高亮是构建期编译进 slot light DOM 的部分,没有 token span 的代码块也照常渲染(可复制)为纯文本。
lib/blog.ts —— 语法高亮配方
import { marked } from 'marked';
import {
createCssVariablesTheme,
createHighlighter,
createJavaScriptRegexEngine,
} from 'shiki';
import type { CollectionOptions } from '../lib/content.ts';
// 构建期高亮:token 颜色是 CSS 变量,由站点样式表解析;严格的 JavaScript
// regex 引擎不加载 WASM,遇到不兼容 grammar 直接让构建失败。不在已加载
// 集合内的语言回落到 marked 的纯文本 <pre><code> 路径。
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,
};自定义 renderer 的输出保持在同一第一方信任边界内。
middleware.use
middleware.use 注册 WinterCG 形态的 fetch 中间件:(request, next) => Promise<Response>——不含任何 HTTP 框架方言。中间件链在生成的 handler 边界按洋葱序组合(use[0] 最外层:最先看到请求,最后看到响应),位于内置 requestId/logger/cors/securityHeaders/csp 中间件之外。中间件语义仅作用于请求时路径:静态 GET/HEAD 由构建产物直接给出(tryStatic),不经过 middleware.use 链与内置中间件,因此不要用中间件守卫预渲染页面。在请求时派发路径(dynamic 路由、POST 与非静态回退)上,dev server、start CLI、e2e fixture server 与 Nitro 生产入口运行同一条中间件链(由 request-time parity 契约测试锁定)。中间件可以不调用 next() 直接返回 Response 来短路。每一项是一个模块路径(解析方式与 appShell.import 相同):模块默认导出中间件,生成的 server entry 直接 import 该模块——因此中间件可以闭包引用模块作用域,也可以 import 本地 helper 和第三方包。路由级 _middleware.ts 文件使用同一 WinterCG 形态:根级或嵌套的 _middleware.ts 默认导出 (request, next) => Promise<Response>,作用于其路由子树。
vite.config.ts —— middleware.use
middleware.use 是唯一保留内联的框架选项:配置文件的 middleware 块只承载 corsOrigin,因此需要中间件链的项目通过 openElement(...) 传入,并把这次调用作为框架选项的家(非空的 openelement.config.ts 与内联选项同时存在是硬错误)。内联形式与配置文件二选一,不可并用。
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 接受静态白名单数据(string | string[]),以 JSON 形式序列化进生成的 entry。当白名单需要逻辑判断时,用 middleware.corsOriginModule 指向一个默认导出 (origin: string) => string | undefined 的模块——entry 会 import 该模块并引用这个回调,因此它可以像 middleware.use 模块一样 import 依赖、闭包引用模块作用域。两个选项互斥(同时配置会在构建期报配置错误)。