DEV Community

takahiro hashito
takahiro hashito

Posted on

Enforcing a style rule with a linter that actually fails the build

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.

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;
Enter fullscreen mode Exit fullscreen mode

\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:

  1. Verbatim quotes from other people
  2. Real proper nouns whose official spelling contains a symbol
  3. 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.

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 };
}
Enter fullscreen mode Exit fullscreen mode

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.

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": ["ラブ★コン", "聖☆おにいさん", "うたの☆プリンスさまっ♪", "ニドラン♀"]
}
Enter fullscreen mode Exit fullscreen mode

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.

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 &#128218; (📚). 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 (&#10003; ✓ and &#10007; ✗). 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 &lt;, &gt;, and &amp; 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.

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": ["ラブ★コン", "聖☆おにいさん", "ニドラン♀", "ニドラン♂"]
}
Enter fullscreen mode Exit fullscreen mode

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 across two registered titles, and genuine emoji detection intact.

One more near-miss. For the "emoji as subject matter" case, my first instinct was to blanket-exclude <code> and <pre>. I dropped it: decorative emoji that happen to land in a code block would sail straight through. The measurement backed this up. On one site, of 417 detections, 54 (across 8 files) were genuinely the subject matter and 160 were plainly decorative. Blanket-deleting and blanket-excluding were both wrong. Exclusion applies only where the emoji is the topic; icons, nav, and button labels stay violations.

The result

One of the sites this runs against: https://cve.autoarticles.net

Wrap-up

Enforce a style rule mechanically and it will collide with things that matter more than the rule: quotation accuracy, correct proper nouns, an article that still makes sense. You get three options. Refuse exceptions and ship wrong content. Let them leak through cosmetic markers and the rule quietly dies. Or scope exclusions to explicit declarations and always report the counts.

Pick the third and the check stops being a pass/fail light. It becomes a tool that tells you what it looked at and what it deliberately did not. If you intend to keep a failing check in the pipeline for years, that is the information you need. A check that excludes silently will eventually check nothing at all.


This article is about my own side project. It was written with AI assistance.

Top comments (0)