Site UI 模块

本页写给要为发行版开发自己公开页面的前端开发者。如果配置和品牌设置已经够用,请直接看发行版定制。

部署可以替换访客看到的公开页面(首页、账号页面的外壳、法律条款页),而不用 fork 控制台。靠的是构建时 UI 模块:一小组 React 组件,构建前端镜像时编译进去,和共享应用并存,而不是取代它。

只有一个 Next.js 应用、一个控制台、一套会话。发行版只需提供一个小小的 UI 模块,构建时会把它编译进去。

hybridinference @ C
  ├── neutral backend image
  └── generic Next.js frontend
        + distribution UI module @ U   →  the distribution's own frontend image

这里的 C 是上游源码的提交,U 是发行版 UI 源码的提交。构建发布版本时,两者都要锁定。模块不会在运行时拉取或替换;改组件、样式表或编译进去的文案,都需要重新构建前端镜像。模块读取的运行时品牌配置,仍然在运行时生效。

模块能改什么、不能改什么

模块负责

共享应用保留

模块导出的首页

仪表盘、聊天、管理后台、团队和授权页面,以及 /agents 代理集成

账号页面外壳,以及每个字段各部分的位置

账号表单、字段、验证与提交请求

模块自带的法律文本和注册确认项

注册同意步骤、注册时记录的那一个 accepted_tos 标志,以及账号页面链接到的条款锚点

它为账号页面写的文案

schema、错误码、会话处理和重定向规则

公开页面的标题,以及它渲染的页面用什么语言

所有控制台页面的标题和语言

它自己的样式表和设计资源

运行时品牌文档与 /site-config

模块决定不了谁能进来:账号页面的业务逻辑、注册开关、邮箱验证要求和权限规则都在共享应用里,不属于这个接口。

共享应用还负责会话存储、代理规则,以及 AuthField / AuthNotice / AuthLoading 的无障碍行为。模块不能注册路由,也不能接管上面没列出的页面。新增 /pricing 这样的路径、重新设计控制台或增加认证方式,都超出了这个接口的范围,需要改应用本身。

宿主接口(host facade)向模块提供公开的站点配置、解析后的品牌配置、会话状态,以及受支持的展示类型和辅助函数。页面可以根据会话状态决定显示登录链接还是控制台链接;但这个接口不提供登录/退出、token 或任何认证操作。TypeScript 接口以 apps/frontend/src/site-ui/contract.ts 为准,可用的导入见 apps/frontend/src/site-ui/host.ts。

模块可以导入什么

模块可以导入 @site-ui/host、react、react/*、react-dom、next、next/* 以及模块自己的文件。相对导入不能跳出模块目录。其他任何导入(尤其是 @/...)都会导致构建失败。

构建时,webpack 每解析模块的一个请求,都会对照这份清单检查一次,Next.js 的每一次编译都是如此;判断依据是请求最终解析到的真实路径,而不是它的写法。下面这些情况同样会导致构建失败:

  • 指向模块之外的相对路径或符号链接;

  • 借助上级目录跳出框架包的子路径,例如 next/../../src/site-ui/routes;

  • 指向模块之外的样式表 @import 或 url();

  • 针对模块外目录的计算式导入,比如 require.context、import.meta.webpackContext,或参数为模板字符串的 import();

  • data: 或 file: URI,以及 Node.js 内置模块;

  • Next.js 自带 loader 以外的内联 loader,例如 !!raw-loader!./notes.txt;

  • 模块内部 node_modules 目录中的任何内容。模块的依赖来自应用的锁文件,暂存步骤也不会复制该目录。

Next.js 编译器会往模块代码里注入辅助库 @swc/helpers 和 styled-jsx,构建也放行它们,并从 Next.js 自身解析。模块本身不需要按名字导入它们。

源码检查能更早发现它看得到的问题:暂存模块时会运行,每次构建、类型检查和测试之前也会运行。它用 TypeScript 解析器解析脚本,读取样式表,一次列出所有问题。test/、tests/、__tests__/ 或 __mocks__/ 下的文件,以及 vitest、jest 或 playwright 的配置文件,只要生产代码没有导入,就不受检查;但如果 client.tsx 导入了某个测试辅助文件,它就和其他文件一样要检查。暂存步骤要复制的每个符号链接,都必须解析到模块内部。

模块是一个目录

my-ui/
  manifest.json     identity: id, site_ui_api, locale
  client.tsx        the components (required)
  server.ts         required entry; optional locale and metaMessages
  styles.css        the module's stylesheet (required, may be empty)
  public/           assets, under public/site-assets/<id>/
  ...               the module's own components, however it likes

styles.css 由宿主加载:根布局在每个页面上通过生成的桥接文件导入它,并排在应用自身样式表之后,因此优先级相同时,模块规则会覆盖应用规则。模块不必自行导入它。

server.ts 必须存在,可以仅包含 export {};。它的两个导出 locale 和 metaMessages 都是可选的;参见文档语言与页面标题。

client.tsx 的导出项:

导出项

必填

含义

descriptor

是

具名的对象字面量 { siteUiApi, id, locale };这三个值必须与 manifest 中的 site_ui_api、id 和 locale 分别一致。

Landing

否

在 / 渲染。提供它会替换控制台首页。

AuthFrame

否

/login、/signup、/forgot-password、/reset-password、/verify-email 的外壳。

fieldLayout

否

每个账号字段的标签、控件、提示、错误和操作放在哪里。

TermsFrame

三个要么都提供,要么都不提供

/terms 上包裹模块法律文本的页面外壳。

TermsContent

三个要么都提供,要么都不提供

模块的法律文本,用于 /terms 和注册同意步骤。

consentItems

三个要么都提供,要么都不提供

注册同意步骤要求访客确认的内容:一个非空的 { id, label, description? } 列表。

authAppearance

否

已弃用:为兼容而保留的共享表单类名映射,整个 API v1 期间都继续支持。

authMessages

否

部署自己为账号页面写的文案。

宿主只读取具名导出。客户端入口如果有默认导出,构建会失败;同一个入口有第二个文件(比如 client.tsx 旁边还有 client.js)也会失败,因为打包器、类型检查器和测试工具补全扩展名的顺序各不相同。服务端入口可以保留默认导出,宿主不会读它。

npm run type-check 和 next build 会对照 contract.ts 里的契约检查导出:client.tsx 对照 SiteUiClientModule,server.ts 对照 SiteUiServerModule。导出的类型不对会报错,并指出是哪个导出;法律条款组不完整、consentItems 为空,也同样报错。模块加载时,宿主还会再查一遍那些被类型断言或 any 瞒过编译器的问题:组件导出其实不是组件、法律条款组不完整,以及 consentItems 为空、id 重复,或者有条目缺少 id 或 label。出现其中任何一种,模块都不会加载。

next build 不会加载模块。 每个页面都按需渲染,因此构建过程不会执行任何客户端模块。只有加载时检查才能发现的错误可以顺利通过构建,却会在第一次请求时失败;此后每个页面都返回 HTTP 500。发布每个镜像前都要做冒烟测试:连同网关一起启动它,并请求 /、五个账号页面和 /terms。

不导出某个可选项,是有意的选择,不是遗漏。Landing: null 表示“这里用控制台的首页”;不导出 AuthFrame 表示“把账号页面放进控制台的容器里”。两个都不导出的模块什么也不替换,开发过程中这样完全没问题。

外壳负责绘制整个页面。AuthFrame 和 TermsFrame 自己渲染页头、<main> 和页脚。只画出一张卡片的外壳,会让五个账号页面都没有页头和页脚,宿主也无法把它们补回来,所以要先把这一点做对。

AuthFrame 必须渲染传进来的共享表单 children,并保留传入的 topbar 和 legal 节点。它拿到的标题和页面标识,让同一个外壳可以用在五个账号路由上。完整的外层页面由它负责,所以宿主不会在它外面再加页头或页脚。

法律条款组要整体取舍。发布自己条款的模块,要同时导出 TermsFrame、TermsContent 和 consentItems;三个都不导出的,沿用控制台的条款和它的四个确认项。访客会在两个地方看到条款,宿主在这两处都渲染 TermsContent:一是 /terms,作为 TermsFrame 的 children;二是注册同意步骤,这里模块的 consentItems 会取代控制台的确认项。访客同意的文本,就是站点发布的文本。

TermsContent 只渲染法律正文,即各章节以及日期之类的前言;页面标题由外壳负责。在 /terms 上,它收到的 headingLevel 为 2、compact 为 false;在注册同意步骤的滚动框中,headingLevel 为 3、compact 为 true。TermsFrame 负责绘制包裹它的页面,并且必须渲染其 children。在 /terms 上,请为每个章节设置 terms-s 锚点:账号页面的隐私链接指向 /terms#terms-s5。设置 manifest 中的文件路径不能替代这里的法律文档。

请用 ConsentItems 类型声明 consentItems,并为每个条目设置唯一的 id。普通的 ConsentItem[] 无法通过类型检查,因为它不能保证至少有一个条目。访客把条款读到末尾后,每个条目才会解锁;Continue 按钮要等所有条目都勾选后才可用。后端只记录一个 accepted_tos 标志,注册请求只有在每个条目都勾选之后才会发送它。

authMessages 只改文案,不改校验规则,也不改必填字段。宿主只保留 AUTH_MESSAGE_KEYS 里声明过、且值为字符串的键,其余内容在页面读取之前就会丢弃,所以写给控制台页面的文案(/authorize 的 auth.authorize.*、/chat 的 chat.*)不会生效。没提供的键沿用共享的默认文案。请保留每条消息里的插值变量。模块可以为自己的页面和支持的键做本地化,但这并不会翻译控制台的所有页面。

文档语言与页面标题

服务端入口里可选的 locale,决定模块渲染的那些路由上 <html lang> 的值:导出了 Landing 就是 /,导出了 AuthFrame 就是五个账号页面,导出了法律条款组就是 /terms。其余路由(控制台本身,以及模块没有提供对应导出的公开路由)都归控制台,语言为 en。locale 为空或不写时,所有路由都是 en。这个属性不只在首次响应里生效,客户端导航时也会跟着更新。

可选的 metaMessages 为七个公开路由提供标题和描述文案。它的键是 meta.<page>.title 和 meta.<page>.description,其中 <page> 为 home、login、signup、forgot、reset、verify 或 terms;contract.ts 中的 META_MESSAGE_KEYS 列出了全部键。宿主会像过滤 authMessages 一样过滤它,丢弃未声明的键和非字符串值。meta.home.title 是完整的文档标题;其他标题显示为 <title> | <site name>,与控制台自身页面一致。每个值都可以使用 {app_name}。

模块未提供文案的页面保留默认值:站点自身的标题和描述,/terms 则为“Terms of Service”。标题与模块渲染哪些路由无关。控制台页面保留英文标题,图标来自运行时品牌配置。

使用模块构建镜像

官方前端 Dockerfile 通过 Buildx 命名上下文接收模块:

# The neutral image. The command is unchanged, and needs no new arguments.
docker build -f deploy/docker/Dockerfile.frontend -t local/frontend .

# A module from this repository, for development and for the public example.
docker buildx build -f deploy/docker/Dockerfile.frontend \
  --build-context site-ui=./distributions/example/frontend/site-ui \
  --build-arg SITE_UI_API=1 \
  --load -t local/frontend:example .

# A distribution's own repository, at a pinned commit.
docker buildx build -f deploy/docker/Dockerfile.frontend \
  --build-context "site-ui=<UI_REPOSITORY>#<U>:frontend" \
  --build-arg SITE_UI_SUBDIR=site-ui \
  --build-arg SITE_UI_API=1 \
  --tag "<FRONTEND_IMAGE>" --push "<CORE_REPOSITORY>#<C>"

输入

默认值

含义

site-ui 上下文

内置标记

没有外部模块,使用默认 UI

SITE_UI_SUBDIR

.

上下文中的模块目录

SITE_UI_API

未设置

使用外部模块时必填,必须为 1

发行版也可以把整个 frontend/ 目录树作为上下文传进去,再用 SITE_UI_SUBDIR=site-ui 选出模块。这样,模块和相关的构建输入都在同一棵锁定了版本的源码树里。

外部上下文未包含模块时,构建会失败。不能悄悄回退到默认 UI,否则发布的首页会变回控制台页面,构建日志却毫无提示。

反过来也会失败:没有提供 --build-context site-ui=...,却设置了 SITE_UI_API 或不等于 . 的 SITE_UI_SUBDIR,构建会停止并报错指出缺少的上下文,而不是用默认 UI 顶替模块。

本地开发

同样的暂存步骤也可以在本机运行,不需要 Docker。先启动一个网关;如果它的地址不是 http://backend:8080,就把 BACKEND_INTERNAL_URL 设为它的地址。应用在运行时依赖 /site-config 这一点不变:

cd apps/frontend
node scripts/site-ui/prepare-module.mjs prepare \
  --app . --context ../../distributions/example/frontend/site-ui \
  --subdir . --into src/site-ui/external --api 1
SITE_UI_DIR="$PWD/src/site-ui/external" SITE_UI_API=1 \
  SITE_ASSETS_DIR="$PWD/src/site-ui/external/public/site-assets" npm run dev
# For build or type-check, pass the same module selection variables.

暂存步骤会先清空 --into 再复制模块,因此 --into 必须是一个独立目录:不能与 --context 重叠,也不能等于或包含 --app。

如果不想暂存副本,可以用 SITE_UI_DIR 和 SITE_UI_API 直接指定模块:

SITE_UI_DIR=/path/to/my-ui SITE_UI_API=1 \
  SITE_ASSETS_DIR=/path/to/my-ui/public/site-assets npm run dev

SITE_UI_DIR 如果是相对路径,总是相对 apps/frontend 解析,不管命令在哪个目录下执行。Tailwind 除了扫描 src/,还会扫描所选模块的目录,所以即使某个工具类只在 src/ 之外的模块里用到,也照样会生成。

上述变量仅作用于当前命令,不会修改 shell 配置。若要恢复默认 UI,停止服务器后执行:

env -u SITE_UI_DIR -u SITE_UI_API -u SITE_ASSETS_DIR npm run dev

本地开发时,模块资源通过 SITE_ASSETS_DIR 提供;构建镜像时会自动打包这些资源。

暂存步骤生成的环境文件是给 Dockerfile 用的,npm 不会自动读取。暂存目录留着不删,之后的命令也不会因此选中它。

无论用哪种方式,解析器都会写出 src/site-ui/active/ 和 tsconfig.generated.json,这样打包器、类型检查器和测试工具读到的都是同一个选择结果。这些文件是自动生成的,不要手工修改。

npm run type-check 和 next build 都使用 tsconfig.generated.json 做类型检查。它通过入口的导入关系覆盖模块的生产代码,并把模块目录排除在文件匹配范围之外;应用也完全不会对模块执行 lint。模块的测试和工具链应由发行版自己的仓库负责检查。没有被任何文件导入的声明文件(例如包含环境声明 declare module 的文件)同样不在该导入图中:请在入口中用 /// <reference path="./types.d.ts" /> 引用它。

资源

模块的图片放在 public/site-assets/<module-id>/。构建镜像时会把它们打包到 standalone 服务器旁边的 site-assets/<module-id>/,而不是 public/:Next.js 在所有路由之前就会直接提供 public/ 里的文件,放在那里就会绕过 /site-assets 路由。下面三条规则都有强制检查:

  • 只能放在模块自己的 id 下。放在别处,构建就会失败。正是这条规则保证了“两个模块不会写到同一路径,也都不能遮住应用自己的文件”。

  • 不允许冲突。 模块资源与应用已有路径冲突时构建失败,不会静默覆盖。

  • 资源由路由提供。/site-assets/* 只提供图片文件(AVIF、GIF、ICO、JPEG、PNG、SVG 和 WebP,最大 20 MB),附带五分钟的重新验证缓存策略和 nosniff,并通过 Content-Security-Policy 头把 SVG 放进沙箱。构建产物里如果 public/site-assets/<module-id>/ 下还留有文件,构建就会失败。要测试 URL,而不是只看文件在不在。

运行时先读 SITE_ASSETS_DIR,再读镜像里自带的那份。挂载了品牌目录的部署,可以继续覆盖模块的文件;什么都不挂载的部署,也能提供完整的站点。

覆盖按文件逐个生效,且 SITE_ASSETS_DIR 必须是绝对路径。构建会把相对符号链接原样复制为链接,目标保持不变,因此留在模块内部的链接在镜像中仍然有效。目标位于所在目录之外的链接,路由不会提供。

共享表单样式

共享账号表单自己渲染 HTML 结构和默认样式。稳定的语义钩子用来挂样式、表达状态;fieldLayout 导出用来调整结构。已弃用的类名映射在整个 API v1 期间仍然支持。

用于样式与状态的 data-auth 属性。 共享表单元素提供:

属性

所在元素

data-auth="form"

账号页面的主体:页面的 <form>,或者放结果、放注册同意步骤的那个容器

data-auth="field"

每个字段的外层元素

data-auth="label" / "control"

标签,以及每个输入框或文本域

data-auth="hint" / "error"

提示与错误段落

data-auth="notice"

信息、错误或成功提示;提示内的按钮可用 [data-auth="notice"] button 选中

data-auth="submit"

页面的主按钮,或代替主按钮的链接

data-auth="field-action"

字段自身操作(如忘记密码链接)的外层元素

data-auth="secondary-actions"

/verify-email 上的备选链接行

data-auth="consent"

每个注册确认项:包含复选框及其说明文字的标签

data-auth="consent-section"

注册同意步骤中的每个分区

data-auth="loading"

正在解析会话时显示的区域

状态通过应用本来就会设置的属性表达:aria-invalid、disabled、aria-busy、data-auth-error、data-auth-tone,所以样式表不用从类名去推断“这个字段出错了”。这组属性是契约的一部分:只会新增,不会挪作他用。

控件的描述关联由宿主自己设置。在 fieldLayout 拿到控件之前,AuthField 会为它设置 aria-describedby,指向字段的提示(<id>-hint)和错误(<id>-error),并保留页面原本设置的值;错误显示期间还会设置 aria-invalid="true"。

字段布局。可选的 fieldLayout 导出会拿到共享的控件、提示、错误和操作节点,以及标签文本和 htmlFor id。它必须渲染传入的每个节点,把标签关联到这个 id,并保留 data-auth="field" 和 data-auth="label" 这两个钩子。控件传进来时已经带好了描述关联,所以只要每个节点都渲染出来,不管怎么排列,关联都不会丢。默认布局把字段操作放在控件和校验信息后面;模块可以把它放到标签旁边。fieldLayout 本身就是一个受支持的独立导出,不属于已弃用的类名映射。

已弃用的 authAppearance 类名映射。只要 API v1 还在,类名映射导出就一直支持;它只能包含类名。新写的样式应该改用限定了作用域的 data-auth 选择器和状态选择器。类名映射要到以后的 API 版本才会移除,届时会附上迁移指南,所以现有的 v1 模块会继续正常工作。

所有定制都受同一条规则约束:模块的 CSS 只能影响模块自己负责的页面。样式表一旦选中 body、:root 或使用裸元素选择器,连控制台的样式也会一起改掉,而且没有任何检查会替你发现。所有规则都要限定在模块给自己外壳设置的根类名之下。

模块组件出错时

抛出异常的模块组件(Landing、AuthFrame、fieldLayout、TermsFrame 或 TermsContent)只会影响它自己所在的页面。每个组件都在各自页面内渲染,应用的路由错误页(app/error.tsx)会用一条中性的提示信息和一个 Try again 按钮替换该页面。如果该路由的外层框架由模块绘制,提示信息会带上控制台的页头和页脚。模块的条款或确认项渲染失败时,错误页绝不会改用控制台的条款或确认项。控制台页面自身的渲染错误也以同样方式显示在控制台外层框架内。

React 在服务器端不会渲染错误边界,因此出错页面的首次响应仍然是错误:HTTP 500;对于在 <Suspense> 内渲染的账号页面(/login、/reset-password 和 /verify-email),则是带加载状态的 HTTP 200。随后浏览器会重新渲染页面并显示提示信息。仅凭状态码无法证明页面正常。

未通过加载时检查的模块则不同:根布局会加载模块,因此每个页面都会随之失败。

兼容性

  • 添加模块不会改变控制台。没有导出 AuthFrame 时,五个账号页面留在控制台的容器里;无论是否导出,共享页面、schema 和会话处理都不受影响。

  • 移除可选导出是安全的。模块不再导出 Landing 后,首页就恢复成控制台的首页。

  • 法律条款组只能整体移除。 同时去掉三个导出会恢复控制台的条款和确认项;只去掉其中一个会导致类型检查失败。

  • 重命名或删除导出、改变某个 prop 的含义,或者把某个 data-auth 值挪作他用,都是破坏性变更,需要升级 API 版本号。重命名 authMessages 或 metaMessages 里的键,或者改变键的插值变量,也一样。

  • 新增可选键或属性是兼容变更。

SITE_UI_API 用于选择 Site UI 接口版本。如果模块声明的版本不是当前源码支持的版本,解析器会拒绝构建,避免产出双方都不支持的组合。

模块 API 不提供共享的公开页面布局;模块内部的布局可以自由组织。

测试模块

校验和镜像构建流程归共享仓库负责。下面这些检查用的是内置的中立模块和公开示例。

cd apps/frontend
npm run lint
npm run type-check
npm test

检查自己的模块时,传入与本地开发相同的模块选择变量。测试共享表单的正常、无效、加载和键盘操作状态,以及模块提供的每个公开页面,并检查控制台是否受到模块样式影响。

然后对构建出的镜像做冒烟测试:每个页面都会读 /site-config,所以要连同网关一起启动,再请求 /、五个账号页面和 /terms。next build 不会加载模块,所以这里才是加载时检查第一次真正运行的地方。

在仓库根目录,用同一套上游构建流程构建中立镜像和公开示例:

docker buildx build -f deploy/docker/Dockerfile.frontend \
  --load -t local/frontend:neutral .
docker buildx build -f deploy/docker/Dockerfile.frontend \
  --build-context site-ui=./distributions/example/frontend/site-ui \
  --build-arg SITE_UI_API=1 --load -t local/frontend:example .
docker run --rm --entrypoint cat local/frontend:neutral /app/site-ui-manifest.json
docker run --rm --entrypoint cat local/frontend:example /app/site-ui-manifest.json

第一个 manifest 里必须是 kind: "neutral",第二个必须是 id: "example"。构建这些镜像需要 Docker,还需要能联网下载依赖。要运行完整应用,还需要网关在运行时提供 /site-config 端点。

控制台或示例模块每次改动时,本仓库的 CI 都会跑同样的这两个构建,并检查示例镜像如何提供它的资源。这只覆盖示例:你发行版自己的镜像和资源,也要自行构建和测试。