Skip to content

Commit 4df5d85

Browse files
authored
improvement(library): rank related posts by tag rarity, add backlinks, and widen comparison and pagination links (#9354)
* improvement(library): rank related posts by tag rarity, add backlinks, and widen comparison and pagination links * fix(library): keep every referencing post out of related posts, not just the six shown
1 parent 7f3a075 commit 4df5d85

15 files changed

Lines changed: 507 additions & 91 deletions

File tree

‎apps/sim/app/(landing)/blog/[slug]/page.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export default async function Page({ params }: { params: Promise<{ slug: string
3333
const { slug } = await params
3434
const post = await getPostBySlug(slug)
3535
if (!post || post.draft) notFound()
36-
const related = await getRelatedPosts(slug, 3)
36+
const related = await getRelatedPosts(slug)
3737

3838
return (
3939
<ContentPostPage

‎apps/sim/app/(landing)/comparisons/components/comparison-link-row/comparison-link-row.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Link from 'next/link'
22
import type { CompetitorProfile } from '@/lib/compare/data'
33
import { BrandIconTile } from '@/app/(landing)/comparisons/components/brand-icon-tile'
4+
import { comparisonPath } from '@/app/(landing)/comparisons/utils'
45
import { ChevronArrow } from '@/app/(landing)/components/chevron-arrow'
56

67
interface ComparisonLinkRowProps {
@@ -12,7 +13,7 @@ export function ComparisonLinkRow({ competitor }: ComparisonLinkRowProps) {
1213
const Icon = competitor.brand?.icon
1314
return (
1415
<Link
15-
href={`/comparisons/${competitor.id}`}
16+
href={comparisonPath(competitor)}
1617
className='group/link flex items-center gap-4 px-6 py-4 transition-colors hover-hover:bg-[var(--surface-hover)]'
1718
aria-label={`Sim vs ${competitor.name} comparison`}
1819
>
Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,44 @@
11
import { escapeRegExp } from '@sim/utils/string'
22
import type { CompetitorProfile } from '@/lib/compare/data'
33
import type { ContentMeta } from '@/lib/content/schema'
4-
import { getAllPostMeta } from '@/lib/library/registry'
5-
import { ALL_COMPETITORS } from '@/app/(landing)/comparisons/utils'
4+
import { getAllPostMeta, getPostsLinkingTo } from '@/lib/library/registry'
5+
import { ALL_COMPETITORS, comparisonPath } from '@/app/(landing)/comparisons/utils'
66

77
/** Most links either side renders, so the section stays a short "read next" list. */
8-
const MAX_LINKS = 3
8+
const MAX_LINKS = 6
99

10-
/** A competitor named in an article's title or tags is its subject; one named only in the description is a mention. */
11-
const SUBJECT_SCORE = 2
10+
/**
11+
* An article titled "{Competitor} Alternatives" answers the same buyer question as the
12+
* comparison page, so it leads, then articles about the competitor, articles linking to its
13+
* comparison page, and articles naming it only in the description.
14+
*/
15+
const ALTERNATIVES_SCORE = 4
16+
const SUBJECT_SCORE = 3
17+
const LINKS_TO_COMPARISON_SCORE = 2
1218
const MENTION_SCORE = 1
1319

1420
interface MentionPatterns {
21+
/** Matches a title naming the competitor's alternatives ("n8n Alternatives", "LangChain and LangGraph Alternatives"). */
22+
alternatives: RegExp
1523
/** Matches Title Case titles and tags, where a bare common-word name ("Make") is ambiguous. */
1624
subject: RegExp
1725
/** Matches sentence-case descriptions, where the bare name is unambiguous. */
1826
mention: RegExp
1927
}
2028

29+
function phraseAlternation(phrases: string[]): string {
30+
return `(?:${phrases.map(escapeRegExp).join('|')})`
31+
}
32+
2133
/** Whole-word, case-sensitive match on any of `phrases`. */
2234
function wordPattern(phrases: string[]): RegExp {
23-
return new RegExp(`\\b(?:${phrases.map(escapeRegExp).join('|')})\\b`)
35+
return new RegExp(`\\b${phraseAlternation(phrases)}\\b`)
2436
}
2537

2638
function buildMentionPatterns(competitor: CompetitorProfile): MentionPatterns {
2739
const aliases = competitor.mentions ?? [competitor.name]
2840
return {
41+
alternatives: new RegExp(`\\b${phraseAlternation(aliases)}(?: and [^,:]+?)? Alternatives\\b`),
2942
subject: wordPattern(aliases),
3043
mention: wordPattern([competitor.name, ...aliases]),
3144
}
@@ -36,8 +49,14 @@ const COMPETITOR_PATTERNS = ALL_COMPETITORS.map((competitor) => ({
3649
patterns: buildMentionPatterns(competitor),
3750
}))
3851

39-
function scoreRelevance(post: ContentMeta, { subject, mention }: MentionPatterns): number {
52+
function scoreRelevance(
53+
post: ContentMeta,
54+
{ alternatives, subject, mention }: MentionPatterns,
55+
linksToComparison: boolean
56+
): number {
57+
if (alternatives.test(post.title)) return ALTERNATIVES_SCORE
4058
if (subject.test(post.title) || post.tags.some((tag) => subject.test(tag))) return SUBJECT_SCORE
59+
if (linksToComparison) return LINKS_TO_COMPARISON_SCORE
4160
return mention.test(post.description) ? MENTION_SCORE : 0
4261
}
4362

@@ -52,22 +71,30 @@ function topByScore<T>(items: T[], score: (item: T) => number): T[] {
5271
}
5372

5473
/**
55-
* Library articles about or naming `competitor`, articles where it is the
56-
* subject first, then newest first.
74+
* Library articles about, linking to, or naming `competitor`, strongest match first, then
75+
* newest first.
5776
*/
5877
export async function getLibraryPostsForCompetitor(
5978
competitor: CompetitorProfile
6079
): Promise<ContentMeta[]> {
6180
const patterns = buildMentionPatterns(competitor)
62-
return topByScore(await getAllPostMeta(), (post) => scoreRelevance(post, patterns))
81+
const [posts, linkingPosts] = await Promise.all([
82+
getAllPostMeta(),
83+
getPostsLinkingTo(comparisonPath(competitor)),
84+
])
85+
const linkingSlugs = new Set(linkingPosts.map((post) => post.slug))
86+
return topByScore(posts, (post) => scoreRelevance(post, patterns, linkingSlugs.has(post.slug)))
6387
}
6488

6589
/**
66-
* Comparison pages for the competitors a library article is about or names,
67-
* subjects first, then in {@link ALL_COMPETITORS} order.
90+
* Comparison pages for the competitors a library article is about, links to (`linkedPaths`,
91+
* from its body), or names, strongest match first, then in {@link ALL_COMPETITORS} order.
6892
*/
69-
export function getComparisonsForPost(post: ContentMeta): CompetitorProfile[] {
70-
return topByScore(COMPETITOR_PATTERNS, ({ patterns }) => scoreRelevance(post, patterns)).map(
71-
({ competitor }) => competitor
72-
)
93+
export function getComparisonsForPost(
94+
post: ContentMeta,
95+
linkedPaths: ReadonlySet<string>
96+
): CompetitorProfile[] {
97+
return topByScore(COMPETITOR_PATTERNS, ({ competitor, patterns }) =>
98+
scoreRelevance(post, patterns, linkedPaths.has(comparisonPath(competitor)))
99+
).map(({ competitor }) => competitor)
73100
}

‎apps/sim/app/(landing)/comparisons/utils.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ export const ALL_COMPETITORS: CompetitorProfile[] = [
5454

5555
const COMPETITOR_BY_SLUG = new Map(ALL_COMPETITORS.map((c) => [c.id, c]))
5656

57+
/** Path of the Sim vs `competitor` comparison page. */
58+
export function comparisonPath(competitor: CompetitorProfile): string {
59+
return `/comparisons/${competitor.id}`
60+
}
61+
5762
export function getCompetitorBySlug(slug: string): CompetitorProfile | null {
5863
return COMPETITOR_BY_SLUG.get(slug) ?? null
5964
}

‎apps/sim/app/(landing)/components/content-index-page/content-index-page.tsx‎

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,12 @@ export function ContentIndexPage({
3636
}: ContentIndexPageProps) {
3737
const { featured, remaining, totalPages } = paginateContentPosts(posts, { tag, page })
3838

39-
const pageHref = (targetPage: number) =>
40-
`${basePath}?page=${targetPage}${tag ? `&tag=${encodeURIComponent(tag)}` : ''}`
39+
const tagQuery = tag ? `tag=${encodeURIComponent(tag)}` : ''
40+
const pageHref = (targetPage: number) => {
41+
if (targetPage === 1) return tagQuery ? `${basePath}?${tagQuery}` : basePath
42+
return `${basePath}?page=${targetPage}${tagQuery ? `&${tagQuery}` : ''}`
43+
}
44+
const pageNumbers = Array.from({ length: totalPages }, (_, index) => index + 1)
4145

4246
return (
4347
<>
@@ -138,7 +142,7 @@ export function ContentIndexPage({
138142

139143
{totalPages > 1 && (
140144
<nav aria-label='Pagination' className='px-6 py-8'>
141-
<div className='flex items-center justify-center gap-3'>
145+
<div className='flex flex-wrap items-center justify-center gap-3'>
142146
{page > 1 && (
143147
<ChipLink
144148
href={pageHref(page - 1)}
@@ -148,9 +152,20 @@ export function ContentIndexPage({
148152
Previous
149153
</ChipLink>
150154
)}
151-
<span className='text-[var(--text-secondary)] text-sm'>
152-
Page {page} of {totalPages}
153-
</span>
155+
<ol className='flex flex-wrap items-center justify-center gap-1'>
156+
{pageNumbers.map((pageNumber) => (
157+
<li key={pageNumber}>
158+
<ChipLink
159+
href={pageHref(pageNumber)}
160+
active={pageNumber === page}
161+
aria-current={pageNumber === page ? 'page' : undefined}
162+
aria-label={`Page ${pageNumber}`}
163+
>
164+
{pageNumber}
165+
</ChipLink>
166+
</li>
167+
))}
168+
</ol>
154169
{page < totalPages && (
155170
<ChipLink
156171
href={pageHref(page + 1)}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import Link from 'next/link'
2+
import type { ContentMeta } from '@/lib/content/schema'
3+
import { ChevronArrow } from '@/app/(landing)/components/chevron-arrow'
4+
import { formatPostDate } from '@/app/(landing)/components/content-utils'
5+
6+
interface ContentBacklinksProps {
7+
/** Route base path of the posts' section, e.g. `/library`. */
8+
basePath: string
9+
/** Posts whose body links to the current post. */
10+
posts: ContentMeta[]
11+
}
12+
13+
/** "Referenced in" list linking a post to the posts that cite it. */
14+
export function ContentBacklinks({ basePath, posts }: ContentBacklinksProps) {
15+
return (
16+
<section aria-labelledby='backlinks-heading'>
17+
<h2
18+
id='backlinks-heading'
19+
className='mb-4 text-[var(--text-primary)] text-xl leading-[100%] tracking-[-0.02em] lg:text-2xl'
20+
>
21+
Referenced in
22+
</h2>
23+
<ul className='divide-y divide-[var(--border)] border-[var(--border)] border-y'>
24+
{posts.map((post) => (
25+
<li key={post.slug}>
26+
<Link
27+
href={`${basePath}/${post.slug}`}
28+
className='group/link flex items-center gap-4 px-6 py-4 transition-colors hover-hover:bg-[var(--surface-hover)]'
29+
>
30+
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
31+
<h3 className='text-[var(--text-primary)] text-sm leading-snug tracking-[-0.02em]'>
32+
{post.title}
33+
</h3>
34+
<time
35+
dateTime={post.date}
36+
className='text-[var(--text-muted)] text-caption leading-[150%]'
37+
>
38+
{formatPostDate(post.date)}
39+
</time>
40+
</div>
41+
<ChevronArrow />
42+
</Link>
43+
</li>
44+
))}
45+
</ul>
46+
</section>
47+
)
48+
}

‎apps/sim/app/(landing)/components/content-post-page/content-related-posts.tsx‎

Lines changed: 34 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,44 +11,46 @@ interface ContentRelatedPostsProps {
1111
label?: string
1212
}
1313

14-
/** Row of post cards (cover, date, title, description) linking to other posts in a content section. */
14+
/** Grid of post cards (cover, date, title, description) linking to other posts in a content section. */
1515
export function ContentRelatedPosts({
1616
basePath,
1717
posts,
1818
label = 'Related posts',
1919
}: ContentRelatedPostsProps) {
2020
return (
21-
<nav aria-label={label} className='flex flex-col sm:flex-row'>
22-
{posts.map((p) => (
23-
<Link
24-
key={p.slug}
25-
href={`${basePath}/${p.slug}`}
26-
className='group flex flex-1 flex-col gap-4 border-[var(--border)] border-t p-6 transition-colors first:border-t-0 hover:bg-[var(--surface-hover)] sm:border-t-0 sm:border-l sm:first:border-l-0'
27-
>
28-
<div className='relative aspect-video w-full overflow-hidden rounded-[5px]'>
29-
<Image
30-
src={p.ogImage}
31-
alt={p.title}
32-
fill
33-
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
34-
className='object-cover'
35-
loading='lazy'
36-
unoptimized
37-
/>
38-
</div>
39-
<div className='flex flex-col gap-2'>
40-
<span className='text-[var(--text-secondary)] text-xs uppercase tracking-[0.1em]'>
41-
{formatPostDate(p.date)}
42-
</span>
43-
<h3 className='text-[var(--text-primary)] text-lg leading-tight tracking-[-0.01em]'>
44-
{p.title}
45-
</h3>
46-
<p className='line-clamp-2 text-[var(--text-secondary)] text-sm leading-[150%]'>
47-
{p.description}
48-
</p>
49-
</div>
50-
</Link>
51-
))}
21+
<nav aria-label={label} className='overflow-hidden'>
22+
<div className='-mt-px -ml-px grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3'>
23+
{posts.map((p) => (
24+
<Link
25+
key={p.slug}
26+
href={`${basePath}/${p.slug}`}
27+
className='group flex flex-col gap-4 border-[var(--border)] border-t border-l p-6 transition-colors hover-hover:bg-[var(--surface-hover)]'
28+
>
29+
<div className='relative aspect-video w-full overflow-hidden rounded-[5px]'>
30+
<Image
31+
src={p.ogImage}
32+
alt={p.title}
33+
fill
34+
sizes='(max-width: 639px) 100vw, (max-width: 1023px) 50vw, 33vw'
35+
className='object-cover'
36+
loading='lazy'
37+
unoptimized
38+
/>
39+
</div>
40+
<div className='flex flex-col gap-2'>
41+
<span className='text-[var(--text-secondary)] text-xs uppercase tracking-[0.1em]'>
42+
{formatPostDate(p.date)}
43+
</span>
44+
<h3 className='text-[var(--text-primary)] text-lg leading-tight tracking-[-0.01em]'>
45+
{p.title}
46+
</h3>
47+
<p className='line-clamp-2 text-[var(--text-secondary)] text-sm leading-[150%]'>
48+
{p.description}
49+
</p>
50+
</div>
51+
</Link>
52+
))}
53+
</div>
5254
</nav>
5355
)
5456
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
export { ContentBacklinks } from './content-backlinks'
12
export { ContentPostPage } from './content-post-page'
23
export { ContentRelatedPosts } from './content-related-posts'

‎apps/sim/app/(landing)/components/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ export { ChevronArrow } from './chevron-arrow'
44
export { ContentAuthorPage } from './content-author-page'
55
export { ContentImage } from './content-image'
66
export { ContentIndexLoading, ContentIndexPage } from './content-index-page'
7-
export { ContentPostPage, ContentRelatedPosts } from './content-post-page'
7+
export { ContentBacklinks, ContentPostPage, ContentRelatedPosts } from './content-post-page'
88
export { ContentTagsLoading, ContentTagsPage } from './content-tags-page'
99
export { Cta } from './cta/cta'
1010
export { FeaturedCustomer } from './featured-customer'

‎apps/sim/app/(landing)/content-routing.test.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ vi.mock('@/lib/library/registry', () => ({
1616
getPostBySlug,
1717
getAllPostMeta: vi.fn().mockResolvedValue([]),
1818
getRelatedPosts: vi.fn().mockResolvedValue([]),
19+
getPostsLinkingTo: vi.fn().mockResolvedValue([]),
20+
getLinkedPaths: vi.fn().mockResolvedValue(new Set()),
1921
}))
2022
vi.mock('@/lib/blog/seo', () => ({
2123
BLOG_SECTION: { basePath: '/blog' },

0 commit comments

Comments
 (0)