Nobody who sponsors creators calls them creators when they go looking. They type "influencer". The people being sponsored, in my experience of reading how they describe themselves, mostly do not use that word about themselves at all.
That is an awkward gap for a product with 63 content pages that have to be findable. You cannot win the search without the searcher's word, and you cannot write the page in a word your subject dislikes.
So Nakodo's content pages use both, in a split that is enforced by a test rather than remembered. Open the guide to finding YouTube creators and you will find "influencer" in the URL, the page title and the H1, once in the opening summary, and once in an FAQ question. Then read the body. It is creators all the way down.
Every page is split into two halves
Content pages are typed objects, not MDX, so a test can take a page apart field by field:
const searchTexts = (p: ContentPage) => [
p.metaTitle, p.ogTitle ?? "", p.metaDescription, p.h1, p.summary,
p.label, p.cardBlurb, ...p.keywords, ...p.faqs.map((f) => f.q),
];
const bodyTexts = (p: ContentPage) => [
...p.sections.flatMap((s) => [s.heading, ...s.blocks.flatMap(blockTexts)]),
...p.faqs.map((f) => f.a),
];
Nine fields in the first group. Those are the places where the page meets the search: the title and description in the result, the H1 and the opening summary the reader lands on, the label and blurb on cards that link to it, the keyword phrasings, and the questions in the FAQ.
Everything else is the second group: section headings, paragraphs, lists, steps, table cells, callouts, glossary-style term blocks, and the FAQ answers. Note that a FAQ splits across the two: the question is search-facing, the answer is body copy.
Then the rule is three lines:
test("follows the voice rules", () => {
for (const t of [...searchTexts(page), ...bodyTexts(page)].map(plain))
for (const re of BANNED) assert.ok(!re.test(t), `banned ${re} in: ${t.slice(0, 90)}`);
for (const t of bodyTexts(page).map(plain))
assert.ok(!INFLUENCER.test(t), `say "creator" in body copy: ${t.slice(0, 90)}`);
});
One list applies everywhere. The influencer rule applies to one half. plain() strips the inline link syntax first, which matters more than it sounds: a link to /guides/how-to-find-youtube-influencers has the banned word inside its href, and linking to a page by its real path should not fail a copy test. The rendered text is what a reader sees, so the rendered text is what gets checked.
blockTexts is a switch over the block union with no default case, which means adding a new block type to the content schema makes TypeScript point at this function until the test knows how to read text out of it. A block type the test cannot see is a block type where any banned word can hide.
function blockTexts(b: Block): string[] {
switch (b.type) {
case "p":
case "callout":
return [b.text, ...(b.type === "callout" && b.title ? [b.title] : [])];
case "list": return b.items;
case "steps": return b.items.flatMap((s) => [s.title, s.text]);
case "table": return [...(b.caption ? [b.caption] : []), ...b.head, ...b.rows.flat()];
case "terms": return b.items.flatMap((t) => [t.term, t.text]);
case "tags": return b.items;
// …
}
}
The other list is mostly about tone
const BANNED = [/* the em dash */, /* the en dash */, /\bscrap(e|es|ed|ing|er|ers)\b/i, /\bhunt(s|ed|ing|er|ers)?\b/i,
/\bsupercharg/i, /\bseamless/i, /\beffortless/i, /game-changing/i, /\bAI-powered\b/i,
/unlock the power/i, /!/];
The dashes and the marketing filler are the obvious ones. Two are specific to this product.
"Scrape" is banned because the word describes something the product deliberately does not claim about itself, and because it is the word a creator would use for the version of this they would resent. "Hunt" is banned for the same reason in the other direction: nobody wants to read that a tool is hunting people, least of all the people being contacted.
The exclamation mark is banned outright. It never once improved a sentence on a page about contracts, pricing or email law.
Thin pages fail the build
The same test file holds the floors that stop a content family turning into 63 near-identical stubs:
test("answers first and has substance", () => {
const n = words(page.summary);
assert.ok(n >= 40 && n <= 90, `summary is ${n} words`);
assert.ok(page.sections.length >= 3, "at least 3 sections");
const total = words([page.summary, ...bodyTexts(page)].map(plain).join(" "));
assert.ok(total >= 600, `only ${total} words: thin pages don't rank and don't help`);
assert.ok(page.faqs.length >= 3, "at least 3 FAQs");
for (const f of page.faqs) assert.ok(words(f.a) >= 15, `FAQ answer too short: ${f.q}`);
});
A summary between 40 and 90 words, because under 40 it does not answer the question the title asked and over 90 it stops being a summary. Three sections, 600 words, three FAQs, and no FAQ answer shorter than 15 words, which is the length at which an answer stops being a restatement of its question.
And the search result itself gets measured:
assert.ok(page.metaTitle.length <= MAX_TITLE, `title is ${page.metaTitle.length} chars`);
assert.ok(!page.metaTitle.includes("Nakodo"), "the layout appends the brand; don't repeat it");
assert.ok(page.metaDescription.length >= 90 && page.metaDescription.length <= MAX_DESCRIPTION);
assert.ok(page.cardBlurb.length >= 40 && page.cardBlurb.length <= 200);
assert.ok(page.keywords.length >= 4, "list at least 4 search phrasings");
MAX_TITLE is 50 rather than 60, because the brand suffix is appended by the layout and the full string still has to survive truncation. The includes("Nakodo") assertion exists because I wrote "… · Nakodo" into a page title by hand exactly once and got it twice in the result.
Uniqueness, across the whole site at once
test("titles, H1s and descriptions are unique across the site", () => {
for (const key of ["metaTitle", "h1", "metaDescription", "label"] as const) {
const seen = new Map<string, string>();
for (const { path, page } of ENTRIES) {
const v = page[key].toLowerCase();
assert.ok(!seen.has(v), `${key} of ${path} duplicates ${seen.get(v)}`);
seen.set(v, path);
}
}
});
When you have per-niche pages, a description written once gets copied and the niche word swapped. Miss one and two pages compete with each other for the same query. The failure message names both paths, which is the only reason this is useful rather than annoying.
Links are resolved too, against the page registry rather than by fetching anything:
function checkLink(href: string, from: string) {
if (/^https?:\/\//.test(href)) {
assert.match(href, /^https:\/\//, `${from}: outside links must be https (${href})`);
return;
}
assert.ok(href.startsWith("/"), `${from}: internal links start with / (${href})`);
const [path] = href.split("#");
assert.ok(path === "/glossary" ? resolveLink(href) || href === "/glossary" : resolveLink(path), `${from}: link to unknown page ${href}`);
}
Every internal link in every paragraph, list item, table cell and FAQ answer has to resolve to a real page. A /glossary# anchor has to name a real term, which keeps the deep links into the glossary honest, and the glossary's own entries are held to a 20-word minimum and the same banned list.
Why a test rather than a style guide
A style guide is a document somebody reads once. There are 63 pages here and I wrote all of them, which is exactly the situation where a rule gets forgotten in page 40 and nobody ever notices, because a thin or mis-titled page does not break. It renders. It just never ranks, and it never helps the person who landed on it.
pnpm test runs the file as one test per page, so a failure names the path, the field and the first 90 characters of the offending text. The last failure I had was an em dash I pasted in from a note. It took four seconds to find, which is roughly four seconds less than it would have taken me to spot it by reading. You can see what the floors produce across the guides hub: every page there has had to earn its own title, its own description, and 600 words that are not a restatement of the one next to it.
Top comments (0)