diff --git a/apps/sim/app/(landing)/models/(shell)/[provider]/[model]/page.tsx b/apps/sim/app/(landing)/models/(shell)/[provider]/[model]/page.tsx index 34afe44c09b..ff909ee47fa 100644 --- a/apps/sim/app/(landing)/models/(shell)/[provider]/[model]/page.tsx +++ b/apps/sim/app/(landing)/models/(shell)/[provider]/[model]/page.tsx @@ -19,6 +19,9 @@ import { formatUpdatedAt, getEffectiveMaxOutputTokens, getModelBySlug, + getModelPageDescription, + getModelPageName, + getModelPageTitle, getPricingBounds, getProviderBySlug, getRelatedModels, @@ -40,14 +43,6 @@ export async function generateStaticParams() { ) } -/** - * Model name qualified by its reseller ("Claude Opus 4.7 on AWS Bedrock"), so a reseller's page is - * distinguishable from the first-party page for the same model in titles and the H1. - */ -function hostedModelName(provider: { name: string; isReseller: boolean }, displayName: string) { - return provider.isReseller ? `${displayName} on ${provider.name}` : displayName -} - export async function generateMetadata({ params, }: { @@ -61,11 +56,12 @@ export async function generateMetadata({ return {} } - const pageName = hostedModelName(provider, model.displayName) + const title = getModelPageTitle(provider, model) + const description = getModelPageDescription(provider, model) return { - title: `${pageName} Pricing, Context Window, and Features`, - description: `${model.displayName} by ${provider.name}: pricing, cached input cost, output cost, context window, and capability support. Explore the full generated model page on Sim.`, + title, + description, keywords: [ model.displayName, `${model.displayName} pricing`, @@ -78,14 +74,14 @@ export async function generateMetadata({ // og:image/twitter:image come from the sibling opengraph-image.tsx - // Next serves it at a hash-suffixed URL, so hardcoding it here 404s. openGraph: { - title: `${pageName} Pricing, Context Window, and Features | Sim`, - description: `${model.displayName} by ${provider.name}: pricing, context window, and model capability details.`, + title: `${title} | Sim`, + description, url: `${baseUrl}${model.href}`, type: 'website', }, twitter: { card: 'summary_large_image', - title: `${pageName} | Sim`, + title: `${title} | Sim`, description: model.summary, }, alternates: { @@ -184,7 +180,7 @@ export default async function ModelPage({ id='model-heading' className='text-[28px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] sm:text-[36px] lg:text-[44px]' > - {hostedModelName(provider, model.displayName)} + {getModelPageName(provider, model)} diff --git a/apps/sim/app/(landing)/models/comparisons.ts b/apps/sim/app/(landing)/models/comparisons.ts index 3dea1b111b3..2086dfa7bc5 100644 --- a/apps/sim/app/(landing)/models/comparisons.ts +++ b/apps/sim/app/(landing)/models/comparisons.ts @@ -3,6 +3,7 @@ import { type CatalogFaq, type CatalogModel, type CatalogProvider, + fitWithinLength, formatPrice, formatTokenCount, getEffectiveMaxOutputTokens, @@ -10,6 +11,7 @@ import { getProviderBySlug, hasPublishedPricing, isPromptModel, + MAX_PAGE_TITLE_LENGTH, } from '@/app/(landing)/models/utils' import { resolveModelTokenPricing } from '@/providers/pricing' @@ -151,27 +153,23 @@ function buildComparisons(): ModelComparison[] { /** Every curated comparison, in {@link MODEL_COMPARISON_PAIRS} order. */ export const MODEL_COMPARISONS: ModelComparison[] = buildComparisons() -/** Longest page title that keeps the rendered `` (with the " | Sim" template) at 60 chars. */ -const MAX_COMPARISON_TITLE_LENGTH = 54 - /** Page title for a comparison, as specific as the 60-character `<title>` budget allows. */ export function getComparisonPageTitle(comparison: ModelComparison): string { - const candidates = [ - `${comparison.title}: API Pricing & Context Window`, - `${comparison.title}: API Pricing & Specs`, - `${comparison.title} API Pricing`, - ] - return ( - candidates.find((candidate) => candidate.length <= MAX_COMPARISON_TITLE_LENGTH) ?? - comparison.title + return fitWithinLength( + [ + `${comparison.title}: API Pricing & Context Window`, + `${comparison.title}: API Pricing & Specs`, + `${comparison.title} API Pricing`, + comparison.title, + ], + MAX_PAGE_TITLE_LENGTH ) } /** Meta description for a comparison page; long model names drop the trailing Sim sentence. */ export function getComparisonPageDescription(comparison: ModelComparison): string { const lead = `${comparison.title}: API pricing per 1M tokens, context window, max output, and capabilities side by side.` - const withSim = `${lead} Use either in Sim's AI agents.` - return withSim.length <= 155 ? withSim : lead + return fitWithinLength([`${lead} Use either in Sim's AI agents.`, lead], 155) } /** The curated comparison at `/models/compare/{slug}`; `null` for a reversed or unknown pair. */ diff --git a/apps/sim/app/(landing)/models/utils.ts b/apps/sim/app/(landing)/models/utils.ts index e37fdf43c9a..169eafc2c51 100644 --- a/apps/sim/app/(landing)/models/utils.ts +++ b/apps/sim/app/(landing)/models/utils.ts @@ -297,17 +297,19 @@ function tokenizeModelName(value: string): string[] { function mergeVersionTokens(tokens: string[]): string[] { const merged: string[] = [] - for (let index = 0; index < tokens.length; index += 1) { - const current = tokens[index] - const next = tokens[index + 1] - - if (/^\d{1,2}$/.test(current) && /^\d{1,2}$/.test(next)) { - merged.push(`${current}.${next}`) - index += 1 + for (const token of tokens) { + const previous = merged[merged.length - 1] + + if ( + previous !== undefined && + /^[a-z]*\d{1,2}(\.\d{1,2})*$/i.test(previous) && + /^\d{1,2}$/.test(token) + ) { + merged[merged.length - 1] = `${previous}.${token}` continue } - merged.push(current) + merged.push(token) } return merged @@ -340,17 +342,21 @@ function formatModelToken(token: string): string { return token.toUpperCase() } - if (/^\d+\.\d+$/.test(token)) { + if (/^\d+(\.\d+)+$/.test(token)) { return token } - if (/^[a-z]{3,}\d+$/i.test(token)) { - const [, prefix, version] = token.match(/^([a-z]{3,})(\d+)$/i) ?? [] + if (/^[a-z]{3,}\d+(\.\d+)*$/i.test(token)) { + const [, prefix, version] = token.match(/^([a-z]{3,})(\d+(?:\.\d+)*)$/i) ?? [] if (prefix && version) { return `${formatModelToken(prefix)} ${version}` } } + if (/^[a-z]\d+(\.\d+)+$/i.test(token)) { + return token.toUpperCase() + } + if (/^[a-z]\d+[a-z]$/i.test(token)) { return token.toUpperCase() } @@ -640,6 +646,12 @@ export const MODEL_PROVIDERS_WITH_DYNAMIC_CATALOGS = MODEL_CATALOG_PROVIDERS.fil export const ALL_CATALOG_MODELS = MODEL_PROVIDERS_WITH_CATALOGS.flatMap( (provider) => provider.models ) +/** Display names more than one first-party provider lists, so their pages name the provider too. */ +const SHARED_FIRST_PARTY_NAMES = new Set( + ALL_CATALOG_MODELS.map((model) => model.displayName).filter( + (name, index, names) => names.indexOf(name) !== index + ) +) export const TOTAL_MODEL_PROVIDERS = MODEL_CATALOG_PROVIDERS.length export const TOTAL_MODELS = ALL_CATALOG_MODELS.length export const TOP_MODEL_PROVIDERS = MODEL_PROVIDERS_WITH_CATALOGS.slice(0, 8).map( @@ -923,6 +935,55 @@ export function getProviderPageDescription(provider: CatalogProvider): string { : `Context windows and capabilities for ${models}. Use any of them in Sim's AI agents.` } +/** Longest page title that keeps the rendered `<title>` (with the " | Sim" template) at 60 chars. */ +export const MAX_PAGE_TITLE_LENGTH = 54 + +/** The first candidate within `maxLength` characters, else the last (shortest) candidate. */ +export function fitWithinLength(candidates: readonly [string, ...string[]], maxLength: number) { + return ( + candidates.find((candidate) => candidate.length <= maxLength) ?? + candidates[candidates.length - 1] + ) +} + +/** + * Model name qualified by its host ("Claude Opus 4.7 on AWS Bedrock") on a reseller's page or when + * two first-party providers list the same name, so every model page has a distinct title and H1. + */ +export function getModelPageName(provider: CatalogProvider, model: CatalogModel): string { + return provider.isReseller || SHARED_FIRST_PARTY_NAMES.has(model.displayName) + ? `${model.displayName} on ${provider.name}` + : model.displayName +} + +/** + * Model page title, as specific as the 60-character `<title>` budget allows. A model without + * published token prices is never titled for pricing. + */ +export function getModelPageTitle(provider: CatalogProvider, model: CatalogModel): string { + const name = getModelPageName(provider, model) + return hasPublishedPricing(model.pricing) + ? fitWithinLength( + [ + `${name} API Pricing & Context Window`, + `${name} API Pricing & Specs`, + `${name} API Pricing`, + `${name} Pricing`, + name, + ], + MAX_PAGE_TITLE_LENGTH + ) + : fitWithinLength([`${name} Context Window & Features`, name], MAX_PAGE_TITLE_LENGTH) +} + +/** Model meta description; long model names drop the trailing Sim sentence. */ +export function getModelPageDescription(provider: CatalogProvider, model: CatalogModel): string { + const lead = hasPublishedPricing(model.pricing) + ? `${model.displayName} by ${provider.name}: API pricing per 1M tokens, cached input cost, context window, and capabilities.` + : `${model.displayName} by ${provider.name}: context window, max output, and capabilities.` + return fitWithinLength([`${lead} Use it in Sim's AI agents.`, lead], 155) +} + /** Most recent `pricing.updatedAt` across a non-empty list of models. */ export function getLatestModelUpdate(models: CatalogModel[]): Date { return new Date(Math.max(...models.map((model) => new Date(model.pricing.updatedAt).getTime()))) diff --git a/apps/sim/content/library/best-zapier-alternatives/index.mdx b/apps/sim/content/library/best-zapier-alternatives/index.mdx index 88a7fc04b42..304c2b1d262 100644 --- a/apps/sim/content/library/best-zapier-alternatives/index.mdx +++ b/apps/sim/content/library/best-zapier-alternatives/index.mdx @@ -1,6 +1,6 @@ --- slug: best-zapier-alternatives -title: '7 Best Zapier Alternatives in 2026, Compared' +title: '15 Best Zapier Alternatives in 2026, Compared' description: 'Compare 15 leading Zapier alternatives in 2026 on current pricing, AI agents, self-hosting, small-team fit, Microsoft environments, and enterprise governance.' seoTitle: 'Best Zapier Alternatives 2026: 15 Tools Compared' seoDescription: 'Compare 15 Zapier alternatives on pricing, free plans, AI agents, open source, self-hosting, no-code use, and enterprise governance.' diff --git a/apps/sim/public/library/best-zapier-alternatives/cover.jpg b/apps/sim/public/library/best-zapier-alternatives/cover.jpg index 6ac80c8b8fb..f07435d2649 100644 Binary files a/apps/sim/public/library/best-zapier-alternatives/cover.jpg and b/apps/sim/public/library/best-zapier-alternatives/cover.jpg differ diff --git a/apps/sim/scripts/test-landing-seo-e2e.ts b/apps/sim/scripts/test-landing-seo-e2e.ts index 22f71fab558..32a05a06ddd 100644 --- a/apps/sim/scripts/test-landing-seo-e2e.ts +++ b/apps/sim/scripts/test-landing-seo-e2e.ts @@ -19,6 +19,7 @@ import { vellumProfile, } from '@/lib/compare/data' import { ContentFrontmatterSchema } from '@/lib/content/schema' +import { mapWithConcurrency } from '@/lib/core/utils/concurrency' import { INTEGRATION_SEO } from '@/app/(landing)/integrations/data/seo-content' /** @@ -243,26 +244,31 @@ async function run() { ) await check( - 'model pages carry distinct search titles within 60 characters and self-canonicalize', + 'every model page carries a distinct search title within 60 characters and self-canonicalizes', async () => { - const comparePaths = sitemapPaths.filter((path) => path.startsWith('/models/compare/')) + const paths = sitemapPaths.filter((path) => path === '/models' || path.startsWith('/models/')) + const comparePaths = paths.filter((path) => path.startsWith('/models/compare/')) assert( comparePaths.length >= 5, `The sitemap lists only ${comparePaths.length} compare pages` ) - const paths = [ + for (const required of [ '/models', '/models/anthropic', '/models/context-windows', '/models/pricing-calculator', '/models/token-counter', - ...comparePaths, - ] + ]) { + assert(paths.includes(required), `The sitemap omits ${required}`) + } + const pages = await mapWithConcurrency(paths, 8, async (path) => ({ + path, + ...(await get(path)), + })) const pathByTitle = new Map<string, string>() let anthropicTitle = '' const failures: string[] = [] - for (const path of paths) { - const { status, body } = await get(path) + for (const { path, status, body } of pages) { const title = pageTitle(body) const canonical = canonicalPath(body) const duplicateOf = pathByTitle.get(title)