DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our copy audit read 56,462 words and found no exclamation marks. The 161 strings it could not see have two.

A while ago I audited every word inside <main> on PubTrivia's 72 public pages: 56,021 words, zero exclamation marks, 855 instances of "you" against 135 of "we". Re-running the count for this post gives 56,462 words and the same zero.

That audit measured rendered HTML, which means it measured the copy that is easiest to measure, easiest to proofread, and read in the calmest moment of a customer's day. The copy people read when something has gone wrong lives somewhere else entirely, so this time I extracted every string literal assigned to an error, message, title, description, subject or label field outside the content pages and the content components.

161 distinct strings, across 52 files:

Where Strings
components 65
dashboard server actions and pages 52
API route JSON bodies 19
utils (Stripe, email, plan limits) 17
auth server actions 10
lib 6
app/manifest.ts, proxy.ts, the small standalone pages 1 to 2 each

None of them is in the sitemap. Most of them are read by somebody who is mid-task and slightly annoyed.

Two strings that arrive on a domain we do not own

The shortest and most consequential entries are in lib/pricing/plans.ts:

/** What Stripe's checkout page and the customer's invoices call each paid plan. */
export const PLAN_PRODUCT_NAMES: Record<PaidPlanType, string> = {
  pro: 'PubTrivia Pro',
  ultimate: 'PubTrivia Ultimate',
}
Enter fullscreen mode Exit fullscreen mode

We deleted our Stripe Products and Prices and build every Checkout line item inline, which means the product name is not configured in a dashboard any more, it is a string in our repository. Those two strings are rendered on checkout.stripe.com, in a layout we do not control, next to the only number in this business that really matters. They also land in the invoice Stripe emails afterwards, which is a document a venue's accountant may read years from now.

For something with that reach it is a remarkably thin piece of copy. It is the right call (a plan name on a card statement should be boring and recognisable, not clever) but it is worth noticing that the words a customer reads at the moment of paying were never reviewed by anything that reviews our page copy.

Nineteen strings that arrive as JSON

Our API routes answer machines most of the time and people some of the time. The strings in them are written for both, inconsistently.

Some are purely diagnostic, which is correct, because the only caller is Stripe or Resend:

return NextResponse.json({ error: 'Missing stripe-signature header' }, { status: 400 })
return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 })
Enter fullscreen mode Exit fullscreen mode

Some are read by a person, through our own dashboard, with no page between them and a human:

return NextResponse.json({ error: "You're already on this plan." }, { status: 409 })
Enter fullscreen mode Exit fullscreen mode

And one of them, in the checkout route, is the only string in the entire product that hands the reader off to a human being:

error: "Your plan is billed in a currency we can't change plans in. Email hello@pub-trivia.app and we'll change it for you."
Enter fullscreen mode Exit fullscreen mode

That is a 115-character apology for a real constraint: a subscription's currency is fixed by Stripe at the moment it is created, so a venue that signed up in one currency cannot be moved to a plan priced in another one by any automated path we have. I like this string. It admits a limit, it does not pretend the limit is a feature, and it gives a next step. It is also the only one of the 161 with an email address in it.

The proxy has its own, sent to anything that trips the global rate limiter without asking for HTML:

return NextResponse.json(
    { error: 'Too many requests. Please slow down.' },
    { status: 429, headers: { 'Retry-After': '60' } }
)
Enter fullscreen mode Exit fullscreen mode

Browsers get redirected to a page instead, which is a nicer experience and also means the same condition is described twice, in two voices, in two places. The same is true of the maintenance message, which is one exported constant read by the proxy for the 503 body and by four auth server actions for the form error, which is the version of this problem I would like the rest of them to look like.

The email is written twice

Our only transactional email is a host invite, and it exists as an HTML part and a plain text part, so every sentence in it is written twice by hand. They do not say the same thing:

HTML Text
a button reading "Accept invite" Accept the invite here: ${acceptUrl}
"The owner of venue has invited you to be a host on PubTrivia. Accept the invite to start running trivia nights together." "You've been invited to join venue on PubTrivia."
"This link expires in 7 days." "This link expires in 7 days."

The text part also carries a sentence the HTML part does not: "If you weren't expecting this, you can safely ignore it." That line belongs in both, and it is in the half of the email almost nobody sees.

The only exclamation mark we ship

Of the 161 strings, exactly two end a sentence with an exclamation mark. The first is a server action's success message: "Account created! Please check your email to confirm your account before logging in."

The second is in a file no copy audit of HTML would ever open:

$ curl -s https://pub-trivia.app/manifest.webmanifest
{"name":"PubTrivia","short_name":"PubTrivia",
 "description":"Live pub quiz nights, scan, join, play!", ...}
Enter fullscreen mode Exit fullscreen mode

The web app manifest. That description is what an install prompt shows, and what an app-listing surface would scrape. It is one of the most widely syndicated sentences on the site, it is not HTML, it is not in <main>, and it carries the only piece of punctuation our style guide has an opinion about. The audit that found "zero exclamation marks" was telling the truth about the thing it measured.

What the other 159 look like under the same rules

I ran the house rules over the whole set:

  • Em dashes: zero. The cleanup that removed them from user-visible copy swept the source, not just the pages, so this held everywhere. One rule that actually transferred.
  • Contractions: four. All four are in the money and invite paths: "You're already on this plan", "we can't change plans in", "You've been invited", "You've reached the table limit". The page copy almost never contracts, so the error strings are, by accident, the friendliest writing in the product.
  • "Failed to": seven. Failed to create tables, Failed to update table, Failed to delete table, and four more. Seven strings that tell a host that something did not happen and nothing about what to do next. These are the worst copy in the repository and they are the copy most likely to be read at ten past eight on a Thursday with forty people waiting.
  • "Please": sixteen. Mostly in the auth and rate-limit paths. Politeness scales inversely with how much the message can actually help.
  • No terminal punctuation: 90 of 161. Roughly half our strings are sentences and half are labels, and which one a given string is was decided independently 161 times.
  • Template placeholders: ten. Strings like "You can only add N more tables on your current plan" cannot be found by grepping the live site for them, which is the practical reason an audit of rendered HTML will always miss this layer.

What I am changing

The seven Failed to strings, first, because they are the only items on this list where the words are doing actual damage. Each one should name the thing that failed and the thing to try.

Then the duplication: the rate limit and the maintenance notice each have a page version and a JSON version, and only the maintenance pair shares a constant. Hoisting the rate-limit wording next to it is a five-line change that makes two surfaces agree forever.

The structural fix is the interesting one, and I am stealing it from a test we already have. The pricing copy is protected by a list of phrases a price may never be described with, asserted against the rendered component. The same technique works on a string table: collect the user-facing strings into one module, and assert the house rules over the module rather than over the HTML. A rule that only holds where you remembered to look is not really a rule, and the places you remember to look are never the places somebody is reading at the worst moment of their evening.

Check it yourself

# the manifest, with the exclamation mark
curl -s https://pub-trivia.app/manifest.webmanifest

# the page a browser gets when it trips the limiter, which says 429 in a 200
curl -s https://pub-trivia.app/too-many-requests | grep -o '<title>[^<]*</title>'
Enter fullscreen mode Exit fullscreen mode

Then read the pricing page, which is the surface every one of those billing strings sits behind, and whose figures are shown in your own currency. The free tier needs no card, so you can sign up and meet the second exclamation mark on your way in.

Top comments (0)