chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
import type { CompetitorProfile } from '@/lib/compare/data'
|
||||
import { simProfile } from '@/lib/compare/data'
|
||||
import { SITE_URL } from '@/lib/core/utils/urls'
|
||||
import { buildLandingMetadata } from '@/lib/landing/seo'
|
||||
import { COMPARISON_SECTIONS, getFactGroup } from '@/app/(landing)/comparison/comparison-sections'
|
||||
import { BrandIconTile, SimIconTile } from '@/app/(landing)/comparison/components/brand-icon-tile'
|
||||
import { ComparisonCards } from '@/app/(landing)/comparison/components/comparison-cards'
|
||||
import { ComparisonTable } from '@/app/(landing)/comparison/components/comparison-table'
|
||||
import {
|
||||
ALL_COMPETITORS,
|
||||
buildBottomLine,
|
||||
buildComparisonFaqs,
|
||||
getCompetitorBySlug,
|
||||
getLatestVerifiedDate,
|
||||
SIM_LATEST_VERIFIED,
|
||||
} from '@/app/(landing)/comparison/utils'
|
||||
import { BackLink } from '@/app/(landing)/components'
|
||||
import { Cta } from '@/app/(landing)/components/cta/cta'
|
||||
import { JsonLd } from '@/app/(landing)/components/json-ld'
|
||||
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
|
||||
|
||||
const baseUrl = SITE_URL
|
||||
|
||||
export const revalidate = 3600
|
||||
export const dynamicParams = false
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return ALL_COMPETITORS.map((competitor) => ({ provider: competitor.id }))
|
||||
}
|
||||
|
||||
/** Flattens a profile's facts into JSON-LD `additionalProperty` entries, in {@link COMPARISON_SECTIONS} order. */
|
||||
function factsToProperties(profile: CompetitorProfile) {
|
||||
return COMPARISON_SECTIONS.flatMap((section) => {
|
||||
const group = getFactGroup(profile, section.group)
|
||||
return section.rows.map((row) => ({
|
||||
'@type': 'PropertyValue',
|
||||
name: row.label,
|
||||
value: group[row.key]?.value ?? 'Unknown',
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ provider: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { provider: providerSlug } = await params
|
||||
const competitor = getCompetitorBySlug(providerSlug)
|
||||
|
||||
if (!competitor) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return buildLandingMetadata({
|
||||
title: `Sim vs ${competitor.name} | Sim, the AI Workspace`,
|
||||
description: `Compare Sim, the open-source AI workspace, to ${competitor.name} on platform, AI, integrations, pricing, security, and support. Sourced and dated facts.`,
|
||||
path: `/comparison/${competitor.id}`,
|
||||
keywords: [
|
||||
`Sim vs ${competitor.name}`,
|
||||
`${competitor.name} alternative`,
|
||||
`${competitor.name} vs Sim`,
|
||||
`open source ${competitor.name} alternative`,
|
||||
`${competitor.name} comparison`,
|
||||
'AI agent workspace',
|
||||
'AI workflow automation comparison',
|
||||
].join(', '),
|
||||
})
|
||||
}
|
||||
|
||||
export default async function ComparisonProviderPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ provider: string }>
|
||||
}) {
|
||||
const { provider: providerSlug } = await params
|
||||
const competitor = getCompetitorBySlug(providerSlug)
|
||||
|
||||
if (!competitor) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const faqs = buildComparisonFaqs(competitor)
|
||||
const verdict = buildBottomLine(competitor)
|
||||
const CompetitorIcon = competitor.brand?.icon
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl },
|
||||
{ '@type': 'ListItem', position: 2, name: 'Comparison', item: `${baseUrl}/comparison` },
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: `Sim vs ${competitor.name}`,
|
||||
item: `${baseUrl}/comparison/${competitor.id}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const latestVerified = new Date(
|
||||
Math.max(SIM_LATEST_VERIFIED.getTime(), getLatestVerifiedDate(competitor).getTime())
|
||||
)
|
||||
|
||||
const productComparisonJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: `Sim vs ${competitor.name}`,
|
||||
description: `Feature and pricing comparison between Sim and ${competitor.name}.`,
|
||||
url: `${baseUrl}/comparison/${competitor.id}`,
|
||||
dateModified: latestVerified.toISOString().slice(0, 10),
|
||||
numberOfItems: 2,
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
item: {
|
||||
'@type': 'SoftwareApplication',
|
||||
name: 'Sim',
|
||||
applicationCategory: 'BusinessApplication',
|
||||
url: SITE_URL,
|
||||
description: simProfile.oneLiner,
|
||||
additionalProperty: factsToProperties(simProfile),
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
item: {
|
||||
'@type': 'SoftwareApplication',
|
||||
name: competitor.name,
|
||||
applicationCategory: 'BusinessApplication',
|
||||
url: competitor.website,
|
||||
description: competitor.oneLiner,
|
||||
additionalProperty: factsToProperties(competitor),
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faqs.map((faq) => ({
|
||||
'@type': 'Question',
|
||||
name: faq.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: faq.answer,
|
||||
},
|
||||
})),
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={breadcrumbJsonLd} />
|
||||
<JsonLd data={productComparisonJsonLd} />
|
||||
<JsonLd data={faqJsonLd} />
|
||||
|
||||
<main id='main-content' className='bg-[var(--bg)]'>
|
||||
<div className='mx-auto w-full max-w-[1446px] px-12 pt-[112px] max-sm:px-5 max-sm:pt-20 max-lg:px-8'>
|
||||
<div className='mb-6'>
|
||||
<BackLink href='/comparison' label='Back to comparisons' />
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-4'>
|
||||
<h1
|
||||
id='comparison-heading'
|
||||
className='text-balance text-[28px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[40px]'
|
||||
>
|
||||
Sim vs {competitor.name}
|
||||
</h1>
|
||||
<p className='max-w-[720px] text-[var(--text-muted)] text-sm leading-[150%] tracking-[0.02em] lg:text-base'>
|
||||
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.
|
||||
</p>
|
||||
<p className='sr-only'>
|
||||
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
|
||||
capabilities, integrations, pricing, security and compliance, observability, and
|
||||
support, using sourced, dated facts for buyers evaluating both platforms.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<div className='mx-auto w-full max-w-[1446px]'>
|
||||
<div className='mx-12 border-[var(--border)] border-x max-sm:mx-5 max-lg:mx-8'>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2'>
|
||||
<section
|
||||
aria-labelledby='what-is-sim-heading'
|
||||
className='border-[var(--border)] border-r px-6 py-6 max-sm:border-r-0 max-sm:border-b'
|
||||
>
|
||||
<h2
|
||||
id='what-is-sim-heading'
|
||||
className='mb-2 flex items-center gap-2.5 text-[18px] text-[var(--text-primary)] leading-snug tracking-[-0.01em]'
|
||||
>
|
||||
<SimIconTile className='size-9' />
|
||||
What is Sim?
|
||||
</h2>
|
||||
<p className='text-[var(--text-body)] text-small leading-[150%]'>
|
||||
{simProfile.oneLiner}
|
||||
</p>
|
||||
</section>
|
||||
<section aria-labelledby='what-is-competitor-heading' className='px-6 py-6'>
|
||||
<h2
|
||||
id='what-is-competitor-heading'
|
||||
className='mb-2 flex items-center gap-2.5 text-[18px] text-[var(--text-primary)] leading-snug tracking-[-0.01em]'
|
||||
>
|
||||
{CompetitorIcon ? (
|
||||
<BrandIconTile
|
||||
icon={CompetitorIcon}
|
||||
selfFramed={competitor.brand?.selfFramed}
|
||||
className='size-9'
|
||||
iconClassName='size-5'
|
||||
/>
|
||||
) : null}
|
||||
What is {competitor.name}?
|
||||
</h2>
|
||||
<p className='text-[var(--text-body)] text-small leading-[150%]'>
|
||||
{competitor.oneLiner}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<section aria-labelledby='comparison-table-heading' className='px-6 py-10'>
|
||||
<h2
|
||||
id='comparison-table-heading'
|
||||
className='mb-4 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[24px]'
|
||||
>
|
||||
Sim vs {competitor.name}: feature-by-feature comparison
|
||||
</h2>
|
||||
<ComparisonTable sim={simProfile} competitor={competitor} />
|
||||
</section>
|
||||
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<div className='grid grid-cols-1 lg:grid-cols-2'>
|
||||
<section
|
||||
aria-labelledby='sim-standout-heading'
|
||||
className='border-[var(--border)] border-r max-lg:border-r-0 max-lg:border-b'
|
||||
>
|
||||
<div className='px-6 pt-6 pb-2'>
|
||||
<h2
|
||||
id='sim-standout-heading'
|
||||
className='text-[18px] text-[var(--text-primary)] leading-snug tracking-[-0.01em]'
|
||||
>
|
||||
Sim standout features
|
||||
</h2>
|
||||
</div>
|
||||
<ComparisonCards items={simProfile.standoutFeatures} />
|
||||
</section>
|
||||
<section aria-labelledby='competitor-limitations-heading'>
|
||||
<div className='px-6 pt-6 pb-2'>
|
||||
<h2
|
||||
id='competitor-limitations-heading'
|
||||
className='text-[18px] text-[var(--text-primary)] leading-snug tracking-[-0.01em]'
|
||||
>
|
||||
Documented {competitor.name} limitations
|
||||
</h2>
|
||||
</div>
|
||||
<ComparisonCards items={competitor.limitations} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<section aria-labelledby='bottom-line-heading' className='px-6 py-10'>
|
||||
<h2
|
||||
id='bottom-line-heading'
|
||||
className='mb-4 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[24px]'
|
||||
>
|
||||
Bottom line
|
||||
</h2>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<p className='text-[var(--text-body)] text-small leading-[150%]'>
|
||||
{verdict.chooseSim}
|
||||
</p>
|
||||
<p className='text-[var(--text-body)] text-small leading-[150%]'>
|
||||
{verdict.chooseCompetitor}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<section aria-labelledby='faq-heading' className='px-6 py-10'>
|
||||
<h2
|
||||
id='faq-heading'
|
||||
className='mb-4 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[24px]'
|
||||
>
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
<div>
|
||||
<LandingFAQ faqs={faqs} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='-mt-px h-px w-full bg-[var(--border)]' />
|
||||
</main>
|
||||
|
||||
<div className='py-16'>
|
||||
<Cta />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { ComparisonFacts, CompetitorProfile, Fact } from '@/lib/compare/data'
|
||||
|
||||
/**
|
||||
* The one place a {@link ComparisonFacts} group is read back out of a profile
|
||||
* by group key. Every render-side consumer (the table, key-differences strip,
|
||||
* JSON-LD builder) needs this same lookup; centralizing it here means there is
|
||||
* exactly one cast to reason about instead of one per call site.
|
||||
*/
|
||||
export function getFactGroup<G extends keyof ComparisonFacts>(
|
||||
profile: CompetitorProfile,
|
||||
group: G
|
||||
): Record<string, Fact> {
|
||||
return profile.facts[group] as Record<string, Fact>
|
||||
}
|
||||
|
||||
/**
|
||||
* One row in a comparison table section. Maps a human label to a fact key
|
||||
* within a {@link ComparisonFacts} group. `key` is intentionally `string`
|
||||
* (rather than a per-group `keyof` union) so a single array can hold rows
|
||||
* for every group without TypeScript collapsing the distributed generic to
|
||||
* `never`; correctness is enforced once, by construction, in
|
||||
* {@link COMPARISON_SECTIONS} below, and the renderer reads through it.
|
||||
*/
|
||||
export interface ComparisonRowDef {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** One section of the comparison table, mirroring a {@link ComparisonFacts} group. */
|
||||
export interface ComparisonSectionDef {
|
||||
group: keyof ComparisonFacts
|
||||
title: string
|
||||
rows: ComparisonRowDef[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-checks a section's rows against its own group's actual fact keys
|
||||
* (via the per-call generic `G`), then widens to the plain `ComparisonRowDef`
|
||||
* shape used by {@link COMPARISON_SECTIONS}. This is where row-key
|
||||
* correctness is actually enforced. A typo here fails the build.
|
||||
*/
|
||||
function defineSection<G extends keyof ComparisonFacts>(section: {
|
||||
group: G
|
||||
title: string
|
||||
rows: Array<{ key: keyof ComparisonFacts[G]; label: string }>
|
||||
}): ComparisonSectionDef {
|
||||
return section as ComparisonSectionDef
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical section/row order for rendering a {@link ComparisonFacts} profile
|
||||
* pair as a table. Single source of truth for row labels. Add a field here
|
||||
* once and every comparison page picks it up.
|
||||
*/
|
||||
export const COMPARISON_SECTIONS: ComparisonSectionDef[] = [
|
||||
defineSection({
|
||||
group: 'platform',
|
||||
title: 'Platform',
|
||||
rows: [
|
||||
{ key: 'builderType', label: 'Builder type' },
|
||||
{ key: 'learningCurve', label: 'Learning curve' },
|
||||
{ key: 'selfHostOption', label: 'Self-hosting' },
|
||||
{ key: 'deploymentOptions', label: 'Deployment options' },
|
||||
{ key: 'templates', label: 'Templates' },
|
||||
{ key: 'license', label: 'License' },
|
||||
{ key: 'environmentPromotion', label: 'Environment promotion' },
|
||||
{ key: 'versionControlDepth', label: 'Version control' },
|
||||
{ key: 'realtimeCollaboration', label: 'Realtime collaboration' },
|
||||
{ key: 'nativeFileStorage', label: 'Native file storage' },
|
||||
{ key: 'dataTables', label: 'Native data tables' },
|
||||
{ key: 'richTextEditor', label: 'Rich-text document editor' },
|
||||
{ key: 'subWorkflows', label: 'Sub-workflows (composition)' },
|
||||
{ key: 'customBlocks', label: 'Custom blocks (org-wide reuse)' },
|
||||
],
|
||||
}),
|
||||
defineSection({
|
||||
group: 'pricing',
|
||||
title: 'Pricing',
|
||||
rows: [
|
||||
{ key: 'pricingModel', label: 'Pricing model' },
|
||||
{ key: 'entryPaidPlan', label: 'Entry paid plan' },
|
||||
{ key: 'freeTier', label: 'Free tier' },
|
||||
{ key: 'byok', label: 'Bring your own key' },
|
||||
],
|
||||
}),
|
||||
defineSection({
|
||||
group: 'security',
|
||||
title: 'Security & compliance',
|
||||
rows: [
|
||||
{ key: 'soc2', label: 'SOC 2' },
|
||||
{ key: 'dataResidency', label: 'Data residency' },
|
||||
{ key: 'rbac', label: 'Role-based access control' },
|
||||
{ key: 'auditLogging', label: 'Audit logging' },
|
||||
{ key: 'additionalCompliance', label: 'Additional compliance' },
|
||||
{ key: 'modelAndToolGovernance', label: 'Model & tool governance' },
|
||||
{ key: 'credentialGovernance', label: 'Credential governance' },
|
||||
{ key: 'sso', label: 'Single sign-on (SSO)' },
|
||||
{ key: 'thirdPartyVetting', label: 'Vetted first-party integrations' },
|
||||
{ key: 'piiRedaction', label: 'PII redaction' },
|
||||
{ key: 'dataRetention', label: 'Custom data retention' },
|
||||
{ key: 'whiteLabeling', label: 'White-labeling' },
|
||||
],
|
||||
}),
|
||||
defineSection({
|
||||
group: 'aiCapabilities',
|
||||
title: 'AI capabilities',
|
||||
rows: [
|
||||
{ key: 'multiLlmSupport', label: 'Multi-LLM support' },
|
||||
{ key: 'agentReasoningBlocks', label: 'Agent reasoning blocks' },
|
||||
{ key: 'naturalLanguageBuilding', label: 'Natural-language building' },
|
||||
{ key: 'knowledgeBaseRag', label: 'Knowledge base / RAG' },
|
||||
{ key: 'mcpSupport', label: 'MCP support' },
|
||||
{ key: 'evaluationGuardrails', label: 'Evaluation & guardrails' },
|
||||
{ key: 'humanInTheLoop', label: 'Human-in-the-loop' },
|
||||
{ key: 'generativeMedia', label: 'Generative media' },
|
||||
{ key: 'dynamicToolUse', label: 'Dynamic tool use' },
|
||||
{ key: 'modelFallback', label: 'Automatic model fallback' },
|
||||
{ key: 'agentSkills', label: 'Agent skills' },
|
||||
{ key: 'nativeChatDeployment', label: 'Native chat deployment' },
|
||||
{ key: 'parallelExecution', label: 'Parallel execution' },
|
||||
{ key: 'a2aProtocol', label: 'Agent2Agent (A2A) protocol' },
|
||||
{ key: 'loopIteration', label: 'Loop / iteration block' },
|
||||
],
|
||||
}),
|
||||
defineSection({
|
||||
group: 'integrations',
|
||||
title: 'Integrations',
|
||||
rows: [
|
||||
{ key: 'integrationCount', label: 'Integrations' },
|
||||
{ key: 'triggerTypes', label: 'Trigger types' },
|
||||
{ key: 'customCodeSteps', label: 'Custom code steps' },
|
||||
{ key: 'apiPublishing', label: 'API publishing' },
|
||||
{ key: 'extensibilitySdk', label: 'SDKs & extensibility' },
|
||||
{ key: 'mcpPublishing', label: 'Publish as MCP server' },
|
||||
],
|
||||
}),
|
||||
defineSection({
|
||||
group: 'observability',
|
||||
title: 'Observability & durability',
|
||||
rows: [
|
||||
{ key: 'tracingDepth', label: 'Tracing & observability' },
|
||||
{ key: 'durabilityModel', label: 'Durability & retries' },
|
||||
{ key: 'failureAlerting', label: 'Failure alerting' },
|
||||
{ key: 'dataDrains', label: 'Data drains' },
|
||||
{ key: 'asyncExecution', label: 'Async execution' },
|
||||
{ key: 'executionLimits', label: 'Execution limits' },
|
||||
{ key: 'partialFailureHandling', label: 'Partial-failure handling' },
|
||||
{ key: 'unattendedExecution', label: 'Unattended execution' },
|
||||
],
|
||||
}),
|
||||
defineSection({
|
||||
group: 'support',
|
||||
title: 'Support',
|
||||
rows: [
|
||||
{ key: 'supportChannels', label: 'Support channels' },
|
||||
{ key: 'sla', label: 'SLA' },
|
||||
{ key: 'community', label: 'Community' },
|
||||
{ key: 'academy', label: 'Academy / training' },
|
||||
],
|
||||
}),
|
||||
]
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import type { CompetitorBrand } from '@/lib/compare/data'
|
||||
import { SimWordmark } from '@/app/(landing)/components/navbar/components/sim-wordmark'
|
||||
|
||||
export interface BrandIconTileProps {
|
||||
icon: ComponentType<SVGProps<SVGSVGElement>>
|
||||
/**
|
||||
* Whether `icon` already renders a full, self-contained brand-colored
|
||||
* square (e.g. a fetched app-store-style logo) rather than a bare
|
||||
* transparent glyph. See {@link CompetitorBrand.selfFramed}.
|
||||
*/
|
||||
selfFramed?: boolean
|
||||
/** Outer tile size, e.g. `size-8`. Defaults to the integrations-page card size. */
|
||||
className?: string
|
||||
/** Icon glyph size inside the tile, e.g. `size-4`. Ignored when `selfFramed`. */
|
||||
iconClassName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A rounded, bordered icon tile matching the platform's app-icon chrome
|
||||
* conventions (border radius, border token, background), so competitor brand
|
||||
* logos read as the same first-class "app icon" chrome as the rest of the
|
||||
* product, instead of a bare, unframed SVG floating in the layout.
|
||||
*
|
||||
* A self-framed logo (already a complete brand-colored square) fills the
|
||||
* tile edge-to-edge, clipped to the same rounded corners. Otherwise the icon
|
||||
* is a small transparent glyph centered on a plain bordered background.
|
||||
*/
|
||||
export function BrandIconTile({
|
||||
icon: Icon,
|
||||
selfFramed = false,
|
||||
className = 'size-8',
|
||||
iconClassName = 'size-4',
|
||||
}: BrandIconTileProps) {
|
||||
if (selfFramed) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 overflow-hidden rounded-xl border border-[var(--border-1)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Icon className='size-full' aria-hidden='true' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center rounded-xl border border-[var(--border-1)] bg-[var(--bg)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Icon className={iconClassName} aria-hidden='true' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SimIconTileProps {
|
||||
/** Outer tile size, e.g. `size-8`. Defaults to the integrations-page card size. */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The same rounded, bordered tile as {@link BrandIconTile}, but for Sim's own
|
||||
* wordmark. So "Sim" gets the identical icon-chip treatment as every
|
||||
* competitor it's compared against, instead of appearing as bare text.
|
||||
*/
|
||||
export function SimIconTile({ className = 'size-8' }: SimIconTileProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center rounded-xl border border-[var(--border-1)] bg-[var(--bg)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className='inline-flex scale-[0.6]'>
|
||||
<SimWordmark />
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { BrandIconTileProps, SimIconTileProps } from './brand-icon-tile'
|
||||
export { BrandIconTile, SimIconTile } from './brand-icon-tile'
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { FactSource } from '@/lib/compare/data'
|
||||
import { SourceLink } from '@/app/(landing)/comparison/components/source-info'
|
||||
|
||||
interface ComparisonCardItem {
|
||||
title: string
|
||||
description: string
|
||||
shortDescription?: string
|
||||
source: FactSource
|
||||
}
|
||||
|
||||
export interface ComparisonCardsProps {
|
||||
items: ComparisonCardItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A vertically stacked list of atomic, independently quotable fact cards,
|
||||
* each self-contained title + a one-line `shortDescription` (falling back to
|
||||
* `description` if a short version hasn't been authored yet). Used for both
|
||||
* a competitor's standout features and its documented limitations.
|
||||
*
|
||||
* The full `description` is always present as `sr-only` text. Server
|
||||
* rendered regardless of hover/JS state. So an LLM or crawler reading the
|
||||
* page still gets the complete claim even though a human sees only the
|
||||
* one-line summary. Hovering the title itself (`SourceLink`) shows a short
|
||||
* "Source: X" tooltip and clicking it opens the source, rather than a
|
||||
* separate info-icon affordance next to every card.
|
||||
*/
|
||||
export function ComparisonCards({ items }: ComparisonCardsProps) {
|
||||
return (
|
||||
<div className='flex flex-col'>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className={index > 0 ? 'border-[var(--border)] border-t px-6 py-4' : 'px-6 py-4'}
|
||||
>
|
||||
<h3 className='mb-1 text-[var(--text-primary)] text-base leading-snug tracking-[-0.01em]'>
|
||||
<SourceLink source={item.source}>{item.title}</SourceLink>
|
||||
</h3>
|
||||
<p className='text-[var(--text-body)] text-small leading-[150%]'>
|
||||
{item.shortDescription ?? item.description}
|
||||
</p>
|
||||
{item.shortDescription ? <span className='sr-only'>{item.description}</span> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { ComparisonCardsProps } from './comparison-cards'
|
||||
export { ComparisonCards } from './comparison-cards'
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import type { CompetitorProfile } from '@/lib/compare/data'
|
||||
import { COMPARISON_SECTIONS, getFactGroup } from '@/app/(landing)/comparison/comparison-sections'
|
||||
import { BrandIconTile, SimIconTile } from '@/app/(landing)/comparison/components/brand-icon-tile'
|
||||
import { FactValue } from '@/app/(landing)/comparison/components/fact-value'
|
||||
|
||||
export interface ComparisonTableProps {
|
||||
sim: CompetitorProfile
|
||||
competitor: CompetitorProfile
|
||||
}
|
||||
|
||||
/**
|
||||
* Pins the row-label column during horizontal scroll on genuinely spacious
|
||||
* viewports (the standard pattern for responsive data tables, e.g.
|
||||
* Stripe/GitHub/Notion comparison tables) so a reader keeps row context while
|
||||
* scrolling to see the Sim/competitor values. Below `lg` (this page's own
|
||||
* tablet-and-below tier, per `.claude/rules` for this route group) the table
|
||||
* switches to a stacked layout instead (see `MOBILE_STACK_*`) rather than
|
||||
* relying on horizontal scroll at a width too narrow to render a 3-column
|
||||
* table comfortably, so sticky positioning is scoped to `lg:` only. The
|
||||
* shadow is a permanent CSS-only affordance (no scroll-position JS) so this
|
||||
* stays a zero-hydration server component.
|
||||
*/
|
||||
const STICKY_LABEL_COL = 'lg:sticky lg:left-0 lg:z-10 lg:shadow-[2px_0_4px_-2px_rgba(0,0,0,0.08)]'
|
||||
|
||||
/**
|
||||
* Below `lg` (1024px) a 3-column grid doesn't reliably have room to be
|
||||
* legible, so each fact instead stacks as label -> Sim's value -> the
|
||||
* competitor's value, with a small name tag on each value since the column
|
||||
* headers are no longer directly above. Applied to the label cell.
|
||||
*/
|
||||
const MOBILE_STACK_LABEL = 'max-lg:border-r-0 max-lg:border-b-0 max-lg:pt-3 max-lg:pb-1'
|
||||
|
||||
/**
|
||||
* Applied to a value cell (Sim's or the competitor's) in the stacked mobile
|
||||
* layout. `items-stretch` overrides the cell's base `items-center` (which
|
||||
* would otherwise shrink-wrap and center each child horizontally in a
|
||||
* flex-col): stretching gives the name tag and the value their own
|
||||
* full-width box to left-align and truncate within, instead of a
|
||||
* content-sized box with no boundary to clip against.
|
||||
*/
|
||||
const MOBILE_STACK_VALUE =
|
||||
'max-lg:flex-col max-lg:items-stretch max-lg:gap-0.5 max-lg:border-r-0 max-lg:px-4'
|
||||
|
||||
function ColumnHeader({
|
||||
name,
|
||||
iconTile,
|
||||
isSim,
|
||||
}: {
|
||||
name: string
|
||||
iconTile: ReactNode
|
||||
isSim: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col items-center gap-2 border-[var(--border-1)] border-b px-3 py-4 text-center',
|
||||
isSim ? 'bg-[var(--surface-2)]' : 'bg-[var(--surface-1)]'
|
||||
)}
|
||||
>
|
||||
{iconTile}
|
||||
<span className='w-full truncate font-medium text-[var(--text-primary)] text-base'>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-column "Sim vs {Competitor}" fact table, styled after the billing
|
||||
* upgrade-page comparison table (same border/hairline rhythm and section
|
||||
* headers) but data-driven off {@link CompetitorProfile.facts} instead of the
|
||||
* fixed 4-tier plan schema. Data cells share one neutral surface for both
|
||||
* columns. The Sim column is called out only in the header row (a bottom
|
||||
* accent border), so the table reads as one clean grid rather than a
|
||||
* checkerboard. Pure server component: every value is plain server-rendered
|
||||
* text so crawlers and AI answer engines read the full comparison without
|
||||
* any client-side hydration.
|
||||
*/
|
||||
export function ComparisonTable({ sim, competitor }: ComparisonTableProps) {
|
||||
return (
|
||||
<div className='w-full overflow-x-auto rounded-xl border border-[var(--border-1)]'>
|
||||
<div
|
||||
role='table'
|
||||
aria-label={`Sim vs ${competitor.name} feature comparison`}
|
||||
className='grid grid-cols-1 lg:min-w-[560px] lg:grid-cols-[minmax(140px,max-content)_1fr_1fr]'
|
||||
>
|
||||
<div className='contents' role='row'>
|
||||
<div
|
||||
role='columnheader'
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col justify-center border-[var(--border)] border-r border-b bg-[var(--surface-1)] px-4 py-4',
|
||||
STICKY_LABEL_COL,
|
||||
'max-lg:border-r-0'
|
||||
)}
|
||||
>
|
||||
<span className='truncate font-medium text-[var(--text-primary)] text-base'>
|
||||
Compare
|
||||
</span>
|
||||
<span className='truncate text-[var(--text-muted)] text-small'>
|
||||
{sim.name} vs {competitor.name}
|
||||
</span>
|
||||
</div>
|
||||
<ColumnHeader name={sim.name} iconTile={<SimIconTile className='size-9' />} isSim />
|
||||
<ColumnHeader
|
||||
name={competitor.name}
|
||||
iconTile={
|
||||
competitor.brand?.icon ? (
|
||||
<BrandIconTile
|
||||
icon={competitor.brand.icon}
|
||||
selfFramed={competitor.brand.selfFramed}
|
||||
className='size-9'
|
||||
iconClassName='size-5'
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
isSim={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{COMPARISON_SECTIONS.map((section, sectionIdx) => {
|
||||
const simGroupFacts = getFactGroup(sim, section.group)
|
||||
const competitorGroupFacts = getFactGroup(competitor, section.group)
|
||||
|
||||
return (
|
||||
<div key={section.title} className='contents'>
|
||||
<div className='contents' role='row'>
|
||||
<div
|
||||
role='columnheader'
|
||||
className={cn(
|
||||
'border-[var(--border)] border-r bg-[var(--surface-1)] px-4 py-2',
|
||||
STICKY_LABEL_COL,
|
||||
'max-lg:border-r-0',
|
||||
sectionIdx > 0 && 'border-[var(--border-1)] border-t'
|
||||
)}
|
||||
>
|
||||
<span className='font-medium text-[var(--text-primary)] text-small'>
|
||||
{section.title}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
role='presentation'
|
||||
className={cn(
|
||||
'col-span-2 bg-[var(--surface-1)] max-lg:hidden',
|
||||
sectionIdx > 0 && 'border-[var(--border-1)] border-t'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{section.rows.map((row, rowIdx) => {
|
||||
const simFact = simGroupFacts[row.key]
|
||||
const competitorFact = competitorGroupFacts[row.key]
|
||||
const isNotLastRow = rowIdx < section.rows.length - 1
|
||||
|
||||
return (
|
||||
<div key={row.key} className='contents' role='row'>
|
||||
<div
|
||||
role='rowheader'
|
||||
className={cn(
|
||||
'flex min-w-0 items-center border-[var(--border)] border-r bg-[var(--surface-1)] px-4 py-2.5',
|
||||
STICKY_LABEL_COL,
|
||||
MOBILE_STACK_LABEL,
|
||||
isNotLastRow && 'border-[var(--border-1)] border-b'
|
||||
)}
|
||||
>
|
||||
<span className='text-[var(--text-body)] text-small max-lg:font-medium max-lg:text-[var(--text-primary)]'>
|
||||
{row.label}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
role='cell'
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-1 border-[var(--border)] border-r bg-[var(--surface-2)] px-3 py-2.5',
|
||||
MOBILE_STACK_VALUE,
|
||||
'max-lg:border-b-0 max-lg:pt-1 max-lg:pb-1',
|
||||
isNotLastRow && 'border-[var(--border-1)] border-b'
|
||||
)}
|
||||
>
|
||||
<span className='font-medium text-[var(--text-muted)] text-caption lg:hidden'>
|
||||
{sim.name}
|
||||
</span>
|
||||
<FactValue fact={simFact} />
|
||||
</div>
|
||||
<div
|
||||
role='cell'
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-1 bg-[var(--surface-2)] px-3 py-2.5',
|
||||
MOBILE_STACK_VALUE,
|
||||
'max-lg:pt-1 max-lg:pb-3',
|
||||
isNotLastRow && 'border-[var(--border-1)] border-b'
|
||||
)}
|
||||
>
|
||||
<span className='font-medium text-[var(--text-muted)] text-caption lg:hidden'>
|
||||
{competitor.name}
|
||||
</span>
|
||||
<FactValue fact={competitorFact} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { ComparisonTableProps } from './comparison-table'
|
||||
export { ComparisonTable } from './comparison-table'
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Check, X } from '@sim/emcn/icons'
|
||||
import type { Fact } from '@/lib/compare/data'
|
||||
import { SourceLink } from '@/app/(landing)/comparison/components/source-info'
|
||||
import { parseFactValue } from '@/app/(landing)/comparison/fact-status'
|
||||
|
||||
export interface FactValueProps {
|
||||
fact: Fact
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one {@link Fact} for a glancing reader while keeping the full
|
||||
* granular fact server-rendered for crawlers and AI answer engines.
|
||||
*
|
||||
* - A true "Yes"/"No" fact renders as an icon alone (a monochrome check or
|
||||
* muted cross, no colored pass/fail styling), no visible text, since the
|
||||
* label column and surrounding context already say what's being asked.
|
||||
* - Any other fact shows its `shortValue` (a compact, pre-authored
|
||||
* restatement of `value`), never the full sentence.
|
||||
* - `Tooltip` here is a cursor-following mini-bubble meant for a short
|
||||
* one-line label (see its own docs/usages: "Refresh", "last updated: X")
|
||||
* . It is deliberately NOT used to hold paragraph-length detail text, only
|
||||
* the compact source citation, which is exactly what it's designed for.
|
||||
* - When a source exists, the visible glance (icon or `shortValue` text)
|
||||
* IS the hover/click target for that source, via `SourceLink`, rather
|
||||
* than a separate info-icon next to every value. One affordance per
|
||||
* fact keeps a 58-row table from reading as icon-cluttered.
|
||||
* - A `sr-only` span always carries the complete value, detail, and source
|
||||
* in the initial server-rendered HTML, independent of hover/JS state, so
|
||||
* an LLM or crawler reading the page gets full granularity even though a
|
||||
* human sees only the compact glance.
|
||||
*/
|
||||
export function FactValue({ fact }: FactValueProps) {
|
||||
const { status, text } = parseFactValue(fact.value)
|
||||
const isBoolean = status === 'yes' || status === 'no'
|
||||
const primarySource = fact.sources[0]
|
||||
|
||||
const fullText = [fact.value, fact.detail].filter(Boolean).join('. ')
|
||||
|
||||
const glance = isBoolean ? (
|
||||
status === 'yes' ? (
|
||||
<Check className='size-[14px] shrink-0 text-[var(--text-primary)]' aria-hidden='true' />
|
||||
) : (
|
||||
<X className='size-[14px] shrink-0 text-[var(--text-muted)]' aria-hidden='true' />
|
||||
)
|
||||
) : null
|
||||
|
||||
// A pure yes/no fact renders as an icon only. The "why" lives in the
|
||||
// source link and the sr-only text, not cluttering the glance view.
|
||||
const shortText = isBoolean ? null : (fact.shortValue ?? text)
|
||||
|
||||
const valueNode = glance ?? (
|
||||
<span className='truncate text-[var(--text-body)] text-small'>{shortText}</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className='flex min-w-0 items-center gap-1.5'>
|
||||
{primarySource ? (
|
||||
<SourceLink source={primarySource} className={glance ? 'shrink-0' : 'min-w-0 truncate'}>
|
||||
{valueNode}
|
||||
</SourceLink>
|
||||
) : (
|
||||
valueNode
|
||||
)}
|
||||
<span className='sr-only'>{fullText}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { FactValueProps } from './fact-value'
|
||||
export { FactValue } from './fact-value'
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { SourceLinkProps } from './source-info'
|
||||
export { SourceLink } from './source-info'
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn, Tooltip } from '@sim/emcn'
|
||||
import type { FactSource } from '@/lib/compare/data'
|
||||
|
||||
export interface SourceLinkProps {
|
||||
source: FactSource
|
||||
children: ReactNode
|
||||
/** Additional classes for the trigger element (the visible value/title). */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a fact's visible value (or a card's title) so hovering it directly
|
||||
* shows a one-line "Source: X" tooltip, and clicking it opens the source,
|
||||
* rather than a separate info-icon affordance next to every value. One
|
||||
* hover/click target per fact instead of two keeps the dense comparison
|
||||
* table and card lists from reading as icon-cluttered. Every {@link FactSource}
|
||||
* carries a real, publicly reachable URL (enforced by the type), so this
|
||||
* always renders as a link.
|
||||
*/
|
||||
export function SourceLink({ source, children, className }: SourceLinkProps) {
|
||||
return (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<a
|
||||
href={source.url}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
aria-label={`${source.label} (opens source)`}
|
||||
className={cn('block min-w-0', className)}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Source: {source.label}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/** Status implied by a fact's leading "Yes"/"No" token, if any. */
|
||||
export type FactStatus = 'yes' | 'no' | 'neutral'
|
||||
|
||||
export interface ParsedFact {
|
||||
status: FactStatus
|
||||
/** The value with any leading "Yes:"/"No:" token stripped, ready to render next to a status icon. */
|
||||
text: string
|
||||
}
|
||||
|
||||
// The negative lookahead (?![a-zA-Z]) requires the "Yes"/"No" token to end at a word
|
||||
// boundary, so values like "Not documented" or "Not publicly documented" (which start
|
||||
// with the letters "No" but aren't the boolean token) fall through to 'neutral' instead
|
||||
// of being misread as a "No" status. The separator group accepts either a colon
|
||||
// ("Yes: ...") or a comma ("Yes, but ...") since both are used throughout the dataset.
|
||||
const STATUS_PREFIX = /^(Yes|No)(?![a-zA-Z])(?:[:,]\s*)?(.*)$/s
|
||||
|
||||
/**
|
||||
* Splits a {@link Fact.value} string into a status (for a compact icon) and
|
||||
* the remaining descriptive text. Every fact in `apps/sim/lib/compare/data`
|
||||
* that represents a yes/no capability is written as `"Yes: ..."` / `"No: ..."`,
|
||||
* or occasionally `"Yes, ..."` / `"No, ..."` as a more natural continuation.
|
||||
* This is the single place that convention is parsed, so the comparison table
|
||||
* and the key-differences strip render it identically.
|
||||
*/
|
||||
export function parseFactValue(value: string): ParsedFact {
|
||||
const match = value.match(STATUS_PREFIX)
|
||||
if (!match) {
|
||||
return { status: 'neutral', text: value }
|
||||
}
|
||||
const [, token, rest] = match
|
||||
const trimmedRest = rest.trim()
|
||||
return {
|
||||
status: token === 'Yes' ? 'yes' : 'no',
|
||||
text: trimmedRest.length > 0 ? trimmedRest : token,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ChipLink } from '@sim/emcn'
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Page Not Found',
|
||||
robots: { index: false, follow: true },
|
||||
}
|
||||
|
||||
export default function ComparisonNotFound() {
|
||||
return (
|
||||
<main
|
||||
id='main-content'
|
||||
className='mx-auto flex min-h-[60vh] w-full max-w-[1446px] flex-col items-center justify-center gap-3 px-12 py-24 text-center max-sm:px-5 max-lg:px-8'
|
||||
>
|
||||
<h1 className='text-balance text-[40px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'>
|
||||
Comparison not found
|
||||
</h1>
|
||||
<p className='text-[var(--text-muted)] text-lg'>
|
||||
The comparison you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
<ChipLink variant='primary' href='/comparison' className='mt-3'>
|
||||
Browse comparisons
|
||||
</ChipLink>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import { simProfile } from '@/lib/compare/data'
|
||||
import { SITE_URL } from '@/lib/core/utils/urls'
|
||||
import { buildLandingMetadata } from '@/lib/landing/seo'
|
||||
import { BrandIconTile } from '@/app/(landing)/comparison/components/brand-icon-tile'
|
||||
import { ALL_COMPETITORS, ensurePeriod, lowercaseFirst } from '@/app/(landing)/comparison/utils'
|
||||
import { ChevronArrow } from '@/app/(landing)/components/chevron-arrow'
|
||||
import { JsonLd } from '@/app/(landing)/components/json-ld'
|
||||
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
|
||||
|
||||
const baseUrl = SITE_URL
|
||||
|
||||
export const revalidate = 3600
|
||||
|
||||
const faqItems = [
|
||||
{
|
||||
question: 'How does Sim compare to workflow automation and AI agent platforms?',
|
||||
answer:
|
||||
'Sim is an open-source AI workspace where teams build, deploy, and manage AI agents visually, conversationally, or with code. Compared to workflow automation tools like n8n, Zapier, and Make, Sim treats AI agents as first-class building blocks rather than an add-on to data routing, and ships a native knowledge base, MCP support, and an in-editor AI Copilot. Compared to enterprise AI builders like Gumloop, Workato, StackAI, and Vellum, Sim is fully open source (Apache 2.0) and self-hostable, so teams can run it on their own infrastructure.',
|
||||
},
|
||||
{
|
||||
question: 'Is Sim open source?',
|
||||
answer:
|
||||
'Yes. Sim is released under the Apache License 2.0 and can be self-hosted via Docker or Kubernetes, or used as a managed cloud-hosted service.',
|
||||
},
|
||||
{
|
||||
question: 'Which AI agent platform should I choose?',
|
||||
answer:
|
||||
"The right platform depends on what you're optimizing for: licensing and data control (Sim, n8n self-hosted), integration breadth (Zapier, Pipedream), enterprise governance (Workato, Tines), or AI-native agent building specifically (Sim, Gumloop, StackAI). Each comparison page on this site lays out sourced, dated facts across platform, AI capabilities, integrations, pricing, security, and support so you can weigh the tradeoffs for your team.",
|
||||
},
|
||||
{
|
||||
question: 'Is Sim free to use?',
|
||||
answer: `${ensurePeriod(simProfile.facts.pricing.freeTier.value)} Sim is also free to self-host under the Apache 2.0 license with no seat or usage limits beyond your own infrastructure.`,
|
||||
},
|
||||
{
|
||||
question: 'Does Sim support MCP (Model Context Protocol)?',
|
||||
answer: `${ensurePeriod(simProfile.facts.aiCapabilities.mcpSupport.value)} Sim can also publish any deployed workflow as its own MCP server, so it works as both an MCP client and an MCP server.`,
|
||||
},
|
||||
{
|
||||
question: 'How many integrations does Sim support?',
|
||||
answer: `Sim ships ${ensurePeriod(lowercaseFirst(simProfile.facts.integrations.integrationCount.value))} Combined with native MCP client support, teams can extend Sim to any service with a public API, not just the built-in catalog.`,
|
||||
},
|
||||
]
|
||||
|
||||
export const metadata: Metadata = buildLandingMetadata({
|
||||
title: 'Sim Comparisons | Sim, the AI Workspace',
|
||||
description:
|
||||
'Compare Sim, the open-source AI workspace, to n8n, Zapier, Make, and other workflow automation and AI agent platforms. Sourced, dated, fact-checked.',
|
||||
path: '/comparison',
|
||||
keywords: [
|
||||
'Sim comparison',
|
||||
'Sim vs n8n',
|
||||
'Sim vs Zapier',
|
||||
'Sim alternative',
|
||||
'AI agent platform comparison',
|
||||
'workflow automation comparison',
|
||||
'open source AI workspace',
|
||||
].join(', '),
|
||||
})
|
||||
|
||||
export default function ComparisonHubPage() {
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl },
|
||||
{ '@type': 'ListItem', position: 2, name: 'Comparison', item: `${baseUrl}/comparison` },
|
||||
],
|
||||
}
|
||||
|
||||
const itemListJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: 'Sim Comparisons',
|
||||
description: 'Directory of Sim comparison pages against AI agent and workflow platforms.',
|
||||
url: `${baseUrl}/comparison`,
|
||||
numberOfItems: ALL_COMPETITORS.length,
|
||||
itemListElement: ALL_COMPETITORS.map((competitor, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: `${baseUrl}/comparison/${competitor.id}`,
|
||||
name: `Sim vs ${competitor.name}`,
|
||||
})),
|
||||
}
|
||||
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faqItems.map((item) => ({
|
||||
'@type': 'Question',
|
||||
name: item.question,
|
||||
acceptedAnswer: { '@type': 'Answer', text: item.answer },
|
||||
})),
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={breadcrumbJsonLd} />
|
||||
<JsonLd data={itemListJsonLd} />
|
||||
<JsonLd data={faqJsonLd} />
|
||||
|
||||
<main id='main-content' className='bg-[var(--bg)]'>
|
||||
<div className='mx-auto w-full max-w-[1446px] px-12 pt-[112px] max-sm:px-5 max-sm:pt-20 max-lg:px-8'>
|
||||
{/* Invisible spacer matching the detail page's BackLink block height/margin, so the divider below lands at the same Y on both pages. */}
|
||||
<div className='mb-6 h-6' aria-hidden='true' />
|
||||
|
||||
<div className='flex flex-col gap-4'>
|
||||
<h1
|
||||
id='comparison-hub-heading'
|
||||
className='text-balance text-[28px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[40px]'
|
||||
>
|
||||
Sim comparisons
|
||||
</h1>
|
||||
<p className='max-w-[720px] text-[var(--text-muted)] text-sm leading-[150%] tracking-[0.02em] lg:text-base'>
|
||||
Sim is the open-source AI workspace where teams build, deploy, and manage AI agents.
|
||||
See how Sim compares to workflow automation platforms and AI agent builders on
|
||||
platform architecture, AI capabilities, integrations, pricing, security, and support.
|
||||
</p>
|
||||
<p className='sr-only'>
|
||||
This directory lists every Sim vs. competitor comparison page, covering workflow
|
||||
automation platforms (n8n, Zapier, Make, Pipedream), enterprise AI builders (Gumloop,
|
||||
Workato, Retool, Tines, StackAI, Power Automate, Vellum), and AI agent products
|
||||
(OpenAI AgentKit, Claude Cowork). Each page gives sourced, dated facts across
|
||||
platform, AI capabilities, integrations, pricing, security, and support.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<div className='mx-auto w-full max-w-[1446px] px-12 max-sm:px-5 max-lg:px-8'>
|
||||
<div className='border-[var(--border)] border-x'>
|
||||
<section aria-labelledby='all-comparisons-heading' className='pt-10'>
|
||||
<h2
|
||||
id='all-comparisons-heading'
|
||||
className='mb-4 px-6 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[24px]'
|
||||
>
|
||||
All comparisons
|
||||
</h2>
|
||||
<div>
|
||||
{ALL_COMPETITORS.map((competitor) => {
|
||||
const Icon = competitor.brand?.icon
|
||||
return (
|
||||
<div key={competitor.id}>
|
||||
<Link
|
||||
href={`/comparison/${competitor.id}`}
|
||||
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`}
|
||||
>
|
||||
{Icon ? (
|
||||
<BrandIconTile
|
||||
icon={Icon}
|
||||
selfFramed={competitor.brand?.selfFramed}
|
||||
className='size-8 shrink-0'
|
||||
iconClassName='size-4'
|
||||
/>
|
||||
) : null}
|
||||
<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]'>
|
||||
Sim vs {competitor.name}
|
||||
</h3>
|
||||
<p className='hidden text-[var(--text-muted)] text-caption leading-[150%] sm:line-clamp-1'>
|
||||
{competitor.oneLiner}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronArrow />
|
||||
</Link>
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby='faq-heading' className='px-6 py-10'>
|
||||
<h2
|
||||
id='faq-heading'
|
||||
className='mb-4 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[24px]'
|
||||
>
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
<div>
|
||||
<LandingFAQ faqs={faqItems} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='-mt-px h-px w-full bg-[var(--border)]' />
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import {
|
||||
type CompetitorProfile,
|
||||
claudeCoworkProfile,
|
||||
crewaiProfile,
|
||||
dustProfile,
|
||||
flowiseProfile,
|
||||
gumloopProfile,
|
||||
langchainProfile,
|
||||
langflowProfile,
|
||||
makeProfile,
|
||||
microsoftCopilotProfile,
|
||||
n8nProfile,
|
||||
openaiAgentkitProfile,
|
||||
openClawProfile,
|
||||
pipedreamProfile,
|
||||
powerAutomateProfile,
|
||||
retoolProfile,
|
||||
simProfile,
|
||||
stackaiProfile,
|
||||
tinesProfile,
|
||||
vellumProfile,
|
||||
workatoProfile,
|
||||
zapierProfile,
|
||||
} from '@/lib/compare/data'
|
||||
|
||||
export interface ComparisonFaq {
|
||||
question: string
|
||||
answer: string
|
||||
}
|
||||
|
||||
/** Every competitor Sim is compared against, in display/build order. */
|
||||
export const ALL_COMPETITORS: CompetitorProfile[] = [
|
||||
n8nProfile,
|
||||
zapierProfile,
|
||||
makeProfile,
|
||||
gumloopProfile,
|
||||
workatoProfile,
|
||||
retoolProfile,
|
||||
pipedreamProfile,
|
||||
openaiAgentkitProfile,
|
||||
tinesProfile,
|
||||
stackaiProfile,
|
||||
powerAutomateProfile,
|
||||
vellumProfile,
|
||||
claudeCoworkProfile,
|
||||
langflowProfile,
|
||||
flowiseProfile,
|
||||
microsoftCopilotProfile,
|
||||
openClawProfile,
|
||||
dustProfile,
|
||||
crewaiProfile,
|
||||
langchainProfile,
|
||||
]
|
||||
|
||||
const COMPETITOR_BY_SLUG = new Map(ALL_COMPETITORS.map((c) => [c.id, c]))
|
||||
|
||||
export function getCompetitorBySlug(slug: string): CompetitorProfile | null {
|
||||
return COMPETITOR_BY_SLUG.get(slug) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recent `asOf` date across every fact source in a profile. Used as
|
||||
* the sitemap `lastModified` for that competitor's comparison page, so the
|
||||
* sitemap reflects when the underlying facts were actually last verified.
|
||||
*/
|
||||
export function getLatestVerifiedDate(profile: CompetitorProfile): Date {
|
||||
let latest = 0
|
||||
for (const group of Object.values(profile.facts)) {
|
||||
for (const fact of Object.values(group as Record<string, { sources: { asOf: string }[] }>)) {
|
||||
for (const source of fact.sources) {
|
||||
const time = new Date(source.asOf).getTime()
|
||||
if (!Number.isNaN(time) && time > latest) {
|
||||
latest = time
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return latest > 0 ? new Date(latest) : new Date()
|
||||
}
|
||||
|
||||
/** Sim's own latest-verified date, identical across every competitor page, computed once. */
|
||||
export const SIM_LATEST_VERIFIED = getLatestVerifiedDate(simProfile)
|
||||
|
||||
/**
|
||||
* A short, atomic "who should pick which platform" verdict. The single
|
||||
* block most comparison-page readers (and AI answer engines asked "should I
|
||||
* use Sim or {competitor}") are actually looking for. Both sentences name
|
||||
* both products explicitly and stay factual, drawing on the competitor's own
|
||||
* documented standout feature rather than a generic claim.
|
||||
*/
|
||||
export interface ComparisonVerdict {
|
||||
chooseSim: string
|
||||
chooseCompetitor: string
|
||||
}
|
||||
|
||||
export function buildBottomLine(competitor: CompetitorProfile): ComparisonVerdict {
|
||||
const strength = competitor.standoutFeatures[0]
|
||||
const chooseCompetitor = strength
|
||||
? `Choose ${competitor.name} if you specifically need ${lowercaseFirst(strength.title)}: ${strength.description}`
|
||||
: `Choose ${competitor.name} if its specific strengths, documented above, matter more to your team than an AI-native, self-hostable workspace.`
|
||||
|
||||
return {
|
||||
chooseSim: `Choose Sim if you want an open-source, self-hostable AI workspace that treats AI agents as first-class citizens: native multi-LLM support, real-time multiplayer editing, environment promotion (dev/qa/prod), human-in-the-loop approvals, and enterprise governance (SSO, credential-level permissions, audit logs) built in rather than bolted on.`,
|
||||
chooseCompetitor,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the FAQ set for a "Sim vs {Competitor}" page. Answer-first, each
|
||||
* 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.
|
||||
*/
|
||||
export function buildComparisonFaqs(competitor: CompetitorProfile): ComparisonFaq[] {
|
||||
const name = competitor.name
|
||||
const facts = competitor.facts
|
||||
const faqs: ComparisonFaq[] = [
|
||||
{
|
||||
question: `Is Sim a good alternative to ${name}?`,
|
||||
answer: `Sim is an open-source AI workspace where teams build, deploy, and manage AI agents visually, conversationally, or with code. ${ensurePeriod(competitor.oneLiner)} Teams considering a switch typically weigh licensing (Sim is Apache 2.0 and self-hostable), pricing model, and how AI-native the platform's agent-building experience is.`,
|
||||
},
|
||||
{
|
||||
question: `What is the main difference between Sim and ${name}?`,
|
||||
answer: buildKeyDifferenceAnswer(competitor),
|
||||
},
|
||||
{
|
||||
question: `Does Sim support self-hosting compared to ${name}?`,
|
||||
answer: `Sim can be self-hosted via Docker or Kubernetes under an Apache 2.0 license, in addition to a managed cloud-hosted plan. ${name}'s self-hosting position: ${ensurePeriod(firstSentence(facts.platform.selfHostOption.value))}`,
|
||||
},
|
||||
{
|
||||
question: `How does Sim's pricing compare to ${name}?`,
|
||||
answer: `Sim uses ${summarizeFact(simProfile.facts.pricing.pricingModel.value)} ${name} uses ${summarizeFact(facts.pricing.pricingModel.value)}`,
|
||||
},
|
||||
{
|
||||
question: `Is Sim more secure than ${name}?`,
|
||||
answer: `Security is a like-for-like comparison, not a one-line verdict. Sim: ${summarizeFact(simProfile.facts.security.additionalCompliance.value)} ${name}: ${summarizeFact(facts.security.additionalCompliance.value)} Check the Security & compliance rows above for the full breakdown, including SSO, audit logging, and data residency.`,
|
||||
},
|
||||
{
|
||||
question: `Which has stronger AI agent capabilities, Sim or ${name}?`,
|
||||
answer: `Sim: ${summarizeFact(simProfile.facts.aiCapabilities.multiLlmSupport.value)} ${name}: ${summarizeFact(facts.aiCapabilities.multiLlmSupport.value)} Sim also ships native human-in-the-loop approvals, a hybrid vector-plus-keyword knowledge base, and an in-editor AI Copilot that can read execution logs and directly edit the workflow to fix a failed run.`,
|
||||
},
|
||||
{
|
||||
question: `What are ${name}'s documented limitations compared to Sim?`,
|
||||
answer: buildLimitationAnswer(competitor),
|
||||
},
|
||||
{
|
||||
question: `Can I migrate from ${name} to Sim?`,
|
||||
answer: `There is no automated one-click migration tool between ${name} and Sim. Workflows and automations need to be rebuilt in Sim's visual builder, natural-language Chat surface, or API. Most teams start by recreating their highest-value automation first to validate the switch before migrating the rest.`,
|
||||
},
|
||||
]
|
||||
|
||||
if (competitor.isWorkflowBuilder === false) {
|
||||
faqs.push({
|
||||
question: `Is ${name} a workflow builder like Sim?`,
|
||||
answer: `Not in the same sense. ${competitor.oneLiner} Sim, by contrast, is a visual and code-based workflow builder that deploys agents as REST APIs, scheduled jobs, or chat interfaces, so the two solve different parts of the AI agent problem rather than competing feature-for-feature.`,
|
||||
})
|
||||
}
|
||||
|
||||
return faqs
|
||||
}
|
||||
|
||||
function buildKeyDifferenceAnswer(competitor: CompetitorProfile): string {
|
||||
const topFeature = competitor.standoutFeatures[0]
|
||||
const topLimitation = competitor.limitations[0]
|
||||
const parts = [
|
||||
`Sim is built specifically as an AI agent workspace, with native multi-LLM support, an in-editor AI Copilot, and a knowledge base with hybrid vector + keyword search.`,
|
||||
]
|
||||
if (topFeature) {
|
||||
parts.push(`${competitor.name}'s standout capability is ${formatClaim(topFeature)}`)
|
||||
}
|
||||
if (topLimitation) {
|
||||
parts.push(`One documented limitation of ${competitor.name} is ${formatClaim(topLimitation)}`)
|
||||
}
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* A dedicated "what's wrong with the competitor" answer, distinct from
|
||||
* {@link buildKeyDifferenceAnswer} (which leads with Sim's own strengths).
|
||||
* Walks every documented limitation rather than just the first, so the
|
||||
* answer stays substantive even for a two-limitation profile.
|
||||
*/
|
||||
function buildLimitationAnswer(competitor: CompetitorProfile): string {
|
||||
if (competitor.limitations.length === 0) {
|
||||
return `No specific limitations of ${competitor.name} are documented in this comparison yet. See the feature-by-feature table above for a full side-by-side of every category.`
|
||||
}
|
||||
const parts = competitor.limitations.map(formatClaim)
|
||||
return `Documented limitations of ${competitor.name} include ${parts.join('; ')}`
|
||||
}
|
||||
|
||||
/** Renders a titled claim (a standout feature or limitation) as "lowercased title: description". */
|
||||
function formatClaim(item: { title: string; description: string }): string {
|
||||
return `${lowercaseFirst(item.title)}: ${item.description}`
|
||||
}
|
||||
|
||||
function firstSentence(value: string): string {
|
||||
const match = value.match(/^[^.]+\./)
|
||||
return match ? match[0] : value
|
||||
}
|
||||
|
||||
/** Appends a period if `value` doesn't already end in sentence-closing punctuation. */
|
||||
export function ensurePeriod(value: string): string {
|
||||
return /[.!?]$/.test(value) ? value : `${value}.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Lowercases the first letter of `value`, unless its leading word is an acronym
|
||||
* (e.g. "AI", "SSO", "MCP") or a CamelCase brand name (e.g. "LangChain",
|
||||
* "OpenClaw", "CrewAI") - detected by 2+ uppercase letters anywhere in that
|
||||
* word, not just consecutive at the start, since lowercasing either would
|
||||
* mangle a proper noun ("langChain", "openClaw").
|
||||
*/
|
||||
export function lowercaseFirst(value: string): string {
|
||||
if (value.length === 0) return value
|
||||
const leadingWord = value.match(/^[A-Za-z]+/)?.[0] ?? ''
|
||||
const upperCaseCount = (leadingWord.match(/[A-Z]/g) ?? []).length
|
||||
if (upperCaseCount >= 2) return value
|
||||
return value.charAt(0).toLowerCase() + value.slice(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes {@link firstSentence} + {@link lowercaseFirst} + {@link ensurePeriod} for
|
||||
* stitching a fact value mid-sentence. Strips a leading "Yes:"/"No:" (or the
|
||||
* comma-separated "Yes,"/"No,") token first so boolean facts don't produce
|
||||
* mid-sentence "yes: ..."/"no: ..." fragments.
|
||||
*/
|
||||
function summarizeFact(value: string): string {
|
||||
const stripped = value.replace(/^(Yes|No)(?![a-zA-Z])(?:[:,]\s*)?/, '').trim()
|
||||
const base = stripped.length > 0 ? stripped : value
|
||||
return ensurePeriod(lowercaseFirst(firstSentence(base)))
|
||||
}
|
||||
Reference in New Issue
Block a user