As a developer working with Astro, I recently implemented a related posts feature for my blog using a straightforward yet effective approach. The implementation combines tag matching and title similarity to create meaningful content recommendations.
Implementation Overview
The core functionality relies on two main components: tag matching and title similarity comparison. Here’s the primary function that handles the related posts generation:
import { type CollectionEntry } from 'astro:content';
export const getRelatedPosts = (
post: CollectionEntry<'posts'>,
postList: CollectionEntry<'posts'>[],
) => {
return postList
.filter((p) => p.slug !== post.slug)
.map((p) => {
const tagPoint = post.data.tags
? post.data.tags.filter((tag) => p.data.tags?.includes(tag)).length
: 0;
const titlePoint = compareTwoStrings(post.data.title, p.data.title);
return {
post: p,
similarity: tagPoint + 3.0 * titlePoint,
};
})
.toSorted((a, b) => b.similarity - a.similarity)
.map((p) => p.post)
.slice(0, 4);
};tsThe algorithm calculates a similarity score based on two factors: the number of matching tags and the title similarity. Title similarity is given more weight (3x) in the final calculation to emphasize content relevance.
Title Similarity Calculation
For title comparison, I implemented the Dice-Sørensen coefficient algorithm, which provides an effective way to measure string similarity. Here’s the implementation:
// Finds degree of similarity between two strings, based on Dice's Coefficient algorithm.
// @see https://github.com/aceakash/string-similarity/blob/master/src/index.js
function function removeWhitespace(str: string): stringremoveWhitespace(str: stringstr: string): string {
return str: stringstr.String.replace(searchValue: {
[Symbol.replace](string: string, replaceValue: string): string;
}, replaceValue: string): string (+3 overloads)
Passes a string and
{@linkcode
replaceValue
}
to the `[Symbol.replace]` method on
{@linkcode
searchValue
}
. This method is expected to implement its own replacement algorithm.replace(/\s+/g, '');
}
function function createBigrams(str: string): Map<string, number>createBigrams(str: stringstr: string): interface Map<K, V>Map<string, number> {
const const bigrams: Map<string, number>bigrams = new var Map: MapConstructor
new <string, number>(iterable?: Iterable<readonly [string, number]> | null | undefined) => Map<string, number> (+3 overloads)
Map<string, number>();
for (let let i: numberi = 0; let i: numberi < str: stringstr.String.length: numberReturns the length of a String object.length - 1; let i: numberi++) {
const const bigram: stringbigram = str: stringstr.String.substring(start: number, end?: number): stringReturns the substring at the specified location within a String object.substring(let i: numberi, let i: numberi + 2);
const const count: numbercount = const bigrams: Map<string, number>bigrams.Map<string, number>.has(key: string): booleanhas(const bigram: stringbigram) ? const bigrams: Map<string, number>bigrams.Map<string, number>.get(key: string): number | undefinedReturns a specified element from the Map object. If the value that is associated to the provided key is an object, then you will get a reference to that object and any change made to that object will effectively modify it inside the Map.get(const bigram: stringbigram)! + 1 : 1;
const bigrams: Map<string, number>bigrams.Map<string, number>.set(key: string, value: number): Map<string, number>Adds a new element with a specified key and value to the Map. If an element with the same key already exists, the element will be updated.set(const bigram: stringbigram, const count: numbercount);
}
return const bigrams: Map<string, number>bigrams;
}
function function getIntersectionSize(first: Map<string, number>, second: Map<string, number>): numbergetIntersectionSize(
first: Map<string, number>first: interface Map<K, V>Map<string, number>,
second: Map<string, number>second: interface Map<K, V>Map<string, number>,
): number {
let let intersectionSize: numberintersectionSize = 0;
for (const [const bigram: stringbigram, const count: numbercount] of second: Map<string, number>second) {
if (first: Map<string, number>first.Map<string, number>.has(key: string): booleanhas(const bigram: stringbigram)) {
let intersectionSize: numberintersectionSize += var Math: MathAn intrinsic object that provides basic mathematics functionality and constants.Math.Math.min(...values: number[]): numberReturns the smaller of a set of supplied numeric expressions.min(const count: numbercount, first: Map<string, number>first.Map<string, number>.get(key: string): number | undefinedReturns a specified element from the Map object. If the value that is associated to the provided key is an object, then you will get a reference to that object and any change made to that object will effectively modify it inside the Map.get(const bigram: stringbigram)!);
}
}
return let intersectionSize: numberintersectionSize;
}
export function function compareTwoStrings(str1: string, str2: string): numbercompareTwoStrings(str1: stringstr1: string, str2: stringstr2: string): number {
const const first: stringfirst = function removeWhitespace(str: string): stringremoveWhitespace(str1: stringstr1);
const const second: stringsecond = function removeWhitespace(str: string): stringremoveWhitespace(str2: stringstr2);
if (const first: stringfirst === const second: stringsecond) return 1;
if (const first: stringfirst.String.length: numberReturns the length of a String object.length < 2 || const second: stringsecond.String.length: numberReturns the length of a String object.length < 2) return 0;
const const firstBigrams: Map<string, number>firstBigrams = function createBigrams(str: string): Map<string, number>createBigrams(const first: stringfirst);
const const secondBigrams: Map<string, number>secondBigrams = function createBigrams(str: string): Map<string, number>createBigrams(const second: stringsecond);
const const intersectionSize: numberintersectionSize = function getIntersectionSize(first: Map<string, number>, second: Map<string, number>): numbergetIntersectionSize(const firstBigrams: Map<string, number>firstBigrams, const secondBigrams: Map<string, number>secondBigrams);
return (2.0 * const intersectionSize: numberintersectionSize) / (const first: stringfirst.String.length: numberReturns the length of a String object.length + const second: stringsecond.String.length: numberReturns the length of a String object.length - 2);
}tsThis algorithm works by creating bigrams (pairs of consecutive letters) from the strings and comparing their overlap, providing a similarity score between 0 and 1.
The combination of tag matching and title similarity ensures that the related posts feature suggests content that is both topically relevant and contextually similar to the current post. The implementation is efficient and can be easily integrated into any Astro-based blog or content website.