Files
cms-website/docs/adr/ADR-018-主题与构建决策.md
浅念 87d6b9a777 docs: ADR-018/README/Plan 5 移交清单
Plan 4 Task 9 收尾(阶段 4 收官):

- ADR-018(主题与构建决策八条):Astro 5.18 选型与 MIT/依赖只进主题包/
  vitest 双版本(根 2.1.9 + 主题 3.2.7——astro vite6 兼容);渲染信任链
  bodyHtml 不二次清洗(三段接力);分页 V1 不做——路由登记义务随迁移移交;
  P0 发布门禁偏离(issue 无 DB 源,Plan 5 SEO diff 重审);编码路径四层
  字节精确 + 资产键 URL 线形归一(236f3dc);预览零依赖 + host 白名单
  收紧(回环纪律,比 spec §12.2 更严);CSP style-src 'unsafe-inline'
  (Astro scoped 样式内联);报告 HTML 先写两域对齐
- README:快速开始补 publish→build→preview 全链命令链示例(含
  artifacts/jobs/<jobId>/ 产物路径);目录结构补 themes/ 与
  apps/preview-server/;当前状态阶段 4 完成(真实 hesupipe 190 页可
  浏览器预览/408 测试/23 验收断言/6 截图);ADR-001~018 导航;下一步
  指向 Plan 4 文末移交清单
- Plan 4 文档:Task 9 勾选 + 验收节 ✅ 全过;文末新增"移交 Plan 5 清单"
  7 项(SEO 三方 diff 新输入源/完整门禁六件含可复现双构建与 footer 年份
  固定化/sitemap-robots 预览可达/og 元数据/836 nginx conf 输出/Task 0.2
  备份续传/度量基线 ~64s 首跑·~15s 复跑)

三绿全量:lint 过 / typecheck 五段过 / test 408(根 382 + 主题 26)全绿。
不 push——推送由用户执行。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 15:59:48 +08:00

8.6 KiB
Raw Permalink Blame History

ADR-018:主题与构建决策(Astro 选型/渲染信任链/分页不做/P0 发布门禁偏离/编码路径贯穿/预览回环与 CSP/报告写序)

  • 状态:已采纳(Plan 4 Task 9 收尾,阶段 4;实现锚点 themes/pilot-minimal/ · apps/api/src/modules/build/ · apps/api/src/modules/publish/publish.service.ts · apps/preview-server/server.mjs;验收证据 docs/evidence/phase4-preview.md——真实 hesupipe 190 页浏览器预览,23 断言 + 6 截图全过)

决策一:Astro 5.18 选型与依赖布局(MIT)

  1. 主题引擎 = Astro 5(锁定 5.18.x minor,实装 5.18.2):静态优先(零客户端运行时)、[...slug].astro 单入口 + getStaticPaths 读输入目录的路由模型与"route-manifest 是唯一权威"的落盘规则天然咬合、文件级布局组件(BaseLayout/SeoHead/RichText)贴合六类页面分派。许可证 MIT(前置事实 6 的版本纪律复查通过),依赖只进 themes/pilot-minimal/package.json(根 workspace 不引入——构建工具不污染 API/测试面)。真实数据规模实测:190 页纯 astro 构建 4.9-7.5s。
  2. vitest 双版本并存(根 2.1.9 / 主题 3.2.7):主题测试要 import .astro 必须挂 astro 的 vite 插件(getViteConfig),astro 5.18 携 vite 6——根 vitest 2.x 系不兼容,故主题包独立 devDependency vitest@^3.2。两套 runner 各跑各的 package(根 vitest.config.ts 不含 themes,主题自有 vitest.config.ts),pnpm test 串联(382 + 26 = 408)。不把根升 3.x:根栈(pg 临时库 globalSetup/长时集成用例)在 2.1.9 上稳定,为一个子包升根属无收益风险面。
  3. 主题五禁的结构保证:渲染数据唯一入口 src/lib/load-render-data.ts(只读 SF_BUILD_INPUT 目录)——不查库/不调 API/不读源快照/无 seed/构建期断网由入口单一性 + 集成测试五禁断言(含 DATABASE_URL 置空、fetch/net stub)共同锁定。

决策二:渲染信任链——bodyHtml 不二次清洗

  1. 主题端 set:html 直出,不清洗(RichText.astro):bodyHtml 的信任链是三段接力——① Plan 2 适配器 HTML 白名单清洗(ADR-009,入库存的就是清洗产物);② Plan 4 Task 2 快照物化期 URL 重写(rewriteBodyHtml——仅做媒体 URL 到根相对 public_path 的映射改写,命中面以 media ready 集合为界,未命中保留原样 + issue);③ 主题经 load-render-data 唯一入口消费。三段之外无第四个写方,主题端再清洗是冗余面(且会破坏与 source_rendered 的逐字节对拍——Plan 5 SEO 三方 diff 的比较基准)。禁"顺手加固":任何在主题层加清洗/改写的行为都属破坏信任链,须先升版本评审。

决策三:分页 V1 一律不做——路由登记义务随迁移移交

  1. 列表页单页全列(/news/ 129 条、taxonomy 列表同;Pagination.astro 保留为禁用态接口组件):分页路由(/news/page/2/ 等)不在 route_registry——未登记路由即不可达,硬做分页会自破门禁①死链(分页链接指向无路由路径)。V1 不做的完整语义:主题不做、管线不造、门禁不查。移交义务:Plan 5 起做分页必须先扩路由登记(route_registry 造行 → routes.json → dist → manifest 全链),路由登记是分页的前置义务而非主题层决定——该义务随迁移移交 Plan 5+,触发时连门禁死链白名单一并定。

决策四:P0 发布门禁偏离(前置事实 8②)

  1. V1 publish 不读导入报告文件做 P0 阻断(publish.service.ts 头注记):spec §8 语义是发布前拒绝未解决 P0 issue,但 import issue 只落报告文件(artifacts/)、无 DB 查询源——publish 要阻断只能重读报告 JSON,而真实数据 206 条 issue 现状下严格门禁会反堵发布(其中大量是源数据固有形态而非阻断级问题)。真实防线不缺位:missing 媒体等在 build/gate 层有实物防线(artifact-completeness/死链/SEO 门禁 + validation_runs 落库)。Plan 5 SEO 三方 diff 时重审——届时 issue 分级(P0-P3)落地,publish 门禁改读 DB 化的 issue 源。

决策五:编码路径贯穿规则 + 资产键编码归一(236f3dc 修补)

  1. 全程编码原样(字节精确):routes.json → dist 文件路径 → route-manifest → preview 匹配,四层对百分号编码路径(真实 54 条如 /%e5%85%b3%e4%ba%8e%e6%88%91%e4%bb%ac/)不解码不归一。理由:解码归一是歧义面(编码大小写/二次编码/非法序列),V1 无消费方需要解码形态;Windows/NTFS 下 '%' 是合法文件名字符,编码原样落盘无障碍。解码归一归 SP3 验证器(届时作为校验规则而非数据改写)。
  2. 资产键例外——URL 线形归一(验收内实证修补 236f3dc):route-manifest 资产行 exact_key 若用文件名本体(裸全角括号/顿号/间隔号),浏览器对非 ASCII 一律百分号编码后发请求,裸键永不命中 → 预览破图(生产面 nginx 先解码不受影响)。修补:pipeline.toManifestRoute 把 exact_key 归一为 URL 线形(可打印 ASCII 原样、其余 UTF-8 百分号编码大写 hex),target_file 仍盘上裸名、DB/渲染快照域不变。配套不变量:manifest 全键纯 ASCII(e2e 断言 ⑦c)。同源修补:预览 Location 头 ASCII 化(asciiLocation()——RFC 9110 头域是 ASCII,裸全角会 ERR_INVALID_CHAR 崩 500)。

决策六:预览零依赖 + host 白名单收紧(比 spec 更严)

  1. 零新依赖:预览服务器 apps/preview-server/server.mjs 纯 node:http(.mjs 不进 TS 链,类型面由相邻 server.d.mts 供给)——匹配器(exact_key + query_key)、MIME 表、流式文件服务(createReadStream 直 pipe,64MB RSS 平坦实测)全手写;route-manifest 是唯一输入。
  2. host 白名单比 spec §12.2 更严:spec 允许"除非显式指定"其他地址,实现收紧为只允许 127.0.0.1/localhost——库级在服务构造点即拒(先于监听),CLI 级先于一切(含 DB 访问前)拒绝。理由:预览产物带 X-Robots-Tag: noindex 但无鉴权,回环纪律是唯一防线;"显式指定对外地址"没有正当用例,留口子只会被误用。要对外 = 走部署器(Plan 7 的领域)。
  3. 安全面全套:穿越预检先于 URL 解析(new URL 会规范化点段——实测洞)、realpath + lexical 双检防 junction 逃逸、目录即 404 禁列表、仅 GET/HEAD、全响应 noindex。

决策七:CSP style-src 'unsafe-inline'(Astro scoped 样式兼容)

  1. 预览 CSP = default-src 'self'; style-src 'self' 'unsafe-inline':Astro 5 静态产物把 scoped 样式内联为 <style> 元素(无外部样式表),default-src 'self' 会拦掉全部内联样式——页面在浏览器裸奔(HTTP 级测试看不见,浏览器时刻才显影,Task 8 实证)。只放开样式面:脚本/连接/图片等仍走 default-src 'self'。不换 Astro 配置规避(external stylesheet 会改变产物形态与可复现面),接受这一条最小放行并在此记档。

决策八:报告 HTML 先写——两域对齐

  1. build 报告与 import 报告同序:HTML 先写(report-html.ts + build.service 落盘点):build.json 在则 build.html 必在——HTML 渲染失败时磁盘不留"JSON 新终态 + HTML 旧终态"的窄窗口(Plan 3 移交项⑤的兑现,两个域从此同纪律)。HTML 渲染面全动态文本转义、静态骨架无 script(Plan 2 Task 6 模板纪律)。
  • 理由:主题层"最薄可信渲染"(信任链三段接力 + 唯一入口)、预览层"最窄暴露面"(零依赖 + 回环 + 只读两文件)、门禁层"实物防线优先于台账阻断"(P0 偏离的真实防线论证)——三条线索共同把复杂度压在数据侧(快照冻结),消费侧全部薄层。
  • 权衡/陷阱:① 分页不做意味着 /news/ 129 条单页——真实站可浏览性达标(验收过),但内容量再涨一个量级前 Plan 5 必须落路由登记义务;② P0 发布门禁偏离是有意欠账——issue DB 化前 publish 无阻断,靠 build/gate 层兜底,Plan 5 重审时若引入 publish 阻断须先做 issue 分级落库;③ 编码路径"原样"与资产键"归一"并存不矛盾——前者是四层贯穿的不解码纪律,后者是键的构造规则(进 manifest 前就写成 URL 线形),排查时先分清键在哪一层;④ vitest 双版本是过渡态——根升 3.x 的时机是 vitest 2.x 出安全分支停维护或根需要 vite 6 特性时,届时一次性迁移并复跑全套。