From c58fda39c6184d49e8e3669cf0176dea155b2606 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 23 Oct 2026 16:09:17 -0700 Subject: [PATCH 1/2] improvement(library): rank related posts by tag rarity, add backlinks, and widen comparison and pagination links --- apps/sim/app/(landing)/blog/[slug]/page.tsx | 2 +- .../comparison-link-row.tsx | 3 +- .../(landing)/comparisons/library-links.ts | 59 ++++-- apps/sim/app/(landing)/comparisons/utils.ts | 5 + .../content-index-page/content-index-page.tsx | 27 ++- .../content-post-page/content-backlinks.tsx | 48 +++++ .../content-related-posts.tsx | 66 +++--- .../components/content-post-page/index.ts | 1 + apps/sim/app/(landing)/components/index.ts | 2 +- .../app/(landing)/content-routing.test.tsx | 2 + .../sim/app/(landing)/library/[slug]/page.tsx | 36 +++- apps/sim/lib/content/links.test.ts | 64 ++++++ apps/sim/lib/content/links.ts | 191 ++++++++++++++++++ apps/sim/lib/content/registry-factory.ts | 90 ++++++--- apps/sim/lib/library/registry.ts | 2 + 15 files changed, 507 insertions(+), 91 deletions(-) create mode 100644 apps/sim/app/(landing)/components/content-post-page/content-backlinks.tsx create mode 100644 apps/sim/lib/content/links.test.ts create mode 100644 apps/sim/lib/content/links.ts diff --git a/apps/sim/app/(landing)/blog/[slug]/page.tsx b/apps/sim/app/(landing)/blog/[slug]/page.tsx index 89385e4b472..15d994b96c0 100644 --- a/apps/sim/app/(landing)/blog/[slug]/page.tsx +++ b/apps/sim/app/(landing)/blog/[slug]/page.tsx @@ -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 ( diff --git a/apps/sim/app/(landing)/comparisons/library-links.ts b/apps/sim/app/(landing)/comparisons/library-links.ts index 990cb23dd0b..2c295da7a2b 100644 --- a/apps/sim/app/(landing)/comparisons/library-links.ts +++ b/apps/sim/app/(landing)/comparisons/library-links.ts @@ -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]), } @@ -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 } @@ -52,22 +71,30 @@ function topByScore(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 { 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 +): CompetitorProfile[] { + return topByScore(COMPETITOR_PATTERNS, ({ competitor, patterns }) => + scoreRelevance(post, patterns, linkedPaths.has(comparisonPath(competitor))) + ).map(({ competitor }) => competitor) } diff --git a/apps/sim/app/(landing)/comparisons/utils.ts b/apps/sim/app/(landing)/comparisons/utils.ts index 2c1a962e142..94c9d0fd6d2 100644 --- a/apps/sim/app/(landing)/comparisons/utils.ts +++ b/apps/sim/app/(landing)/comparisons/utils.ts @@ -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 } diff --git a/apps/sim/app/(landing)/components/content-index-page/content-index-page.tsx b/apps/sim/app/(landing)/components/content-index-page/content-index-page.tsx index e4ca2bcf9b5..c0d735ab346 100644 --- a/apps/sim/app/(landing)/components/content-index-page/content-index-page.tsx +++ b/apps/sim/app/(landing)/components/content-index-page/content-index-page.tsx @@ -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 ( <> @@ -138,7 +142,7 @@ export function ContentIndexPage({ {totalPages > 1 && (