DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A feature shipped as 21 files of prose, and the word "five" had to survive in five other sentences

Our app ships with built-in question packs so a venue can run a quiz night without writing a question first. We increased how many, from five to thirteen. The interesting part is not the feature.

The code was dull in the way additive changes are dull: the seed became a directory of one module per pack with a shared types file and an index that exports the list, and the seed script itself shrank from 504 lines to 105 now that it contains logic rather than data. A test asserts every pack has enough questions for a full night, that no two packs ask the same question, and that nothing a player sees contains an em dash. That is the whole engineering story.

Then there was a second commit, with no logic in it at all: 21 files, every one of them prose, titled "Update site copy from five to thirteen question packs".

Where a number actually lives

Here is the diff by file, and it is a decent map of how a count spreads through a marketing site:

app/features/question-packs/page.tsx         37 +   5 -
lib/content/registry.ts                      21 +  21 -
app/features/page.tsx                         5 +   5 -
app/compare/ahaslides-alternative/page.tsx    3 +   3 -
app/compare/speedquizzing-alternative/...     3 +   3 -
app/compare/best-pub-quiz-apps/page.tsx       1 +   1 -
app/compare/kahoot-for-pub-quiz/page.tsx      1 +   1 -
app/compare/sporcle-live-alternative/...      1 +   1 -
app/faq/page.tsx                              2 +   2 -
app/pricing/page.tsx                          2 +   2 -
app/quiz-questions/page.tsx                   2 +   2 -
app/quiz-questions/{christmas,halloween,hard,true-or-false,food-and-drink}  1 + 1 - each
app/solutions/pubs/page.tsx                   2 +   2 -
app/solutions/charity-fundraisers/page.tsx    1 +   1 -
components/FeaturesGrid.tsx                   1 +   1 -
docs/seo-plan.md                              2 +   2 -
Enter fullscreen mode Exit fullscreen mode

Five of those are competitor comparison pages, where the count is part of an argument rather than a description, so leaving them stale would have left us arguing a weaker case than the product supports. One is a planning document, which nobody reads but which future-me will believe. One is a component, where the number sits in a feature grid.

The registry line is the one worth expanding. Our content pages are built from a registry of nodes carrying the title, the heading, the meta description and a related list for each path, so a number that appears in a page's <title>, its search snippet and its <h1> is edited there rather than in the page:

-        heading: 'Five question packs, included on every plan',
+        heading: 'Thirteen question packs, included on every plan',
         description:
-            'General Knowledge, Pop Culture, Music & Audio, Sports and Food & Drink come with every plan. How to spread them across a season and build a mixed night.',
+            'Thirteen packs and over 800 questions on every plan, from General Knowledge to a hard pack and seasonal specials. How to build a mixed night from them.',
Enter fullscreen mode Exit fullscreen mode

That old description had been a list of all five names, which only works while there are five of them. Thirteen names do not fit in a meta description, so the description had to change shape and not just change a word: the count replaced the enumeration. That is the kind of edit a find-and-replace cannot do, and the reason this commit is 21 files of hand editing rather than one sed.

Thirteen live URLs now contain the word "thirteen". You can see the top of the chain on the question packs page, whose heading and search snippet both come from that registry node.

The find-and-replace that would have been a disaster

The first thing anybody wants to do here is replace "five" with "thirteen". Our copy still contains "five" in at least five other places, all of them unrelated facts about different parts of the product:

lib/pricing/plans.ts      /** Ultimate: up to five host accounts, £100/month. */
registry.ts (plans)       'Ultimate adds five host accounts on one subscription and five sessions at once.'
registry.ts (builder)     'Pro includes five; Ultimate unlimited.'
registry.ts (timer)       'then five to fifteen seconds on the clock'
quiz-builder/page.tsx     'A 500-point closer really is worth five times a 100-point opener'
Enter fullscreen mode Exit fullscreen mode

A global replace turns a five seat plan into a thirteen seat plan, a five second question timer into a thirteen second one, and a sentence about relative scoring into arithmetic that is simply wrong. Three of those are claims a customer could hold us to, and one of them is a number the billing code enforces, so the copy and the code would disagree about what a plan is.

This is not an argument for being careful with sed. It is the thing I actually took away: a number in prose carries no type and no name. PRO_PRICE_PENCE can only ever mean one thing, and the compiler knows where it is used. The string "five" in a sentence means whatever the sentence means, and the only tool that can tell two of them apart is a person reading both.

Why we spell it out, and why that defeats the test

Our house style spells small numbers as words in prose, which is right for reading and awkward for everything else. grep -r 13 finds nothing. The count appears in three grammatical shapes in the same release: as a heading noun phrase ("Thirteen question packs, included on every plan"), as a bare count inside a sentence ("a venue running one night a week gets months out of the thirteen before anything repeats"), and as a determiner in a call to action ("All thirteen packs are on the free session too").

The test that would catch the drift is easy to describe and annoying to write:

it('says in the copy how many packs actually ship', () => {
  expect(pageText('/features/question-packs')).toContain(numberToWord(seedPacks.length))
})
Enter fullscreen mode Exit fullscreen mode

The pack modules are pure data with no database or network import, exactly like our price module, so seedPacks.length is readable from a test or even from a page. We do exactly that for money: prices are imported, formatted and rendered, never typed into a component, and there is a test that fails if any price surface describes our own price as an estimate.

We did not do it here, for two reasons that I think hold up.

First, a price is a token. It sits alone in a component and reads correctly as a numeral in every language of copy around it. A count is a word inside a sentence, with grammar on both sides of it, and interpolating one turns readable prose into a template: "13 packs are included" is not what a person would write, and rendering it from the data would mean writing every sentence around it to survive substitution.

Second, counting an array means importing it. The pack modules are a couple of hundred kilobytes of question data on disk whose only purpose is to be inserted into a database once per account. Pulling all of it into the render path of a marketing page to learn the length of a list is a bad trade for a number that changes once a quarter.

So the duplication is accepted, and the mitigation is in how the claims are written.

Claims that stay true in one direction

Look again at the new description: "Thirteen packs and over 800 questions". The seed contains comfortably more than 800. The number was rounded down, and down is the only safe direction: the sentence survives a question being pulled for being wrong, or a whole pack being rewritten, and it only becomes false if the product shrinks a lot. "Over 800" is a claim with slack in it; an exact total is a claim that goes wrong the first time somebody fixes an answer by deleting the question.

That is the cheapest available form of this discipline. Where a count must be exact, like the number of packs, it is a word in thirteen pages and a human's job. Where it can be approximate, it is written as a bound rather than a figure, and bounds do not drift.

The copy edit was also a crawl signal

One more coupling, which caught me out pleasantly. Each registry node carries an updated date, and our sitemap.xml reads lastModified from it rather than from the build clock, so the date in the sitemap is the date the page's content actually changed. That makes this copy commit eighteen date edits as well as a prose edit, and you can count the result in the live file:

$ curl -s https://pub-trivia.app/sitemap.xml | grep -c '2026-10-15'
17
Enter fullscreen mode Exit fullscreen mode

Seventeen URLs claiming the same lastmod, which is honest, because they were genuinely all edited in the same sitting for the same reason. The only pages falling back to the build date are the three legal documents, which have no registry node.

A copy change that moves a crawl signal is a copy change worth doing in one commit rather than drizzling across a week, which is an argument for the shape of this commit that I did not have when I started writing it.

Check it yourself

  • pub-trivia.app/features/question-packs: the heading and the meta description both come from the registry node above, and the body explains how to build a night from the packs.
  • curl -s https://pub-trivia.app/features/question-packs | grep -o '<title>[^<]*' to see the registry's title rendered.
  • pub-trivia.app/faq and pub-trivia.app/pricing both repeat the count in their own words, which is the duplication this whole post is about, visible in two tabs.
  • The free tier at pub-trivia.app/signup needs no card, and every built-in pack is on it, which is the claim all thirteen of those pages are making.

Top comments (0)