11import { escapeRegExp } from '@sim/utils/string'
22import type { CompetitorProfile } from '@/lib/compare/data'
33import 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
1218const MENTION_SCORE = 1
1319
1420interface 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`. */
2234function wordPattern ( phrases : string [ ] ) : RegExp {
23- return new RegExp ( `\\b(?: ${ phrases . map ( escapeRegExp ) . join ( '|' ) } ) \\b` )
35+ return new RegExp ( `\\b${ phraseAlternation ( phrases ) } \\b` )
2436}
2537
2638function 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 */
5877export 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}
0 commit comments