Enforcing Content Tone With a Lint That Fails the Build
Introduction
I run about twenty static sites on an automated pipeline. Article data lives in JSON, and a dependency-free build.js emits the HTML. Since updates run unattended, most of the writing is done by machines too. That created a problem I did not anticipate: tone drifted. I had a rule that these sites use no emoji. Yet every run slipped one in somewhere. A heading here, an FAQ answer there. I would remove them, and the next day they showed up somewhere else. Trying to hold a style rule in place through review does not scale. So I turned it into a lint that fails the build.
Architecture
The shape is simple: walk the files, report any line containing an emoji, and exit non-zero if there is even one.
update data
โ
emoji-lint โ exits 1 if a single emoji is found
โ
build.js (generate site)
โ
deploy
The important part is failing via the exit code. A lint that only prints warnings does not get read. Once the following steps stop running, the rule starts to mean something.
The core of the implementation
It is one regular expression plus a loop over every line.
// Main ranges for emoji, pictographs, regional indicators, and variation selectors.
// (Arrows and similar symbols used in real content are deliberately excluded
// to avoid false positives.)
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;
Range selection followed a deliberate policy. \u{2600}-\u{27BF} contains pictographs, but banning the whole block also catches arrows and other symbols. False positives on characters people legitimately use are how a lint ends up disabled. I optimized for zero false positives and accepted that a few cases slip through to human review.
Including \u{FE0F} (variation selector) and \u{200D} (ZWJ) matters. Emoji are not always a single code point; many are composed as "base character + FE0F" or "emoji + ZWJ + emoji". Matching on the selectors catches that whole family of compositions.
Findings are reported as JSON with line numbers.
const lines = text.split("\n");
lines.forEach((line, i) => {
if (EMOJI_RE.test(line)) {
findings.push({
file,
line: i + 1,
text: line.trim().slice(0, 200)
});
}
});
emit({ ok: findings.length === 0, count: findings.length, findings });
process.exit(findings.length === 0 ? 0 : 1);
Truncating text at 200 characters keeps a single long line from flooding the log. File and line number are enough to locate the problem.
What bit me
An extension-matching bug made the lint silently pass everything. I added --ext to narrow the scan. path.extname() returns the extension with a leading dot, like ".html". But on the calling side you naturally want to write --ext html, without one. Compare those directly and:
// Bug: with --ext html, nothing matches ".html", so zero files are scanned
if (!exts || exts.includes(path.extname(target))) out.push(target);
exts was non-empty but matched nothing, so zero files scanned, zero findings, ok: true. The lint kept reporting success. Emoji were getting through while it said everything was fine. The fix is to normalize the input.
const exts = args.ext
? String(args.ext)
.split(",")
.map((e) => e.trim())
.filter(Boolean)
.map((e) => (e.startsWith(".") ? e : `.${e}`))
: null;
The lesson: a lint that cannot distinguish "found nothing" from "looked at nothing" is dangerous. Printing the number of files scanned alongside a pass would have surfaced the empty scan immediately. I now read the count together with the findings.
One more thing: skipping node_modules and .git is essential, or you drown in hits from dependency READMEs.
for (const name of fs.readdirSync(target)) {
if (name === "node_modules" || name === ".git") continue;
out.push(...listFiles(path.join(target, name), exts));
}
The result
One of the sites running on this pipeline: https://cve.autoarticles.net. It covers vulnerability information and uses no emoji at all. A serious subject deserves a serious presentation, and that presentation is now enforced mechanically.
Conclusion
Consistency in tone and style will always leak if you rely on review. If a rule can be decided mechanically, write the code that decides it and fail on violations. But the lint itself can break and still report success. The extension bug was exactly that. Verifying that a check can detect violations matters as much as verifying that it is actually looking. After writing a lint, deliberately introduce a violation once and confirm it fails.
This article is about my own side project. It was written with AI assistance.
Comments
No comments yet. Start the discussion.