一篇文章,只维护一次;两个网站,各自完整呈现;内容自动流动,个人站的上线节奏仍掌握在人手里。
问题不是“复制文件”,而是管理内容的所有权
很多人同时维护两个网站:一个是纯粹的内容站,只负责沉淀文章;另一个是更完整的个人网站,除了文章,还包含作品、项目、履历或其他长期模块。
最直觉的做法,是每写完一篇文章,就把 Markdown 文件复制到两个项目里。这个办法短期有效,长期一定会出现问题:
- 修正错别字时只改了一边;
- 两边的标题、摘要和发布日期逐渐不一致;
- 同名文件互相覆盖,却无法判断哪一份才是最新版本;
- 搜索引擎发现两份正文相同的页面,不知道应该收录哪一个;
- 自动化做得越激进,误删和静默覆盖的风险越高。
因此,真正需要设计的不是一条复制命令,而是一套内容所有权模型:谁是源头,谁是副本,哪些内容允许流动,发生冲突时谁有权覆盖,以及两边的页面如何向搜索引擎表达关系。
一、先确定边界:一个内容源,两个展示端
这套方案的第一原则是:共享文章只能有一个权威来源。
可以把两个网站抽象成两个角色:
- 内容源站:负责文章创作、修改、版本历史和内容发布声明;
- 聚合展示站:拥有自己的信息架构,同时原生展示来自源站的共享文章。
这里的“原生展示”很重要。聚合站不是跳转到源站,也不是用 iframe 把另一个页面包进来,而是使用自己的路由、导航、主题和页面布局渲染同一份 Markdown 内容。
这样既保留了两个网站的独立体验,又避免形成两套需要人工维护的正文。
唯一内容源
│
Markdown + 元信息
│
┌──────────┴──────────┐
│ │
内容源站原生渲染 受控同步到聚合站
│
聚合站原生渲染
两个网站仍然是两个产品,只是共享同一套内容资产。共享的是文章,不是整个网站。
二、用发布声明代替目录猜测
仅靠“文件放在哪个目录”判断发布范围并不够清晰。更稳妥的做法,是让每篇文章在 frontmatter 中显式声明发布目标:
---
title: 示例文章
date: 2026-09-24
tags: [架构, 实践]
summary: 文章摘要
publishTo: [blog, personal]
---
发布规则可以保持非常简单:
| 声明 | 含义 |
|---|---|
[blog, personal] | 共享文章,在两个网站展示 |
[blog] | 内容源站专属文章 |
[personal] | 聚合站专属文章,由聚合站自行维护 |
显式声明比约定文件名、目录前缀或标签更可靠,因为它把“这篇文章应该去哪里”变成了文章自身的一部分。
同步程序还应该严格校验这些字段:标题、日期、摘要、标签和发布范围缺失时立即失败,而不是带着不完整数据继续运行。自动化系统最危险的行为不是报错,而是悄悄接受一个模糊状态。
三、共享渲染能力,但不要把内容塞进共享包
两个网站要呈现一致的 Markdown 阅读体验,通常会共享这些能力:
- GFM 表格、任务列表与删除线;
- 代码块语法高亮;
- 标题 ID 生成;
- 目录抽取与滚动高亮;
- 图片、引用、列表和表格样式;
- 原始 HTML 与危险链接的安全边界。
这些能力适合抽成一个共享渲染包,但文章正文不应该打进这个包里。
原因是代码与内容具有完全不同的更新节奏:
- 调整目录算法、代码高亮或正文排版时,更新共享包;
- 发布、修改或下架文章时,只更新 Markdown 和资源文件;
- 发一篇文章不应该触发共享组件版本升级。
这个拆分避免了“为了改一个标点,两个项目都升级依赖”的荒谬流程。
最终形成三层结构:
- 内容层:Markdown、frontmatter、文章图片;
- 渲染层:正文组件、目录、标题解析和作用域样式;
- 站点层:各自的导航、主题、首页、侧边栏和其他业务模块。
内容可以同步,渲染能力可以复用,站点体验仍然独立。
四、同步清单:让自动化知道自己拥有什么
跨项目同步最容易犯的错误,是把目标目录当作可以随意覆盖的镜像目录。
更安全的方式,是在聚合站维护一份同步清单。清单记录:
- 当前共享内容版本;
- 由同步程序管理的文章路径;
- 每个文件的 SHA-256;
- 由文章引用并受管理的静态资源;
- 清单结构版本与内容来源标识。
示意结构如下:
{
"schemaVersion": 1,
"source": "content-source",
"contentVersion": "...",
"articles": [
{
"path": "content/shared/articles/example.md",
"sha256": "..."
}
],
"assets": []
}
清单的作用不是记录“现在有哪些文件”,而是明确告诉同步程序:哪些文件是我创建的,因此以后只有这些文件允许由我更新或删除。
这使同步过程具备几个关键性质。
不覆盖未知文件
如果目标路径已经存在同名文件,但它不在旧清单中,而且内容也不完全一致,同步必须停止。
它不能猜测这个文件是历史副本、个人站文章,还是用户临时编辑的内容。拒绝覆盖比自动判断更安全。
不覆盖被人工修改的托管文件
如果某个文件原本由同步程序管理,但目标站中的当前哈希既不等于旧清单哈希,也不等于新内容哈希,说明它曾被人工修改。
此时同步同样停止,要求先处理冲突,而不是把修改静默抹掉。
只删除自己管理过的文件
文章从共享范围中移除后,同步程序只能删除旧清单中存在、但新清单中已经消失的文件。
聚合站自己的文章永远不在这份删除集合中。这样才能保证“同步下架”不会演变成“清空目标目录”。
五、把同步变成 PR,而不是直接修改生产分支
文章同步可以自动化,但不应该直接绕过验证并修改生产分支。
更稳妥的链路是:源站文章进入主分支后,由持续集成任务完成同步,并向聚合站创建或刷新一个专用分支上的 Pull Request。PR 先执行代码检查和生产构建;验证成功后,再由一个独立的受信任工作流复核来源分支、目标分支、已验证提交和文件白名单,全部一致才自动合并。
文章提交到源站主分支
│
▼
校验发布范围与资源完整性
│
▼
根据旧清单计算写入、更新与删除
│
▼
更新聚合站专用同步分支
│
▼
创建或刷新 Pull Request
│
▼
Lint + 生产构建
│
▼
复核来源、提交 SHA 与文件白名单
│
┌────┴────┐
│ │
全部通过 任一不符
│ │
自动合并 保留 PR
│
▼
由维护者决定何时发布服务器
为什么不让同步任务直接推送聚合站主分支?
因为“文章内容可以自动流动”和“未经验证就修改生产分支”是两件不同的事。PR 提供了清晰的变化记录和自动检查入口;独立的后置工作流只在验证成功后运行,并且不执行 PR 分支中的代码,从而避免把合并权限暴露给待验证内容。
这是一种刻意保留的控制边界:
- 源站可以自动部署静态页面;
- 聚合站可以自动接收内容 PR;
- 只有通过校验且完全符合白名单的同步 PR 才自动合并;
- 普通功能 PR、来源异常的 PR 和夹带其他文件的 PR 不受影响;
- 自有服务器何时发布,仍由人决定。
自动化负责减少重复劳动,但每一次合并仍然具备可审查记录和明确的安全条件。
六、凭据应遵循最小权限,而不是“能用就行”
跨项目创建分支和 PR,需要一份能够访问目标项目的凭据。它不应该使用拥有全部项目权限的长期令牌,而应满足最小权限原则:
- 只授权目标项目;
- 内容权限只开放读写;
- Pull Request 权限只开放读写;
- 不授予项目管理、发布环境、密钥管理等无关权限;
- 令牌只保存在源站的自动化 Secret 中,不写入代码和日志。
工作流还应该在 Secret 缺失时安全跳过,并在任务摘要中说明原因,而不是因为空凭据执行一半后留下难以理解的失败。
七、重复内容 SEO:展示可以重复,权威必须唯一
两个网站原生展示同一篇文章后,会出现一个新的问题:搜索引擎看到两份正文几乎完全相同的页面,应该把哪一个视为权威版本?
解决方法不是隐藏聚合站页面,而是建立明确的 canonical 规则:
- 共享文章在两个网站上的 canonical 都指向内容源站;
- 聚合站专属文章 canonical 指向聚合站自身;
- 共享文章不进入聚合站 sitemap;
- 共享文章在聚合站仍然可以访问、分享和站内导航;
- Open Graph URL 与 JSON-LD 中的文章 URL 使用同一权威地址;
- JSON-LD 的
mainEntityOfPage与 canonical 保持一致。
这套规则表达的是:聚合站拥有展示页面,但源站拥有内容权威。
需要特别注意,canonical 不是跳转。读者仍然留在当前网站阅读,只是搜索引擎知道应把两个页面的信号归并到哪一个地址。
八、不要只测试“复制成功”,还要测试拒绝路径
同步系统的价值主要体现在异常情况下,因此测试重点不应该只有“文件成功复制”。至少要覆盖:
- 共享文章可以被复制并写入清单;
- 相同内容的历史文件可以被安全接管;
- 聚合站专属文章不会被删除;
- 未托管的同名冲突会拒绝覆盖;
- 托管文件被人工修改后会拒绝覆盖;
- 下架时只删除旧清单管理的文件;
- Markdown 引用的共享资源必须真实存在;
- 重复标题、Setext 标题和代码块伪标题不会破坏目录;
- 原始 HTML 与危险 URL 不会被执行;
- 两个网站的生产构建都能通过。
此外,还应直接检查最终 HTML,而不是只看 TypeScript 是否编译成功:
<link rel="canonical">是否符合文章来源;og:url是否与 canonical 一致;- JSON-LD 是否指向同一个权威页面;
- sitemap 是否包含了应该收录的页面,并排除了共享副本。
构建成功只能证明代码能运行,不能证明搜索引擎看到的语义正确。
九、一次完整的文章发布会发生什么
当作者新增一篇共享文章并推送后,完整链路应该是:
- 源站读取 frontmatter,确认文章同时发布到两个站点;
- 源站完成测试和静态构建,并部署自己的文章页面;
- 同步任务解析文章及其引用资源;
- 同步任务读取目标站旧清单,进行冲突检查;
- 新文章被写入目标站的共享内容目录;
- 清单重新计算内容版本与文件哈希;
- 自动化更新专用分支,并创建或刷新 PR;
- 目标站在 PR 中运行定向检查和生产构建;
- 后置工作流复核来源、提交 SHA 和文件白名单,通过后自动合并;
- 维护者按自己的节奏发布目标站服务器。
从作者视角看,只发布了一次文章;从工程视角看,每个阶段都有自己的边界、证据和失败方式。
十、从写作到双站上线:日常应该怎么操作
架构设计最终要落到一条足够简单的日常路径。对于这套双站系统,作者不需要同时操作两个内容目录,也不需要逐次进入聚合站点击合并。
第一步:只在内容源项目中写文章
共享文章始终创建或修改在内容源项目的文章目录中,并显式声明发布到两个站点:
---
title: 文章标题
summary: 文章摘要
date: 2026-09-24
pinned: false
tags: [标签一, 标签二]
publishTo: [blog, personal]
---
如果文章只属于内容源站,则将发布范围改为 [blog]。聚合站专属文章则在聚合站自己的文章目录中维护,不进入共享同步链路。
第二步:在推送前做同步预演和生产构建
提交前至少执行两类检查:
npm run sync:articles:check
npm run build
同步预演应明确列出本次准备写入和删除的文件,但不真正修改聚合站。作者需要确认:
- 本次只更新预期文章和资源;
- 没有意外删除;
- 没有同名冲突或人工修改冲突;
- 文章 frontmatter 和图片引用完整;
- 内容源站能够完成生产构建。
如果本次还修改了 Markdown 渲染、目录算法或同步程序,则应额外运行共享渲染测试和同步安全测试。
第三步:提交内容源站主分支
验证通过后,正常提交文章并推送主分支:
git add content/articles/<article>.md
git commit -m "docs: add article"
git push origin main
共享图片也应与文章一起提交到约定的文章资源目录。推送完成后,维护者不再手工复制 Markdown。
第四步:等待两条自动流水线完成
推送会同时触发:
- 内容源站部署:安装依赖、生产构建并发布静态页面;
- 聚合站内容同步:校验发布范围,更新共享文章与资源,重新生成 manifest,并创建或刷新同步 PR。
聚合站随后执行 ESLint 和生产构建。只有验证成功,并且来源分支、目标分支、提交 SHA 和文件白名单全部匹配,PR 才会被自动 squash merge;任何条件不满足都会保留 PR,等待人工处理。
第五步:按需发布聚合站服务器
自动合并只代表共享内容已经进入聚合站主分支,并不代表自有服务器已经更新。维护者先同步聚合站本地代码:
git pull --ff-only origin main
如果本次只有文章、manifest 或文章资源变化,可以使用项目提供的内容发布模式;如果还包含应用代码、依赖或共享渲染包变化,则执行完整发布。
因此,正常发布一篇共享文章时,人工动作最终只剩五步:
- 在内容源项目写文章;
- 运行同步预演和生产构建;
- 提交并推送主分支;
- 等待部署、同步、验证和自动合并;
- 需要更新聚合站线上内容时,手动发布服务器。
修改、下架和失败处理
修改共享文章时,仍然只修改内容源中的原文件。只想从聚合站下架时,将发布范围改为 [blog];两个站点都不再展示时,删除源文章。
出现异常时按照流水线位置定位:
| 现象 | 优先检查 |
|---|---|
| 内容源站没有更新 | 静态站部署任务 |
| 没有创建同步 PR | 跨项目同步任务与访问凭据 |
| PR 检查失败 | 聚合站的代码检查和生产构建 |
| PR 没有自动合并 | 来源、目标、提交 SHA 与文件白名单门禁 |
| 聚合站代码已更新但线上没有变化 | 自有服务器是否完成手动发布 |
这条路径的关键不是“所有事情都自动”,而是把重复且可验证的步骤自动化,把服务器上线这种具有环境风险的动作保留为显式决策。
十一、为什么不选 iframe、跳转或运行时拉取
iframe
iframe 实现快,但会带来主题割裂、移动端高度处理、滚动嵌套、可访问性、分享语义和 SEO 等问题。它复用了整个页面,却没有复用内容模型。
直接跳转
直接跳转最简单,但聚合站的文章模块会退化成外链目录,无法形成完整的信息架构,也无法保留自己的阅读体验。
浏览器运行时拉取 Markdown
运行时从远端请求 Markdown 看起来更“实时”,但它把内容可用性绑定到另一个服务,增加跨域、缓存、失败降级和首屏性能问题。对于构建型网站,发布时同步通常更确定。
复制粘贴
复制粘贴没有系统成本,却把所有成本推迟到了未来:版本漂移、遗漏修改、冲突和重复劳动都会随着文章数量增长。
相比之下,“单一来源 + 构建时同步 + 原生渲染 + PR 审核”并不是代码最少的方案,却是长期维护成本更低、边界更清晰的方案。
十二、后续如何演进
这套架构已经能稳定支持个人规模的双站发布。只有出现明确需求时,才值得继续升级:
- 文章数量显著增加:增加增量同步报告和更细粒度的变更摘要;
- 出现第三个展示端:把发布目标扩展为可配置渠道,而不是复制第二套脚本;
- 多人共同写作:为文章增加作者、审核状态和发布日期门禁;
- 图片数量增加:加入资源去重、尺寸校验和失效引用检查;
- 更新频率很高:在 PR 中生成文章预览地址,缩短审查路径;
- 服务器发布足够稳定:再考虑把人工发布升级为带环境保护的自动部署。
没有这些触发条件时,不必急着引入 CMS、消息队列或复杂的内容平台。好的架构不是预先实现所有可能性,而是让下一次变化仍然有清晰的落点。
总结
双站文章发布的核心,不是把一个文件从 A 搬到 B,而是同时解决四个问题:
- 内容所有权:共享文章只在一个地方维护;
- 展示独立性:两个网站都使用自己的页面体系原生渲染;
- 同步可信度:清单、哈希和冲突保护阻止静默覆盖;
- 搜索权威性:canonical、结构化数据和 sitemap 对同一篇文章给出一致答案。
最终得到的不是一段更聪明的复制脚本,而是一条可审查、可回滚、可解释的内容供应链。
当自动化只做它能证明安全的事情,并在不确定时主动停止,发布一次、两站可见才真正从“方便”变成了“可靠”。