站点已经有 /writing/search/,新文章又叫 search。两个功能在编辑界面里互不相关,到了 URL 层却争夺同一地址。静态站点没有运行时数据库约束来替你发现冲突,必须在内容进入构建之前建立规则。
本文假设文章详情使用 /writing/[slug]/,项目还有分类和分页。具体框架的路由优先级可以决定最终哪一个页面胜出,但“恰好胜出”不是可靠的产品契约。内容系统应把冲突视为错误。
先确定 URL 的身份
const reserved = new Set(['search', 'tags', 'page', 'feed']);
function validateSlug(slug: string) {
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
throw new Error(`Invalid article slug: ${slug}`);
}
if (reserved.has(slug)) {
throw new Error(`Reserved article slug: ${slug}`);
}
}
ASCII slug 不是唯一正确选择,中文路径也能工作。但如果采用中文,需要明确 Unicode 规范化、百分号编码和大小写处理,以及编辑器复制链接后的表现。不要把“浏览器可以打开”误认为所有分析工具、CDN 规则和链接生成器都理解一致。
上面的限制放弃了下划线、连续连字符等形式,收益是更容易人工检查。已有站点迁移时不能直接改名了事,旧 URL 应使用明确重定向,并检查 canonical、RSS 和站内链接是否统一更新。
如果边缘规则把多个连续斜杠折叠成一个,内容系统就不应允许 /writing//a/ 与 /writing/a/ 分别登记。如果服务器区分大小写,擅自把所有历史路径小写也可能破坏已有链接。先明确生产服务器做哪些变换,再把这些变换作为测试输入,而不是把自己喜欢的形式当成协议保证。
编码尤其容易制造两套理解。原始路径里的百分号序列和解码后的字符不能反复混用;重复解码可能让编码分隔符突然变成真正的路径分隔符。对编辑器提供的 slug,推荐在输入边界使用允许字符规则,然后由 URL API 负责编码。对于既有复杂地址,保留原始地址作为迁移源,不用手写替换函数试图“清洁”全部历史路径。
Unicode 还涉及视觉相似与编码相同的区别。两段文本看起来相同,不代表其字符序列相同;采用统一规范形式能降低部分差异,却不会消除所有形似字符。中文 slug 并非不可用,只是搜索、复制、展示和迁移都需要相应测试。个人技术博客若没有明确中文路径需求,稳定 ASCII slug 能减少工具链之间的解释差异。
检查最终路径,而不只检查 slug
如果中英文集合允许同一个 slug,它们只有在语言前缀明确时才不会冲突。检查程序应计算完整路径,例如 /en/writing/cache-key/,将其作为去重键。还要考虑尾斜杠策略:/writing/a 与 /writing/a/ 在服务器配置中可能归一到同一个资源。
分页同样会扩展路由空间。假设 /writing/page/2/ 用于第二页,就应保留 page 命名空间;不要允许文章 slug 内含任意斜杠,然后期待路由器自动区分。层级 slug 有价值,但它要求更完整的路径树校验。
假设文章生成器检查了重复 slug,标签生成器也检查了重复标签,两者各自没有错误,却分别生成 /writing/tags/。问题不是检查缺失,而是检查范围小于实际路由空间。应在构建计划阶段汇总固定页面、内容详情、标签、分页、订阅源和重定向的最终路径,再执行一次全局碰撞检查。每条记录都保留生成器名称和源文件,便于判断谁占用了地址。
路径登记表不是让所有模块共享一大块业务代码。各生成器仍然负责自己的输入和路径生成,只提交标准记录,例如路径、输出类型和来源。统一检查器负责识别重复、保留名称和不合法字符。这样新增搜索索引时,只要登记其路径,就能与已有内容比较;不必让文章模块知道所有新功能。缺少来源的记录应被拒绝,否则错误出现时又回到手工猜测。
还应区分文件路径与公开路径。某个部署适配器可能把 /writing/a/ 输出为 writing/a/index.html,另一个可能使用运行时路由。检查公开路径保证用户看到的身份唯一,检查输出路径防止文件覆盖,两项都可能需要。不要只在构建完成后查看目录是否存在,因为被覆盖的文件已经丢失,目录看起来依然整齐。
重复错误必须指出两个来源
const paths = new Map<string, string>();
for (const article of articles) {
const path = `/writing/${article.slug}/`;
const previous = paths.get(path);
if (previous) {
throw new Error(`${path}: ${previous} conflicts with ${article.file}`);
}
paths.set(path, article.file);
}
只报“存在重复路径”会迫使编辑者逐个搜索。输出来源文件,并把这项检查放在 CI 与本地发布命令中,让相同输入得到相同失败结果。不要在碰到重复时静默保留第一篇,文件遍历顺序不应决定公开内容。
设计层级路径前先计算编辑成本
把文章地址写成 /writing/http/cache/key/ 能表达分类,却把分类调整变成了 URL 迁移。若一年后缓存文章被归入性能主题,作者必须选择保留旧分类路径还是进行重定向。稳定平面 slug 加独立标签页面,往往更适合主题会不断演化的技术笔记;层级路径适合结构长期稳定的文档产品。分类看起来整齐,不代表它应该成为资源永久身份。
若确实采用层级路径,每一级也需要所有权。目录页与文章页是否可以同名、空目录是否生成、父路径删除后子内容是否仍可访问,都要写进规则。检查树结构比检查单个字符串更可靠:一个节点被登记为文章终点后,是否允许再拥有子节点取决于路由实现。让系统拒绝不支持的形态,比到部署时才得到意外页面更容易修复。
迁移成本可以通过外部链接、订阅历史和站内引用估计,而不是只数文件数量。一个只有三篇文章的旧目录,也可能已经被大量文档引用。对路径调整设置审查点,要求说明内容身份是否真的发生变化。大多数标题润色不需要新 URL;大多数分类变化可以由索引页解决。减少无必要迁移,比维护一个越来越庞大的重定向表更简单。
旧地址是一项维护资产
文章标题可以不断改进,slug 则尽量稳定。用标题自动生成 slug 很方便,但每次改标题都重新生成会积累大量坏链接。可以首次创建时建议 slug,保存后作为独立字段维护。
重定向表也需要防环检查:A 指向 B,B 又指向 A,会让浏览器和爬虫无法到达内容。连续重定向应压平到最终地址;删文时区分永久移除与迁移,不要把所有不存在的文章都重定向到首页,这会掩盖失效链接。
验收时构建一个路径清单,检查保留路由、大小写、尾斜杠和语言前缀。用真实部署预览请求旧地址,确认状态码与 Location;不能只测试开发服务器,因为生产边缘层可能采用不同的重定向配置。路径规则越早固定,编辑者越少需要理解底层路由优先级。
给文章准备三个独立字段:稳定内部标识、可读 slug、可编辑标题。标题修改不改变内部标识或路径;slug 修改属于明确迁移动作,需要旧路径重定向。内部标识还可用于关联评论和分析,避免它们依赖标题。不要用数组索引当标识,增加一篇文章后索引可能改变,历史关系就会指向另一条记录。
假设文章从 cache-guide 改为 cache-key-design。迁移清单应记录旧地址、最终地址和变更原因,并检查最终文章存在。下一次又修改 slug 时,把第一次旧地址直接指向最新地址,避免不断增加跳转层数。重定向的源也要参加路径登记:如果新文章后来使用了旧 slug,它会与历史迁移发生冲突,必须由作者选择保留旧链接还是复用名称。
不要把删除后的名称立刻回收。旧链接可能在订阅器、邮件和外部文章中存活多年,复用地址会让旧引用看似仍然有效却指向不同内容。可建立已撤销路径名单,永久移除时返回适当的缺失响应,有替代内容时才重定向。统一跳首页隐藏了这层区别,也让链接检查无法判断原内容是否还存在。
通配规则也会占用没有实体文件的地址
假设产物里只有 /writing/search/ 与正常文章,边缘却新增一条 /writing/* 转发规则,把整个目录送到搜索服务。文件清单仍无碰撞,文章也成功生成,但请求处理权已经转移。路径登记因此还需列出通配规则及其适用方法、匹配范围和优先级。检查具体文章是否落入规则范围,比只登记通配字符串本身更能揭示这种遮蔽。
准备搜索入口、普通文章、附件和缺失路径四个样本,逐个计算预期处理者,再在预览入口请求核对。搜索服务需要接管某个子目录时,缩小规则范围,不依赖“静态文件应该优先”这类未确认假设。若平台确实具有固定优先级,将对应文档与样本作为适配器契约;更换部署平台时重跑,不能把一个平台的处理顺序继承为所有静态站的保证。
在真实输出树中检查父子冲突
假设详情生成器声明 /writing/cache/,同时附件生成器声明 /writing/cache/diagram.png。公开路径不同,并不意味着文件系统输出兼容。如果详情输出为 writing/cache.html,附件可以放在目录里;如果某个生成器把 writing/cache 当作无后缀文件,附件目录就无法建立。检查器需要知道适配器的输出映射,而不能只做完整字符串去重。
可以把公开路径转换成输出节点,并在路径树中登记节点类型:目录、文件或运行时路由。插入文件时检查同一节点是否已经是目录;插入子节点时检查祖先是否是文件。相同公开地址的冲突与不同公开地址的文件冲突分开报告。这样编辑者能知道是资源重名,还是构建配置选择了不兼容的输出格式。
检查实际输出时,还要考虑目标系统大小写行为。本地大小写不敏感文件系统可能提前覆盖两个仅大小写不同的文件,而 Linux 构建机可以同时保存;生产边缘的请求规范化又可能把它们合并。测试不能只以某一台机器“能写两个文件”作为契约。对新内容使用统一小写规则比较简单,迁移历史内容则先生成完整清单,识别需要保留的外部地址。
输出目录中的资源也应登记,包括手工放入 public 的静态文件。一个旧的 writing/search/index.html 即使不由当前路由生成器创建,仍然会占用部署地址。发布前将生成计划与静态资源合并检查,构建后再核对实际树。两次检查承担不同职责:前者阻止计划冲突,后者发现插件额外生成或配置遗漏的文件。
给重定向图制定终点约束
只检查环还不够。A 指向 B,B 指向不存在的 C,没有环,却依然损坏。可以将每个重定向源作为图节点,沿边追踪到最终节点,要求终点是当前公开资源,或者是经明确批准的外部目标。重定向源同时不能占用一个当前正文地址。若产品决定让旧地址重新成为正文,那是迁移决策,不是允许检查器自动忽略冲突。
图遍历要有上界,并保存已检查的终点。递归实现遇到很长历史链条可能耗尽调用栈,迭代遍历更容易控制。错误报告提供路径序列,而不是只说某节点非法。对于 A 到 B 到 C 的正常历史迁移,生成部署规则时可以将 A 与 B 直接指向 C;内部迁移记录仍保留原始变更历史,便于解释为什么某个名称不能复用。
查询参数的处理另立契约。旧文章上附带追踪参数,保留它们通常不会改变新正文;旧搜索入口的查询词却可能需要映射到新的搜索参数。不能把所有旧参数统一删除,也不能盲目把用户提供的 next 解释为跳转目标。路径图负责资源身份,参数转换负责请求语义,两项测试各自覆盖后才组合到部署规则。
重定向状态码也不是随意选择。永久迁移表达稳定的新身份,临时迁移用于尚未确定或短期策略;不同状态码对非 GET 方法的处理存在差异。文章浏览主要是 GET,但迁移规则若覆盖整个站点,就不能假定没有表单或接口。把文章规则限制在其命名空间,减少一条内容迁移意外影响写请求的可能。
在两个并行分支合并时重新规划
分支甲增加 /writing/archive/ 功能页,分支乙新增 slug 为 archive 的文章,两者在各自基础版本上都通过。合并后不能因为两个 CI 状态都是绿色就跳过检查。需要在最终合并树计算完整计划,并使用与生产相同的保留规则。路径唯一性是集合性质,旧结论不能自动组合成新结论。
可以让变更报告展示新增路径与新占用的命名空间。功能页占用 archive,应同时展示其对未来文章名称的影响;文章重命名应展示旧地址登记为重定向源。审阅者关注的是所有权变化,自动检查负责具体相等与父子关系。两者配合,才能发现“当前没有撞名,但新增规则会让后续编辑困惑”的设计问题。
预留系列路径时,不必把私人标题写进公开 CI 产物。内部清单可以只包含稳定 ID 与路径,公开报告只显示已准备公开的变更。预留失败意味着编辑者需要换一个地址,不代表系统应该生成一个占位页面。若占位页面确有产品价值,则应作为正式公开资源单独审批,而不是路由检查的附带输出。
一次加载器失效怎样变成链接事故
把 Markdown 加载目录改错以后,构建可能仍然成功,因为首页和固定页面存在。此时路径差异报告会显示文章路径大批消失。设置删除比例警报只是辅助手段,不能替代明确确认:真正删除一整个系列也可能超过阈值。报告应列出消失路径的来源变化,区分文件删除、公开状态改变与加载规则变化。
验收部署时选择消失清单中的代表性地址请求,不只打开新首页。如果平台复用了旧文件,某些地址可能继续显示旧文;如果完整替换,所有文章可能成为缺失页。两种结果都与预期发布不符,但原因不同。保留前后路径清单与部署版本,可以把事故定位到构建输入还是发布产物,而不是不断修改 slug 试图恢复访问。
子路径部署要把站点前缀纳入碰撞模型
同一份站点从根目录移到 /notes/,文章地址变成 /notes/writing/cache/,但手工资源可能仍位于 /writing/cache/。它们当前不冲突,不意味着以后去掉前缀仍安全。路径记录应区分站点内部路径、部署前缀和最终公开路径,让检查器能够解释前缀变化影响哪些资源,而不是对所有字符串盲目追加一段。
准备两个固定样本:一个在站点内部生成的文章链接,一个刻意指向外部独立应用的根路径链接。切换前缀后,前者随站点移动,后者保持原目标。若两者都被改写,说明工具丢掉了链接用途;若两者都不改,说明内部导航可能跳出新站点。这是迁移契约测试,不是让编辑者逐篇修补路径。
测试要覆盖错误计划与真实响应
构建测试故意放入固定页面冲突、标签与文章冲突、尾斜杠变体和语言前缀遗漏,断言所有冲突在写入产物之前失败。测试不要依赖文件读取顺序;交换输入顺序后应得到同一组冲突,仅错误消息中的来源顺序可以不同。对于允许同 slug 的语言版本,要同时验证各自完整路径存在。
部署验收则请求旧路径、新路径和不存在的路径,检查状态码、跳转次数及最终正文。测试环境可以跟随重定向,但应另外检查第一跳的 Location 是否保留业务查询参数。路径迁移不能直接复制未知查询参数到不受信任目的地;如果目的地来自用户输入,还需要独立的开放重定向防护。
最终生成的清单可以作为 CI 产物,比较本次新增、删除和迁移路径。大范围路径消失应需要人工确认,不能因为构建没有语法错误就自动发布。这样路由检查不只是拦截两个重名文件,也能发现加载器配置错误导致整批文章突然不再进入构建。