Files
cms-website/docs/superpowers/plans/2026-10-21-slice-build-preview-phase4.md
T

125 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SiteForge 纵向切片 · Plan 4:发布、主题与静态预览(阶段 4 / 快线第 3 步)实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** hesupipe 真实数据第一次在浏览器亮起来:`content:publish`(显式发布)→ 冻结 Render Snapshot → pilot-minimal 主题渲染六类页面 → Astro 静态构建(媒体本地化)→ 最小门禁 → 本地 Preview Server 按真实路由响应。验收 = 快线"第 10 天演示标准"(六类页面可浏览、真实内容、图片本地化、SEO 基线、200/404、sitemap、移动端无明显破版)。
**Architecture:** 依据快线 §8-§9、V2 spec §8(发布)/§9(主题与 Render Snapshot)/§12(Route Manifest 与 Preview)/§13(门禁子集);消费 Plan 3 移交清单 9 项(**必读**——含五条既定事实)。表已冻结(build_jobs/build_input_snapshots/build_artifacts/validation_runs 均为 Plan 1 遗产);契约已冻结(ThemeManifest/RENDER_SNAPSHOT_FILES 六文件)。**主题五禁**:不查库/不调 API/不读源快照/无 Seed/构建时断网。
**Tech Stack:** Astro 5(新依赖,进 themes/pilot-minimal 包)+ 既有栈。预览服务器零新依赖(Node http + route-manifest 匹配)。
**边界(不做)**:双构建 Hash 一致性(可复现性归 Plan 5/7)、SEO 三方 diff(Plan 5)、部署器/Release/发布锁(Plan 7)、第二主题、可视化编辑、构建沙箱容器化(本地进程纪律代替)。
---
## 前置事实(执行者必读)
1. **Plan 3 移交清单 9 项**(Plan 3 文末)+ 五条既定事实:① `/products/` 被 basic_page 占据——产品列表演示用 `/product-category/*`(10 个真实分类页)+ `/products/` 内容页走通用模板;② 19 条 `?p=` 无路由不进构建;③ is_primary first-import-wins;④ 五列锚点查询三份拷贝(本计划 publish 复用 content-writer 的或抽共享 helper——**抽共享 helper**,第 4 个消费方触发 Plan 3 审查既定条件);⑤ JSON/HTML 双写窄窗口——build 报告落盘改 HTML 先写或接受(顺手修)。
2. **数据现状**(临时库已 DROP——本计划验收时重建):192 entry(129 news/48 product/15 page)全为 current 未发布;2772 routes(html 190/redirect 1746/asset 836);836 media ready(public_path=旧 uploads 路径);**媒体副本已在 `storage/incoming/hesupipe-media/`(2625 文件 164.5MiB)+ content-addressed 快照**。bodyHtml 中图片是**旧站绝对 URL**(https://hesupipe.com/...)——渲染快照生成时映射为根相对 public_path(day-10"图片本地化"的兑现位)。
3. **发布语义**(V2 §8):publish = entry_locales.published_revision_id ← current_revision_id(首次批量发布 192 条);dry-run 展示差异清单 + confirm token;审计落 audit_logs。导入永不发布(已由 Plan 3 保证,本计划不碰导入代码)。
4. **Render Snapshot 六文件**(contracts 冻结):site/content/taxonomies/routes/assets/render-manifest.json——从 published 修订 + route_registry + media ready 集合物化,canonical JSON + 总哈希(build_input_snapshots 表记录)。
5. 既有纪律全套:TDD/临时库/四段 typecheck/身份断言/cms 只读/artifacts 与 storage 不入库。
6. 版本纪律:新依赖 Astro 锁 minor;许可证 MIT 记 ADR-018。
7. **路径形态事实(评审实测)**:真实 hesupipe 有 **54 条百分号编码中文 permalink**(如 `/%e5%85%b3%e4%ba%8e%e6%88%91%e4%bb%ac/`,多为 page/news)且 **0 条 .html permalink**(该规则仅夹具用)。**编码路径贯穿规则钉死**:routes.json→dist 文件路径→route-manifest→preview 匹配**全程编码原样(字节精确)**,V1 不做解码归一(Windows/NTFS '%' 合法;解码归一属 SP3 验证器)。`.html` 规则保留但标注"仅夹具防御"。
8. **发布补充钉死**:① `--actor` 参数(CLI 必传或缺省 `super_admin@cli`,audit_logs.actor_label NOT NULL——本计划是全仓第一个审计写入方);② **P0 门禁决策**:V1 publish **不读报告文件做 P0 阻断**(ADR-018 记偏离——import issue 无 DB 查询源,206 条真实现状下严格门禁会反堵发布;missing 媒体等已在 build/gate 层有真实防线),Plan 5 SEO diff 时重审。
## 仓库结构增量
```text
themes/pilot-minimal/ # Astro 项目(独立 package.json:astro 依赖)
theme.manifest.ts # contracts.ThemeManifest 实现
src/layouts/BaseLayout.astro
src/components/ # SeoHead/SiteHeader/SiteFooter/Breadcrumbs/RichText/ResponsiveMedia/ProductCard/NewsCard/Pagination/NotFound
src/pages/[...slug].astro # 六类页面按 render data 分派(getStaticPaths 读输入目录)
src/lib/load-render-data.ts # 只读输入目录(SF_BUILD_INPUT 环境变量)
apps/api/src/modules/publish/ # publish.service(零装饰器)+ module
apps/api/src/modules/build/ # build.service 扩展(已有 spawn 构建骨架?无——Plan 1 只建了 build 触发占位?核实:无 build 模块,本计划新建)
apps/preview-server/ # 预览服务器(Node http,零依赖)
scripts/sf.ts # + content:publish / build:create / preview:start
```
---
### Task 1: content:publish 命令(显式发布)✅ 完成(c35f108 + rider 6a1d9a3,双审通过;340 测试;dry-run/confirm 单事务/审计首写入方/config v1/anchors helper 三处合并逐字保真。审查移交:brand 容忍契约已入 Task 3;audit 第二写入方时抽共享 helper)
**Files:** `apps/api/src/modules/publish/{publish.service.ts,publish.module.ts}`;sf.ts + `content:publish`;Test `tests/integration/publish.test.ts`
- [ ] 失败测试(临时库,mini 全链 register→import 后):① dry-run:列出 current 非空且 published≠current 的 192 条(mini 3 条)清单(typeKey/slug/修订对)+ 汇总计数,零写入;② confirm(token 从 dry-run 输出取):published_revision_id=current、audit_logs 行(action='content.publish' 批量摘要含条数)、重复发布幂等(已等则 skip);③ site_config_revisions 首版:首次 publish 时若站点无 config 修订 → 从 site 行生成 v1(name/origin/locale 空品牌占位——data 结构按后续主题消费定义:{name, origin, locale, brand:{}});④ 未导入条目不受影响
- [ ] 实现:`publishSiteContent(db,{siteKey, dryRun, confirmToken, actor})`——查询起点 `entry_locales.current_revision_id IS NOT NULL AND (published_revision_id IS NULL OR published_revision_id != current_revision_id)`(事务批量 UPDATE);dry-run token=随机;**actor 按 --actor 传入、缺省 super_admin@cli**(前置事实 8);P0 门禁按前置事实 8 决策不做(ADR-018 记偏离);五列锚点共享 helper 顺手抽 `apps/api/src/modules/import/anchors.ts`(三写入器改 import——零行为变更由既有测试守护)
- [ ] 绿 → commit `feat(publish): 显式发布命令(dry-run/confirm/审计/站点配置首版)`
### Task 2: Render Snapshot 物化 + build job ✅ 完成(8f42c83 + 修复 5434afe,双审通过;354 测试;审查战果:哈希链===文件字节互证、media 块 first-wins 确定性、job 复用只认 succeeded+#a 谱系崩溃回收、repeatable read 隔离探针实证。遗留 Task 5/6:stale 宣卫/幂等键事务外窗口/existsSync 出事务)
**Files:** `apps/api/src/modules/build/{build.service.ts,render-snapshot.ts,build.module.ts}`;sf.ts + `build:create`;Test `tests/integration/build-snapshot.test.ts`
- [ ] 失败测试(临时库,publish 后):① build_jobs 行(queued→snapshotting→**validating(空)→succeeded**——dist 尚空;Task 6 接真实构建时**新建 build job**而非改行——build_input_snapshots 有不可变触发器)+ build_input_snapshots 行(三哈希/site_config_revision_id/theme_key='pilot-minimal');② 六文件物化:site.json(config v1)/content.json(published 修订——**bodyHtml URL 重写钉死匹配键**:URL 剥 host 后按 path 与 media ready 集合的 public_path/source_url 路径段比对(覆盖绝对/根相对/协议相对变体),命中→根相对 public_path;未命中保留原样+issue)/taxonomies.json(term+taxonomy)/routes.json(html+asset 行,redirect 并入)/assets.json(ready:public_path→storage 实体路径+sha256)/render-manifest.json(版本块+总数+源哈希);③ canonical JSON 确定性(键排序);④ 未 publish 站点 → NOT_PUBLISHED;⑤ **ADR-010 双修订测试**:发布 A→导入改内容 current=B→快照 content.json 仍为 A(published 冻结语义,spec §15 阶段 4 验收明文)
- [ ] 实现:一致性读取(单事务 repeatable read 或快照时点标记——简化:顺序读取+记录行数基线,差异检查);媒体实体路径解析用 register 快照 metadata 的 storageRoot+storage_path(`storage/snapshots/media_bundle/…` 已存在的 content-addressed 副本)+ incoming 副本兜底
- [ ] 绿 → commit `feat(build): 冻结 Render Snapshot 物化与 build job`
### Task 3: pilot-minimal 主题骨架 + 布局组件 ✅ 完成(1cd309d + 修复 eb81a57,双审通过;372 测试;审查战果:**payload 契约上提 contracts 双端咬合**、空路由明错(静默 0 页消除)、eslint astro ignore、routeHrefFor 预备。遗留:og:type/og:image、footer 年份可复现、origin 空守卫——均记 Plan 5/Task 5)
**Files:** `themes/pilot-minimal/`(package.json/tsconfig/theme.manifest.ts/src/{layouts,components,lib});根 workspace 已含 themes/*;Test `themes/pilot-minimal/test/manifest.test.ts`
- [ ] 失败测试:theme.manifest 满足 contracts 的 isThemeManifest(key='pilot-minimal'/version/renderContractVersion='1'/supportedContentTypes 三类/requiredSiteConfig/六模板条目:home/page/product-list/product-detail/news-list/news-detail+404);渲染数据 loader 对六文件夹具的加载形状
- [ ] 实现:Astro 5 项目(dependencies 仅 astro);BaseLayout(html 骨架/SeoHead:title/desc/canonical/og 从 page data)/SiteHeader(site.name+简导航:首页/Products/News)/SiteFooter(©+name)/基础样式(design tokens 极简 CSS 变量——快线 §8.5 视觉优先级:宽度/字体/颜色/间距先对,不动像素级还原);**brand 恒可能为 {} 且 name 可能是 siteKey 派生占位(Task 1 审查移交)——主题必须对空 brand/占位 name 缺省容忍,禁止直接读 brand.xxx 落空**;组件:RichText(set:html——输入已清洗+重写,信任链 JSDoc 记录,不二次清洗)/ResponsiveMedia(alt/尺寸/懒加载)/Breadcrumbs/ProductCard/NewsCard/Pagination/NotFound
- [ ] 绿 → commit `feat(theme): pilot-minimal 骨架与布局组件`
### Task 4: 六类页面路由与渲染 ✅ 完成(f916f72 + rider 0a7578b,双审通过;380 测试;审查战果:**sf-literal-html-paths 折平集成**(Astro 5.18 三态均无法产字面文件的实证解法)、真实数据字段探测三纠偏(title@knownFields/media键=fieldPath 全路径/产品无结构化参数表)、stub 清理+format 显式契约、195 页规模实测 5.3s/113MB。Task 8 分派观察项已记)
**Files:** `themes/pilot-minimal/src/pages/[...slug].astro` + 模板分派;Test `themes/pilot-minimal/test/render-cases.test.ts`(Astro 构建级测试:六类夹具数据 → 构建产物断言)
- [ ] 失败测试(六夹具页 = 真实 hesupipe 抽样数据的脱敏化形状——home(/)、generic page(**/products/ 内容页 + 一条编码中文路径页**如 `/%e5%85%b3%e4%ba%8e%e6%88%91%e4%bb%ac/`)、product-category 列表、product 详情、news 列表 /news/、news 详情 .html(仅夹具防御——真实 0 条)):构建产物存在且含关键内容(title/slug 渲染进 HTML、产品参数/新闻正文、图片 src=根相对;**编码路径产物按编码原样落盘**);404.html 存在
- [ ] 实现:getStaticPaths 读 SF_BUILD_INPUT 的 routes.json(html 行)+ 按内容类型/路径特征分派模板:`/`→home;`/product-category/*` 或 `/category/*`→列表(taxonomy.json 查 term+content.json 过滤关联内容);内容页按 typeKey(product→详情含参数表+相册、news_article→正文+日期、basic_page→富文本);/news/ 特判 news-list。分页:**V1 一律不做**(/news/ 129 条单页全列 + taxonomy 列表同——分页路由不在 routes.json 会自破门禁①死链;触发条件与路由登记义务一并记 ADR-018 移交 Plan 5)
- [ ] 绿 → commit `feat(theme): 六类页面渲染与路由分派`
### Task 5: 构建管线(astro 构建 + 媒体落盘 + sitemap/robots)
**Files:** `build.service.ts` 扩展(spawn astro build→媒体复制→sitemap→产物登记);Test `tests/integration/build-pipeline.test.ts`
- [ ] 失败测试(小夹具全链):① spawn **`pnpm --filter @sf/theme-pilot-minimal build`**(SF_BUILD_INPUT 指输入目录;**Task 3 备忘:包全名 filter;Windows astro 构建失败判据="非零退出+stderr 标记"勿匹配精确码——127+libuv 噪音**)→ dist/ 页面按 routes.json 的 html 行路径落盘——**映射规则钉死**:尾斜杠→`<path>index.html`;`.html` 后缀→字面文件(仅夹具);**百分号编码路径→编码原样目录结构**(Astro 产物若不符则构建后按 manifest 重命名/移动对齐——manifest 的 target_file 是唯一权威);② 媒体复制:assets.json 逐条复制到 dist 的 public_path(跳过 quarantined);sha256 抽检;③ sitemap.xml(origin+indexable html 行,**编码 URL 原样进 sitemap**)+ robots.txt(Allow / + Sitemap 行);④ **route-manifest.json 生成与字段面断言**(spec §12.1:manifest_version/site_id/build_job_id/origin/generated_from_route_snapshot_hash/routes[] 含 exact_key/query_policy/kind/target_file/status/location/content_type/indexable——含 redirect 行与 ?attachment_id= 查询形态)+ build_artifacts 行(dist/manifest 各 kind+哈希);⑤ 构建环境五禁断言:SF_BUILD_INPUT 之外零数据源(loader 单一入口 grep + **DATABASE_URL 置空跑构建仍成功** + **无网证明**:构建进程内 stub global fetch 抛错仍成功——spec §10.4 明文要求)
- [ ] 实现:build.service 编排(快照→astro→媒体→sitemap→登记);产物落 `artifacts/jobs/<buildJobId>/{input,dist,manifests}`
- [ ] 绿 → commit `feat(build): astro 构建管线与媒体落盘`
### Task 6: 最小门禁 + 完整 build 状态机
**Files:** `apps/api/src/modules/build/gates.ts`;sf.ts + `build:validate`;Test `tests/integration/build-gates.test.ts`
- [ ] 失败测试(夹具产物注入错误逐项触发):① 内部死链:dist 内 a[href]/img[src] 内部目标全命中(页面或 assets)——注入坏链 → fail;② sitemap 一致:sitemap 条目=实际 indexable html 集;③ SEO 基线:每 html 有 title/meta description/canonical(指向 origin+自身路径)/恰一 H1;④ 大小写冲突:dist 路径 lowercase 无重复;⑤ 产物完整性:route-manifest 的 target_file 全存在。全过 → validation_runs 行 PASS
- [ ] 实现:门禁引擎(纯函数读 dist+manifests → issues 按 P0/P2 分级——P0 阻断 P2 报告;**HTML 解析用 parser 复用清洗管线,禁纯正则** spec §13.4);build 状态机补 validating→succeeded/failed;**报告落盘 HTML 先写**(Plan 3 窄窗口顺手修)
- [ ] **Task 5 审查移交**:① 测试套件 spawn 缩减——build-snapshot 7 处成功路径改 materializeRenderSnapshot 直调或复用单次 job(spawn 降到 1-2,五禁覆盖留 build-pipeline);Task 8 开工前完成 ② CLI 与测试**禁止同时构建**(themeDistDir 结构性共享暂存)③ Task 8 时间预算:全链分钟级(astro 5.3s+媒体 IO+门禁+1746 回环+Playwright),300s spawn 超时余量充足
- [ ] 绿 → commit `feat(build): 最小门禁与状态机闭环`
### Task 7: Preview Server
**Files:** `apps/preview-server/{server.mjs,package.json}`;sf.ts + `preview:start`;Test `tests/integration/preview-server.test.ts`
- [ ] 失败测试(真实 http 请求小夹具构建产物):① html 行 200+Content-Type;② redirect 行 301+Location(?attachment_id= 查询形态匹配);③ asset 行 200+正确 MIME(png/jpg/webp/pdf);④ 未命中 404+自定义页;⑤ 全响应 `X-Robots-Tag: noindex`;⑥ 绑定 127.0.0.1;⑦ 路径穿越拒绝(../ 与编码形式);⑧ **gone 行 410**(夹具手构——真实数据暂无)+ 目录列表禁用 + HTML 响应基础 CSP 头(self-only)
- [ ] 实现:Node http 服务器读 dist+route-manifest.json(exact_key 匹配+query_key 提取);MIME 表复用 magic-bytes 思路(按扩展名+魔数双确认可选——扩展名够用)
- [ ] 绿 → commit `feat(preview): 路由驱动的本地预览服务器`
### Task 8: 真实 hesupipe 端到端(验收——浏览器时刻)
**Files:** `docs/evidence/phase4-preview.md`;可能 `scripts/e2e-real.mjs`(编排复验)
- [ ] 临时库重建全链:register(副本复用)→ import → **publish(192 条)** → build → validate → preview:start
- [ ] 验收断言(快线 §8.7 第 10 天标准逐条):① 六类页面 HTTP 200 且内容来自真实数据(首页/一个内容页(含一条编码路径页)/产品分类列表/产品详情含参数/新闻列表/新闻详情);② 图片本地化抽检 10 张(dist 内存在+页面引用根相对);③ 每页 title/description/canonical 抽检 5 页;④ 404 与 301 行为(?attachment_id= 抽检);⑤ sitemap.xml 条目数=indexable html 数;⑥ 移动端 375px + **桌面 1440px** 截图无明显破版(Playwright MCP 各 3 页存证据——快线 §8.7 原文双端);⑦ 全量 URL 冒烟:route-manifest html 行全 200(190 页)+ **redirect 全量 1746 逐条 301+Location 校验**(回环零成本)
- [ ] **Task 4 审查移交的 Task 8 观察项**:分派器对以下形态是有意明错(build 期 throw)——`/news/` 硬编码特判外的其他主列表路径、分类嵌套(/product-category/parent/child/)、分类索引路径(/product-category/ 裸前缀);真实 190 路由集若含这些形态构建会失败——**届时按错误信息补分派规则即可,非缺陷**
- [ ] 耗时记录(快照物化/astro 构建/媒体复制/门禁分解——对照 Plan 3 基线 51.8s);差异如实分析。证据文档 + commit `test(evidence): 阶段4端到端预览验收`;临时库 DROP;storage 副本保留
### Task 9: 收尾
- [ ] ADR-018(Astro 5 选型与许可证/渲染信任链——bodyHtml 不二次清洗决策/分页 V1 不做及路由登记义务移交/P0 发布门禁偏离/编码路径贯穿规则/预览零依赖);README(preview 截图/命令链 publish→build→preview);typecheck 链含 themes/pilot-minimal 与 preview-server(如 TS);lint;三绿全量
- [ ] 移交 Plan 5 清单:SEO 三方 diff(source_rendered 抓取需求)/完整门禁(redirect 环/OG/hreflang)/可复现双构建/部署前置(836 nginx conf 输出);续传 Task 0.2 备份
- [ ] commit + push 由用户执行
> 组件清单省略注记:快线 §8.2 的 ProductGrid/NewsList 由模板内联网格实现、TaxonomyList V1 无消费场景(首页不做分类导航)——避免与 §8.2 对照误判遗漏。
## 验收(对照快线 §8.7 + V2 §15 阶段 4 子集)
- [ ] 显式发布可用且导入产物发布前不可见(published 指针语义)
- [ ] Render Snapshot 冻结六文件 + 构建五禁(无库无网无 seed)
- [ ] 六类页面真实数据渲染 + 图片本地化
- [ ] Preview 200/301/404/noindex/回环
- [ ] 最小门禁五项全绿
- [ ] 真实 hesupipe 端到端验收 + 移动端截图证据