# 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.

- Source: https://oharu121.com/blog/astro-related-posts-tag-overlap-idf-weighting-score-floor/
- Published: 2026-08-24T09:22:48+09:00
- Tags: Astro, TypeScript, i18n, SEO

---
**Key takeaways**

- Tag overlap weighted by how rare each tag is produces usable related posts with no embeddings, no body index, and no new dependency.
- TF-IDF over article text fails silently on a trilingual site. Japanese and Chinese do not split on whitespace, so English scores correctly while the other two degrade to noise.
- A score floor chosen by taste cannot be checked. This one was set from the distribution of real scores, and it turned out to have about two articles of headroom before it stops working.
- Rendering the section outside the article column makes a sticky sidebar stop travelling on its own, because sticky travel is bounded by the containing block rather than by any scroll handler.

## 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](https://darko.io/posts/build-you-a-related-post-classifier/) 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:

```ts title="src/lib/related.ts"
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.

*Figure — IdfWeighting: Raw overlap gives both shared tags the same weight, so it cannot tell the house tag from the rare one. The weighted column is the same pair scored by `log(N / df)`.*

The score for a pair is the sum of the shared tags' weights, divided by a length normaliser:

```ts title="src/lib/related.ts"
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.327
```

That 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.

*Figure — StickyContainingBlock: The two arrangements differ only in where `.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: 88
```

The 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.

*Figure — ScrimLadder: Each swatch draws a real scrim at the stated opacity over the same mock cover, so the first two panels show the residue that the arithmetic said would not be there.*

## 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:

```bash
pnpm related:preview en
pnpm related:preview ja
```

The 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 by `sqrt` of 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](https://darko.io/posts/build-you-a-related-post-classifier/)
- [Creating a Similar Posts component in Astro.js, the naive tag-match version](https://www.joshfinnie.com/blog/creating-a-similar-posts-component-in-astrojs/)
- [Pagefind indexing documentation, including how data-pagefind-body restricts the index to tagged elements](https://pagefind.app/docs/indexing/)
- [MDN on position: sticky, whose containing block is what bounds the travel](https://developer.mozilla.org/en-US/docs/Web/CSS/position#sticky_positioning)
- [Shopify on internal linking, where the three-to-five related items guidance comes from](https://www.shopify.com/blog/internal-links-seo)
