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

123 lines
17 KiB
Markdown
Raw Normal View History

# 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 命令(显式发布)
**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
**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 主题骨架 + 布局组件
**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 视觉优先级:宽度/字体/颜色/间距先对,不动像素级还原);组件:RichText(set:html 净化输出——输入已被 Plan 2 清洗+Plan 4 重写,信任链完整但组件仍加一层 sanitize-html 复检?**不加**——输入已清洗,重复清洗违反 YAGNI,JSDoc 记录信任链)/ResponsiveMedia(alt/尺寸/懒加载)/Breadcrumbs/ProductCard/NewsCard/Pagination/NotFound
- [ ] 绿 → commit `feat(theme): pilot-minimal 骨架与布局组件`
### Task 4: 六类页面路由与渲染
**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 pilot-minimal build`(SF_BUILD_INPUT 指输入目录)→ 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 窄窗口顺手修)
- [ ] 绿 → 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 校验**(回环零成本)
- [ ] 耗时记录(快照物化/astro 构建/媒体复制/门禁分解——对照 Plan 3 基线 51.8s);差异如实分析。证据文档 + commit `test(evidence): 阶段4端到端预览验收`;临时库 DROP;storage 副本保留
### Task 9: 收尾
- [ ] ADR-018(Astro 5 选型与许可证/渲染信任链——bodyHtml 双重清洗决策/分页简化决策/预览零依赖);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 端到端验收 + 移动端截图证据