
An Astro blog that picks related posts from its own tags — IDF weighting and a measured floor
Picking related posts at build time from tag overlap weighted by tag rarity, why TF-IDF over three languages fails, and how the score floor was measured.
On this page
Introduction
I was reading one of my own published articles and got to the bottom, and there was nothing there. A row of share buttons, then the footer. No next article, no suggestions, no way onward except scrolling back up to the tags in the header. Thirty-two published English articles on the site and the only thing an arriving reader could do at the end of one was leave.
The fix is a related-posts strip, and the interesting part is not the layout. It is how a static site decides which articles are related when it has no search backend, no embeddings, and no interest in shipping either. The answer this blog uses is tag overlap, weighted by how rare each tag is, computed during astro build and baked into the HTML.
This article covers why the two obvious approaches were rejected, how the weighting works, how the cut-off score was set from measurement rather than taste, and the layout trick that let the section run wider than the article it sits under.
The archive this had to work on
Numbers first, because every decision below is downstream of them. At the time the work started the site had 32 published English articles, 31 Japanese, and 31 Traditional Chinese. Tags come from a closed registry in src/data/tags.ts, validated at build time, and articles carry between two and six of them with a median of three.
The distribution is lopsided in a way that matters:
| Tag | Articles carrying it |
|---|---|
astro |
12 |
generative-ai |
7 |
developer-tooling |
7 |
claude-code |
6 |
web-api, i18n, automation |
5 |
| 14 further tags | 1 each |
Twelve of thirty-two articles carry astro. That single fact is what breaks the naive approach, and 14 of the 39 tags in use appear exactly once, which means roughly a third of the vocabulary can never appear in an intersection at all.
Pitfall 1: TF-IDF over article bodies breaks on two of three languages
The approach most examples reach for is TF-IDF over the article text. The natural package implements it, and a published related-post classifier for Astro wraps it into a relatinator package that trains on title, description, tags and body.
The agent rejected it before any code was written, on one ground: this site publishes every article in English, Japanese and Traditional Chinese, and Japanese and Chinese do not put spaces between words. A whitespace tokeniser handed a Japanese body produces a handful of enormous pseudo-tokens rather than words. English would have scored correctly while the other two locales quietly produced garbage, with nothing failing and no error to notice.
That failure mode is not hypothetical here. The same site already hit it in its search layer, where Pagefind’s query-side segmentation for Japanese had to be patched out in scripts/patch-pagefind-ja.ts after katakana compounds started returning the wrong pages. A scoring bug of the same shape would have been harder to spot, because a related-posts list has no obviously correct answer to compare against.
Tags avoid the problem entirely. They are a closed enum of 41 slugs, identical across all three locale files by build-time enforcement, and language-neutral by construction.
Pitfall 2: raw tag overlap makes every Astro article related to every other
The obvious tag-based scorer counts shared tags. With astro on 12 of 32 articles, that scorer thinks all twelve are equally related to each other, because a shared astro is the only signal it can see. An article about the service worker and an article about table-of-contents scroll spy come out as close neighbours on the strength of sharing the site’s house tag.
What is missing is that sharing a rare tag is evidence and sharing a common one is not. Two articles that both carry pagefind, which sits on two articles, have almost certainly got something to do with each other. Two that both carry astro have established only that they are both on this blog.
Weighting a tag by how rare it is
That is inverse document frequency, and it is one line:
export function inverseDocumentFrequency( articles: readonly RelatedInput[],): Map<TagSlug, number> { const documentFrequency = new Map<TagSlug, number>();
for (const article of articles) { for (const tag of article.tags) { documentFrequency.set(tag, (documentFrequency.get(tag) ?? 0) + 1); } }
const idf = new Map<TagSlug, number>(); for (const [tag, frequency] of documentFrequency) { idf.set(tag, Math.log(articles.length / frequency)); } return idf;}At N = 32, that gives idf(pagefind) = log(32/2) ≈ 2.77 against idf(astro) = log(32/12) ≈ 0.98. Sharing pagefind counts almost three times as much as sharing astro, which is the intuition above written as arithmetic.
log(N / df).The score for a pair is the sum of the shared tags’ weights, divided by a length normaliser:
if (a.tags.length === 0 || b.tags.length === 0) return 0;
let overlap = 0;for (const tag of a.tags) { if (!b.tags.includes(tag)) continue; overlap += idf.get(tag)!;}
return overlap / Math.sqrt(a.tags.length * b.tags.length);The sqrt denominator is cosine-style length normalisation, and it is load-bearing rather than decorative. Tags per article range from two to six. Without it, the six-tag articles win every comparison on volume alone: more tags is more chances to intersect, whether or not the article is about the same thing.
The zero-tag guard is also not an optimisation. sqrt(0 * n) is 0, and an untagged article’s intersection is always empty, so the result would be 0 / 0. That NaN would in fact be filtered out downstream, because every comparison against NaN is false, but only by accident. The schema declares tags with .default([]), so the state is reachable.
Rejecting a fallback, on measurement rather than argument
The tag registry also groups its 41 tags into four families: AI and LLMs, cloud and platforms, web and browsers, tooling and languages. An obvious refinement is a small bonus when two articles share a family but no actual tag, so a narrowly-tagged article still gets suggestions.
The agent measured it instead of arguing about it, and the option turned out to be unreachable. For a group-only bonus to ever render, it must score at or above the cut-off. For it never to outrank a genuine tag overlap, it must score below the weakest real overlap, which was 0.327. Those two constraints leave an empty window.
It barely discriminates either. 147 of the 353 zero-overlap English pairs, 42% of them, share a group, so a flat bonus on that population degenerates into “newest first”. Narrowing it to the two articles’ primary tags cut it to 55 of 353, better and still coarse.
The article it was meant to rescue settled it. git-gc-loose-objects-vs-filter-repo-history-rewrite carries [git, webp] and has exactly one genuine match. Padding it from the group tier produced a Claude Code routines article, matched because automation and git both sit in the tooling family. A worse suggestion than showing one tile, so the branch was deleted rather than shipped as a setting nobody would ever turn on.
Setting the floor from the distribution rather than from taste
A cut-off is needed, or the top-N always returns N results no matter how weak. The question is what number, and the honest answer is that nobody can guess it.
So a throwaway script printed every score. Thirty-two articles make 496 possible pairs, and 143 of them shared at least one tag, scoring from 0.200 to 2.264. The number that sets the floor is not the minimum, though. It is 0.327, which is what two three-tag articles score when their only shared tag is astro:
log(32 / 12) / 3 = 0.327That is the pair the floor exists to reject. “Both of these mention Astro” is not a recommendation. A floor of 0.35 clears it, and the tiles-per-article distribution at that setting is:
| Floor | 0 tiles | 1 | 2 | 3 | 4+ |
|---|---|---|---|---|---|
| 0.25 | 0 | 1 | 0 | 1 | 30 |
| 0.35 | 0 | 1 | 1 | 3 | 27 |
| 0.45 | 0 | 2 | 1 | 6 | 23 |
| 0.55 | 0 | 4 | 5 | 8 | 15 |
At 0.55 only 15 of 32 articles still reach four or more, which is over-tuned. At 0.25 the astro-only pairs come back. The script that produced this became pnpm related:preview, kept precisely so the number can be re-derived rather than trusted.
That last column really is 4+ rather than exactly four, and the reason is the third defect in this article: the script capped its histogram at the narrow-viewport tile count instead of the layout’s six. So this table cannot say how many articles fill a full grid, only how many clear four. It is left as measured, because rewriting it against today’s larger corpus would quietly replace the evidence the floor was actually chosen from.
The floor rises as the archive grows
Nobody went looking for this one. It surfaced by accident during the release rather than during the work.
Merging required rebasing onto a newly published article. That moved N from 32 to 33, and re-running the preview showed the astro-only pair score had moved with it, from 0.327 to 0.3372. One article ate roughly a third of the headroom above a fixed floor of 0.35.
The expression is log(N / 12) / 3. It rises as the archive grows while the floor stays where it was put, and it crosses 0.35 at about N = 35. On that day every astro-only pair silently becomes a recommendation again. No error, no failing check, no visible change except that the suggestions get worse.
The constant now carries that arithmetic in a TODO, and the README entry for pnpm related:preview says to re-run it as the corpus grows. A floor picked by taste would have had the same problem and no way to notice it.
Moving the section outside the article column stopped the sidebar
The layout half had one genuinely surprising mechanic. The plan was a full-width strip, three cards across, which the article’s 68ch reading column cannot hold. The obvious obstacle was the sticky table of contents sitting in the right-hand gutter and the share rail in the left one, both of which would collide with a wider section.
The fix turned out to be the same act, and the causality runs the opposite way to the intuition. .page is the containing block for the sticky table of contents and for the absolutely-positioned rail. A sticky element cannot travel past the box that contains it. Render the section outside .page, and both stop at the end of the article by themselves, with no scroll listener and no extra CSS.
.page ends. The rail beside the table of contents is its sticky travel, and it is the container’s height rather than the content’s.Measured in the browser, the table of contents’ bottom edge, the rail’s bottom edge and the related section’s top edge all landed on the same pixel:
tocBottom: 88 railBottom: 88 relatedTop: 88The width came free with it. The stylesheet already carried a --measure-wide token at 76rem for the footer, with a comment explaining that the reading column stays narrow because that is a legibility limit and a footer is not read. Cards are scanned rather than read, so the same argument applied without needing a new token.
The description moved onto the cover, and the scrim was the wrong axis
The card treatment was my call, and it went against the agent’s recommendation. The agent argued for keeping a visible description under each title, on the evidence that the covers are per-topic art rather than per-article: measured across the archive, 24 of 31 pages showed at least two tiles sharing an identical cover image, with one page showing four tiles all carrying the same Claude mark. Without descriptions, the agent’s position was, those cards look like duplicates.
I ruled it out anyway and asked for the alternative that solves both problems: darken the thumbnail on hover and reveal the description over it. Cards stay uniform and the text is still there, which is the outcome neither option had on its own.
Getting the scrim right took three attempts and the first two were the agent being confidently wrong about its own arithmetic. Most thumbnails are a dark logo on flat white. At 86% opacity the logo read straight through the text. At 92% it still did. The agent computed that at 97% only 3% of the cover survives and declared that invisible, which is false: against a near-black field a 3% residue is a large relative luminance step, and human brightness perception is roughly logarithmic at that end of the range. Rendered side by side against a fully opaque control, 97% and 92% were indistinguishable from each other and both plainly wrong.
More opacity was never going to fix it, because opacity was the wrong axis. Blurring the cover destroys the logo as a shape, so the scrim above it no longer has to hide it as a value. The shipped rule is a 10px backdrop-filter blur at 86% opacity, behind an @supports guard whose fallback is fully opaque rather than 86% with the filter dropped, which would be worse than anything already rejected.
Three defects that a screenshot could not show
All three of these rendered perfectly in every visual check.
The overlay ate every click on the thumbnail. I caught this one from the browser, not from the code: hovering a thumbnail sometimes gave an arrow cursor instead of a pointer. The overlay sits at inset: 0 across the cover, and opacity: 0 does not stop an element receiving pointer events, so it sat between the cursor and the cover’s link at all times. Hit-testing confirmed it:
centreOfThumbnail: { tag: "P", cursor: "auto", insideCoverLink: false }The word “sometimes” was the diagnostic. The archive and tag pages use a different card with no overlay, so those kept working. One pointer-events: none fixed it, and the rule now carries a comment saying its absence is invisible in a screenshot, because the obvious future edit is to delete it as noise.
An empty wrapper still reserved 64px. Astro.slots.has('after') is true when a slot is passed, not when it renders anything, and the layout passes the component unconditionally. An article whose candidates all fell below the floor therefore emitted an empty wrapper carrying padding-block-end: 4rem, producing a blank band before the footer on exactly the pages the feature promises to leave clean. Moving the vertical space onto the component’s own section fixed it. A review pass caught this one, verified against the built HTML rather than the source.
A tool that could not check its own headline number. The preview script capped its histogram at four, which is the narrow-viewport tile cap, while the layout’s cap is six. Every article with four or more candidates collapsed into one bucket, so the script could not reproduce the “23 of 33 articles fill a six-tile grid” figure that the component and the floor constant both cite.
Verification
The scorer is deliberately free of value-level imports. Its only import is import type { TagSlug }, which Node erases during type-stripping, so the preview script loads the real scoring module under bare node rather than reimplementing it:
pnpm related:preview enpnpm related:preview jaThe best single test case in the archive is one article. git-gc-loose-objects-vs-filter-repo-history-rewrite carries [git, webp], and its only genuine match is png-to-webp-before-the-first-commit, which is published in English only. Both git and webp therefore drop to a document frequency of 1 in the other two locales, and a tag on exactly one article can never appear in an intersection. So that page renders one tile in English and no section at all in Japanese and Chinese, exercising the hiding path, the fewer-than-limit path and the per-locale frequency property in a single URL.
The other check worth naming is the search index. The related tiles carry other articles’ titles and descriptions, which would pollute the index if they were ever walked, so the section sits outside the element carrying data-pagefind-body. Rather than trust that, the built fragment was decompressed and read directly. The indexed content ends at the article’s own last sentence, with every neighbour title, the section heading and the share-row labels absent, and the page and word counts identical to a build of the same commit without the feature.
Summary
Related posts on a static trilingual site turned out to need no machine learning and no new dependency. The pieces that mattered:
- Weight each shared tag by
log(N / df)and normalise bysqrtof the two tag counts. Sharing a rare tag should count for more than sharing the house tag, and without the normaliser the most-tagged articles win everything. - Do not run TF-IDF over bodies on a multilingual corpus unless every language is whitespace-delimited. The failure is silent in exactly the locales you are least able to proofread.
- Set the cut-off from the score distribution, and write down the pair it is meant to reject. This one rejects two three-tag articles sharing only
astro, and that pair’s score rises as the archive grows. - Sticky travel is bounded by the containing block. Moving a section out of the article’s wrapper stops a sticky sidebar without any scroll code.
- A defect that renders correctly is the expensive kind. Two of the three found here were invisible in every screenshot taken.
References
- Build you a related post classifier for Astro, the TF-IDF-over-bodies approach this rejected
- Creating a Similar Posts component in Astro.js, the naive tag-match version
- Pagefind indexing documentation, including how data-pagefind-body restricts the index to tagged elements
- MDN on position: sticky, whose containing block is what bounds the travel
- Shopify on internal linking, where the three-to-five related items guidance comes from




