Enforcing a style rule with a linter that actually fails the build
Enforcing a style rule with a linter that actually fails the build
通过 Linter 强制执行样式规则并导致构建失败
Background
I run a fleet of static sites that publish new content every day, mostly unattended. One of the house style rules is simple: no emoji anywhere in our own copy. That rule is impossible to hold by hand. A single site builds a few hundred HTML files, and emoji can slip into nav icons, button labels, <title>, the RSS feed, or JSON-LD (the JSON-formatted metadata embedded in a page to describe its structure to search engines). Nobody is going to review all of that before every deploy. So I wrote emoji-lint, a check that exits 1 the moment it finds a single emoji. It sits in the pre-deploy gate, which means a failure stops that day’s publish. This post is not about the regex. It’s about what happens when you put a failing check into real operation: you immediately discover the places where the rule must not apply.
背景
我运营着一批静态网站,每天都会自动发布新内容,基本无需人工干预。其中一条内部样式规则很简单:我们自己的文案中严禁出现任何 Emoji。这条规则靠人工是无法维持的。单个网站构建出的 HTML 文件多达几百个,Emoji 可能会溜进导航图标、按钮标签、<title>、RSS 订阅源或 JSON-LD(嵌入页面中用于向搜索引擎描述结构的 JSON 格式元数据)中。在每次部署前,没人能检查完所有这些内容。因此,我编写了 emoji-lint,这是一个一旦发现单个 Emoji 就会返回退出代码 1 的检查工具。它位于部署前的门控环节,这意味着一旦检查失败,当天的发布就会停止。这篇文章不是关于正则表达式的,而是关于当你将一个会触发失败的检查投入实际运行时会发生什么:你会立即发现那些规则不应适用的场景。
How it works
The core is unremarkable. A regex holds the emoji code point ranges, the scanner walks each file line by line, and matching lines are reported as JSON.
const EMOJI_RE = /[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{1F1E6}-\u{1F1FF}\u{FE0F}\u{200D}\u{2049}\u{203C}\u{2122}\u{2139}]/u;
\u{FE0F} (variation selector) and \u{200D} (ZWJ) are in there because emoji are not always a single code point. Arrows and similar symbols used in ordinary technical writing are deliberately left out. Catch everything and the check drowns in false positives, at which point people stop reading it. The interesting part came later. Three categories of content look exactly like a violation but must not be treated as one:
- Verbatim quotes from other people
- Real proper nouns whose official spelling contains a symbol
- Passages where the emoji itself is the subject being explained
Delete the emoji in any of those and you break something more important than the style rule. One term up front: “masking” here means replacing a range with spaces so the scanner cannot see it. Nothing is deleted from the file.
工作原理
其核心部分平淡无奇。一个正则表达式包含了 Emoji 的码点范围,扫描器逐行遍历每个文件,并将匹配的行以 JSON 格式报告出来。
\u{FE0F}(变体选择符)和 \u{200D}(零宽连接符)包含在内,因为 Emoji 并不总是由单个码点组成。普通技术写作中使用的箭头和类似符号被特意排除在外。如果什么都抓,检查就会淹没在误报中,届时人们就不会再理会它了。有趣的部分在后面。有三类内容看起来完全像是违规,但绝不能被视为违规:
- 他人的逐字引用
- 官方拼写中包含符号的真实专有名词
- 以 Emoji 本身为解释对象的段落
删除其中任何一个 Emoji 都会破坏比样式规则更重要的东西。先定义一个术语:“掩码(masking)”在这里指用空格替换某个范围,使扫描器无法看到它。文件中的任何内容都不会被删除。
Implementation
Scope exclusions to an explicit marker, never to a CSS class. Quotes are excluded only when the element carries data-quote="verbatim".
function maskVerbatimQuotes(text) {
let masked = 0;
const out = text.replace(VERBATIM_RE, (m, open, tag, inner, close) => {
masked++; // keep newlines, blank everything else -> line numbers stay intact
return open + inner.replace(/[^\n]/g, " ") + close;
});
return { text: out, masked };
}
The obvious alternative is to exclude anything with a quote-ish class such as .quote. I rejected it. If a cosmetic class buys you an exemption, anyone who wants an emoji just adds that class, and the rule is gone. Excluding only on a marker that declares intent keeps the escape hatch narrow. inner.replace(/[^\n]/g, " ") preserves newlines on purpose. Line numbers found in the masked text map straight back to the original file. Shrink the text and every reported line number is off, which makes the report useless for actually fixing anything.
实现
将排除范围限制在显式标记上,绝不要使用 CSS 类。只有当元素带有 data-quote="verbatim" 时,引用才会被排除。
显而易见的替代方案是排除任何带有类似 .quote 类的元素。但我拒绝了。如果一个装饰性的类就能换取豁免权,那么任何想要使用 Emoji 的人只需加上那个类,规则就失效了。仅在声明意图的标记上进行排除,可以将“逃生舱”保持在很小的范围内。inner.replace(/[^\n]/g, " ") 特意保留了换行符。掩码文本中发现的行号可以直接映射回原始文件。如果缩减文本,报告的每个行号都会出错,这会使报告在实际修复问题时毫无用处。
Proper nouns can’t be covered by a marker
The second case needed a different unit. Take the manga title ラブ★コン: the ★ (U+2605) is part of the official title. Rewrite it as ラブコン and the title is simply wrong. The catch is that this string also expands into <title>, meta description, JSON-LD, and even href="/tags/ラブ★コン.html". An element-level attribute cannot cover all of that. So for proper nouns, the unit of exclusion is the name itself:
{ "names": ["ラブ★コン", "聖☆おにいさん", "うたの☆プリンスさまっ♪", "ニドラン♀"] }
Those exact strings are blanked before the scan. There’s a guard against the obvious abuse: an entry consisting only of symbols (a bare ★, for instance) is rejected with exit 2. Allowing ”★ is always fine” would defeat the point of scoping to names.
专有名词无法通过标记覆盖
第二种情况需要不同的处理单元。以漫画标题《ラブ★コン》为例:其中的 ★ (U+2605) 是官方标题的一部分。如果将其重写为《ラブコン》,标题就错了。问题在于,这个字符串还会扩展到 <title>、元描述、JSON-LD,甚至 href="/tags/ラブ★コン.html" 中。元素级别的属性无法覆盖所有这些情况。因此,对于专有名词,排除的单位就是名称本身。
在扫描之前,这些确切的字符串会被清空。针对明显的滥用行为有一个防护措施:仅由符号组成的条目(例如单独的 ★)会被拒绝并返回退出代码 2。允许“★ 总是没问题”会违背将范围限制在名称上的初衷。
Numeric character references were invisible to the scanner
The third finding wasn’t an exception at all, it was a hole. emoji-lint scans raw HTML, so it could not see a single emoji written as 📚 (📚). The browser decodes it and renders the emoji, which produced the worst possible state: emoji live in production while the check reports green. Measured: one site had 26 such instances in production (13 of them the 📚 in a favicon), another had 10 across 8 places in its Japanese and English pages (✓ ✓ and ✗ ✗). Both were reporting zero findings.
The fix is to decode, before scanning, only those numeric references whose code point falls in an emoji range. Structural references like <, >, and & are left untouched. Order matters: decode first, mask second. Masking inspects tag and attribute structure, so restoring emoji entities beforehand does not disturb it. Reverse the order and an emoji written as an entity inside a quote escapes the exclusion and gets flagged.
数字字符引用对扫描器不可见
第三个发现根本不是例外,而是一个漏洞。emoji-lint 扫描的是原始 HTML,因此它无法识别写成 📚 (📚) 的 Emoji。浏览器会对其进行解码并渲染出 Emoji,这导致了最糟糕的情况:生产环境中存在 Emoji,而检查却报告通过。经测量:一个网站在生产环境中有 26 个此类实例(其中 13 个是 favicon 中的 📚),另一个网站在其日语和英语页面的 8 个位置有 10 个(✓ ✓ 和 ✗ ✗)。两者都报告没有发现问题。
修复方法是在扫描前,仅对码点落在 Emoji 范围内的数字引用进行解码。像 <、> 和 & 这样的结构性引用则保持不变。顺序很重要:先解码,后掩码。掩码会检查标签和属性结构,因此预先还原 Emoji 实体不会干扰它。如果颠倒顺序,写在引用内部的实体形式 Emoji 就会逃脱排除范围并被标记出来。
Gotchas
Never exclude silently. This turned out to be the single most valuable decision. Adding exclusions naturally makes the check easier to pass. If you can’t see that it got easier, you eventually arrive at a check that inspects nothing while still reporting success. So every exclusion is counted in the output:
{
"ok": true,
"count": 0,
"quotedSkipped": 0,
"sampleSkipped": 0,
"nameSkipped": 0,
"namesUsed": {},
"entityDecoded": 0,
"namesUnused": ["ラブ★コン", "聖☆おにいさん", "ニドラン♀", "ニドラン♂"]
}
That is a real run against one site’s public/ directory. count: 0 means no violations; the zeroes elsewhere mean this particular site has nothing to exclude. Had it printed nameSkipped: 117, you would know the check passed after skipping 117 spots. “Clean” and “looked at nothing” become distinguishable. namesUnused runs the other direction: allow-list entries that never matched. Titles that are no longer published pile up, and a growing allow list drifts toward being an escape hatch, so this is the cleanup signal.
The payoff is measurable. Before the proper-noun handling, one manga-deals site reported 97 false positives. When 97 lines are permanently red, a genuine violation hiding among them is invisible; the check ran but did not function. Afterward: zero false positives, with exclusions limited to 117 occurrences.
陷阱
永远不要静默排除。事实证明,这是最有价值的决定。添加排除项自然会让检查更容易通过。如果你看不出它变得更容易了,最终你会得到一个什么都不检查却依然报告成功的检查工具。因此,每一个排除项都会在输出中被统计:
这是针对某个网站 public/ 目录的真实运行结果。count: 0 表示没有违规;其他地方的零表示该特定网站没有任何需要排除的内容。如果它打印出 nameSkipped: 117,你就会知道检查是在跳过了 117 个位置后通过的。“干净”和“什么都没看”变得可以区分了。namesUnused 则指向另一个方向:从未匹配过的白名单条目。不再发布的标题会堆积起来,不断增长的白名单会逐渐变成一个逃生舱,所以这是清理信号。
回报是可衡量的。在处理专有名词之前,一个漫画交易网站报告了 97 个误报。当 97 行永久显示为红色时,隐藏在其中的真正违规行为就不可见了;检查虽然运行了,但没有发挥作用。之后:误报为零,排除项限制在 117 处。