Our house style is a 63-line unit test, and the first thing it caught was an email

typescript dev.to

Munchable has a house style rule: no em dash (U+2014) in anything a visitor can read. Commas, full stops, or a rewritten sentence. The rule is easy to agree with and easy to re-break, because copy reaches a page from more places than the page. So it is enforced the same way the SEO limits are: as a test that runs with everything else.

This post is about what a 63-line test has to get right to be worth having, and what it found on its first run.

Where visible words come from

The naive version is a grep over app/. It misses most of the places a dash can reach a reader:

  • metadata and JSON-LD strings, which surface in search results and social cards
  • alt text on images
  • the shared legal notices, which are rendered into every page's footer
  • the emails a script sends, which are read in an inbox rather than on a page
  • the rules engine's own reason messages, which the result screen prints verbatim

So the test declares the directories whose source can end up as words, and walks all of them:

// 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 = '\u2014';
Enter fullscreen mode Exit fullscreen mode

In the repo that last line holds the literal character. The directory walk skips __tests__, which is what lets the test hold the character it forbids without failing itself.

Comments are exempt, and that takes a state machine

Comments never render, and the codebase's comments are long and argumentative, so banning the character from them would be a different rule. The check is line-based rather than a parse, to stay cheap and need no build step, which means it has to track block comments by hand:

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 detail that matters is wasInBlock. The state is captured before the line is classified, so the opening /** line is judged by its own prefix and the closing */ line still counts as comment. Get that wrong and the last line of every doc block is a false positive.

The assertion collects every offence with a file and line number, so one run reports the whole set:

test('no em dashes in visible copy', () => {
  const found = VISIBLE_DIRS.flatMap((dir) => sourceFiles(dir)).flatMap(offences);
  assert.deepEqual(found, [], `em dashes found:\n${found.join('\n')}`);
});
Enter fullscreen mode Exit fullscreen mode

What the first run found

Four offences, and the interesting thing is where they were.

One was a button label on the post-signup page, "Not now, take me to the app", which had a dash in the middle. That one a grep over app/ would have caught.

One was in the body of the email that goes out when the terms change. It lived in scripts/, not in a page, and it is read in an inbox. A user who never opens the site again still receives it. That is the case that justified putting scripts and lib/email in the list.

Two were log lines in a curation script that prints decisions for the operator. Arguably not visible to a visitor, but the directory is in the list because the same script sends mail, and a rule with exceptions is a rule that needs a second test.

Every replacement was a comma or a full stop, never a hyphen, because a hyphen in the same position reads as a typo.

The boundary

The test covers the web app. It does not walk the rules engine package or the mobile app, which are separate workspaces with separate runners. The engine's reason strings do reach the answer pages, so that is the known gap, and closing it means the same walk in that package rather than a wider one here. It also does not check emoji, which were swept out of the repo in a separate pass and have not come back.

What it does guarantee is narrow and useful: the character cannot get back into a page, a search snippet, a social card, a legal notice or a transactional email without the test run going red and naming the line.

You can see the result on any page of munchable.app. The legal notices in the footer, the privacy policy, the support form, and the emails it sends are all under this test. If you sign up, the post-signup page shows the button that was the first line item on the list.

Source: dev.to

arrow_back Back to Tutorials