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);
};
ts
The 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): string
removeWhitespace(str: string
str: string): string {
return str: string
str.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: string
str: 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: number
i = 0; let i: number
i < str: string
str.String.length: number
Returns the length of a String object.length - 1; let i: number
i++) {
const const bigram: string
bigram = str: string
str.String.substring(start: number, end?: number): string
Returns the substring at the specified location within a String object.substring(let i: number
i, let i: number
i + 2);
const const count: number
count = const bigrams: Map<string, number>
bigrams.Map<string, number>.has(key: string): boolean
has(const bigram: string
bigram) ? const bigrams: Map<string, number>
bigrams.Map<string, number>.get(key: string): number | undefined
Returns 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: string
bigram)! + 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: string
bigram, const count: number
count);
}
return const bigrams: Map<string, number>
bigrams;
}
function function getIntersectionSize(first: Map<string, number>, second: Map<string, number>): number
getIntersectionSize(
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: number
intersectionSize = 0;
for (const [const bigram: string
bigram, const count: number
count] of second: Map<string, number>
second) {
if (first: Map<string, number>
first.Map<string, number>.has(key: string): boolean
has(const bigram: string
bigram)) {
let intersectionSize: number
intersectionSize += var Math: Math
An intrinsic object that provides basic mathematics functionality and constants.Math.Math.min(...values: number[]): number
Returns the smaller of a set of supplied numeric expressions.min(const count: number
count, first: Map<string, number>
first.Map<string, number>.get(key: string): number | undefined
Returns 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: string
bigram)!);
}
}
return let intersectionSize: number
intersectionSize;
}
export function function compareTwoStrings(str1: string, str2: string): number
compareTwoStrings(str1: string
str1: string, str2: string
str2: string): number {
const const first: string
first = function removeWhitespace(str: string): string
removeWhitespace(str1: string
str1);
const const second: string
second = function removeWhitespace(str: string): string
removeWhitespace(str2: string
str2);
if (const first: string
first === const second: string
second) return 1;
if (const first: string
first.String.length: number
Returns the length of a String object.length < 2 || const second: string
second.String.length: number
Returns 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: string
first);
const const secondBigrams: Map<string, number>
secondBigrams = function createBigrams(str: string): Map<string, number>
createBigrams(const second: string
second);
const const intersectionSize: number
intersectionSize = function getIntersectionSize(first: Map<string, number>, second: Map<string, number>): number
getIntersectionSize(const firstBigrams: Map<string, number>
firstBigrams, const secondBigrams: Map<string, number>
secondBigrams);
return (2.0 * const intersectionSize: number
intersectionSize) / (const first: string
first.String.length: number
Returns the length of a String object.length + const second: string
second.String.length: number
Returns the length of a String object.length - 2);
}
ts
This 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.