Munchable tells you whether a packaged food fits the gut conditions you manage. You point the phone at a barcode and it answers. Alongside the app there is a public side: a guide per condition at munchable.app/conditions, and a set of generated pages that each answer one ingredient question, indexed at munchable.app/answers.
For most of this year the app covered seven conditions. Now it covers eight: coeliac disease went in at munchable.app/conditions/coeliac-disease. The commit that did it reads:
63 files changed, 2926 insertions(+), 128 deletions(-)
Sixty-three files is a lot. It spans the mobile app, the website, two shared packages, a database migration and eight test files. But the files I expected to be in that list mostly are not, and that is the part worth writing down.
The number seven used to be a string
Here is a comment I wrote in the web app's recipe layer, which exists because of the previous time a count moved:
/**
* "all eight conditions", counted from the registry rather than written down.
*
* The count was the literal word "seven" here, and in the test that checked
* this line, and in a dozen pieces of copy besides. Adding a condition left
* every one of them wrong, and the test was wrong in the same direction as the
* code so it still passed. Spelled out from the registry, it cannot be.
*/
The fix is unglamorous:
const CONDITION_IDS = Object.keys(RULE_SETS) as ConditionId[];
const COUNT_WORDS = [
'zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight',
'nine', 'ten', 'eleven', 'twelve',
] as const;
export function countWord(n: number): string {
return COUNT_WORDS[n] ?? String(n);
}
export const ALL_CONDITIONS_LINE = `all ${countWord(CONDITION_IDS.length)} conditions`;
A number-to-word table is the sort of thing you feel slightly silly committing. It is there because the copy rule is that body text spells small numbers, so "all 8 conditions" was never an option, and because the only honest source for how many conditions exist is the thing that holds them.
The clause worth noticing is the test. The old test asserted the string 'all seven conditions'. The code produced 'all seven conditions'. Both were edited from the same wrong assumption, so when the eighth condition arrived the test had no opinion about it. Now the test asserts against ALL_CONDITIONS_LINE, which means it has stopped checking the wording and started checking that one derivation is used in both places. That is a weaker test and a more useful one.
What 88 new pages look like from the outside
The question pages are generated, one per (ingredient, condition) pair worth answering. There are 461 of them live now, and this is how they divide up:
88 /is-...-gluten-free
67 /is-...-ok-with-bam
64 /is-...-ok-for-sibo
64 /is-...-low-fodmap
52 /is-...-high-in-lactose
48 /is-...-ok-with-ibd
44 /does-...-cause-reflux
34 /is-...-ok-with-gastroparesis
The newest condition is the biggest slice of the site, which was not a content decision. It is a long list because the vocabulary of that condition is long: the grain is usually named as the thing it was milled or baked or brewed into, so there are many words to answer for and each one is somebody's actual search. Try munchable.app/is-barley-malt-vinegar-gluten-free against munchable.app/is-baguette-gluten-free: same question shape, different reason, and neither page was written by hand.
None of those 88 URLs appear in the commit. They appear because the generator ran over a longer list. The three places that had to agree about them did so without being told:
-
app/sitemap.tsmaps over the generated set, so the sitemap went from 426 URLs to 515 with no edit to it (a later commit added one reference page and made it 516). - The IndexNow job fingerprints each page's content and submits what moved, so the engines were told about 88 new URLs within the hour and about nothing else.
- The answers index and each condition guide link their own subset, so every new page had two inbound links the moment it existed.
The sentence on 19 recipe pages
There are 38 recipes at munchable.app/recipes. Each page carries one line naming who the recipe suits, and that line is computed rather than authored. Today the distribution is:
19 recipes suit all eight conditions
15 suit seven
1 suits six
2 suit five
1 suits three
So 19 pages now read "all eight conditions" where they used to read "all seven", and nobody opened a recipe file. munchable.app/recipes/gentle-rice-congee is one of them.
This is the half of derived copy that people underrate. Deriving the count stops the page from lying. But it also means the copy changes under you: a line of published text moved on 19 URLs because of a commit that never mentioned recipes. That is why those pages are fingerprinted on their derived output rather than on their source, and why the announcement job treats them as changed. A generated site needs the change detection and the generation to be built from the same projection, or you get a site that is quietly right and visibly stale.
Where the 63 files actually went
To be fair to the number, here is how the diff splits by area:
14 the shared rules package
11 website pages and components
9 website lib
8 mobile app screens and stores
7 test files
4 website content modules
4 docs
3 database migration and snapshot
1 the video package's theme
And here is the honest line. Fourteen of those 63 files changed by three lines or fewer, and most of those were the word "seven" becoming the word "eight":
- seven digestive conditions, and it runs a separate set of rules for each
+ eight digestive conditions, and it runs a separate set of rules for each
- title: 'The seven conditions',
+ title: 'The eight conditions',
That happened in the about page, the terms of service, the 404 page, the pricing FAQ, the paywall in the app and the condition picker. Each one is the same failure as the literal 'seven' in the test, caught by hand this time because no test reads marketing prose. Only one of those sentences is counted today, the one on the recipe pages. The rest are still literals, which means the ninth condition will cost that same handful of files again, and I now know exactly which ones they are.
The thing I would take from this into another project: a count in your copy is a dependency, and the second copy of a number is the one that goes wrong. Spell it from the data, and let the test check the derivation rather than the string. The payoff arrives the first time the registry grows, on pages you had forgotten existed.
Top comments (0)