Munchable generates a few hundred pages of the form "Is onion low FODMAP?" from its rules engine. Each one has a title and a meta description, and a search result shows about 60 characters of one and 160 of the other before cutting. On these pages the tail is where the answer lives, so a description cut mid-verdict has spent the click and told the reader nothing.
You cannot check that by reading the copy, because nobody wrote the copy. Adding one long ingredient name to a rule map is what breaks it. So the budget is a constant and the check is a test over the whole set.
The budget, and where the brand goes
/**
* A title of 60 characters or more, or a description of 160 or more, gets cut
* mid-word, and the part that gets cut is usually the part carrying the answer.
* Both are exclusive: a title must be shorter than TITLE_LIMIT.
*/
export const TITLE_LIMIT = 60;
export const DESCRIPTION_LIMIT = 160;
The root layout appends " | Munchable" to every title, which is twelve characters of a sixty-character budget. On the question pages the title tag has to be the question itself, and for the longest ingredient names that twelve is the difference between a whole title and a truncated one. The resolution is to drop the suffix rather than the subject:
function resolvedTitle(title: string): string | { absolute: string } {
return title.length + TITLE_SUFFIX.length < TITLE_LIMIT ? title : { absolute: title };
}
A reader scanning results is matching the question, and the brand is already in the URL and the breadcrumb beside it.
Two halves of the test
The generated pages are checked through the same functions the routes call. No sampling, because the longest ingredient name decides this and there is no way to know which one it is from the test:
it('fits the snippet on every question page', () => {
assert.ok(ANSWER_PAGES.length > 100, 'expected the full question page set');
for (const ref of ANSWER_PAGES) {
const page = answerPage(ref.question);
checkTitle(`/${ref.question}`, page.ask);
checkDescription(`/${ref.question}`, page.metaDescription);
}
});
At the time of writing that is 373 question pages, plus 7 condition guides and 18 recipes through their own loops.
The hand-written pages are the harder half. They are server components whose modules pull in React, next/navigation and the database client, none of which loads under node --test. So the test reads their source instead:
function literal(block: string, key: string): string | null {
const match = new RegExp(`\\b${key}:\\s*(?:{\\s*absolute:\\s*)?(['"])((?:\\\\.|(?!\\1).)*)\\1`).exec(block);
return match ? match[2].replace(/\\(['"])/g, '$1') : null;
}
It walks the app directory for every page.tsx, layout.tsx and not-found.tsx, slices 1,200 characters after export const metadata or generateMetadata, and pulls the first string literal for title and description. A page with no literal metadata is not silently passed. It has to be named in a skip list with a reason, and today that list has eight entries: four whose metadata comes from the content library and is checked through it, the root layout, and three noindex routes where a title is enough because there is nothing for a crawler to show.
What had to change to pass
The first run failed on the question pages, and the fix was a second wording rather than a longer limit. The on-page answer sentence reads "In a packaged food, suet is flagged as an avoid when it is one of the main ingredients, and is flagged as a caution further down the label." It spends a third of the description budget on "is flagged as" before the ingredient name has been paid for. At the longest names, "fully hydrogenated vegetable oil", the page's own sentence is over 160 characters on its own.
const META_ANSWER_LEAD: Record<VerdictValue, string> = {
good: 'is not flagged',
caution: 'is a caution',
avoid: 'is an avoid',
unknown: 'cannot be assessed',
};
/**
* What the snippet asks the reader to do, sized to what is left of the 160
* characters once the longest ingredient name and the longest verdict have been
* paid for. Every page carries it, so it cannot be lengthened without checking
* the worst case in the test.
*/
const META_CLOSE = ' Scan a barcode to check a real product.';
The description now opens with the page's one-word answer, because that is the thing a reader is scanning the result for, and it is the only part that differs between an ingredient's sibling pages ("Is apple low FODMAP?" and "Is apple OK for SIBO?" ask the same question of different rule sets).
The result on Is acesulfame K OK with IBD? is a 28-character title that keeps the brand and a 115-character description: "Worth limiting. Acesulfame K is a caution wherever it appears on the label. Scan a barcode to check a real product." View the page source and both are there as written.
Five hand-written descriptions were trimmed in the same commit. The recipes index went from "Simple recipes for IBS, reflux and other gut conditions" to "Gut-friendly recipes for IBS and reflux". The IBD guide lost "a real risk" for "a risk". Each cut was a few characters that the test said were about to become an ellipsis.
Why a test and not a lint rule
A lint rule sees a string literal. It cannot see a template that concatenates a verdict, an ingredient name and a closing sentence, and it cannot see a title that the layout will lengthen. The test runs the same functions the routes run and measures what the browser will receive. That is also why the generated half asserts on the full set rather than a fixture: the fixture would be a copy of the data, and copies rot.
This is a tangent to two earlier posts, one on auditing programmatic SEO pages for whether their claims were true, and one from a sibling project on a content registry that cannot disagree with itself. This one is narrower: the claims are true and consistent, and the question is whether Google will show them whole. The full set of pages it protects is indexed at munchable.app/answers.
Top comments (0)