前阵子把十年的内部 Wiki 从 Confluence 搬到纯静态站,本以为只是「导出 HTML → 打包 → 上线」的一条龙服务,结果第一天就把整个知识库推倒了。罪魁祸首不是 CI/CD,而是那些「看起来没事儿」的 HTML 恶习。下面把我们踩过的十个坑一次性摊开来聊------每踩一个,我都顺手写了一个小修复脚本,最后挂到 GitHub Actions 里做实时守门员。
1. 自闭合标签忘了加 /
,导致 React Hydration 崩
场景:我们在 Markdown → HTML 的流水线里用 marked,marked 默认输出 <img>
、<input>
都不带 /
。拿到 Gatsby 做 SSR 的时候,客户端 React 校验发现服务端 img
节点没闭合,差异大到直接拒绝 Hydration,页面白屏。
js
// markdown-to-html.js
const { marked } = require('marked')
marked.use({
renderer: {
image(href, title, text) {
// 🔍 加自闭合,省掉 React diff 报错
return `<img src="${href}" alt="${text}" loading="lazy"/>`
}
}
})
把这段脚本放在 prepare
生命周期,CI 每次推送都会自动扫一遍。
2. alt="""
空字符串埋了一颗 SEO 炸弹
问题:老库里 40% 的旧图缺失产品号,原本偷懒放空字符串,结果 Lighthouse accessibility 直接给了 0 分。
js
// fix-alt.js
const { JSDOM } = require('jsdom')
const fs = require('fs')
for (const file of allHtmlFiles()) {
const dom = new JSDOM(fs.readFileSync(file, 'utf8'))
dom.window.document.querySelectorAll('img[alt=""]').forEach(img => {
const filename = img.src.split('/').pop()
// 🔍 用文件名反推一个可读替代文本
img.alt = filename.replace(/[-_\d]/g, ' ').replace('.jpg', '')
})
fs.writeFileSync(file, dom.serialize())
}
跑完脚本,accessibility 分值直接飙回了 92。
3. <h1>
→ <p>
→ <h2>
的「降级嵌套」破坏目录树
老 Wiki 编辑习惯在段落里套标题,导出后变成:
html
<p><strong><h2>Features</h2></strong></p>
不仅非法,还导致静态网站生成器里自动 TOC(table of contents)提取失败。我们写了一个 Babel AST 插件在 MDX → JSX 阶段就拦下来:
js
// babel-plugin-smart-heading.js
module.exports = () => ({
visitor: {
JSXElement(path) {
if (path.node.openingElement.name.name === 'h2') {
const parent = path.findParent(p => p.isJSXElement())
// 🔍 只允许顶层出现 h2
if (parent && parent.node.openingElement.name.name !== 'div') {
parent.replaceWith(path.node)
}
}
}
}
})
4. <br><br><br>
做「空行」被屏幕阅读器连喊三次
在「周报详情页面」出现过三次以上 <br>
的地方,我们统一收拢成可切换的段落:
Before:
html
<p>昨日进展 <br><br><br>今天计划</p>
After:
html
<style>
.v-spacer { margin: 1.2em 0 }
</style>
<p>昨日进展</p>
<div class="v-spacer"></div>
<p>今天计划</p>
用 CSS margin
控制垂直距离,屏幕阅读器朗读停顿更合理,同时方便我们后期切换主题时统一调整节奏。
5. id="user"
在全站出现 400+ 次,点击目录永远跳到第一处
ID 本应独一无二,但后台富文本编辑器默认把「用户」锚点全映射成 #user
。我们做了「全局唯一 ID 自动后缀」:
js
// fix-duplicate-ids.js
const idMap = {}
function makeUnique(id) {
idMap[id] = (idMap[id] || 0) + 1
return idMap[id] === 1 ? id : `${id}--${idMap[id]}`
}
for (const file of allHtmlFiles()) {
const dom = new JSDOM(fs.readFileSync(file, 'utf8'))
dom.window.document.querySelectorAll('[id]').forEach(el => {
el.id = makeUnique(el.id)
})
fs.writeFileSync(file, dom.serialize())
}
跑完脚本,锚点跳转不再跳错楼。
6. 忘写 <meta name="viewport">
,移动端直接放大到 0.3x
公司手机端访问量占 62%,第一次上线后产品经理在地铁里打开网站直接崩溃:页面缩到看不清。最终把下面两行放进了模板引擎的全局头:
html
<meta name="viewport" content="width=device-width, initial-scale=1.0">
顺带把 theme-color
写上,PWA splash screen 也一并解决。
7. 行内样式 style="color: #f40"
无法跟随暗黑模式
旧 Wiki 到处散落手写 color
/background
,我们想一次性迁移到 Tailwind 的 dark:
方案:
js
// style-to-class.js
const RE_COLOR = /color:\s*#f40/g
for (const file of allHtmlFiles()) {
let html = fs.readFileSync(file, 'utf8')
html = html.replace(
/<[^>]+style="[^"]*color:\s*#f40[^"]*"[^>]*>/g,
match => match.replace(RE_COLOR, '').concat(' class="text-brand-600 dark:text-brand-400"')
)
fs.writeFileSync(file, html)
}
脚本跑完,后续只需改一次主题变量即可全站更新。
8. 错误 MIME:把 <link rel="stylesheet" href="styles.ts">
写成 .ts
TypeScript 直接打进产物,但忘记改扩展名,Chrome 控制台狂吐:
python
Resource interpreted as Stylesheet but transferred with MIME type text/typescript
CI 上补了一道关卡:
yml
# .github/workflows/lint-assets.yml
- name: Ensure css extension for stylesheets
run: |
if grep -r '<link.*href.*\.ts' build/; then
exit 1
fi
9. 语义化缺失:div soup
让「一键朗读」读不出结构
我们把 <div class="header">
直接转成 <header>
,同理 footer、nav、aside 都规范化。这一步我们没在本地脚本里折腾,而是直接改 Markdown → HTML 的 Remark 插件:
js
// remark-semantic-layout.js
function plugin() {
return tree => {
visit(tree, 'element', node => {
if (node.properties.className?.includes('header')) node.tagName = 'header'
if (node.properties.className?.includes('footer')) node.tagName = 'footer'
})
}
}
10. 表格里再塞 <ul>
,屏幕阅读器直接失声
老 Wiki 最过分的是「功能清单」列里直接插 <ul>
,无障碍测试直接报「不可导航」。解决思路:把列表平铺成多行列,或改成 Collapsible 摘要。我们选了后者:
html
<details>
<summary>功能清单(点击查看)</summary>
<ul>
<li>Markdown 渲染</li>
<li>全文搜索</li>
</ul>
</details>
自动化:一个脚本全干完
最后,我们汇总了所有修复到单一 Node 任务:
json
// package.json
{
"scripts": {
"fix": "node tasks/fix-html.js"
}
}
在 GitHub Actions 里:
yml
- uses: actions/checkout@v4
- name: Lint & Fix HTML
run: |
npm ci
npm run fix
- uses: stefanzweifel/git-auto-commit-action@v5
举一反三:三个变体场景
- CMS 模板市场 为 WordPress 批量修复器:把文章 HTML 导出后,运行同样的脚本,再写个 WP-CLI 命令批量回写。
- 邮件模板 Email 客户端对语义化极不友好,可反向把
<section>
→<table>
,脚本里加「email」开关。 - SSR 站点迁移 Next.js 的
getStaticProps
阶段引入cheerio
解析并修复旧数据,直接嵌入构建流水。
我们把全部脚本挂在了 GitHub:mycorp/html-guardian ,拿下来直接 npm install && npm run fix
,你的历史包袱就跟我这一样清得干干净净。祝你别再被「看似无害」的 HTML 坑到上线。