Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/sim/app/(landing)/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export default async function Page({ params }: { params: Promise<{ slug: string
const { slug } = await params
const post = await getPostBySlug(slug)
if (!post || post.draft) notFound()
const related = await getRelatedPosts(slug, 3)
const related = await getRelatedPosts(slug)

return (
<ContentPostPage
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Link from 'next/link'
import type { CompetitorProfile } from '@/lib/compare/data'
import { BrandIconTile } from '@/app/(landing)/comparisons/components/brand-icon-tile'
import { comparisonPath } from '@/app/(landing)/comparisons/utils'
import { ChevronArrow } from '@/app/(landing)/components/chevron-arrow'

interface ComparisonLinkRowProps {
Expand All @@ -12,7 +13,7 @@ export function ComparisonLinkRow({ competitor }: ComparisonLinkRowProps) {
const Icon = competitor.brand?.icon
return (
<Link
href={`/comparisons/${competitor.id}`}
href={comparisonPath(competitor)}
className='group/link flex items-center gap-4 px-6 py-4 transition-colors hover-hover:bg-[var(--surface-hover)]'
aria-label={`Sim vs ${competitor.name} comparison`}
>
Expand Down
59 changes: 43 additions & 16 deletions apps/sim/app/(landing)/comparisons/library-links.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,44 @@
import { escapeRegExp } from '@sim/utils/string'
import type { CompetitorProfile } from '@/lib/compare/data'
import type { ContentMeta } from '@/lib/content/schema'
import { getAllPostMeta } from '@/lib/library/registry'
import { ALL_COMPETITORS } from '@/app/(landing)/comparisons/utils'
import { getAllPostMeta, getPostsLinkingTo } from '@/lib/library/registry'
import { ALL_COMPETITORS, comparisonPath } from '@/app/(landing)/comparisons/utils'

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

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

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

function phraseAlternation(phrases: string[]): string {
return `(?:${phrases.map(escapeRegExp).join('|')})`
}

/** Whole-word, case-sensitive match on any of `phrases`. */
function wordPattern(phrases: string[]): RegExp {
return new RegExp(`\\b(?:${phrases.map(escapeRegExp).join('|')})\\b`)
return new RegExp(`\\b${phraseAlternation(phrases)}\\b`)
}

function buildMentionPatterns(competitor: CompetitorProfile): MentionPatterns {
const aliases = competitor.mentions ?? [competitor.name]
return {
alternatives: new RegExp(`\\b${phraseAlternation(aliases)}(?: and [^,:]+?)? Alternatives\\b`),
subject: wordPattern(aliases),
mention: wordPattern([competitor.name, ...aliases]),
}
Expand All @@ -36,8 +49,14 @@ const COMPETITOR_PATTERNS = ALL_COMPETITORS.map((competitor) => ({
patterns: buildMentionPatterns(competitor),
}))

function scoreRelevance(post: ContentMeta, { subject, mention }: MentionPatterns): number {
function scoreRelevance(
post: ContentMeta,
{ alternatives, subject, mention }: MentionPatterns,
linksToComparison: boolean
): number {
if (alternatives.test(post.title)) return ALTERNATIVES_SCORE
if (subject.test(post.title) || post.tags.some((tag) => subject.test(tag))) return SUBJECT_SCORE
if (linksToComparison) return LINKS_TO_COMPARISON_SCORE
return mention.test(post.description) ? MENTION_SCORE : 0
}

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

/**
* Library articles about or naming `competitor`, articles where it is the
* subject first, then newest first.
* Library articles about, linking to, or naming `competitor`, strongest match first, then
* newest first.
*/
export async function getLibraryPostsForCompetitor(
competitor: CompetitorProfile
): Promise<ContentMeta[]> {
const patterns = buildMentionPatterns(competitor)
return topByScore(await getAllPostMeta(), (post) => scoreRelevance(post, patterns))
const [posts, linkingPosts] = await Promise.all([
getAllPostMeta(),
getPostsLinkingTo(comparisonPath(competitor)),
])
const linkingSlugs = new Set(linkingPosts.map((post) => post.slug))
return topByScore(posts, (post) => scoreRelevance(post, patterns, linkingSlugs.has(post.slug)))
}

/**
* Comparison pages for the competitors a library article is about or names,
* subjects first, then in {@link ALL_COMPETITORS} order.
* Comparison pages for the competitors a library article is about, links to (`linkedPaths`,
* from its body), or names, strongest match first, then in {@link ALL_COMPETITORS} order.
*/
export function getComparisonsForPost(post: ContentMeta): CompetitorProfile[] {
return topByScore(COMPETITOR_PATTERNS, ({ patterns }) => scoreRelevance(post, patterns)).map(
({ competitor }) => competitor
)
export function getComparisonsForPost(
post: ContentMeta,
linkedPaths: ReadonlySet<string>
): CompetitorProfile[] {
return topByScore(COMPETITOR_PATTERNS, ({ competitor, patterns }) =>
scoreRelevance(post, patterns, linkedPaths.has(comparisonPath(competitor)))
).map(({ competitor }) => competitor)
}
5 changes: 5 additions & 0 deletions apps/sim/app/(landing)/comparisons/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ export const ALL_COMPETITORS: CompetitorProfile[] = [

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

/** Path of the Sim vs `competitor` comparison page. */
export function comparisonPath(competitor: CompetitorProfile): string {
return `/comparisons/${competitor.id}`
}

export function getCompetitorBySlug(slug: string): CompetitorProfile | null {
return COMPETITOR_BY_SLUG.get(slug) ?? null
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,12 @@ export function ContentIndexPage({
}: ContentIndexPageProps) {
const { featured, remaining, totalPages } = paginateContentPosts(posts, { tag, page })

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

return (
<>
Expand Down Expand Up @@ -138,7 +142,7 @@ export function ContentIndexPage({

{totalPages > 1 && (
<nav aria-label='Pagination' className='px-6 py-8'>
<div className='flex items-center justify-center gap-3'>
<div className='flex flex-wrap items-center justify-center gap-3'>
{page > 1 && (
<ChipLink
href={pageHref(page - 1)}
Expand All @@ -148,9 +152,20 @@ export function ContentIndexPage({
Previous
</ChipLink>
)}
<span className='text-[var(--text-secondary)] text-sm'>
Page {page} of {totalPages}
</span>
<ol className='flex flex-wrap items-center justify-center gap-1'>
{pageNumbers.map((pageNumber) => (
<li key={pageNumber}>
<ChipLink
href={pageHref(pageNumber)}
active={pageNumber === page}
aria-current={pageNumber === page ? 'page' : undefined}
aria-label={`Page ${pageNumber}`}
>
{pageNumber}
</ChipLink>
</li>
))}
</ol>
{page < totalPages && (
<ChipLink
href={pageHref(page + 1)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Link from 'next/link'
import type { ContentMeta } from '@/lib/content/schema'
import { ChevronArrow } from '@/app/(landing)/components/chevron-arrow'
import { formatPostDate } from '@/app/(landing)/components/content-utils'

interface ContentBacklinksProps {
/** Route base path of the posts' section, e.g. `/library`. */
basePath: string
/** Posts whose body links to the current post. */
posts: ContentMeta[]
}

/** "Referenced in" list linking a post to the posts that cite it. */
export function ContentBacklinks({ basePath, posts }: ContentBacklinksProps) {
return (
<section aria-labelledby='backlinks-heading'>
<h2
id='backlinks-heading'
className='mb-4 text-[var(--text-primary)] text-xl leading-[100%] tracking-[-0.02em] lg:text-2xl'
>
Referenced in
</h2>
<ul className='divide-y divide-[var(--border)] border-[var(--border)] border-y'>
{posts.map((post) => (
<li key={post.slug}>
<Link
href={`${basePath}/${post.slug}`}
className='group/link flex items-center gap-4 px-6 py-4 transition-colors hover-hover:bg-[var(--surface-hover)]'
>
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
<h3 className='text-[var(--text-primary)] text-sm leading-snug tracking-[-0.02em]'>
{post.title}
</h3>
<time
dateTime={post.date}
className='text-[var(--text-muted)] text-caption leading-[150%]'
>
{formatPostDate(post.date)}
</time>
</div>
<ChevronArrow />
</Link>
</li>
))}
</ul>
</section>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,44 +11,46 @@ interface ContentRelatedPostsProps {
label?: string
}

/** Row of post cards (cover, date, title, description) linking to other posts in a content section. */
/** Grid of post cards (cover, date, title, description) linking to other posts in a content section. */
export function ContentRelatedPosts({
basePath,
posts,
label = 'Related posts',
}: ContentRelatedPostsProps) {
return (
<nav aria-label={label} className='flex flex-col sm:flex-row'>
{posts.map((p) => (
<Link
key={p.slug}
href={`${basePath}/${p.slug}`}
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'
>
<div className='relative aspect-video w-full overflow-hidden rounded-[5px]'>
<Image
src={p.ogImage}
alt={p.title}
fill
sizes='(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw'
className='object-cover'
loading='lazy'
unoptimized
/>
</div>
<div className='flex flex-col gap-2'>
<span className='text-[var(--text-secondary)] text-xs uppercase tracking-[0.1em]'>
{formatPostDate(p.date)}
</span>
<h3 className='text-[var(--text-primary)] text-lg leading-tight tracking-[-0.01em]'>
{p.title}
</h3>
<p className='line-clamp-2 text-[var(--text-secondary)] text-sm leading-[150%]'>
{p.description}
</p>
</div>
</Link>
))}
<nav aria-label={label} className='overflow-hidden'>
<div className='-mt-px -ml-px grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3'>
{posts.map((p) => (
<Link
key={p.slug}
href={`${basePath}/${p.slug}`}
className='group flex flex-col gap-4 border-[var(--border)] border-t border-l p-6 transition-colors hover-hover:bg-[var(--surface-hover)]'
>
<div className='relative aspect-video w-full overflow-hidden rounded-[5px]'>
<Image
src={p.ogImage}
alt={p.title}
fill
sizes='(max-width: 639px) 100vw, (max-width: 1023px) 50vw, 33vw'
className='object-cover'
loading='lazy'
unoptimized
/>
</div>
<div className='flex flex-col gap-2'>
<span className='text-[var(--text-secondary)] text-xs uppercase tracking-[0.1em]'>
{formatPostDate(p.date)}
</span>
<h3 className='text-[var(--text-primary)] text-lg leading-tight tracking-[-0.01em]'>
{p.title}
</h3>
<p className='line-clamp-2 text-[var(--text-secondary)] text-sm leading-[150%]'>
{p.description}
</p>
</div>
</Link>
))}
</div>
</nav>
)
}
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { ContentBacklinks } from './content-backlinks'
export { ContentPostPage } from './content-post-page'
export { ContentRelatedPosts } from './content-related-posts'
2 changes: 1 addition & 1 deletion apps/sim/app/(landing)/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export { ChevronArrow } from './chevron-arrow'
export { ContentAuthorPage } from './content-author-page'
export { ContentImage } from './content-image'
export { ContentIndexLoading, ContentIndexPage } from './content-index-page'
export { ContentPostPage, ContentRelatedPosts } from './content-post-page'
export { ContentBacklinks, ContentPostPage, ContentRelatedPosts } from './content-post-page'
export { ContentTagsLoading, ContentTagsPage } from './content-tags-page'
export { Cta } from './cta/cta'
export { FeaturedCustomer } from './featured-customer'
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/app/(landing)/content-routing.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ vi.mock('@/lib/library/registry', () => ({
getPostBySlug,
getAllPostMeta: vi.fn().mockResolvedValue([]),
getRelatedPosts: vi.fn().mockResolvedValue([]),
getPostsLinkingTo: vi.fn().mockResolvedValue([]),
getLinkedPaths: vi.fn().mockResolvedValue(new Set()),
}))
vi.mock('@/lib/blog/seo', () => ({
BLOG_SECTION: { basePath: '/blog' },
Expand Down
Loading
Loading