{
  "version": "https://jsonfeed.org/version/1.1",
  "title": "Atelier · 造物间",
  "home_page_url": "https://website.85138175.xyz/",
  "feed_url": "https://website.85138175.xyz/feed.json",
  "description": "以作品为证据、以创作过程建立信任的独立开发者主页",
  "language": "zh-CN",
  "items": [
    {
      "id": "https://website.85138175.xyz/log/2026-11-13-%E6%8A%8A%E7%9C%9F%E5%AE%9E%E5%9F%9F%E5%90%8D%E5%86%99%E8%BF%9B%E4%BB%A3%E7%A0%81/",
      "url": "https://website.85138175.xyz/log/2026-11-13-%E6%8A%8A%E7%9C%9F%E5%AE%9E%E5%9F%9F%E5%90%8D%E5%86%99%E8%BF%9B%E4%BB%A3%E7%A0%81/",
      "title": "把真实域名写进代码",
      "summary": "M7 拿到域名、联系方式与预约链接后，把清单上的待提供项变成代码默认值并跑完首推前全链",
      "content_html": "<h2 id=\"从清单到默认值\">从「清单」到「默认值」</h2>\n<p>上一轮我把 M7 需要的东西列成了一张待提供清单，每一项都写清「怎么获取」。\n这一轮用户一次性补齐：子域名前缀和 Pages 项目名都用 <code>website</code>、Cal.com 预约链接、\nCloudflare Pages 已按推荐 A 方案连通 GitHub 仓库、本机代理端口 3067。\n清单上的 ⬜ 该变成代码里的默认值了。</p>\n<h2 id=\"一处改动五个地方生效\">一处改动，五个地方生效</h2>\n<p><code>astro.config.mjs</code> 的 <code>site</code> 改成读 <code>process.env.PUBLIC_SITE_URL</code>，默认\n<code>https://website.85138175.xyz</code>。这一处同时决定 canonical、sitemap、OG、RSS、JSON Feed\n的绝对地址——所以改完不用到处找硬编码。顺手把 <code>Seo.astro</code>、<code>privacy.astro</code>、\n<code>robots.txt</code>、作品 <code>url</code>、<code>.env.example</code> 里的占位域名 <code>atelier.example.com</code> 全清掉。</p>\n<p>这里有个不显眼的坑：<strong><code>vite</code> 在项目根不可解析</strong>（pnpm 严格隔离），所以不能用\n<code>loadEnv</code>；而且 config 在 Vite 加载 <code>.env</code> 之前执行，本来也读不到 <code>import.meta.env</code>。\n用 <code>process.env</code> 是唯一正确的选择。</p>\n<h2 id=\"三个看起来没问题的地方\">三个「看起来没问题」的地方</h2>\n<p><strong>微信号不是 URL。</strong> <code>contact_channels</code> 原本强制 <code>{label, url}</code>，组件渲染成\n<code>&lt;a href&gt;</code>。把 <code>dianfeng1998</code> 塞进去会造出一条死链。改成支持 <code>value</code> 纯文本字段、\n用 <code>&lt;code&gt;</code> 展示；也刻意不加「一键复制」按钮——那需要客户端 JS，会破坏首屏零脚本\n(E7) 这个从 M1 守到现在的约束。取舍要明说，不是偷懒。</p>\n<p><strong>CSP 的「首方」必须和 site 同源。</strong> <code>csp-check.mjs</code> 的 <code>originOf()</code> 原来拿占位域名\n当 base 解析绝对 URL。产物里一旦出现指向真实域名的链接，就会被误判成外域、写进\n<code>form-action</code> / <code>script-src</code> 白名单——一个静默放宽的策略。给两个 CSP 脚本加\n<code>SELF_ORIGIN</code>，和 <code>site</code> 用同一个环境变量，首方相对/绝对 URL 才都归类正确。</p>\n<p><strong>冒烟测试要能发现「域名没换」。</strong> 给 <code>deploy-smoke.mjs</code> 加一条 <code>robots.txt</code>\n断言，检查它指向 <code>website.85138175.xyz</code> 的 sitemap。否则这类回归只能靠人眼。</p>\n<h2 id=\"首推前的全链复验\">首推前的全链复验</h2>\n<p><code>pnpm run quality</code>（build + CSP + check 61 files 0/0/0 + E4 三条 + 24 单测 +\nCI 自检 23 项）、<code>pnpm run quality:browser</code>（LCP 1004ms / Lighthouse 100×4 /\naxe 0 / Playwright 7 / 拔动态层 5 页可读）、<code>pnpm deploy:smoke</code>（11 页 + feed +\nsitemap + robots + 真 CSP 头）全绿。产物审计确认 canonical/OG/RSS/Feed/sitemap\n都是真实域名、无占位残留，<code>/contact</code> 有真实邮箱、微信、预约链接且微信无死链。</p>\n<p>清单文档与近况页也同步成 ✅。接下来就是把它推到远端、触发 GitHub Actions 与\nCloudflare Pages 的首次真实构建——那是另一个「纸上通过」与「真跑过」的差距，\n等它真的绿了再说。</p>\n",
      "date_published": "2026-11-13T00:00:00.000Z",
      "tags": [
        "m7",
        "deployment",
        "config",
        "csp"
      ]
    },
    {
      "id": "https://website.85138175.xyz/log/2026-11-13-%E7%AC%AC%E4%B8%80%E6%AC%A1%E4%B8%8A%E7%BA%BF%E4%B8%8E%E5%9B%9E%E6%BB%9A%E6%BC%94%E7%BB%83/",
      "url": "https://website.85138175.xyz/log/2026-11-13-%E7%AC%AC%E4%B8%80%E6%AC%A1%E4%B8%8A%E7%BA%BF%E4%B8%8E%E5%9B%9E%E6%BB%9A%E6%BC%94%E7%BB%83/",
      "title": "第一次上线与回滚演练",
      "summary": "M7 上线后第一次发布→回滚→再发布演练，这条日志就是哨兵——它在线上出现或消失，用来证明平台真的切了产物",
      "content_html": "<h2 id=\"今天做到了哪一步\">今天做到了哪一步</h2>\n<p>站点终于真的上线了，不是「CI 全绿」意义上的绿，而是自定义域名\n<code>https://website.85138175.xyz</code> 打开就是完整的 11 个页面、RSS、JSON Feed、\nsitemap 和站内搜索。这一步之前有一道坎：Cloudflare Pages 没配构建命令时，\n会把整个仓库源码当静态站发布却照样报 “Deploy successful”，是远端冒烟\n<code>deploy:smoke:remote</code> 把它揪出来的。配好 <code>pnpm build</code> + <code>dist</code> +\n<code>PNPM_VERSION=11.28.5</code>、重新触发构建后，线上冒烟才转成 <code>PASS / failed:0</code>。</p>\n<h2 id=\"为什么专门发一条日志\">为什么专门发一条日志</h2>\n<p>「发布→回滚→再发布」演练需要一个<strong>人眼能分辨的信号</strong>来证明平台确实切了产物，\n而不是又一轮「按钮变绿」。所以这条日志本身就是那枚哨兵：</p>\n<ul>\n<li>它出现在日志页 <code>/log/</code>、RSS <code>&lt;item&gt;</code>、JSON Feed <code>items</code>、sitemap 和 Pagefind 索引里；</li>\n<li>回滚到上一份部署后，它应当<strong>全部消失</strong>（回到发布前的产物）；</li>\n<li>再发布后，它应当<strong>全部回来</strong>。</li>\n</ul>\n<p>每一步都用同一条 <code>deploy:smoke:remote</code> 加一次「这条日志在不在」的定点检查做裁判。\n如果回滚后它还在，说明回滚没生效，演练不算通过。</p>\n<h2 id=\"演练里让我踏实的一点\">演练里让我踏实的一点</h2>\n<p>回滚之所以可以是「指一下上一份静态产物」这么轻，是因为整个 R1 的读路径是全静态的：\n没有数据库、没有会话、没有服务端渲染。表单、预约、分析这些动态层就算短暂抖动，\n首页、作品、日志、能力页仍然完整可读——M5 的 <code>test:nodynamic</code> 早就把第三方域名\n全指向不可达地址验证过一次了。架构上少一个耦合，运维上就少一个要一起回滚的东西。</p>\n<h2 id=\"还没闭环的\">还没闭环的</h2>\n<p>回滚按钮在 Cloudflare 后台，只能由账号本人在 <strong>Deployments → All deployments →\n目标部署的三点菜单 → Rollback to this deployment</strong> 里点：本项目走 Git 集成、\n刻意不放 API Token 给自动化。演练完成后，这条日志会留在站上，算 M8 首批真实内容的\n第一条。邮件送达（需要真实表单端点）、5 人 3 秒测试都还等真人来做。</p>\n",
      "date_published": "2026-11-13T00:00:00.000Z",
      "tags": [
        "m7",
        "deployment",
        "rollback",
        "dogfooding"
      ]
    },
    {
      "id": "https://website.85138175.xyz/log/2026-11-13-%E7%BB%BF%E7%81%AF%E4%B8%8D%E7%AD%89%E4%BA%8E%E4%B8%8A%E7%BA%BF/",
      "url": "https://website.85138175.xyz/log/2026-11-13-%E7%BB%BF%E7%81%AF%E4%B8%8D%E7%AD%89%E4%BA%8E%E4%B8%8A%E7%BA%BF/",
      "title": "绿灯不等于上线",
      "summary": "首推后 CI 4/4 全绿、CF 也报 Deploy successful，但远端冒烟发现线上发布的是仓库源码而非 dist/",
      "content_html": "<h2 id=\"今天做到了哪一步\">今天做到了哪一步</h2>\n<p>把代码首推到 <code>zjc99999/website</code> 之后，第一次在 GitHub 托管 runner 上把整套门禁真跑了一遍。\n第一次 4 个 job 里红了 1 个：audit 的「确认没有密钥被提交」。查下来不是站点漏了密钥，\n而是 <code>sk_live_</code> 这条规则太宽，把一篇创作日志里「提到这条正则」的正文也扫成了密钥。\n更麻烦的是本地 <code>pnpm verify:ci</code> 当时却是全绿——不是因为干净，而是 Windows 下 git 默认\n<code>core.quotepath</code>，中文文件名被八进制转义，<code>existsSync</code> 找不到就静默跳过，等于什么都没测。</p>\n<p>一处假红、一处假绿，方向相反，病根相同：规则和执行各写了两份，谁也不保证对方在测什么。\n所以没有给两边各打补丁，而是收敛成 <code>scripts/secret-scan.mjs</code> 一个引擎：规则收紧到\n<code>sk_live_</code> 后面必须跟 20 个以上字母数字（这样规则文本自己不会匹配自己，也就不需要白名单），\n<code>git ls-files -z</code> 配 <code>core.quotepath=false</code> 拿真实文件名，再断言「确实扫到了文件」「没有静默跳过」\n「ci.yml 引用了同一个脚本」。故意把一条真密钥探针塞进 tracked 的 README，它红了；\n拿出来，它绿了。第二次 push run <code>43395389553</code> 4/4 全绿。</p>\n<h2 id=\"但绿灯没把站点送上线\">但绿灯没把站点送上线</h2>\n<p>Cloudflare Pages 的 check-run 也是绿的：「✅ Deploy successful!」。\n直到跑 <code>deploy:smoke:remote https://website-1tq.pages.dev</code> 才看见另一幅景象：\n11 个核心页全部 404，<code>/package.json</code> 却返回 200，<code>/index.html</code> 404，RSS、JSON Feed、\nsitemap、Pagefind 全 404，连 <code>/robots.txt</code> 都是 Cloudflare 自己的默认文案。</p>\n<p>也就是说：平台把<strong>整个 Git 仓库根目录</strong>当静态站发布了。根因在项目没配构建命令/输出目录——\nCF 的规则是「没有 build command 就把仓库根当静态资源上传，退出码 0 即成功」，\n所以一个没跑过 <code>pnpm build</code> 的部署照样能报绿。顺带还纠正了一个推断：平台分配域是\n<code>website-1tq.pages.dev</code>（带随机后缀），不是清单里写的 <code>website.pages.dev</code>，后者实测 522 不存在。</p>\n<h2 id=\"记下来的三条\">记下来的三条</h2>\n<ol>\n<li><strong>CI 全绿 ≠ 线上产物正确。</strong> CI 只能保证 <code>dist/</code> 造得对；平台按什么目录发布、用哪个\npnpm 版本，是另一层配置。消费端冒烟才是唯一裁判，平台按钮的绿不能当验收签字。</li>\n<li><strong>假测试往往成对出现。</strong> 一边把说明文字当密钥（假红），一边因文件名转义啥也没扫（假绿）。\n与其修两个现象，不如问「这个断言到底测到了什么」——然后让它自己证明扫过文件、规则不自匹配。</li>\n<li><strong>默认值要按平台文档来，不按本机来。</strong> CF Build image v3 默认 pnpm 10.11.1，不认\n<code>packageManager</code> 字段，要在后台显式设 <code>PNPM_VERSION=11.28.5</code>；Node 22.23.4 恰好够 astro 用，\n不用另设。这些都不是能从本机 <code>pnpm run quality</code> 里推出来的。</li>\n</ol>\n<h2 id=\"还没闭环的\">还没闭环的</h2>\n<p>CF 后台的 Build command / 输出目录 / <code>PNPM_VERSION</code> 要用户本人去配，配完重新部署，\n远端冒烟才会从 FAIL 转 PASS；之后才是自定义域名 CNAME、HTTPS、发布→回滚→再发布演练、\n国内与第三方实测、作者真实收到通知、5 人 3 秒测试。今天先把「绿灯不等于上线」这件事\n写进演练文档和近况里，等远端冒烟真的全绿了，再回头把这句话划掉。</p>\n",
      "date_published": "2026-11-13T00:00:00.000Z",
      "tags": [
        "m7",
        "deployment",
        "ci",
        "testing",
        "cloudflare"
      ]
    },
    {
      "id": "https://website.85138175.xyz/log/2026-11-12-first-screen-budget/",
      "url": "https://website.85138175.xyz/log/2026-11-12-first-screen-budget/",
      "title": "给首屏定了一条硬性预算",
      "summary": "身份 1 句、价值 1 句、作品 ≤3、主 CTA 1、次 CTA 1、导航 ≤5",
      "content_html": "<p>「简洁、一目了然」不能只当形容词，得变成能验收的数字。</p>\n<p>首屏（不滚动）只允许：身份一句话、价值主张一句话、最多 3 张作品卡、1 个主 CTA、1 个次 CTA、导航不超过 5 项。禁止长段落、技能标签云、全量日志列表、动效轮播、第三方聊天挂件、cookie 横幅。</p>\n<p>配套性能预算：首屏 JS ≤50KB、LCP &lt;1.5s、CLS &lt;0.1、Lighthouse ≥95。</p>\n<p>验收方式是找 5 个人做「3 秒测试」——看 3 秒后能不能说清我是谁、做什么、能帮他什么、下一步点哪，至少 4 个人说得出才算过。</p>\n",
      "date_published": "2026-11-12T00:00:00.000Z",
      "tags": [
        "设计",
        "首屏"
      ]
    },
    {
      "id": "https://website.85138175.xyz/log/2026-11-12-front-end-lands/",
      "url": "https://website.85138175.xyz/log/2026-11-12-front-end-lands/",
      "title": "前台 15 个页面落地了",
      "summary": "从占位首页到完整前台，顺手修正了一个关于证据链的错误假设",
      "content_html": "<p>今天把 M3 做完了：15 个静态页面、8 个组件、3 个 lib 模块，<code>pnpm build</code> / <code>check</code> / <code>test:rules</code> 全绿，全站 15 个页面都是零 <code>&lt;script&gt;</code>。</p>\n<p>过程中有个值得记下的修正：规划书里原本假定 Astro 的 <code>reference()</code> 会校验「证据指向的条目是否真实存在」。写了个探针把 <code>product_id</code> 指向一个不存在的 id，<code>astro build</code> 居然照样通过——说明它只管类型，不管存在性。于是加了一层 <code>src/lib/evidence.ts</code>，在构建期逐条解析每条能力的 <code>evidence_ids</code>，目标不存在就直接抛错中断构建，并补了第三条自动化回归用例。</p>\n<p>这件事的教训是：<strong>「看起来被框架保证了」和「真的被保证了」之间，差一次故意写错的探针</strong>。证据链是这个产品的立身之本，不能只靠约定。</p>\n",
      "date_published": "2026-11-12T00:00:00.000Z",
      "tags": [
        "工程",
        "Astro",
        "决策"
      ]
    },
    {
      "id": "https://website.85138175.xyz/log/2026-11-12-scaffold-works/",
      "url": "https://website.85138175.xyz/log/2026-11-12-scaffold-works/",
      "title": "脚手架跑通了",
      "summary": "Astro 7.4.11 空构建、类型检查、冻结锁文件全部通过",
      "content_html": "<p>今天把最小工程立起来了：</p>\n<ul>\n<li><code>pnpm install</code> → 295 个包，零 peer 冲突；</li>\n<li><code>pnpm build</code> → <code>output: \"static\"</code>，<code>dist/index.html</code> 1554 B，零客户端 JS；</li>\n<li><code>pnpm check</code> → 0 errors / 0 warnings / 0 hints；</li>\n<li><code>--frozen-lockfile</code> → 可复现。</li>\n</ul>\n<p>一个小坑：Astro 7 把 <code>compressHTML</code> 默认值从 <code>true</code> 改成了 <code>'jsx'</code>，会吞掉行内元素之间的空格。中文文案里行内链接多，显式设回 <code>true</code> 才稳。</p>\n<p>另一个坑：TypeScript latest 已经到 7.0.2，但 <code>@astrojs/check</code> 的 peer 只接受 <code>^5 || ^6</code>，所以锁死 6.0.3，不跟 latest。</p>\n",
      "date_published": "2026-11-12T00:00:00.000Z",
      "tags": [
        "工程",
        "Astro"
      ]
    },
    {
      "id": "https://website.85138175.xyz/log/2026-11-12-why-static-first/",
      "url": "https://website.85138175.xyz/log/2026-11-12-why-static-first/",
      "title": "为什么先做纯静态",
      "summary": "把「读路径」与「写路径」拆开，是这次最关键的一个决定",
      "content_html": "<p>独立开发者最怕的不是功能少，是运维把自己拖垮。</p>\n<p>所以这次把系统切成两半：<strong>读路径全静态</strong>（Git 里的 MDX → 构建成 HTML → CDN），<strong>写路径才动态</strong>（订阅、联系表单）。这样一来：</p>\n<ul>\n<li>没有数据库、没有会话、没有运行时依赖，部署就是传一堆 HTML；</li>\n<li>第三方表单服务、邮件服务、分析服务即使全挂，访客照样能看完整个网站；</li>\n<li>内容与代码同仓，<code>git push</code> 就是发布，版本史就是备份。</li>\n</ul>\n<p>代价是「在线后台编辑」得等到真有第二个人要写内容时再做。这个代价现在完全付得起。</p>\n",
      "date_published": "2026-11-12T00:00:00.000Z",
      "tags": [
        "架构",
        "决策"
      ]
    },
    {
      "id": "https://website.85138175.xyz/log/2026-11-12-%E6%8A%8A%E6%89%8B%E5%B7%A5%E9%AA%8C%E8%AF%81%E5%8F%98%E6%88%90%E4%B8%80%E6%9D%A1%E5%91%BD%E4%BB%A4/",
      "url": "https://website.85138175.xyz/log/2026-11-12-%E6%8A%8A%E6%89%8B%E5%B7%A5%E9%AA%8C%E8%AF%81%E5%8F%98%E6%88%90%E4%B8%80%E6%9D%A1%E5%91%BD%E4%BB%A4/",
      "title": "把手工验证变成一条命令",
      "summary": "M6 把前面所有「跑过一次」的验证固化成可复现的门禁，并补上欠着的 Lighthouse 分数",
      "content_html": "<h2 id=\"为什么做这一步\">为什么做这一步</h2>\n<p>前几步的验证有一个共同问题：它们都是「我跑过一次，当时是绿的」。\nE4 三条硬校验、Slow 4G 下的 LCP、Pagefind 能不能搜到、拔掉动态层后 5 个页面可读、\n表单字段有没有带来源——这些散落在十几个脚本里，靠人记得按顺序跑。\n一旦换了机器、或者有人改了 <code>src/pages/search.astro</code> 里那段内联脚本，\n之前的「绿」就悄悄失效了，而没人会知道。</p>\n<p>M6 要解决的不是「再测一遍」，而是<strong>让「测过」这件事本身可复现、且断了会报警</strong>。</p>\n<h2 id=\"落地了什么\">落地了什么</h2>\n<ul>\n<li>两条链：<code>pnpm run quality</code>（构建 + CSP 生成 + check + E4 三条 + 24 个单测 + 链自检），\n<code>pnpm run quality:browser</code>（性能/搜索/线索/不变量 + Playwright + Lighthouse + axe）。\n本地和 <code>.github/workflows/ci.yml</code> 跑的是同一组脚本，CI 不另搞一套只在 CI 里绿的断言。</li>\n<li>补上 M4 欠的账：之前只有 CDP 自测 LCP，不是 Lighthouse 分数。这一步用本机真实 Chrome\n跑 Lighthouse 13，四类分数都是 100（门槛 95），G2 才算真的闭环。</li>\n<li>严格 CSP 从<strong>产物</strong>反推：审计 18 个 HTML 后确认只有 <code>/search</code> 一个内联脚本，\n于是 <code>script-src</code> 只放行 <code>'self'</code> + 一个 sha256 + <code>'wasm-unsafe-eval'</code>，不放 <code>unsafe-inline</code>。\n本地服务器和 Playwright 把 CSP 作为真实 HTTP 响应头发出，测的是与托管平台一致的行为。</li>\n</ul>\n<h2 id=\"又一次假测试的教训\">又一次「假测试」的教训</h2>\n<p>这一步修掉的 bug 里，绝大多数不在站点代码，而在测试自己：</p>\n<ul>\n<li><code>verify-ci.mjs</code> 初版用字符串包含判断「回归脚本有没有进链」，把经 <code>test</code> 间接进入的\n<code>test:rules</code>/<code>test:unit</code> 误报成孤立 → 改成 BFS 可达性。</li>\n<li>Playwright 首跑 4 条红：主 CTA 在首屏和次级区各出现一次（设计如此）、\nCSP 断言误伤了 <code>style-src</code> 里明确放开的 <code>unsafe-inline</code>、中文 slug 被浏览器百分号编码。</li>\n<li>axe 首跑倒是<strong>真的</strong>抓到 2 处站点缺陷：作品/日志列表页 h1 下面直接是 h3，\n跳过了 h2。这是 M6 唯一改到生产代码的修复（卡片标题支持 <code>headingLevel</code>）。</li>\n</ul>\n<p>还有一个不大但会咬人的发现：<code>pnpm ci</code> 是 pnpm 内建的 <code>clean-install</code> 命令，\n会静默遮蔽 package.json 里的同名脚本。所以门禁改叫 <code>quality</code>，不叫 <code>ci</code>。</p>\n<h2 id=\"故意写坏一次\">故意写坏一次</h2>\n<p>CI 的 DoD 要求「故意破坏一条 schema 时 CI 必须红」。我没有只写一个断言，\n而是真的往 <code>src/content/capabilities/</code> 塞了一条 <code>evidence_ids: []</code> 的内容：\n<code>pnpm build</code> 退出码 1、错误可读；删掉后退出码 0。\n因为 <code>quality</code> 链里包含真实 <code>build</code>，把违规内容提交进仓库时 CI 也必然红。</p>\n<blockquote>\n<p>「测试通过了」和「测试真的测了」之间，差的往往是一次故意写坏的探针。\n这句话 M3 写过一次，M5 又写过一次，M6 再写一次——它值得重复。</p>\n</blockquote>\n",
      "date_published": "2026-11-12T00:00:00.000Z",
      "tags": [
        "m6",
        "ci",
        "testing",
        "lighthouse",
        "csp"
      ]
    },
    {
      "id": "https://website.85138175.xyz/log/2026-11-12-%E6%8B%94%E6%8E%89%E5%8A%A8%E6%80%81%E5%B1%82%E4%B9%8B%E5%90%8E/",
      "url": "https://website.85138175.xyz/log/2026-11-12-%E6%8B%94%E6%8E%89%E5%8A%A8%E6%80%81%E5%B1%82%E4%B9%8B%E5%90%8E/",
      "title": "把动态层拔掉，站还站得住吗",
      "summary": "用一次故意写坏的构建，验证「读路径全静态」不是一句口号",
      "content_html": "<h2 id=\"为什么要做这一步\">为什么要做这一步</h2>\n<p>概要设计里写了一条不变量：<strong>即使动态层全部宕机，网站（读）仍完整可用</strong>。\n这句话很容易写成口号——因为默认情况下我们根本没配表单端点、没配统计脚本，\n页面当然能打开，但那只是「碰巧没依赖」，不是「被验证过没有依赖」。</p>\n<p>M5 的做法是反过来的：构建时故意把在线表单、预约外链、Umami 脚本<strong>全部指向一个不可达域名</strong>\n（<code>offline-dynamic.invalid</code>），再用 Chrome 的 <code>Network.setBlockedURLs</code> 真正拦掉它们，\n然后依次打开首页、作品列表、日志列表、作品详情、一条中文 slug 的日志详情。</p>\n<h2 id=\"实测结果\">实测结果</h2>\n<p>5 个页面全部有标题、有正文、有导航、图片没坏；首方资源零失败；\n第三方脚本被拦了 7 次（<code>blockedReason: \"inspector\"</code>）。\n也就是说：表单/预约/统计全断，读者依然能完整认识我、读到作品和创作记录。</p>\n<h2 id=\"一个比站点-bug-更值得记的教训\">一个比站点 bug 更值得记的教训</h2>\n<p>这个回归脚本第一次跑是 FAIL，但站点一行代码都不用改——<strong>坏的是测试自己</strong>：</p>\n<ul>\n<li>探针环境变量漏了 <code>PUBLIC_</code> 前缀，Vite 根本没把它们内联进产物，动态层压根没启用，\n测试却准备宣称「拔掉动态层后一切正常」。这是最危险的一种假通过。</li>\n<li>本地静态服务器没有模拟托管平台的 clean URL，详情页 404。</li>\n<li>中文 slug 在 <code>location.href</code> 里被百分号编码，而传入的 URL 是 Unicode，等待条件永远不成立。</li>\n<li>把「正文 &gt; 200 字」当可读性下限，结果把本来就很短的作品列表页（162 字）判成坏页。</li>\n</ul>\n<p>修完这 6 处，脚本才真正测到了它想测的东西。\n<strong>「测试通过了」和「测试真的测了」之间，差的往往就是一次故意写坏的探针。</strong>\n这与 M3 那次 <code>reference()</code> 探针是同一个道理，只是这次翻车的是测试代码本身。</p>\n<h2 id=\"还有一件没闭环的事\">还有一件没闭环的事</h2>\n<p><code>pnpm test:lead</code> 证明了：订阅和联系表单会带着邮箱、同意、来源留痕被完整 POST 到端点，\n浏览器能拿到回执，而且即使端点打不开，页面上仍留着邮件兜底链接。\n但「作者真的收到邮件/通知」属于外部送达，得等部署时配上真实端点、人工提交一次才算数。\n脚本不冒充这一步，规划书里也把它单列成了待办。</p>\n",
      "date_published": "2026-11-12T00:00:00.000Z",
      "tags": [
        "m5",
        "invariant",
        "testing",
        "cdp"
      ]
    },
    {
      "id": "https://website.85138175.xyz/log/2026-11-12-%E8%AE%A9%E6%90%9C%E7%B4%A2%E5%BC%95%E6%93%8E%E5%92%8C%E9%98%85%E8%AF%BB%E5%99%A8%E8%AF%BB%E6%87%82%E8%BF%99%E4%B8%AA%E7%AB%99/",
      "url": "https://website.85138175.xyz/log/2026-11-12-%E8%AE%A9%E6%90%9C%E7%B4%A2%E5%BC%95%E6%93%8E%E5%92%8C%E9%98%85%E8%AF%BB%E5%99%A8%E8%AF%BB%E6%87%82%E8%BF%99%E4%B8%AA%E7%AB%99/",
      "title": "让搜索引擎和阅读器读懂这个站",
      "summary": "接上 RSS/JSON Feed、SEO、站内搜索与性能实测",
      "content_html": "<p>前台 16 个页面已经能看，但一个只给人看的站还不够。这一步把它接到机器能\n消费的通道上：RSS 2.0（带全文 <code>content:encoded</code>）、JSON Feed 1.1、canonical /\nOG / JSON-LD、sitemap，以及一个完全静态的站内搜索。</p>\n<p>几个没有想当然的地方：</p>\n<ul>\n<li><strong>先读源码再写端点</strong>。<code>@astrojs/rss</code> 的 <code>site</code> 会经 <code>createCanonicalURL</code> 补尾斜杠，\n<code>pubDate</code> 必须传 <code>Date</code>；Astro 的内容条目自带 <code>rendered.html</code>，所以 RSS 能给全文，\n不必自己重渲染。JSON Feed 官网取不到，就按 1.1 结构手写，不赌记忆。</li>\n<li><strong>搜索不引入服务端</strong>。Pagefind 在 <code>astro:build:done</code> 钩子里扫 <code>dist/</code> 生成静态索引，\n搜索页按需懒加载 UI 脚本——首页依然是 0 KB 可执行 JS。UI 的构造参数是从压缩\n产物里反推出来的，所以又写了一个真实浏览器回归：输入「静态」返回 8 条结果。</li>\n<li><strong>性能不靠感觉</strong>。用 Chrome DevTools Protocol 模拟 Slow 4G + CPU×4 实测：\nLCP 972ms、CLS 0、TBT 0、首屏 JS 0KB。脚本明确写了「这不是 Lighthouse」，\n分数留到 M6 的 CI 固化。</li>\n</ul>\n<p>一个诚实的坑：<code>pnpm test</code> 之前指向还没装的 vitest，一直是个哑弹；嵌套脚本里\n<code>npm_execpath</code> 又不透传，硬校验回归会找不到 <code>pnpm</code>。最后改成直接调用\n<code>node_modules/astro/bin/astro.mjs</code>，不依赖包管理器，回归才真正跑通。</p>\n<p>教训和上次 <code>reference()</code> 那次是同一条：<strong>「配置写上了」不等于「真的生效了」</strong>，\n能写探针就写探针，能在浏览器里点一下就点一下。</p>\n",
      "date_published": "2026-11-12T00:00:00.000Z",
      "tags": [
        "seo",
        "rss",
        "search",
        "performance"
      ]
    },
    {
      "id": "https://website.85138175.xyz/log/2026-11-12-%E8%AE%A9%E9%97%A8%E7%A6%81%E5%9C%A8%E4%BB%BB%E4%BD%95%E6%9C%BA%E5%99%A8%E4%B8%8A%E9%83%BD%E8%83%BD%E8%B7%91/",
      "url": "https://website.85138175.xyz/log/2026-11-12-%E8%AE%A9%E9%97%A8%E7%A6%81%E5%9C%A8%E4%BB%BB%E4%BD%95%E6%9C%BA%E5%99%A8%E4%B8%8A%E9%83%BD%E8%83%BD%E8%B7%91/",
      "title": "让门禁在任何机器上都能跑",
      "summary": "M7 准备把写死本机路径的 CI 改成标准 GitHub Actions，并把部署回滚变成可签字清单",
      "content_html": "<h2 id=\"一条自己骗自己的-ci\">一条自己骗自己的 CI</h2>\n<p>M6 收尾时我列了四项诚实留白，其中最刺眼的是这条：<code>.github/workflows/ci.yml</code>\n里写着 <code>NODE: D:\\Ai\\dsh-data\\...\\node.exe</code>、<code>PNPM: ...\\pnpm.mjs</code>、\n<code>CHROME_PATH: C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe</code>，注释还一本正经地说\n「Windows runner 因为本机 Chrome 和 DSH bundled Node 都在这里」。</p>\n<p>这些路径只在这台开发机上存在。GitHub 托管 runner 上既没有 <code>D:\\Ai\\dsh-data</code>，\n也没有 <code>C:\\Program Files\\Google\\Chrome</code>；<code>&amp; $env:NODE $env:PNPM ...</code> 的 PowerShell 写法\n更是在 Linux 上直接不存在。本地两条链全绿、23 项 CI 自检全绿，但那检查的是\n「脚本链没断」，不是「这份 workflow 真的能在远端跑起来」。<strong>全绿 ≠ 真绿</strong>，\n和 M3 那次 <code>reference()</code> 探针是同一类问题，只是这次骗的是部署而不是证据链。</p>\n<h2 id=\"这轮做了什么\">这轮做了什么</h2>\n<p>用户选了「先做不依赖账号的准备」，所以我没有碰任何域名或平台账号，只修工程：</p>\n<ul>\n<li>把 16 处 Windows 硬编码 Chrome 收敛成 <code>scripts/find-chrome.mjs</code> 一个探测器：\n显式 <code>CHROME_PATH</code>/<code>PW_CHROME</code> 覆盖 → 三平台常见绝对路径 → PATH 扫描。\n关键是<strong>惰性</strong>：<code>serve.mjs</code> 会 import <code>cdp-harness.mjs</code> 却从不启动浏览器，\n顶层抛错会连本地预览都起不来。</li>\n<li><code>.npmrc</code> 里 <code>storeDir=D:\\.pnpm-store</code> 删掉。Linux 上这会被当成一个字面目录名；\npnpm v11 自己会选同卷 store，真要固定就用环境变量或 <code>--store-dir</code>，不写进仓库。</li>\n<li>ci.yml 整篇重写：<code>ubuntu-latest</code> + <code>pnpm/action-setup@v4</code> + <code>actions/setup-node@v4</code>，\nbrowser job 用 <code>node scripts/find-chrome.mjs</code> 把探测结果写进 <code>$GITHUB_ENV</code>。\n部署 job 刻意<strong>不加</strong>——没有账号时把它塞进 CI 只会制造第二种假绿。</li>\n<li>新增 <code>docs/部署与回滚演练.md</code> 和 <code>scripts/deploy-smoke.mjs</code>：\n本地托管 <code>dist/</code> 验 11 页 / feed / sitemap / Pagefind / 真 CSP 头，\n远端模式对线上 URL 跑同一套消费端检查，发布→回滚→再发布有签字表。</li>\n</ul>\n<h2 id=\"又踩到两次假测试\">又踩到两次「假测试」</h2>\n<p><code>verify-ci.mjs</code> 的密钥 hygiene 检查突然红了：M6 把 ci.yml 和脚本自身提交进仓库后，\n它们<strong>自己</strong>就含有 <code>sk_live_|AKIA...</code> 那条检测正则，被自己扫中。这不是站点有密钥，\n是检测器照到了镜子。修法是把两个检测器文件列入白名单、改检查项名，\nci.yml 的 audit job 同步排除。</p>\n<p>另一次是 YAML 自检：想用 <code>pwsh -c</code>/<code>node -e</code> 内联解析，分别被引号吞掉、\n<code>require('yaml')</code> 在 pnpm 严格隔离下不可解析、Python 没有 <code>yaml</code> 模块。最后还是写独立\n<code>.mjs</code> 引 <code>node_modules/.pnpm/yaml@2.8.3</code> 才通过。「复杂 JS 一律独立 .mjs」\n这条纪律，这轮又替我省了一次自我怀疑。</p>\n<h2 id=\"仍然没有做的事\">仍然没有做的事</h2>\n<ul>\n<li>没有真部署，没有 GitHub Actions 托管 runner 首跑——这两件都等 ⑤域名 / ⑥联系方式 /\n⑧远端仓库 / ⑨平台账号。</li>\n<li>没有验证表单外部送达、DNS 邮件记录、国内可访问性。<code>deploy-smoke</code> 在远端模式里\n连 CSP 响应头都只记录不判失败（不同平台对 <code>_headers</code> 的映射不一样），\n因为这些必须真人按演练文档核对，脚本不冒充。</li>\n</ul>\n<p>M6 把手工验证变成一条命令；M7 准备则把那条命令从「只在我这台机器上成立」\n搬到了「任何装了 Node 24 和 Chrome 的机器上成立」。剩下的，就是等一个真实的域名。</p>\n",
      "date_published": "2026-11-12T00:00:00.000Z",
      "tags": [
        "m7",
        "ci",
        "deployment",
        "portability"
      ]
    },
    {
      "id": "https://website.85138175.xyz/products/atelier/",
      "url": "https://website.85138175.xyz/products/atelier/",
      "title": "Atelier / 造物间",
      "summary": "一个先自用、以作品为证据、以创作过程建立信任的独立开发者主页",
      "date_published": "2026-11-12T00:00:00.000Z",
      "tags": [
        "独立开发",
        "Astro",
        "内容站点"
      ]
    }
  ]
}