本仓库是 www.whu.sb 的源码:一个 Astro 静态站点,部署在
Cloudflare Pages 上,functions/ 目录提供少量 Pages Functions。
部署、环境变量与域名相关内容在 deployment.md。
Node >= 22.12(见 package.json 的 engines)。包管理器用 npm,仓库里提交的是
package-lock.json。
npm install
npm run dev # http://localhost:4321
npm run build # 产物在 dist/
npm run preview # 本地预览构建产物astro dev 支持后台运行,适合让服务器一直挂着:
astro dev --background
astro dev status
astro dev logs
astro dev stop| 部分 | 选型 |
|---|---|
| 框架 | Astro 7,纯静态输出(没有 SSR,没有前端框架 island) |
| 样式 | Tailwind v4(@tailwindcss/vite)+ 手写设计 token |
| 字体 | Plus Jakarta Sans(拉丁)+ MiSans(中文),均自托管 |
| 背景 | @paper-design/shaders + three.js 的极光着色器 |
| 滚动 | Lenis(平滑滚动,实例挂在 window.ltLenis) |
| 图标 | @material-symbols/svg-400,构建时内联成 SVG |
| 路由 | Astro Client Router(<ClientRouter fallback="swap" />) |
页面上的交互一律是原生 TypeScript,通过 onPageSetup() 挂载 —— 没有 React/Vue。
新增交互前请先读 页面约定。
src/
components/ # 复用的 Astro 组件(Navbar / Footer / PageHeader / …)
pages/ # 整页正文(HomePage / FeaturesPage),被各语种的路由共用
config/ # 内容即数据:features / downloads / friendLinks / legal / deeplinkRoutes
i18n/ # 多语言:语种表 / 发布集 / 词典 / 查表 / 路由表,见下
layouts/ # BaseLayout.astro —— 全站外壳
pages/ # 路由。legal/ 下有 [slug].astro 动态路由,[lang]/ 下是每个语种的副本
scripts/ # 客户端运行时:page-lifecycle、icon-svg、icon-source.server
styles/ # global.css —— 设计 token 的唯一来源
functions/ # Cloudflare Pages Functions
api/ # /api/releases、/api/appstore、/api/status
lib/ # 运行时无关的核心逻辑(可被 Node 直接 import)
_middleware.ts # 域名白名单、深链域名重写、404 的表示形式
public/ # 静态资源、.well-known、hot-update、scales
tools/ # 构建脚本(热更新清单生成、量表清单生成、AASA 校验)
内容改动优先落在 src/config/ 而不是页面里 —— 功能列表、下载平台、友链、法律
文档都是数据驱动的。
配置里除了 Tailwind 和 i18n 还有四个自定义 Vite 插件,每个都对应一个不写下来就会 被人「顺手优化掉」的坑:
misansWeights() —— MiSans 自带的 @font-face 声明的是小米切字体时用的字重
(Regular 330 / Medium 380 / Bold 630),不是本站请求的值。不重写的话
font-weight: 400 和 500 都会落到 Medium 380,MD3 字阶里 body 与 label 的区别
就没了。插件在 import 时把每个 face 的 descriptor 改写成标准字重,字形数据和
unicode-range 分片都不动。
pruneFontSubsets() —— MiSans 按字符簇切成 56 个 unicode-range 分片(每个
字重一份),172 条 @font-face 规则占了 293 KB 共享样式表的 81%,而全站十二个页面
一共只用到 1038 个字形。插件扫 src/** 的源码文本收集字符集,把覆盖不到任何
一个字符的分片连同规则一起删掉,对应的 woff2 也就根本不会产出。
必须在 transform 阶段做而不是 generateBundle:Vite 是在 transform 之后算 CSS
哈希的,晚剪会让剪完的字节顶着剪前的文件名发出,而那个名字是 immutable、缓存一年。
只在构建时生效,dev 下始终提供完整字族(否则改一个字要重启服务器才看得到,很难查)。
扫的是文本,注释也算。 这条会咬到 i18n,见多语言。
flattenCssLayers() —— Tailwind v4 把所有产物塞进
@layer properties/theme/base/utilities,而级联层是 Chromium 99+ 的特性。更老的
Android WebView(无 Play 服务的停更设备)整块丢弃解析不了的 @layer,于是所有
工具类消失、页面塌成无样式 HTML。插件在 generateBundle 阶段把层展开,保留原本的
层优先级顺序。只在构建时生效 —— astro dev 仍然输出分层 CSS,所以验证老内核行为
必须用 npm run build && npm run preview。
statusDevApi() —— astro dev 不会运行 functions/(Pages Functions 是
Cloudflare 运行时的东西),本地访问 /api/status 会 404。这个中间件在本地提供同一个
端点,调用的是同一份 functions/lib/uptimerobot.ts,只把 caches.default 换成
内存 Map。凭据按 wrangler 的顺序读:.dev.vars → .env.local → .env →
真实环境变量;没有 key 时返回 503 not_configured,和生产一致。
其他 /api/*(releases、appstore)本地没有对应中间件,/download 会退回静态占位
文案 —— 要验证它们得用 npm run build && npx wrangler pages dev dist。
target: ['chrome66', 'safari12', 'firefox68', 'edge88']Vite 默认的 baseline-widely-available(约 Chrome 107)会原样输出 ?. / ??,而
这在 Chrome 80 以下是解析错误 —— 模块一行都跑不起来。66 是这份代码实际能服务的
下限:AbortController(66,page-lifecycle 的核心)、动态 import()(63,着色器
chunk)、import.meta(64)都在,其余由 esbuild 降级。
再往下就退化成无 JS 路径:可读的静态 HTML(展平后的 CSS 不需要 JS)加上 <head>
里的 reveal 兜底 / <noscript>。
写客户端代码时的硬性约束:
- 不用
replaceAll(85+)、Array.prototype.flat/flatMap(69+)、Object.hasOwn(93+)、AbortSignal.any(116+)、AbortSignal.timeout(103+)、structuredClone(98+) - 不用
inset简写(87+)—— 写top/right/bottom/left @property(85+)、color-mix()(111+)可以用,但必须有可接受的降级- 监听器统一用
{ signal },page-lifecycle.ts会给老内核打补丁
src/styles/global.css 是 token 的唯一来源,分两层:
:root/:root.dark里的--lt-*原始值 —— 永远输出、不会被 tree-shake, 也是深色模式唯一的切换点@theme里的别名 —— 教 Tailwind 生成工具类(text-title-medium、bg-surface、rounded-card、backdrop-blur-glass)
色板与字阶来自 MD3(种子色 #005BAC,与 App 的
AppDesignLanguage 同源),但表面模型不是 MD3 的:不透明 tonal surface 被换成
了一整套透明度阶梯(玻璃)。几何尺寸不是网页自己发明的 —— 圆角取自 App 的
AppDesignLayout.glass()(control 14 / nested 18 / card 28 / group 32),模糊取自
kAppGlassBlurSigma(10)与 appGlassStyle(blur:)(8)。
改样式之前先读完 global.css 里的注释,这里只列会咬人的部分:
backdrop-filter 需要背后有东西。 没有活动内容的背景,模糊会退化成一坨半透明
塑料。这就是 BaseLayout 在每一页都铺一层 page-fixed 极光的原因,不只是装饰首屏。
opacity < 1 会让元素变成 backdrop root。 此时它内部的 backdrop-filter 不再
采样页面,玻璃会瞬间变平。所以:
- 含玻璃的元素入场动画只能动
transform(.lt-reveal-lift) - 需要淡入的纯文本元素才用
.lt-reveal - 淡入淡出的浮层(tooltip / snackbar)用不透明的 inverse surface,不用玻璃
嵌套玻璃没有意义。 父面板已经模糊过极光了,子元素再模糊一次采样到的是父面板
自己的填充,视觉无变化、每个元素多一个合成层。面板内的行用 .lt-inset /
.lt-inset-raised(FriendLinks 与 /status 都是这个写法)。
圆角容器要滚动,就得是两个元素。 border-radius 只圆容器自己的背景,不圆它的
滚动条:滚动条按 border box 的整个高度画成方的,滑块走到两端时就落在圆角轮廓外面,
而不是被圆角切掉。把滑块本身设成圆角也没用——轨道仍然是顶天立地的。
只有裁剪祖先能解决,因为在祖先内部滚动条只是普通子内容,会被祖先的圆角裁剪区切到。
所以形状固定为:外层放圆角 + overflow: hidden,内层放 .lt-scroll-y +
overflow-y: auto。.lt-dialog-card/.lt-dialog-body、.lt-lang-panel/
.lt-lang-scroll、.lt-mobile-menu/.lt-mobile-menu-scroll 都是这个结构,照抄即可。
clip-path: inset(0 round Npx) 直接加在滚动容器上、或者给 ::-webkit-scrollbar-track
加 margin,都能省掉这层元素——但两者都建立在「某个引擎会裁剪自己的滚动条」这个猜测
上。包一层是级联规则保证的行为,而本站的标准是:停更的 Android WebView 也得能活。
/status 上曾经有 12 张各自带 backdrop-filter 的卡片,快速滚动时合成器来不及对
固定的 WebGL 画布重新采样,就直接画卡片自己的白色半透明底 —— 表现为滚动中的白块。
改成「每组一个玻璃面板 + 内部 inset 行」之后消失。
入场动画用 backwards,不要用 both。终帧本来就是元素的自然状态,forwards 只会
让合成层在动画结束后永久留着,之后每一帧滚动都要重新光栅化。
外壳:所有页面走 BaseLayout(title 必填,description 可选,bare 去掉
导航/页脚,noindex 加 robots 标签)。内页顶部用 PageHeader,它自带比首屏更弱的
hero wash —— 内页是文档,不是落地页。
客户端脚本必须用 onPageSetup()(src/scripts/page-lifecycle.ts):
import { onPageSetup } from "../scripts/page-lifecycle";
onPageSetup((signal) => {
// 所有 addEventListener 传 { signal }
// 定时器 / observer 在返回的清理函数里销毁
return () => clearInterval(timer);
});原因是 Astro 会按 URL/内容去重打包后的脚本:第二次访问同一页时脚本不会重新执行,
而它上次绑定的 DOM 已经被换掉了。astro:page-load 是唯一可靠的每次导航入口,
AbortSignal 负责在下次 swap 之前自动解绑。直接在模块顶层绑事件会泄漏。
滚动揭示:.lt-reveal / .lt-reveal-lift 由 BaseLayout 里的
IntersectionObserver 在页面加载时统一认领。运行时插入的 DOM 不会被观察到,加了
这两个类就会永远停在 opacity: 0。异步渲染的内容请自己写入场动画(只动
transform),参考 /status 的 lt-st-rise。
图标:Astro 模板里用 <Icon name="material_symbol_name" />(构建时内联);
客户端脚本里拼 HTML 时按 download.astro 的写法:
import { normalizeIconSvg } from "../scripts/icon-svg";
import raw from "@material-symbols/svg-400/outlined/open_in_new.svg?raw";
const icon = normalizeIconSvg(raw, "text-base");Tailwind 的类名扫描:v4 扫的是源码文本,所以 JS 模板字符串里的工具类也能被
识别(download.astro、status.astro 都这么写)。反过来说,scripts/、tools/、
functions/、docs/ 里的类形状字符串会被 @source not 排除,否则注释里的示例会
被编译成没人用的工具类。
转义:凡是把 API 数据拼进 innerHTML 的地方都要过 escapeHtml()。监控名、
Release 说明这些都来自外部。
App 的解析器热更新脚本放在 public/hot-update/scripts/,清单是
public/hot-update/manifest.json。App 只接受有有效 Ed25519 签名的清单 ——
校验和对了但签名无效同样会被拒。
首次本地开发要初始化一次签名密钥:
npm run hot-update:init-key私钥写进已被忽略的 .env.hot-update,公钥只安装到相邻的 App 工作区。然后:
npm run hot-update:generate
npm run hot-update:verifynpm run build 会通过 prebuild 自动生成清单,签名密钥不可用时构建失败。
生产环境必须以 secret 提供 HOT_UPDATE_ED25519_PRIVATE_KEY(base64 编码的 PKCS#8
私钥)。
prebuild 还会跑 check:aasa(tools/verify-aasa.mjs),校验
apple-app-site-association 的格式。
App 的心理量表数据放在 public/scales/data/,清单是
public/scales/manifest.json。与热更新不同,这份清单不签名,也不需要任何密钥
—— 量表是数据而不是可执行代码,完整性由 HTTPS 加每文件 sha256 保证,客户端下载
后逐个校验摘要。所以它在本地、PR Preview 和生产构建里的生成结果完全一致,不存在
「Preview 拿不到密钥」的问题。
npm run scales:generate # 扫描 data/ 重新生成 manifest.json
npm run scales:check # 只校验已提交的清单是否最新,不写文件(CI 用)生成器(tools/generate-scale-manifest.mjs)在以下情况直接失败:文件不是合法
JSON、文件内 id 与文件名不符、缺 name 或 abbreviation、单文件超过 4 MB、
或生成的清单为空。updatedAt 与两个 version 只在内容真的变了时才动,因此重复
运行是幂等的 —— 这正是 scales:check 能用来卡住「改了数据忘了重新生成清单」的
前提。
字段含义、客户端的缓存与校验流程,以及量表数据的版权与许可(MMPI-2 上游为 GPLv3,需要单独评估),见 public/scales/README.md。
站点是纯静态的:没有 adapter,没有 SSR。所以 Astro.preferredLocale 恒为
undefined,Astro 自带的 i18n 中间件也只在构建期跑一次。分工因此是固定的:
| 层 | 文件 | 职责 |
|---|---|---|
| 配置 | astro.config.mjs 的 i18n |
声明语种、决定 URL 形状(prefixDefaultLocale: false,默认语种留在 /) |
| 语种表 | src/i18n/locales.ts |
code → URL 段 / 本地名 / hreflang / og:locale,以及 Accept-Language 的折叠规则 |
| 发布集 | src/i18n/published.json |
真正构建的语种。配置文件、语种表、校验脚本三方共读这一份 |
| 词典 | src/i18n/<code>.json |
扁平点号 key;默认语种那份定义 key 集 |
| 查表 | src/i18n/ui.ts |
useTranslations()、routeUrl()、hreflangAlternates() |
| 路由表 | src/i18n/routes.ts |
ROUTES 与 TRANSLATED_ROUTES:哪个语种翻了哪个页面 |
| 路由 | src/pages/[lang]/*.astro |
每个已发布语种一份,正文共用 src/components/pages/*.astro |
| 法律文档 | src/config/legal.ts |
只存结构(slug、lastUpdated、每节有无正文、几个子节),正文按位置派生 key 落在词典里 |
| 首访识别 / 404 语言 | functions/_middleware.ts |
Accept-Language → 302,以及按语种替换 404 文档,见部署文档 |
页面组件里不传 locale,一律读 Astro.currentLocale(Astro 按路由填:/en/features
→ en,/features → 默认语种)。这样路由文件只是壳,两份实现不可能对不上。
十个语种当前都翻了全部 12 条路由。TRANSLATED_ROUTES 因此对每个语种都是
ALL_ROUTES——但保留这张表,因为「某语种只翻一部分」是随时可能再出现的状态,而
链接生成、hreflang、中间件的重定向闸门三处都靠它。
- 写
src/i18n/<code>.json—— key 集必须与默认语种完全一致,占位符也要一致,npm run i18n:check会卡住缺漏与多余 - 在
src/i18n/locales.ts的LOCALES里加一行 - 把 code 加进
src/i18n/published.json - 在
src/i18n/routes.ts的TRANSLATED_ROUTES里登记翻了哪些页面
页面文件不用动:src/pages/[lang]/*.astro 由 PREFIXED_LOCALES 驱动,自动多出一份。
只翻一部分页面是可以的,登记没翻的那部分留给默认语种即可。
TRANSLATED_ROUTES 是唯一的事实来源。某个语种没登记的页面:
- 导航与页脚由
routeUrl()生成链接,直接指向默认语种的 URL,不经过重定向 - 切换器由
pathInLocale()决定目标,切到没有译文的页面时停在原页——但 cookie 会 记住这次选择,之后有译文的页面就是那个语种 hreflang不声明该语种有这个页面。声明一个会 404 的 alternate 比不声明更糟: 搜索引擎会校验整个 cluster,有一个成员不回应就整组作废- 中间件不会把首访者重定向到该路径的语种版本。否则会先 404、再被 404 回退弹回 原路径、然后又被识别一次——死循环
带语种前缀的路径 404 时,中间件先探一次去掉前缀的路径:命中就 302 过去,没命中就直接 回该语种的 404 文档。
Astro 自带的 i18n.fallback 不要用:它认不出 src/pages/[lang]/* 建出来的路由,
会把 /en 当成一个待翻译的普通页面,在 /en/en 生成跳转壳,而且它给 /en/features
生成的壳优先级高于真正的英文页——真的译文会被直接丢掉,构建只报一条 warning。
它产出的壳还是 2 秒 meta refresh、canonical 指向裸域,等于两跳重定向。
dist/404.html 只能有一份——这个文件本身就是 Pages 的配置(见部署文档)。所以九种
译文是普通路由 src/pages/[lang]/404.astro,构建出 /<locale>/404/,由中间件在 HTML
404 上取对应那份、仍以 404 状态返回。ROUTES 里因此有一条 notFound: "/404",
/404 与 /<locale>/404/ 都能直接访问,这与本站一直允许 curl /404 是一致的。
它是 noindex 的,所以 BaseLayout 不给它输出 hreflang 簇——/open 同理。给
noindex 页面声明 alternate 没有意义,而且簇不对称比不声明更糟。
app/lib/core/config/app_config.dart 直接从 siteBaseUrl 拼这些 URL,没有语种前缀,
所以它们必须永远以默认语种、原路径、原字节提供:
| URL | 用途 |
|---|---|
/data/legal.json |
法律文档目录(版本 + 各文档 lastUpdated),客户端据此判断缓存是否过期 |
/data/friend-links.json |
友链目录,含 QQ 群原生唤起所需的 groupUin / androidKey / iosAuthSig |
/legal/<slug>.md |
法律文档的 Markdown 源,客户端原生渲染而非 WebView |
/legal/<slug>/ |
同上的 WebView 兜底 |
config/legal.ts 与 config/friendLinks.ts 的重构都是加法:默认语种的输出字节不变,
译文走 /<locale>/legal/<slug>.md 这类新路径。renderLegalMarkdown() 只在非默认语种
多输出一行「以简体中文为准」的声明,这正是默认语种那份必须保持原样的原因——它是 App
会缓存并按 lastUpdated 校验的文档。
改这两个文件之后必须比对摘要:
npx astro build
certutil -hashfile dist\data\legal.json SHA256
certutil -hashfile dist\data\friend-links.json SHA256
certutil -hashfile dist\legal\terms.md SHA256tools/check-i18n.mjs 会走 legalDocuments 的结构、按位置推出全部 key,并断言默认
语种词典一个不缺、也没有哪个 legal.* key 指向已不存在的章节。派生 key 是这套 i18n
里唯一一处类型系统管不到的地方:加一节而忘了十份词典,页面上就会直接渲染出
legal.privacy.s12.heading。
字体裁剪把注释也算进去,而十种语言加上区域字体基本上取消了这项优化。 pruneFontSubsets()
扫的是 src/** 的源码文本,数据和注释一视同仁。全站十种语言意味着字符集必须覆盖繁体
专有字、假名、谚文、西里尔与越南语拉丁扩展。zh-Hant/ja/ko 现在用 Noto Sans TC/JP/KR
(MiSans 是简体导向,没有 locl,简繁同码位的字会渲染成简体字形)。构建产物实测:
629 条 @font-face 规则、534 个 woff2 文件(MiSans 110 + Noto Sans TC 184 + JP 226 +
KR 124);整份共享样式表 133 KB → 727 KB raw、38 → 246 KB gzip、19.0 → 90 KB brotli,
其中字体分片占 raw 的 86%,也几乎是全部增量。真正传输的增量是 brotli 那 +71 KB;
raw 的 +594 KB 主要是解析成本,而它几乎全是 unicode-range 的十六进制列表,所以压缩率
约 6.6:1。
几条相关的约定:
- 分片是惰性的。
unicode-range决定浏览器只在页面真的渲染到某个字符时才去下对应 的 woff2,所以西班牙语访客不会下载谚文分片。涨的是 CSS 体积,不是字体流量 - MiSans 只在 zh-Hans 页面用到;zh-Hant/ja/ko 分别用 Noto Sans TC/JP/KR。
--font-sans里 Plus Jakarta Sans 排在它们前面,拉丁文案根本走不到 CJK 字体。按语种加载已经做了, 但 CSS 是共享的,所以所有页面都会解析全部@font-face规则(字体文件本身是惰性的, 只在用到时才下载) - i18n 相关模块的注释一律英文;要引用中文原文时改成转述,别照抄。一条引用了十种语言 本地名的注释,实测要多留 6 个分片、6,893 字节
本地名(endonym)不能省:切换器必须在任何语种下都能让看不懂当前页面的人找到自己的 语言,所以它按各自文字渲染,不翻译成本地语的说法。
transition:persist 与语种是冲突的,四处。 持久化元素不参与 swap,所以:
Navbar.astro的 persist 名带语种(lt-navbar-${locale}):同语种导航照旧走持久化, 跨语种导航重新渲染一份,否则旧语言的标签会留在新语言的页面上。<html lang>不用管, Astro 会从新文档同步根元素属性(BaseLayout的preserveRootState只负责 theme 与 kiosk 这两个纯客户端状态)。- 而 persist 名一变,元素就被换掉,导航栏脚本里所有模块级引用都指向了脱离文档的
节点——Astro 按内容去重脚本,模块不会重新执行。后果是切一次语言之后主题开关、
汉堡按钮、语言面板全部静默失效。所以
Navbar.astro的脚本必须走onPageSetup()(见页面约定),每次导航重新查询元素;onBeforeNavigation()里同理,只能实时查询,不能捕获。 - 同语种那次「便宜的持久化」还有另一面:服务端渲染进导航栏内部的东西,全部冻结在
第一个把这条栏搬上屏的页面上。导航链接与 logo 是按语种绝对定位的,冻结了也照样
对;但切换器的 href 来自
pathInLocale(Astro.url.pathname, …),描述的是它被构建 出来的那个页面。于是从/en/features/走到/en/download/再点日本語,会落到/ja/features/;更糟的是一次从/开始的访问,会把该语种之后所有切换都钉死在/<lang>/——这正是「切语言总是回首页」的成因。Navbar.astro因此在astro:before-swap里把新文档的 href 拷到持久化的栏上。选择拷贝而不是在客户端重算, 是为了把「目标语种没翻译这条路由时回退到默认语种」这个判断留在服务端,而不是把TRANSLATED_ROUTES(连同语种表里的本地名)复制进导航栏 chunk。 - 导航链接的
aria-current同样是按页生成的,由同一个 handler 一起拷。它曾经在客户端 由第二份匹配规则重算,而两份不一致:客户端用base === "/"表示「这是首页,精确 匹配」,这只在默认语种下成立。其它语种里首页的 base 是/en、/ja……是该语言下每个 页面的前缀,于是整个访问期间首页一直是高亮的。现在服务端改成与routeUrl(locale, "home")比较而不是与字面量/比较,客户端那一份直接删掉。同一条 规则写两遍不是冗余,是定时炸弹。
切换器链接直接指向目标 URL,不带 ?lang=、不经过重定向,形状与站内其它链接完全
一致。曾经带过 ?lang=,指望中间件 302 换 cookie 再落到干净 URL,并靠
data-astro-reload 让它走整页导航——但那个属性在 Astro 7 里根本不存在(整个包里
搜不到),所以点击始终走 Client Router 的 fetch+swap,中间夹一个 302。改成直链后重定向
从点击路径里消失了,路由能保住是因为 href 本身就是那条路由;偏好 cookie 由捕获阶段的
委托监听在导航开始前写入。中间件仍然认 ?lang=,那是给 App 深链和无 JS 调用方用的。
data-astro-prefetch={false} 是有效的(Astro 的 elMatchesStrategy 对字符串
"false" 直接返回 false),必须留着:prefetchAll: true 否则会让鼠标划过就把十个语种
的整页文档全预取一遍。
npm run i18n:check # 源码层:词典、语种表、配置、路由表互相一致
npm run i18n:check:dist # 构建后:TRANSLATED_ROUTES 与 dist/ 双向对齐i18n:check 已挂进 prebuild。--dist 那半边需要产物,所以是单独一条命令,改了路由
登记之后要手动跑一次。
仓库没有测试套件,也没有 lint 脚本。最低要求是构建能过:
npm run buildonBrokenLinks: 'throw' 是文档站的设置,本站没有等价物 —— 改了路由记得手动点一遍
导航和页脚链接。涉及老 WebView 的改动(CSS 层、语法降级)必须用
build && preview 验证,dev server 看不到。
functions/ 也一样,astro dev 不跑它。语言协商、404 的表示形式、深链域名改写都要
用 npm run build && npx wrangler pages dev dist 才看得到;本地 workerd 的兼容日期可能
落后于当天,报 requires compatibility date 时加 --compatibility-date <它支持的那天>。