From 74988ffd1f467e8976c5001981ad931a68268330 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Fri, 13 Feb 2026 06:19:37 -0700 Subject: [PATCH] Add related books section and linkable topic badges --- site/src/components/RelatedBooks.astro | 70 ++++++++++++++++++++++++++ site/src/lib/related-books.ts | 45 +++++++++++++++++ site/src/pages/mims/[slug].astro | 21 ++++---- site/src/pages/uglys/[slug].astro | 21 ++++---- 4 files changed, 139 insertions(+), 18 deletions(-) create mode 100644 site/src/components/RelatedBooks.astro create mode 100644 site/src/lib/related-books.ts diff --git a/site/src/components/RelatedBooks.astro b/site/src/components/RelatedBooks.astro new file mode 100644 index 0000000..f24ec61 --- /dev/null +++ b/site/src/components/RelatedBooks.astro @@ -0,0 +1,70 @@ +--- +import { Badge } from '@/components/ui/badge'; +import type { CollectionEntry } from 'astro:content'; + +interface RelatedBookResult { + book: CollectionEntry<'books'>; + sharedTopics: string[]; + overlapCount: number; +} + +interface Props { + relatedBooks: RelatedBookResult[]; + currentCollection: string; +} + +const { relatedBooks, currentCollection } = Astro.props; +--- + +{relatedBooks.length > 0 && ( +
+

Related Notebooks

+
+ {relatedBooks.map(({ book, sharedTopics }) => { + const slug = book.slug.split('/').pop(); + const isCrossCollection = book.data.collection !== currentCollection; + return ( + +
+ {book.data.coverImage ? ( + {`Cover + ) : ( +
+ + + + +
+ )} + {isCrossCollection && ( +
+ {book.data.collection === 'mims' ? 'Mims' : "Ugly's"} +
+ )} +
+
+

+ {book.data.shortTitle} +

+
+ {sharedTopics.slice(0, 2).map((topic) => ( + + {topic.replace(/-/g, ' ')} + + ))} +
+
+
+ ); + })} +
+
+)} diff --git a/site/src/lib/related-books.ts b/site/src/lib/related-books.ts new file mode 100644 index 0000000..2c16247 --- /dev/null +++ b/site/src/lib/related-books.ts @@ -0,0 +1,45 @@ +import type { CollectionEntry } from 'astro:content'; + +interface RelatedBookResult { + book: CollectionEntry<'books'>; + sharedTopics: string[]; + overlapCount: number; +} + +function normalizeTopic(topic: string): string { + return topic.toLowerCase().replace(/[\s_]+/g, '-'); +} + +export function getRelatedBooks( + currentBook: CollectionEntry<'books'>, + allBooks: CollectionEntry<'books'>[], + maxResults = 4 +): RelatedBookResult[] { + const currentTopics = new Set(currentBook.data.topics.map(normalizeTopic)); + + return allBooks + .filter((b) => b.slug !== currentBook.slug) + .map((book) => { + const bookTopics = new Set(book.data.topics.map(normalizeTopic)); + const sharedTopics = [...currentTopics].filter((t) => bookTopics.has(t)); + const union = new Set([...currentTopics, ...bookTopics]); + const jaccard = union.size > 0 ? sharedTopics.length / union.size : 0; + + return { + book, + sharedTopics: sharedTopics.map((t) => + book.data.topics.find((orig) => normalizeTopic(orig) === t) || t + ), + overlapCount: sharedTopics.length, + jaccard, + }; + }) + .filter((r) => r.overlapCount > 0) + .sort((a, b) => { + if (b.overlapCount !== a.overlapCount) return b.overlapCount - a.overlapCount; + if (b.jaccard !== a.jaccard) return b.jaccard - a.jaccard; + return a.book.data.sortOrder - b.book.data.sortOrder; + }) + .slice(0, maxResults) + .map(({ book, sharedTopics, overlapCount }) => ({ book, sharedTopics, overlapCount })); +} diff --git a/site/src/pages/mims/[slug].astro b/site/src/pages/mims/[slug].astro index 3a5ae76..d236f0b 100644 --- a/site/src/pages/mims/[slug].astro +++ b/site/src/pages/mims/[slug].astro @@ -3,6 +3,8 @@ import Layout from '@/layouts/Layout.astro'; import { Badge } from '@/components/ui/badge'; import EBookReader from '@/components/EBookReader'; import { getCollection, type CollectionEntry } from 'astro:content'; +import { getRelatedBooks } from '@/lib/related-books'; +import RelatedBooks from '@/components/RelatedBooks.astro'; export async function getStaticPaths() { const books = await getCollection('books'); @@ -30,6 +32,7 @@ const mimsBooks = allBooks const currentIndex = mimsBooks.findIndex(b => b.slug === book.slug); const prevBook = currentIndex > 0 ? mimsBooks[currentIndex - 1] : null; const nextBook = currentIndex < mimsBooks.length - 1 ? mimsBooks[currentIndex + 1] : null; +const relatedBooks = getRelatedBooks(book, allBooks); --- @@ -68,16 +71,13 @@ const nextBook = currentIndex < mimsBooks.length - 1 ? mimsBooks[currentIndex +
- {topics.slice(0, 5).map((topic) => ( - - {topic.replace(/-/g, ' ')} - + {topics.map((topic) => ( + + + {topic.replace(/-/g, ' ')} + + ))} - {topics.length > 5 && ( - - +{topics.length - 5} more - - )}
@@ -124,6 +124,9 @@ const nextBook = currentIndex < mimsBooks.length - 1 ? mimsBooks[currentIndex + /> + + +
{prevBook ? ( diff --git a/site/src/pages/uglys/[slug].astro b/site/src/pages/uglys/[slug].astro index 7f8f14e..3472035 100644 --- a/site/src/pages/uglys/[slug].astro +++ b/site/src/pages/uglys/[slug].astro @@ -3,6 +3,8 @@ import Layout from '@/layouts/Layout.astro'; import { Badge } from '@/components/ui/badge'; import EBookReader from '@/components/EBookReader'; import { getCollection, type CollectionEntry } from 'astro:content'; +import { getRelatedBooks } from '@/lib/related-books'; +import RelatedBooks from '@/components/RelatedBooks.astro'; export async function getStaticPaths() { const books = await getCollection('books'); @@ -30,6 +32,7 @@ const uglysBooks = allBooks const currentIndex = uglysBooks.findIndex(b => b.slug === book.slug); const prevBook = currentIndex > 0 ? uglysBooks[currentIndex - 1] : null; const nextBook = currentIndex < uglysBooks.length - 1 ? uglysBooks[currentIndex + 1] : null; +const relatedBooks = getRelatedBooks(book, allBooks); --- @@ -68,16 +71,13 @@ const nextBook = currentIndex < uglysBooks.length - 1 ? uglysBooks[currentIndex
- {topics.slice(0, 5).map((topic) => ( - - {topic.replace(/-/g, ' ')} - + {topics.map((topic) => ( + + + {topic.replace(/-/g, ' ')} + + ))} - {topics.length > 5 && ( - - +{topics.length - 5} more - - )}
@@ -124,6 +124,9 @@ const nextBook = currentIndex < uglysBooks.length - 1 ? uglysBooks[currentIndex /> + + +
{prevBook ? (