diff --git a/apps/sim/app/(landing)/comparisons/[provider]/page.tsx b/apps/sim/app/(landing)/comparisons/[provider]/page.tsx index 3f690f18624..000827730f5 100644 --- a/apps/sim/app/(landing)/comparisons/[provider]/page.tsx +++ b/apps/sim/app/(landing)/comparisons/[provider]/page.tsx @@ -11,11 +11,14 @@ import { BrandIconTile, SimIconTile } from '@/app/(landing)/comparisons/componen import { ComparisonCards } from '@/app/(landing)/comparisons/components/comparison-cards' import { ComparisonLinkRow } from '@/app/(landing)/comparisons/components/comparison-link-row' import { ComparisonTable } from '@/app/(landing)/comparisons/components/comparison-table' +import { GlanceTable } from '@/app/(landing)/comparisons/components/glance-table' import { MigrationSection } from '@/app/(landing)/comparisons/components/migration-section' import { ProseText } from '@/app/(landing)/comparisons/components/prose-text' +import { QuestionSections } from '@/app/(landing)/comparisons/components/question-sections' import { getLibraryPostsForCompetitor } from '@/app/(landing)/comparisons/library-links' import { ALL_COMPETITORS, + betterThanQuestion, buildBottomLine, buildComparisonFaqs, getCompetitorBySlug, @@ -117,6 +120,17 @@ export default async function ComparisonProviderPage({ Math.max(SIM_LATEST_VERIFIED.getTime(), getLatestVerifiedDate(competitor).getTime()) ) + const verifiedDate = ( + + ) + const productComparisonJsonLd = { '@context': 'https://schema.org', '@type': 'ItemList', @@ -186,26 +200,41 @@ export default async function ComparisonProviderPage({ > Sim vs {competitor.name} - {competitor.leadAnswer ? ( -

- -

- ) : null} -

- Sim is the open-source AI workspace where teams build, deploy, and manage AI agents - visually, conversationally, or with code. Here is how Sim compares to{' '} - {competitor.name} on platform architecture, AI capabilities, integrations, pricing, - security, and support. Every fact below is sourced and dated, last verified{' '} - - . -

+ {competitor.leadQuestion && competitor.leadAnswer ? ( +
+

+ {competitor.leadQuestion} +

+

+ +

+

+ Every fact below is sourced and dated, last verified {verifiedDate}. +

+
+ ) : ( + <> + {competitor.leadAnswer ? ( +

+ +

+ ) : null} +

+ Sim is the open-source AI workspace where teams build, deploy, and manage AI + agents visually, conversationally, or with code. Here is how Sim compares to{' '} + {competitor.name} on platform architecture, AI capabilities, integrations, + pricing, security, and support. Every fact below is sourced and dated, last + verified {verifiedDate}. +

+ + )}

Sim is an open-source AI workspace for building, deploying, and managing AI agents. This page compares Sim to {competitor.name} across platform architecture, AI @@ -232,7 +261,7 @@ export default async function ComparisonProviderPage({ id='better-than-heading' className='mb-4 text-[var(--text-primary)] text-xl leading-[100%] tracking-[-0.02em] lg:text-2xl' > - Is Sim better than {competitor.name}? + {betterThanQuestion(competitor)}

@@ -241,6 +270,9 @@ export default async function ComparisonProviderPage({

) : null} + {competitor.questions?.intro ? ( + + ) : null}
+ {competitor.glance ? ( + <> + +
+ + ) : null} + {competitor.questions?.glance ? ( + + ) : null} +

+ {competitor.questions?.closing ? ( + + ) : null} +

-
-

- Frequently asked questions -

-
- -
-
+ {competitor.questions ? null : ( +
+

+ Frequently asked questions +

+
+ +
+
+ )}

diff --git a/apps/sim/app/(landing)/comparisons/components/glance-table/glance-table.tsx b/apps/sim/app/(landing)/comparisons/components/glance-table/glance-table.tsx new file mode 100644 index 00000000000..cabdce9c482 --- /dev/null +++ b/apps/sim/app/(landing)/comparisons/components/glance-table/glance-table.tsx @@ -0,0 +1,85 @@ +import { cn } from '@sim/emcn' +import type { GlanceRow } from '@/lib/compare/data' +import { ProseText } from '@/app/(landing)/comparisons/components/prose-text' + +interface GlanceTableProps { + rows: GlanceRow[] + competitorName: string +} + +const HEADER_CELL_CLASS = + 'border-[var(--border)] border-l bg-[var(--surface-1)] px-4 py-3 font-medium text-[var(--text-muted)] text-small' +const VALUE_CELL_CLASS = + 'align-top text-[var(--text-body)] text-small leading-[150%] max-lg:block max-lg:px-4 max-lg:py-1 lg:border-[var(--border)] lg:border-t lg:border-l lg:px-4 lg:py-3' +const STACKED_LABEL_CLASS = 'block font-medium text-[var(--text-muted)] text-caption lg:hidden' + +/** + * The main buying decision points as one table, each cell a sourced sentence with its citation + * links. Below `lg` each row stacks with labeled values, as the feature-by-feature table does. + * Server-rendered, so answer engines can extract the table without hydration. + */ +export function GlanceTable({ rows, competitorName }: GlanceTableProps) { + const heading = `Sim vs ${competitorName}: at a glance` + return ( +
+

+ {heading} +

+
+ + + + + + + + + + + + + + + + {rows.map((row, index) => ( + 0 && 'max-lg:border-[var(--border)] max-lg:border-t' + )} + > + + + + + ))} + +
{heading}
+ Decision point + + Sim + + {competitorName} +
+ {row.label} + + Sim + + + {competitorName} + +
+
+
+ ) +} diff --git a/apps/sim/app/(landing)/comparisons/components/glance-table/index.ts b/apps/sim/app/(landing)/comparisons/components/glance-table/index.ts new file mode 100644 index 00000000000..c07b040c291 --- /dev/null +++ b/apps/sim/app/(landing)/comparisons/components/glance-table/index.ts @@ -0,0 +1 @@ +export { GlanceTable } from './glance-table' diff --git a/apps/sim/app/(landing)/comparisons/components/question-sections/index.ts b/apps/sim/app/(landing)/comparisons/components/question-sections/index.ts new file mode 100644 index 00000000000..d90011152ee --- /dev/null +++ b/apps/sim/app/(landing)/comparisons/components/question-sections/index.ts @@ -0,0 +1 @@ +export { QuestionSections } from './question-sections' diff --git a/apps/sim/app/(landing)/comparisons/components/question-sections/question-sections.tsx b/apps/sim/app/(landing)/comparisons/components/question-sections/question-sections.tsx new file mode 100644 index 00000000000..c3c7d8444a4 --- /dev/null +++ b/apps/sim/app/(landing)/comparisons/components/question-sections/question-sections.tsx @@ -0,0 +1,37 @@ +import { Fragment } from 'react' +import type { ComparisonQuestion } from '@/lib/compare/data' +import { ProseText } from '@/app/(landing)/comparisons/components/prose-text' + +interface QuestionSectionsProps { + questions: ComparisonQuestion[] + /** Prefix for each section's id, unique on the page. */ + idPrefix: string +} + +/** + * One section per buyer question, its heading worded as the buyer asks it and its answer + * directly beneath, each followed by a divider. Server-rendered, so answer engines can quote + * every answer on its own. + */ +export function QuestionSections({ questions, idPrefix }: QuestionSectionsProps) { + return questions.map(({ question, answer }, index) => { + const sectionId = `${idPrefix}-${index}` + const headingId = `${sectionId}-heading` + return ( + +
+

+ {question} +

+

+ +

+
+
+ + ) + }) +} diff --git a/apps/sim/app/(landing)/comparisons/library-links.ts b/apps/sim/app/(landing)/comparisons/library-links.ts index 3b6ddf616d9..2bcadc99e85 100644 --- a/apps/sim/app/(landing)/comparisons/library-links.ts +++ b/apps/sim/app/(landing)/comparisons/library-links.ts @@ -76,12 +76,16 @@ function topByScore(items: T[], score: (item: T) => number): T[] { } /** - * Library articles about, linking to, or naming `competitor`, strongest match first, then - * newest first. + * The profile's `relatedReading` articles when it sets them; otherwise library articles about, + * linking to, or naming `competitor`, strongest match first, then newest first. */ export async function getLibraryPostsForCompetitor( competitor: CompetitorProfile ): Promise { + if (competitor.relatedReading) { + const postsBySlug = new Map((await getAllPostMeta()).map((post) => [post.slug, post])) + return competitor.relatedReading.flatMap((slug) => postsBySlug.get(slug) ?? []) + } const patterns = buildMentionPatterns(competitor) const [posts, linkingPosts] = await Promise.all([ getAllPostMeta(), diff --git a/apps/sim/app/(landing)/comparisons/utils.ts b/apps/sim/app/(landing)/comparisons/utils.ts index 94c9d0fd6d2..7e279b81e95 100644 --- a/apps/sim/app/(landing)/comparisons/utils.ts +++ b/apps/sim/app/(landing)/comparisons/utils.ts @@ -1,4 +1,5 @@ import { + type ComparisonQuestion, type CompetitorProfile, claudeCoworkProfile, crewaiProfile, @@ -12,6 +13,7 @@ import { n8nProfile, openaiAgentkitProfile, openClawProfile, + type Prose, pipedreamProfile, powerAutomateProfile, retoolProfile, @@ -118,9 +120,16 @@ export function buildBottomLine(competitor: CompetitorProfile): ComparisonVerdic * question/answer pair is independently quotable per the landing GEO rules, * and every answer names "Sim" and the competitor explicitly. Every answer * draws on a real, sourced {@link Fact} field rather than a generic claim, - * and no two questions repeat the same answer. + * and no two questions repeat the same answer. A profile that answers its buyer questions as + * page sections gets those questions instead, so its FAQ data matches what the page shows. */ export function buildComparisonFaqs(competitor: CompetitorProfile): ComparisonFaq[] { + if (competitor.questions) { + return getQuestionSections(competitor).map(({ question, answer }) => ({ + question, + answer: proseToText(answer), + })) + } const name = competitor.name const facts = competitor.facts const faqs: ComparisonFaq[] = [ @@ -169,6 +178,45 @@ export function buildComparisonFaqs(competitor: CompetitorProfile): ComparisonFa return faqs } +/** Heading of the section answering {@link CompetitorProfile.betterThanAnswer}. */ +export function betterThanQuestion(competitor: CompetitorProfile): string { + return `Is Sim better than ${competitor.name}?` +} + +/** + * The buyer questions the page answers in their own sections, grouped by position: `opening` + * (the lead, "better than", and intro questions) ahead of the product definitions, `glance` + * after the at-a-glance table, and `closing` after the documented limitations. + */ +export function getQuestionSectionGroups(competitor: CompetitorProfile): { + opening: ComparisonQuestion[] + glance: ComparisonQuestion[] + closing: ComparisonQuestion[] +} { + const { leadQuestion, leadAnswer, betterThanAnswer, questions } = competitor + return { + opening: [ + ...(leadQuestion && leadAnswer ? [{ question: leadQuestion, answer: leadAnswer }] : []), + ...(betterThanAnswer + ? [{ question: betterThanQuestion(competitor), answer: betterThanAnswer }] + : []), + ...(questions?.intro ?? []), + ], + glance: questions?.glance ?? [], + closing: questions?.closing ?? [], + } +} + +/** Every question from {@link getQuestionSectionGroups}, in page order. */ +function getQuestionSections(competitor: CompetitorProfile): ComparisonQuestion[] { + const { opening, glance, closing } = getQuestionSectionGroups(competitor) + return [...opening, ...glance, ...closing] +} + +function proseToText(prose: Prose): string { + return prose.map((segment) => (typeof segment === 'string' ? segment : segment.text)).join('') +} + function buildKeyDifferenceAnswer(competitor: CompetitorProfile): string { const topFeature = competitor.standoutFeatures[0] const topLimitation = competitor.limitations[0] diff --git a/apps/sim/app/llms-full.txt/route.ts b/apps/sim/app/llms-full.txt/route.ts index eedce680267..f955ffdf4c7 100644 --- a/apps/sim/app/llms-full.txt/route.ts +++ b/apps/sim/app/llms-full.txt/route.ts @@ -1,5 +1,5 @@ import { CREDIT_TIERS } from '@/lib/billing/constants' -import type { CompetitorProfile, Fact, Prose } from '@/lib/compare/data' +import type { ComparisonQuestion, CompetitorProfile, Fact, Prose } from '@/lib/compare/data' import { simProfile } from '@/lib/compare/data' import { toSiteUrl } from '@/lib/core/utils/urls' import { getAllCustomerStoryMeta, getCustomerStorySource } from '@/lib/customers/registry' @@ -13,6 +13,7 @@ import { ALL_COMPETITORS, buildBottomLine, getLatestVerifiedDate, + getQuestionSectionGroups, SIM_LATEST_VERIFIED, } from '@/app/(landing)/comparisons/utils' import { PLATFORM_MENU } from '@/app/(landing)/components/navbar/components/nav-menu-chip' @@ -80,8 +81,13 @@ function isoDate(date: Date): string { } /** The key facts of one `/comparisons/{id}` page as markdown, from the same profile data. */ +function questionMarkdown({ question, answer }: ComparisonQuestion): string { + return `**${question}**\n\n${proseToMarkdown(answer)}` +} + function comparisonMarkdown(competitor: CompetitorProfile): string { const verdict = buildBottomLine(competitor) + const questions = getQuestionSectionGroups(competitor) const verified = new Date( Math.max(SIM_LATEST_VERIFIED.getTime(), getLatestVerifiedDate(competitor).getTime()) ) @@ -98,8 +104,19 @@ function comparisonMarkdown(competitor: CompetitorProfile): string { `### Sim vs ${competitor.name}`, `URL: ${toSiteUrl(`/comparisons/${competitor.id}`)} ยท Facts last verified ${isoDate(verified)}`, `${competitor.name}: ${competitor.oneLiner}`, - competitor.leadAnswer ? proseToMarkdown(competitor.leadAnswer) : '', - competitor.betterThanAnswer ? proseToMarkdown(competitor.betterThanAnswer) : '', + competitor.leadAnswer && !competitor.leadQuestion ? proseToMarkdown(competitor.leadAnswer) : '', + ...questions.opening.map(questionMarkdown), + competitor.glance + ? [ + `| Decision point | Sim | ${competitor.name} |`, + '| --- | --- | --- |', + ...competitor.glance.map( + (row) => + `| ${row.label} | ${proseToMarkdown(row.sim)} | ${proseToMarkdown(row.competitor)} |` + ), + ].join('\n') + : '', + ...questions.glance.map(questionMarkdown), `- ${verdict.chooseSim}\n- ${verdict.chooseCompetitor}`, competitor.standoutFeatures.length > 0 ? `Standout features of ${competitor.name}:\n\n${competitor.standoutFeatures.map((f) => `- ${f.title}: ${f.description}`).join('\n')}` @@ -108,6 +125,7 @@ function comparisonMarkdown(competitor: CompetitorProfile): string { ? `Documented limitations of ${competitor.name}:\n\n${competitor.limitations.map((l) => `- ${l.title}: ${l.description}`).join('\n')}` : '', [`| Feature | Sim | ${competitor.name} |`, '| --- | --- | --- |', ...rows].join('\n'), + ...questions.closing.map(questionMarkdown), ] .filter(Boolean) .join('\n\n') diff --git a/apps/sim/lib/compare/data/competitors/microsoft-copilot.ts b/apps/sim/lib/compare/data/competitors/microsoft-copilot.ts index f36d2f9de0f..2593ea14843 100644 --- a/apps/sim/lib/compare/data/competitors/microsoft-copilot.ts +++ b/apps/sim/lib/compare/data/competitors/microsoft-copilot.ts @@ -16,6 +16,212 @@ export const microsoftCopilotProfile: CompetitorProfile = { }, oneLiner: 'Microsoft Copilot Studio is a low-code Microsoft tool for building, testing, and publishing conversational and autonomous AI agents with topics or LLM-driven generative orchestration, connectors, agent flows, and Dataverse-grounded knowledge.', + leadQuestion: 'How does Sim compare to Microsoft Copilot Studio for building agents?', + leadAnswer: [ + 'Sim is an open-source AI workspace for building and deploying agents across different models and business systems, with managed cloud and self-hosted options. ', + { + text: 'Microsoft Copilot Studio', + href: 'https://learn.microsoft.com/en-us/microsoft-copilot-studio/fundamentals-what-is-copilot-studio', + }, + ' is a low-code, Microsoft-operated studio that also connects to ', + { + text: 'non-Microsoft services', + href: 'https://learn.microsoft.com/en-us/microsoft-copilot-studio/advanced-connectors', + }, + '. Choose Sim when direct model-provider configuration and control of the agent runtime matter; choose Copilot Studio when Power Platform governance and Microsoft publishing channels are the better fit.', + ], + questions: { + intro: [ + { + question: + 'Is Sim a Microsoft Copilot Studio alternative that is not locked into Microsoft 365?', + answer: [ + 'Yes, if the requirement is an agent-building workspace with ', + { text: 'direct model-provider integrations', href: 'https://docs.sim.ai/introduction' }, + ' and a choice of ', + { + text: 'cloud or self-hosted deployment', + href: 'https://docs.sim.ai/platform/self-hosting/docker', + }, + '. That does not mean Copilot Studio is confined to Microsoft 365: Microsoft documents ', + { + text: 'connectors for outside apps, including Salesforce and Google services', + href: 'https://learn.microsoft.com/en-us/microsoft-copilot-studio/advanced-connectors', + }, + ', ', + { + text: 'non-default and bring-your-own-model options', + href: 'https://learn.microsoft.com/en-us/microsoft-copilot-studio/requirements-messages-management', + }, + ', and ', + { + text: 'standalone Copilot Studio', + href: 'https://www.microsoft.com/en-us/copilot/pricing/copilot-studio', + }, + ". Sim is the different product fit when control over the workspace's hosting and provider configuration is the deciding factor.", + ], + }, + ], + glance: [ + { + question: + 'How do Copilot Studio, Copilot Pro, Microsoft 365 Premium, and Agent Builder differ?', + answer: [ + '"Copilot Pro" is an ', + { + text: 'older consumer subscription name', + href: 'https://www.microsoft.com/en-us/copilot/blog/2025/10/01/meet-microsoft-365-premium-your-ai-and-productivity-powerhouse/', + }, + ' that still appears in searches; Microsoft describes ', + { + text: 'Microsoft 365 Premium', + href: 'https://support.microsoft.com/en-us/office/introducing-microsoft-365-premium', + }, + ' as a subscription for one to six people with Copilot features, not as the standalone organizational agent-building license. Microsoft calls ', + { + text: 'Agent Builder', + href: 'https://www.microsoft.com/en-us/copilot/pricing/copilot-studio', + }, + ' the lightweight, in-context creation experience inside Copilot, while Copilot Studio is its low-code agent-building product; its pricing page distinguishes capabilities included for eligible Microsoft 365 Copilot business users from standalone Studio use. If the goal is a workspace with direct model-provider integrations and self-hosting, compare Studio with Sim, not a consumer Copilot subscription.', + ], + }, + ], + closing: [ + { + question: + 'What should an IT team switch to if Copilot Studio feels too limited outside the Microsoft ecosystem?', + answer: [ + 'Evaluate Sim if the sticking point is where the agent workspace runs or how directly the team configures model providers and tools, rather than assuming Copilot Studio cannot reach external systems. Before switching, map the agents, connectors, access rules, and publishing channels you actually use: Microsoft documents ', + { + text: 'outside-app connectors', + href: 'https://learn.microsoft.com/en-us/microsoft-copilot-studio/advanced-connectors', + }, + ', ', + { + text: 'granular governance', + href: 'https://learn.microsoft.com/en-us/microsoft-copilot-studio/guidance/sec-gov-phase3', + }, + ', and ', + { + text: 'multiple publishing channels', + href: 'https://learn.microsoft.com/en-us/microsoft-copilot-studio/publication-fundamentals-publish-channels', + }, + '. Rebuild and test one representative agent in Sim first; this is a migration decision, not a one-click replacement for every Microsoft capability.', + ], + }, + ], + }, + glance: [ + { + label: 'Model choice', + sim: [ + { + text: 'Direct integrations with multiple model providers', + href: 'https://docs.sim.ai/introduction', + }, + ', including a ', + { + text: 'bring-your-own-key path', + href: 'https://docs.sim.ai/platform/costs#bring-your-own-key-byok', + }, + '.', + ], + competitor: [ + { + text: 'Provided models and separately billed bring-your-own-model configurations, including Azure Foundry', + href: 'https://learn.microsoft.com/en-us/microsoft-copilot-studio/requirements-messages-management', + }, + '.', + ], + }, + { + label: 'Non-Microsoft integrations', + sim: [{ text: 'App integrations and tools for custom agents', href: '/integrations' }, '.'], + competitor: [ + { + text: 'Standard, premium, and custom connectors', + href: 'https://learn.microsoft.com/en-us/microsoft-copilot-studio/advanced-connectors', + }, + ', including connections to services outside Microsoft.', + ], + }, + { + label: 'Self-hosting', + sim: [ + { + text: 'Docker or Kubernetes deployment', + href: 'https://docs.sim.ai/platform/self-hosting/docker', + }, + '; the core is open source, while Enterprise features have ', + { text: 'separate licensing', href: 'https://docs.sim.ai/introduction' }, + '.', + ], + competitor: [ + 'Microsoft documents ', + { + text: 'cloud deployments', + href: 'https://learn.microsoft.com/en-us/microsoft-copilot-studio/requirements-licensing-gcc', + }, + ', not a self-hosted Copilot Studio authoring or runtime deployment.', + ], + }, + { + label: 'Governance and access control', + sim: [ + { text: 'Workspace roles', href: 'https://docs.sim.ai/platform/permissions' }, + ', with additional Enterprise controls separately licensed.', + ], + competitor: [ + { + text: 'Entra ID access, Dataverse roles, and tenant- or environment-level data policies', + href: 'https://learn.microsoft.com/en-us/microsoft-copilot-studio/guidance/sec-gov-phase3', + }, + '.', + ], + }, + { + label: 'Pricing model', + sim: [ + { text: 'Credit-based cloud plans', href: '/pricing' }, + ', with ', + { + text: 'BYOK billing distinctions', + href: 'https://docs.sim.ai/platform/costs#bring-your-own-key-byok', + }, + '.', + ], + competitor: [ + { + text: 'Copilot Credits', + href: 'https://learn.microsoft.com/en-us/microsoft-copilot-studio/requirements-messages-management', + }, + ' for metered standard-harness usage, with prepaid and pay-as-you-go options and different rules for eligible Microsoft 365 Copilot usage or BYO models.', + ], + }, + { + label: 'Deployment options', + sim: [ + { + text: 'Managed cloud or self-hosted workspace', + href: 'https://docs.sim.ai/platform/self-hosting/docker', + }, + '.', + ], + competitor: [ + { + text: 'Microsoft-operated commercial and government cloud environments', + href: 'https://learn.microsoft.com/en-us/microsoft-copilot-studio/requirements-licensing-gcc', + }, + '; agents can be ', + { + text: 'published to supported channels', + href: 'https://learn.microsoft.com/en-us/microsoft-copilot-studio/publication-fundamentals-publish-channels', + }, + '.', + ], + }, + ], + relatedReading: ['best-microsoft-copilot-studio-power-automate-alternatives'], standoutFeatures: [ { title: 'Broad, independently audited compliance certification list', diff --git a/apps/sim/lib/compare/data/index.ts b/apps/sim/lib/compare/data/index.ts index fe98fe9e6ff..fee93aab365 100644 --- a/apps/sim/lib/compare/data/index.ts +++ b/apps/sim/lib/compare/data/index.ts @@ -21,11 +21,13 @@ export { zapierProfile } from '@/lib/compare/data/competitors/zapier' export { simProfile } from '@/lib/compare/data/sim' export type { ComparisonFacts, + ComparisonQuestion, CompetitorBrand, CompetitorMigration, CompetitorProfile, Fact, FactSource, + GlanceRow, Prose, ProseSegment, } from '@/lib/compare/data/types' diff --git a/apps/sim/lib/compare/data/types.ts b/apps/sim/lib/compare/data/types.ts index 04715fdf327..fa9af61bb9e 100644 --- a/apps/sim/lib/compare/data/types.ts +++ b/apps/sim/lib/compare/data/types.ts @@ -184,6 +184,19 @@ export type ProseSegment = string | { text: string; href: string } /** A paragraph of comparison prose, as an ordered run of {@link ProseSegment}s. */ export type Prose = ProseSegment[] +/** A buyer question answered in its own section, worded the way buyers ask it. */ +export interface ComparisonQuestion { + question: string + answer: Prose +} + +/** One row of the at-a-glance table: a buying decision point and each product's sourced answer. */ +export interface GlanceRow { + label: string + sim: Prose + competitor: Prose +} + /** Brand icon + colors for a competitor, sourced from a brand-intelligence lookup rather than the vendor's own docs. */ export interface CompetitorBrand { /** Icon component from @/components/icons rendering this competitor's logo. */ @@ -244,6 +257,35 @@ export interface CompetitorProfile { * condition under which each one wins. */ leadAnswer?: Prose + /** + * The question {@link leadAnswer} answers. When set, the lead renders under it as the page's + * first section, in place of the generic introduction. + */ + leadQuestion?: string + /** + * Buyer questions answered as page sections, by position. A profile that sets them answers its + * buyer questions in place, so the page omits the FAQ block and its FAQ structured data lists + * these questions instead of {@link faqs} and the generated set. + */ + questions?: { + /** After the lead, ahead of the product definitions: whether Sim fits the buyer's need. */ + intro?: ComparisonQuestion[] + /** After the at-a-glance table, ahead of the detailed comparison. */ + glance?: ComparisonQuestion[] + /** After the documented limitations, ahead of the bottom line: the switching decision. */ + closing?: ComparisonQuestion[] + } + /** + * Summary of the main buying decision points, shown after the product definitions and ahead of + * the feature-by-feature comparison, which it summarizes rather than replaces. + */ + glance?: GlanceRow[] + /** + * Library slugs listed under Related reading, in order, in place of the articles matched by + * {@link mentions}. Set it to point at one broader article, such as an alternatives roundup, + * instead of listing several that repeat this page. + */ + relatedReading?: string[] /** * Answer to the "Is Sim better than {name}?" section, phrased the way buyers * ask the question of an AI model. Verdict first, then the condition that diff --git a/apps/sim/scripts/test-landing-seo-e2e.ts b/apps/sim/scripts/test-landing-seo-e2e.ts index 6b7171bff39..70bccd4127b 100644 --- a/apps/sim/scripts/test-landing-seo-e2e.ts +++ b/apps/sim/scripts/test-landing-seo-e2e.ts @@ -13,9 +13,11 @@ import { dustProfile, gumloopProfile, langflowProfile, + microsoftCopilotProfile, n8nProfile, openaiAgentkitProfile, openClawProfile, + type Prose, pipedreamProfile, vellumProfile, } from '@/lib/compare/data' @@ -467,6 +469,78 @@ async function run() { assert.deepEqual(failures, []) }) + await check( + 'a comparison page answering its buyer questions in place keeps their order', + async () => { + const profile = microsoftCopilotProfile + const { status, body } = await get(`/comparisons/${profile.id}`) + assert.equal(status, 200) + const text = decodeHtml(body) + const { intro = [], glance = [], closing = [] } = profile.questions ?? {} + assert( + profile.leadQuestion && intro.length && glance.length && closing.length, + 'profile no longer exercises every question position' + ) + const questions = [ + profile.leadQuestion, + ...[...intro, ...glance, ...closing].map((q) => q.question), + ] + assert.deepEqual(faqQuestions(body), questions, 'FAQPage lists the page questions in order') + + const headings = [ + profile.leadQuestion, + ...intro.map((q) => q.question), + `Sim vs ${profile.name}: at a glance`, + ...glance.map((q) => q.question), + 'feature-by-feature comparison', + ...closing.map((q) => q.question), + 'Bottom line', + ] + const positions = headings.map((heading) => text.indexOf(`${heading}`)) + assert( + positions.every((at) => at >= 0), + `missing heading: ${headings[positions.indexOf(-1)]}` + ) + assert.deepEqual( + [...positions].sort((a, b) => a - b), + positions, + 'question sections are out of reading order' + ) + assert(!text.includes('Frequently asked questions'), 'generated FAQ block still renders') + for (const row of profile.glance ?? []) { + assert(text.includes(`${row.label}`), `at-a-glance row missing: ${row.label}`) + } + + const visibleText = decodeHtml( + body + .replace(//g, '') + .replace(//g, '') + .replace(/<[^>]+>/g, '') + ) + const proseText = (prose: Prose) => + prose.map((part) => (typeof part === 'string' ? part : part.text)).join('') + const expectedProse = [ + ...(profile.leadAnswer ? [profile.leadAnswer] : []), + ...[...intro, ...glance, ...closing].map((q) => q.answer), + ...(profile.glance ?? []).flatMap((row) => [row.sim, row.competitor]), + ] + for (const prose of expectedProse) { + const expected = proseText(prose) + assert(visibleText.includes(expected), `page text missing: ${expected.slice(0, 80)}`) + } + + const relatedStart = text.indexOf('Related reading') + const relatedEnd = text.indexOf('More comparisons') + assert(relatedStart >= 0 && relatedEnd > relatedStart, 'related reading section missing') + const relatedSlugs = new Set( + [...text.slice(relatedStart, relatedEnd).matchAll(/href="\/library\/([^"/?#]+)"/g)].map( + (match) => match[1] + ) + ) + assert.deepEqual([...relatedSlugs], profile.relatedReading) + } + ) + await check('rebuilt integration pages render their comparison and FAQs', async () => { const failures: string[] = [] for (const slug of ['github', 'shopify', 'snowflake']) {