Every public page on Nakodo that exists to answer a search is typed data in the repository. Not MDX, not a CMS: 63 objects in src/content, each with a summary, sections of blocks, FAQs, and a list of related pages. Eight block types, 853 blocks in total, and an array that the sitemap, the hub pages, the footer, the structured data and /llms-full.txt all derive from.
The body copy inside those blocks still needs two things markdown gives you for free: a link and a bit of emphasis. This post is about the smallest version of that I could get away with, and the test that makes the links safe.
The entire markup is one regex
// The inline markup content is written in: [label](href) and **bold**. Kept to
// two forms so the same string is valid markdown for llms-full.txt.
export const RICH_TOKEN = /\[([^\]]+)\]\(([^)\s]+)\)|\*\*([^*]+)\*\*/g;
That comment is the design. Two forms, both of which happen to be real markdown, because the same strings have to serve two audiences: a browser reading a page, and a model reading our llms-full.txt, which is 574,505 bytes of the same content rendered as one markdown document.
You can watch one sentence come out both ways. On the how much to pay YouTubers guide:
$ curl -s https://nakodo.app/guides/how-much-to-pay-youtubers |
grep -o 'href="/glossary#cpm"[^>]*>[^<]*'
href="/glossary#cpm">CPM
And in the text file, the same sentence with the host added:
$ curl -s https://nakodo.app/llms-full.txt | grep -o "That's the \[CPM\].*compare\."
That's the [CPM](https://nakodo.app/glossary#cpm), or cost per mille, and it turns any quote into something you can compare.
One string in one file produced both. No markdown parser, no remark plugin chain, no sanitiser.
Three readers of the same token
The regex has three consumers, and keeping them to three is what keeps the format honest.
The page. A React component walks the matches and decides per link:
for (const m of text.matchAll(RICH_TOKEN)) {
if (m.index > last) out.push(text.slice(last, m.index));
const [, label, href, bold] = m;
if (bold) out.push(<strong key={m.index} className="font-semibold text-foreground">{bold}</strong>);
else if (href.startsWith("/") || href.startsWith("#"))
out.push(<Link key={m.index} href={href} className={linkClass}>{label}</Link>);
else
out.push(<a key={m.index} href={href} target="_blank" rel="noopener noreferrer" className={linkClass}>{label}</a>);
last = m.index + m[0].length;
}
Internal links get next/link and client navigation. External links get a new tab and rel="noopener noreferrer". Writers never choose, and never forget, because the string has no slot for that decision.
Meta tags and structured data. Those cannot contain markup at all, so they get the text with the tokens removed:
/** 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 ?? "");
}
A FAQ answer is rendered on the page with its links, and the same answer goes into the JSON-LD acceptedAnswer as flat text. One function, so a **bold** can never leak into a meta description.
The test. This is the reader that justifies the whole arrangement:
/** Every link target in the text. */
export function linksIn(text: Rich): string[] {
return [...text.matchAll(RICH_TOKEN)].map((m) => m[2]).filter(Boolean);
}
What the test actually asserts
Content has 342 internal links in body copy, 296 external ones, 37 of the internal ones pointing at an anchor, and another 43 inside glossary definitions. Nobody is clicking through that by hand after an edit.
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}`);
}
resolveLink is the function the "read next" cards already use: give it a path and it returns the page's name and blurb, or null. It knows three kinds of target: a content page, a hand-built page such as the pricing page, and a glossary anchor.
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;
}
So the same function that renders a link card is the function that validates a link, which means the two cannot disagree. A renamed slug breaks the build in the test rather than shipping a 404 that only a crawler notices. A deleted glossary term does the same, and the anchor case is checked all the way down to the term id: /glossary#cpm only passes while a term with id: "cpm" exists, which is also the id attribute the page renders.
Two details I would copy into any similar suite.
The messages are written as the consequence, not the condition. "/guides/how-to-vet-a-youtube-creator: link to unknown page /glossary#cmp" tells you what to fix. "expected true, got false" tells you to go reading.
And the external branch asserts something small and real, https only, rather than pretending to check more. That is the honest edge of this test, and worth saying out loud: 296 external links are checked for scheme and nothing else. Nothing here knows whether a source has moved since it was cited. A crawl would catch it, and a crawl is a different kind of job with a different failure mode, so it does not belong in a unit test that must pass on a plane.
Would I do typed content again?
For search-intent pages, yes, and the links are the reason. Prose in a CMS is a graph of strings pointing at each other with nothing enforcing the arrows. Prose as typed data in the repo means the arrows are checkable, and checking them costs one regex and one 10-line function.
The price is real: writing body copy means editing TypeScript, and a block type you do not have yet means a code change rather than a new paragraph style in an editor. Eight block types in, that has stopped being a cost and started being a feature, because the page can only be built out of pieces that already have a renderer, a markdown form, and a schema mapping. There is no "just this once" HTML anywhere on the guides.
Top comments (0)