How to generate related posts in Astro

Nov 24, 2024 · 3 min read

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:

dice-coefficient.ts
// 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.
@paramsearchValue An object that supports searching for and replacing matches within a string.@paramreplaceValue The replacement text.
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: number
Returns the length of a String object.
length
- 1; let i: numberi++) {
const const bigram: stringbigram = str: stringstr.String.substring(start: number, end?: number): string
Returns the substring at the specified location within a String object.
@paramstart The zero-based index number indicating the beginning of the substring.@paramend Zero-based index number indicating the end of the substring. The substring includes the characters up to, but not including, the character indicated by end. If end is omitted, the characters from start through the end of the original string are returned.
substring
(let i: numberi, let i: numberi + 2);
const const count: numbercount = const bigrams: Map<string, number>bigrams.Map<string, number>.has(key: string): boolean
@returnsboolean indicating whether an element with the specified key exists or not.
has
(const bigram: stringbigram) ? 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.
@returnsReturns the element associated with the specified key. If no element is associated with the specified key, undefined is returned.
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): boolean
@returnsboolean indicating whether an element with the specified key exists or not.
has
(const bigram: stringbigram)) {
let intersectionSize: numberintersectionSize += 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.
@paramvalues Numeric expressions to be evaluated.
min
(const count: numbercount, 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.
@returnsReturns the element associated with the specified key. If no element is associated with the specified key, undefined is returned.
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: number
Returns the length of a String object.
length
< 2 || const second: stringsecond.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: 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: number
Returns the length of a String object.
length
+ const second: stringsecond.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.


Share this article with your friends

Bluesky XformerlyTwitter LinkedIn Reddit
프로필 이미지

frndhoon

친근함으로 연결하는, 프론트엔드 개발자