Notifio has a house rule about punctuation: no em dashes in anything a user reads. A few weeks ago I wrote up what it took to enforce that in six places that are not pages: a Stripe line item, an email subject line, the plain-text half of an email, a few others. I was fairly pleased with that pass. It found every place I could think of where a user reads a string that nobody treats as copy.
It searched for the character.
$ cd server
$ grep -rn "—" src --include="*.ts" --include="*.tsx" | wc -l
16
Sixteen em dashes, written as the HTML entity, in a codebase where the rule is that there are none. Here is where they live:
| File | Count | Who reads it |
|---|---|---|
app/alerts/page.tsx |
2 | the supported sites hub |
app/guides/page.tsx |
2 | the guides index |
app/compare/page.tsx |
2 | the comparison hub |
lib/emails/alert.ts |
4 | every listing alert, every auth-failure and blocked notice, every auto-reply receipt |
lib/emails/free-access.ts |
5 | anyone granted a free licence |
lib/emails/activation.ts |
1 | everyone who buys |
Six of them are on pages you can load right now
The three page files are ordinary JSX, so the entity renders as the character and lands in the HTML:
$ curl -s https://notifio.app/alerts | grep -o '.\{40\}—.\{40\}'
ritten a guide for because we have monitored them ourselves — if yours is missing, it ver
es not have per-site connectors that we build one at a time — it loads the URL you give i
ritten a guide for because we have monitored them ourselves — if yours is missing, it ver
es not have per-site connectors that we build one at a time — it loads the URL you give i
Two sentences, four matches, because a React Server Components response carries each string twice: once in the rendered HTML and once in the serialised flight payload at the end of the document. If you want the honest count of what a reader sees, halve it. /guides and /compare each give the same four.
The source line that produces the first one is the paragraph I am most attached to on that page, which is probably why nobody reread it:
// app/alerts/page.tsx
Notifio drives a real browser, so it can watch nearly any site that shows rental
listings as a web page. The sites below are the ones we have written a guide for
because we have monitored them ourselves — if yours is missing, it very probably
still works.
The same sentence, spelled both ways, in two emails
The remaining ten are in email templates, which are template literals of hand-written table HTML rather than JSX. Six of them are the footer line, Notifio — notifio.app, pasted into six different email bodies. The interesting pair is this one. Activation email:
// lib/emails/activation.ts:37
Keep this email safe — you'll need this token if you reinstall the app.
Free-access email:
// lib/emails/free-access.ts:62
Keep this email safe — you'll need this token if you reinstall the app.
Same sentence, same position in the same layout, two spellings of the same character, and a mail client renders both identically. The literal one is the more damning of the two: it is the exact thing the first pass was searching for, in a file the first pass opened, and it survived anyway. One file was read, the other was grepped, and the grep is why I stopped reading.
Why a character search was never going to be enough
Searching for the character in this repo is not a clean signal:
$ grep -rn "—" src --include="*.ts" --include="*.tsx" | wc -l
53
Thirty-seven of those are in JSDoc or // comments, thirteen more are JSX comments like {/* Hero demo — full max-w-6xl width */}, and two are a console.error and a Sentry message. Code comments and log lines are explicitly out of scope for the rule, so fifty-two of fifty-three hits are noise, and the fifty-third is the live one in the activation email. That is a ratio at which a human scrolls.
The entity form never appears in that output at all, and there is no reason it would occur to you to look for it unless you have recently written an HTML email by hand. In JSX, — is what you type when you do not want a bare non-ASCII character in your source. In a template literal of email HTML, it is what you type because there is no JSX escaping to lean on and entities feel safer. Both are reasonable local decisions. Neither is visible to a search for —.
We have a test for the other copy rule
This is the part that annoys me, because the shape of the fix already exists in the repo. Every page on the site has to keep its title under 60 rendered characters and its description under 160, and that is not enforced by remembering:
/**
* Search engines truncate a title past roughly 60 characters and a description
* past roughly 160, so every page has to fit inside those budgets. The budget
* that matters is the *rendered* one: the root layout applies a `%s | Notifio`
* template, which silently spends ten of the sixty on every page but the home
* page. Hand-counting that is what let three titles drift to exactly 60, so it
* is counted here instead.
*/
const TITLE_MAX = 60;
const DESCRIPTION_MAX = 160;
That test reads layout.tsx to extract the live title template, then globs the alert-site directory rather than listing the files, so a new market file is covered the day somebody adds it:
const SITES_DIR = path.join(import.meta.dirname, "../../src/lib/alerts/sites");
const ALERT_SITES = (
await Promise.all(
readdirSync(SITES_DIR)
.filter((name) => name.endsWith(".ts"))
.map((name) => import(path.join(SITES_DIR, name))),
)
).flatMap((module) => Object.values(module).flat());
So the project already knows how to load every string of user-visible copy on the site and assert something about it. It asserts length. It does not assert punctuation. npm test in server/ runs five test files, covering metadata lengths, IndexNow, display currency and two Stripe paths, and not one of them reads a sentence.
One more thing, for the record. The assertion message inside that same file reads:
`${label}: title is ${full.length} chars, must be under ${TITLE_MAX} — ${full}`,
A test failure is not page content, so that one is genuinely fine under the rule. It is still an em dash inside the only file on the project that enforces a copy rule.
What I am changing
A sixth test file, next to metadata-length.test.ts, reusing the same loader. It fails on —, —, — and — across the string fields of every catalogue entry and every email builder, and it reports the offending sentence rather than just the file, because the useful half of the failure is the sentence.
Scoping is the only real design decision in it. Running it over whole files would fail on fifty comment lines that the rule does not cover, and a test that is wrong fifty times out of fifty-three gets an exception list, and an exception list is how the next em dash gets in. Scoping to the catalogue fields and the rendered output of the email builders keeps comments out by construction instead of by exclusion.
Until that lands, the six on the pages are the ones worth looking at, because you can check my work: the supported sites hub, the guides index and the comparison hub each carry two. If you find a seventh, it is spelled some way I have not thought of yet, which is rather the point.
Top comments (0)