Nakodo has a glossary, and it is 27 objects in a file:
export type Term = {
/** Anchor on /glossary, kebab-case. Link to it as /glossary#id. */
id: string;
term: string;
/** One sentence, for link cards and structured data. */
short: string;
/** The full definition. May link to guides. */
text: Rich;
};
// Plain definitions for the words that come up in sponsorships and outreach.
// Alphabetical. Definitions say what the term means, then why it matters.
export const GLOSSARY: Term[] = [
{
id: "affiliate-link",
term: "Affiliate link",
short: "A tracked link that credits a creator with the sales it brings in, so they can be paid a commission on each one.",
text: "A link carrying a code that identifies the creator ... Many creators also ask for a [flat fee](/glossary#flat-fee), because commission alone leaves all the risk with them.",
},
// 26 more
];
That last line is the interesting part, and the rest of this post is about what it costs to let a definition link to another definition.
The numbers first: 27 terms, 43 links inside the definitions, 23 of which point at other terms on the same page, 20 of which point out to guides and tools. 36 more links point in from 22 other pages, which makes the page it produces the second most linked destination in our own content, behind only the page that explains what the product does.
The page is 71 lines, and most of it is the list twice
<nav aria-label="Terms" className="mt-8 flex flex-wrap gap-x-4 gap-y-1.5 text-sm">
{GLOSSARY.map((t) => (
<a key={t.id} href={`#${t.id}`} className="text-link underline-offset-4 hover:underline">
{t.term}
</a>
))}
</nav>
<dl className="mt-10 divide-y divide-foreground/10 border-y border-foreground/10">
{GLOSSARY.map((t) => (
<div key={t.id} id={t.id} className="scroll-mt-20 space-y-1.5 py-6">
<dt className="text-[20px] leading-7 font-semibold">{t.term}</dt>
<dd className="leading-7 text-ink-800 dark:text-foreground">
<RichText text={t.text} />
</dd>
</div>
))}
</dl>
Two things in there are load bearing and neither is obvious.
<dl> with <dt>/<dd> is the correct element for this and it costs nothing, but the wrapper matters: the id goes on the div around each pair, not on the dt. Put the anchor on the term and the browser scrolls the term to the top of the viewport with the definition below the fold on a short phone. Put it on the pair and you land on both.
scroll-mt-20 is the other one. The app has a sticky header, so an anchor without scroll margin lands the heading underneath it. 20 in Tailwind's scale is 5rem, which is the header plus a little air. This is the single most common broken thing I see on documentation sites, and it is one class.
The structured data is generated from the same array
{
"@type": "DefinedTermSet",
"@id": `${absoluteUrl("/glossary")}#terms`,
name: "YouTube sponsorship terms",
hasDefinedTerm: GLOSSARY.map((t) => ({
"@type": "DefinedTerm",
"@id": absoluteUrl(`/glossary#${t.id}`),
name: t.term,
description: plain(t.text),
url: absoluteUrl(`/glossary#${t.id}`),
})),
}
DefinedTermSet and DefinedTerm are the schema.org types for exactly this, and the rule I hold myself to with structured data is that no node may claim anything a reader cannot see on the page. Here that is free, because the JSON-LD is built from the objects that render. name is the dt, description is the dd, url is the anchor the reader can actually click.
The one transformation is plain():
export const RICH_TOKEN = /\[([^\]]+)\]\(([^)\s]+)\)|\*\*([^*]+)\*\*/g;
/** The text without markup, for structured data and meta tags. */
export function plain(text: Rich): string {
return text.replace(RICH_TOKEN, (_, label, _href, bold) => label ?? bold ?? "");
}
Definitions are written with two inline tokens, [label](href) and **bold**, so they are valid markdown for the plain text version of the site and valid input to a small renderer for the HTML version. Structured data wants neither, so it gets the labels and loses the links. Without that call, Google's rich result test shows a description full of square brackets, which is the kind of thing that is invisible in review and obvious in a crawler.
An anchor is a link target, not a scroll position
Every internal link on the site goes through one resolver, which returns the name and blurb used by link cards:
export function resolveLink(path: string): { path: string; name: string; blurb: string } | null {
const entry = byPath.get(path);
if (entry) return { path, name: entry.page.label, blurb: entry.page.cardBlurb };
const page = staticByPath.get(path);
if (page) return { path, name: page.name, blurb: page.description };
const term = path.startsWith("/glossary#") && GLOSSARY.find((t) => `/glossary#${t.id}` === path);
if (term) return { path, name: term.term, blurb: term.short };
return null;
}
The third clause is why Term has a short field that the glossary page itself never renders. A page can list /glossary#usage-rights among its related links and get a card titled "Usage rights" with a one sentence blurb, the same shape as a card for a full page. The term is a destination in the same namespace as the sponsorship formats guide, and the thing that makes that true is 2 lines and a find.
The test rule that applies to one path prefix
Every link in every piece of content gets checked at test time. Here is the whole check:
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})`);
// A glossary anchor must name a real term; other anchors only need their page.
const [path] = href.split("#");
assert.ok(path === "/glossary" ? resolveLink(href) || href === "/glossary" : resolveLink(path), `${from}: link to unknown page ${href}`);
}
Read the ternary. For /guides/youtube-engagement-rate#formats, only /guides/youtube-engagement-rate has to exist: the anchor is not checked. For /glossary#engagement-rate, the whole string including the anchor has to resolve, so the term must exist.
That asymmetry is deliberate and it is about what the two failures feel like. A dead anchor on an ordinary page lands the reader at the top of a page about the right subject, with the right heading visible. A dead anchor on the glossary lands them at the top of an alphabetical wall of 27 definitions with no indication which one they were promised, because nothing highlights the target. One is a near miss. The other is a maze.
There is a second reason, which is that /glossary#flat-fee is a name. /guides/x#section-2 points at a position in a document; the anchor is navigation. The glossary anchor is the identity of the thing, which is why it is also the @id in the structured data. Names should not dangle.
The || href === "/glossary" is there so a link to the page itself, with no term, still passes.
What the definitions themselves have to pass
test("glossary terms are unique, defined and linked correctly", () => {
const ids = GLOSSARY.map((t) => t.id);
assert.equal(new Set(ids).size, ids.length);
for (const t of GLOSSARY) {
assert.match(t.id, /^[a-z0-9]+(-[a-z0-9]+)*$/);
assert.ok(words(t.text) >= 20, `${t.term}: definition too short`);
for (const re of BANNED) assert.ok(!re.test(plain(t.text)) && !re.test(t.short), `${t.term}: banned ${re}`);
for (const href of linksIn(t.text)) checkLink(href, `/glossary#${t.id}`);
}
});
Unique kebab-case ids, at least 20 words, no banned words in either field, every link resolvable. The 20 word floor exists because a glossary is where one line definitions go to die: "CPM: cost per mille" is a dictionary entry, not an answer. The convention in the comment at the top of the file, "say what it means, then why it matters", is the part no test can check, and it is the only part that decides whether the page is worth having.
What the test does not check, which I noticed while writing this: nothing stops a definition linking to itself. None of the 27 do, and if one did it would render as a link that scrolls nowhere. That is a two line assertion I have not written, which tells you the honest state of this: the rules that exist are the ones that were broken once.
One page or 27?
The obvious alternative is a page per term, which is what most sites with a glossary do, and the reasons I did not are specific.
A definition here is two to four sentences. Our content pages have to clear 600 words to pass their own test, because a thin page does not rank and does not help, and 27 pages of three sentences would be 27 pages failing that test on purpose. Anchors on one page also mean a reader who clicks "flat fee" from a guide can see "rate card", "CPM" and "exclusivity" without another request, and in a glossary that is how reading actually goes.
The cost is real and I will name it: search engines treat /glossary#flat-fee as /glossary, so no individual term can rank for its own query, and the page gets one title and one meta description for all 27. If one of these terms turned out to be a real search market, it would become a guide of its own, and the glossary would link to it. How the product works is a page; "exclusivity" is a word.
The other cost is that llms.txt describes the page with a count, so the description is generated rather than written:
description: `${GLOSSARY.length} terms used in YouTube sponsorships and creator outreach, defined in plain words.`
Which looks like over engineering until you remember what the alternative is: a hardcoded "27 terms" that becomes a lie the first time someone adds the 28th.
Top comments (0)