DEV Community

Daniel Pertu
Daniel Pertu

Posted on

One punctuation mark is banned from our copy, three unrelated tests enforce it, and the app with most of the words in it is not one of them

Munchable reads a food label and tells you whether a product suits a digestive condition you have. Most of what it ships is sentences: eight condition guides, hundreds of answer pages, a terms and a privacy notice, the copy in the app, the emails it sends.

House style for all of it bans the em dash. You can argue about whether that is a good rule. What interested me is that it turned out to be an unusually hard rule to hold, and that holding it took three tests written in three different styles, none of which knows the other two exist.

Why a grep at review time is not enough

The reason is not discipline. It is that the words on one page arrive from several places at once. A condition guide's text comes from JSX, its title and description come from a metadata helper, some of its strings are in a shared content module, its structured data stringifies a few of the same values, its image alt text is written somewhere else again, and the emails have their own layout module. A reviewer looking at a diff sees one of those at a time.

So the rule became a test. Its header says the problem plainly:

 * House style: no em dashes in anything a visitor can read. The rule is easy to
 * re-break, because copy reaches a page from several places at once (JSX text,
 * metadata and JSON-LD strings, alt text, the shared legal notices, the emails a
 * script sends), so a grep at review time is not enough. This test walks the
 * source of every directory that can produce visible words and fails on the
 * character itself.
Enter fullscreen mode Exit fullscreen mode

63 lines, one test() call, no framework beyond node:test. It walks five directories:

// Every directory whose source can end up as words on a page or in an email.
const VISIBLE_DIRS = ['app', 'components', 'lib/content', 'lib/email', 'scripts'];

const SOURCE_EXTENSIONS = ['.ts', '.tsx', '.mts', '.mjs', '.txt'];

const EM_DASH = '—';
Enter fullscreen mode Exit fullscreen mode

That is 121 files and about 17,300 lines on the current tree. The assertion at the bottom is a deepEqual against an empty array rather than a boolean, for the only reason that ever matters in a test like this: when it fails, it prints every offending file:line with the line's text, so the fix is mechanical.

scripts in that list is the surprise, and it is there because of history, which I will come back to.

Comments are exempt, and that needs a state machine

The decision that makes the test usable is that comments are exempt. This repository's comments are long, argumentative, and full of em dashes, because prose about code is not copy. Only rendered strings matter.

Being line-based rather than parsing the file means exempting a comment is slightly more than a startsWith check, because block comments have bodies:

/** Lines that are wholly comment, including the body of a block comment. */
function isComment(line: string, inBlock: boolean): boolean {
  const trimmed = line.trim();
  return inBlock || trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*');
}

function offences(file: string): string[] {
  const found: string[] = [];
  let inBlock = false;
  const lines = readFileSync(join(ROOT, file), 'utf8').split('\n');
  for (const [index, line] of lines.entries()) {
    const wasInBlock = inBlock;
    const trimmed = line.trim();
    if (trimmed.includes('/*') && !trimmed.includes('*/')) inBlock = true;
    else if (trimmed.includes('*/')) inBlock = false;

    if (!line.includes(EM_DASH) || isComment(line, wasInBlock)) continue;
    found.push(`${file}:${index + 1}: ${trimmed}`);
  }
  return found;
}
Enter fullscreen mode Exit fullscreen mode

The wasInBlock capture is the detail I would have got wrong on the first attempt. The state has to be read as it was at the start of the line and updated afterwards, because the line that opens a block comment is itself a comment line, and so is the line that closes it. Flip those two and the test either exempts a line of real code or flags a line of prose.

It is deliberately not a parse. The comment says why: "The check is deliberately line-based rather than a parse, so it stays cheap and needs no build step." It runs in milliseconds over the whole tree, as part of a plain node --test run, with nothing to configure. The price is that it is slightly wrong in both directions. A trailing /* comment */ on a line of code is not exempt, so an em dash in one would be flagged. A string containing */ could confuse the state. Neither has happened, and both would fail loudly rather than quietly, which is the right way round for this trade.

My favourite consequence of the comment exemption is this line, which sits directly above the shared user-facing notices in the product lookup module:

// Shared response copy (plain punctuation — user facing).
Enter fullscreen mode Exit fullscreen mode

The comment announcing that the strings below it use plain punctuation contains an em dash. It is entirely legal, because comments never render, and I am leaving it exactly where it is.

The test came second, which is the normal order

The commit that added this test is called "Remove em dashes and add copy-style test", and the removal half touched four files: one page, and three operator scripts.

That ratio is the whole argument for the directory list. The page was the obvious offender. The scripts were a reminder that "copy" is not the same as "the website": one of those three sends an email to users when the terms change, and the characters in its sentences reach a person's inbox with no page involved. A test scoped to app and components would have passed on the day the rule was written while the rule was being broken in three files.

The second enforcer checks data, not source

The recipe library is a data module: structured objects with titles, summaries and steps. A character scan over its source would work, but the test that lives next to it does something more useful, because once you are iterating recipes you may as well assert everything house style has an opinion about:

/** House style (docs, and the app's copy rules): no em dashes, no emoji. */
forEachRecipe('copy follows house style', (r) => {
  const copy = [r.title, r.summary, ...r.steps];
  for (const text of copy) {
    assert.ok(!text.includes('—'), `"${text}" contains an em dash`);
    assert.ok(!/\p{Extended_Pictographic}/u.test(text), `"${text}" contains an emoji`);
  }
});
Enter fullscreen mode Exit fullscreen mode

forEachRecipe turns that into one named test per recipe, so a failure names the dish rather than the library. It also sits beside a test asserting that recipe copy makes no health outcome claims, which is the same shape of check with much higher stakes, and sharing a helper with the punctuation test is how the serious one stays maintained.

Note the second assertion. The emoji half of house style is enforced here, on 38 recipes, and nowhere else in the repository.

The third enforcer checks output, which the first one structurally cannot

Prices are formatted per currency, and two pieces of copy are built at runtime rather than written down:

// Munchable's user-visible copy avoids em dashes.
test('no em dashes reach the page', () => {
  assert.ok(!VAT_NOTICE.includes('—'));
  assert.ok(!priceChangedNotice(PREMIUM_PRICE.unitAmount, PREMIUM_PRICE.interval, 'gbp').includes('—'));
});
Enter fullscreen mode Exit fullscreen mode

This is the one the character scan cannot replace. priceChangedNotice assembles a sentence out of a template, a formatted amount and an interval word. Scanning its source tells you about the template. It tells you nothing about what the function returns for a currency whose formatting inserts something unexpected, and the sentence is what a person reads. Source scanning and output assertion are different guarantees, and this file happens to need the second one.

Three enforcers, then, in three repository packages, with three techniques: source, data, output. None imports anything from the others. Each was written where a problem was, which is how you get coverage that works and a rule with no single owner.

The gap, which is embarrassing when you say it out loud

The mobile app has 103 TypeScript source files and no copy style test at all.

That is the surface where somebody with a gut condition actually reads sentences: the result screen, the condition picker, the onboarding, the safety notices. The website is marketing and legal text. The app is the product. The rule is enforced on the half with fewer readers.

It is clean today. I ran the same two state scanner over apps/mobile while writing this, and it reports zero offences across all 103 files, which is a pleasant result and exactly as reassuring as any other measurement of a thing nothing is holding in place. The reason it is clean is that the strings live mostly in one small module and I wrote them after the rule existed.

For completeness I ran it over packages too, and the only hits are four lines inside test files: three of them are the assertions quoted above, which contain the character because they are looking for it, and one is a test name. Which is a neat demonstration that a character scan cannot tell a rule from its violation.

The one line that genuinely escapes

Outside the five scanned directories there is exactly one non-comment line in the repository with an em dash in it:

console.error(
  `[webhook-idempotency] Redis not configured — cannot de-duplicate ${provider} event.`,
);
Enter fullscreen mode Exit fullscreen mode

A log line, in a module no scan covers, read by me and by an error tracker. I am not going to pretend that is a copy defect. I am including it because it is the exact question the directory list exists to answer, and the answer in this one case is "that is not copy", which is a judgement, not a rule.

What is still wrong with this

  • The app is unguarded. 103 files, the majority of the sentences a user reads, no test. The fix is five lines in the mobile package reusing the same scanner, and the only reason it has not happened is that the rule was written on the web side and nothing failed.
  • The emoji rule is enforced on 38 recipes. It is a repository-wide house rule with one test behind it, and that test is not in the place the rule is most likely to be broken.
  • The rules engine writes sentences too. The reason lines a user reads under a result come from the engine package, which none of the three tests scan. Clean today, unguarded today.
  • Nothing checks the rendered page. Everything here reads source or calls a function. An em dash arriving through a dependency, a stored string, or data that was curated rather than typed, would reach a page with three green tests behind it.
  • It is one character. The rule in my head is "plain punctuation", which also covers the en dash used as an em dash, and a few other things. The test knows about one codepoint.

Read the output and look for the character

The pages where all of this ends up are public, so you can check the result rather than taking my word for it: the recipe library, which is the one body of text with both halves of house style enforced on it, and any recipe in it; the condition guides, which are the longest prose the scan covers; and the terms and privacy notice, which are the pages where punctuation style and legal clarity have to coexist. The pricing notices that the third test checks are on the homepage.

If you find an em dash on any of those, you have found something three tests agreed was impossible, and I would genuinely like to know.

Related: 605 label words are a promise our test suite enforces, which is the same idea applied to something that matters more than punctuation, and twenty lines of our copy spell out eight conditions, and the one that still says seven is the description Google shows, which is what happens when copy has no test at all.

Top comments (0)