# 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 不二次清洗 4. **主题端 `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 一律不做——路由登记义务随迁移移交 5. **列表页单页全列**(/news/ 129 条、taxonomy 列表同;`Pagination.astro` 保留为禁用态接口组件):分页路由(/news/page/2/ 等)不在 route_registry——未登记路由即不可达,硬做分页会**自破门禁①死链**(分页链接指向无路由路径)。V1 不做的完整语义:主题不做、管线不造、门禁不查。**移交义务**:Plan 5 起做分页必须**先扩路由登记**(route_registry 造行 → routes.json → dist → manifest 全链),路由登记是分页的前置义务而非主题层决定——该义务随迁移移交 Plan 5+,触发时连门禁死链白名单一并定。 ## 决策四:P0 发布门禁偏离(前置事实 8②) 6. **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 修补) 7. **全程编码原样(字节精确)**:routes.json → dist 文件路径 → route-manifest → preview 匹配,四层对百分号编码路径(真实 54 条如 `/%e5%85%b3%e4%ba%8e%e6%88%91%e4%bb%ac/`)**不解码不归一**。理由:解码归一是歧义面(编码大小写/二次编码/非法序列),V1 无消费方需要解码形态;Windows/NTFS 下 '%' 是合法文件名字符,编码原样落盘无障碍。解码归一归 SP3 验证器(届时作为校验规则而非数据改写)。 8. **资产键例外——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 更严) 9. **零新依赖**:预览服务器 `apps/preview-server/server.mjs` 纯 node:http(.mjs 不进 TS 链,类型面由相邻 server.d.mts 供给)——匹配器(exact_key + query_key)、MIME 表、流式文件服务(createReadStream 直 pipe,64MB RSS 平坦实测)全手写;route-manifest 是唯一输入。 10. **host 白名单比 spec §12.2 更严**:spec 允许"除非显式指定"其他地址,实现收紧为**只允许 127.0.0.1/localhost**——库级在服务构造点即拒(先于监听),CLI 级先于一切(含 DB 访问前)拒绝。理由:预览产物带 `X-Robots-Tag: noindex` 但无鉴权,回环纪律是唯一防线;"显式指定对外地址"没有正当用例,留口子只会被误用。要对外 = 走部署器(Plan 7 的领域)。 11. **安全面全套**:穿越预检先于 URL 解析(new URL 会规范化点段——实测洞)、realpath + lexical 双检防 junction 逃逸、目录即 404 禁列表、仅 GET/HEAD、全响应 noindex。 ## 决策七:CSP style-src 'unsafe-inline'(Astro scoped 样式兼容) 12. **预览 CSP = `default-src 'self'; style-src 'self' 'unsafe-inline'`**:Astro 5 静态产物把 scoped 样式**内联为 `