Notifio has a house rule about punctuation: no long dashes in anything a user reads. Commas, colons, or a second sentence, but never that character. The reason is not grammatical. It reads as a machine-written tell, and the product's whole claim is that a person thought about what it tells you.
I have now written two posts about enforcing that rule.
The first was Six places a copy rule has to reach, and not one of them is a page. Its point was that page copy is the easy part, and the rule also has to reach a Stripe line item, an email subject, the plain-text half of an email, an Electron window title and a few other places nobody proofreads.
The second was Six HTML emails, five plain-text twins, and the one without a twin carries the activation code. While auditing the email templates for that one I found ten instances hiding behind an HTML entity, and wrote this sentence:
the templates contain ten
—entities and one literal em dash, in a product whose copy rule is that user-facing text does not use them. The website follows that rule. The emails, written earlier, quietly do not.
The count was right. The sentence after it was not.
Six of them, on three pages, in production right now
# $'\u2014' is the shell writing the glyph so this post does not have to
$ curl -s https://notifio.app/alerts | grep -o $'\u2014' | wc -l
$ curl -s https://notifio.app/guides | grep -o $'\u2014' | wc -l
$ curl -s https://notifio.app/compare | grep -o $'\u2014' | wc -l
Each of those returns a non-zero number. Every occurrence is doubled in the response, because Next.js serialises the same text into the flight payload alongside the HTML, so the visible count is two per page. Six in total, on the three section index pages:
// src/app/alerts/page.tsx
because we have monitored them ourselves — if yours is missing, it very probably
// src/app/guides/page.tsx
from somewhere — a portal’s own documentation, a fraud helpline, a market
report — the source is linked at the bottom of the page so you can check it.
// src/app/compare/page.tsx
the other tool is the better buy — because for some people it is.
Those are the hub pages for the alert site catalogue, the guides and the comparisons. Three of the most linked pages on the site, and they have been rendering the one character the rule forbids for as long as the rule has existed.
Why two audits walked straight past them
The sweep in the first post was a grep. A grep for the character.
$ grep -rn $'\u2014' src/
That command is correct and it is also the whole bug. — does not contain the character. Neither does —, nor —. They are four different byte sequences that a browser renders as one glyph, and a search for any one of them finds exactly one of the four.
Running that grep today returns 26 files, which sounds alarming and mostly is not. Filter out the comment lines and three survive:
src/lib/emails/activation.ts:37 Keep this email safe <dash> you'll need this token ...
src/lib/stripe/checkout.ts:157 "[checkout] Managed Payments session rejected <dash> retrying ..."
src/lib/stripe/checkout.ts:160 "Managed Payments checkout rejected <dash> retried without MoR"
I am writing the glyph as <dash> there so that this post does not contain it either. The two in checkout.ts are a console line and a Sentry message, which are out of scope along with comments. The one in activation.ts is a sentence in the activation email, and it is the same literal instance the email post counted. Nothing has been edited since. The emails still hold ten entities and that one literal, exactly as reported.
So the comment hits are the overwhelming majority of what the literal-character search returns, and the one real hit in it is a case somebody had already found by other means. The check is loud where it does not matter and silent where it does, which is close to the worst possible shape for a check.
The second audit had a different failure. It was looking at src/lib/emails/, found the entity form, counted it correctly inside those three files, and then asserted something about the rest of the repository without searching the rest of the repository. The count of ten was a measurement. "The website follows that rule" was a memory of having swept it once, with the wrong pattern, weeks earlier.
How those three pages ended up different
This is not random. Look at what else is on those lines:
$ grep -ro "'\|’" src/ | wc -l
31
Those three hub pages were written in an entity style: ’ for apostrophes in prose, ' in attributes, and — for the dash, which is a perfectly ordinary way to write JSX and is in fact what some linters push you towards for unescaped entities. The pages that came later were written with plain characters.
So the codebase contains two typographic conventions, split by when the file was written, and the enforcement grep only understands one of them. The generated child pages are clean, because their copy lives in the data catalogues that the original sweep did cover:
$ curl -s https://notifio.app/alerts/kamernet | grep -o $'\u2014' | wc -l
0
I checked all twenty-eight of them. Fifteen alert pages, five comparison pages, three audience pages and five guides, every one clean. Three hand-written index pages, not clean. The derived content is more consistent than the hand-written content, which is an argument for deriving content that I keep finding in different forms.
The check that would actually work
The fix for the six instances is six edits. The fix for the class of bug is a different kind of check, and there are three options with quite different costs.
Search for all the spellings. One alternation instead of one character:
$ grep -rnE "$(printf '\u2014')|—|—|—" src/
Cheap, and still wrong in the same way the original was: it is a list of spellings somebody thought of. — with a capital X is valid HTML. So is —. A denylist of encodings has the same problem as any denylist, which is that it is complete until it is not.
Normalise, then search. Decode HTML entities in the source and then look for the character. Better, because it collapses every spelling into one, and it needs a parser that knows which parts of a .tsx file are text.
Check the rendered output. The only place where all four spellings have already become one glyph is the HTML a browser receives. This site already has a test file that walks every route and asserts things about its metadata, run by node --test with no framework, which I wrote about in Our title template spends 10 of our 60 characters, so a test counts them instead of us. A punctuation assertion belongs in the same place, against rendered output rather than source:
for (const route of ALL_ROUTES) {
const html = await render(route);
assert.equal(visibleText(html).includes("\u2014"), false, `${route} renders a forbidden dash`);
}
The visibleText step is the part with actual work in it, because a <script> tag full of flight payload will contain the character legitimately and stripping tags naively will flag it.
I am going to do the third one, and the reason is the thing I keep relearning: the resolution of a rule is the resolution of the check behind it. A rule enforced by eye holds where somebody has looked. A rule enforced by a grep for one byte sequence holds against that byte sequence. Only a check that sees what the reader sees holds against what the reader sees.
The uncomfortable part
Two posts about enforcing this rule, one of which was specifically about the places you forget to look, and the pages I used as examples of a clean result in that post were the Kamernet page, the Visualping comparison and the help page.
All three of those are genuinely clean. Their parent index pages, one click up, are not. I picked the examples by opening the pages I had just edited.
If you have a house rule like this, the question worth asking is not whether you follow it. It is what your check would have to be fooled by, and then whether anything in your codebase is already shaped that way.
Top comments (0)