From 0867c7dc5818f721933bc43dd587318d30819382 Mon Sep 17 00:00:00 2001 From: Bruno Perez Date: Tue, 7 Jul 2026 10:37:45 +0200 Subject: [PATCH] feat: boost long-tail SEO for parameter and model pages The homepage now links every per-parameter page and leads with a concrete, query-shaped title and description. Model pages gain a data-driven FAQ (visible plus FAQPage JSON-LD), and the homepage adds an Organization node linked to GitHub and npm. --- package-lock.json | 2 +- packages/modelparams/README.md | 2 + src/build/render-model.ts | 5 ++- src/build/render.ts | 26 ++++++++++-- src/build/structured-data.ts | 36 +++++++++++++++- src/data/faq.ts | 60 ++++++++++++++++++++++++++ src/views/index.ejs | 23 ++++++++++ src/views/model.ejs | 16 ++++++- tests/faq.test.ts | 78 ++++++++++++++++++++++++++++++++++ tests/render-meta.test.ts | 17 ++++++++ tests/server.test.ts | 15 +++++++ tests/structured-data.test.ts | 24 +++++++++++ 12 files changed, 296 insertions(+), 8 deletions(-) create mode 100644 src/data/faq.ts create mode 100644 tests/faq.test.ts diff --git a/package-lock.json b/package-lock.json index 60a2515..b679c0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7210,7 +7210,7 @@ } }, "packages/modelparams": { - "version": "0.0.0", + "version": "0.0.1", "license": "MIT", "devDependencies": { "tsd": "^0.31.0" diff --git a/packages/modelparams/README.md b/packages/modelparams/README.md index 2d44c6a..c629368 100644 --- a/packages/modelparams/README.md +++ b/packages/modelparams/README.md @@ -8,6 +8,8 @@ npm install modelparams Stop guessing which knobs each model accepts. Get autocomplete on every parameter, compile-time errors on typos and unsupported settings, and the catalog's defaults at runtime — for every provider in one tiny zero-dependency package. +**Catalog & API:** [browse the catalog](https://modelparams.dev) · [JSON API](https://modelparams.dev/api/v1/models.json) · [API docs](https://modelparams.dev/api) · [llms.txt](https://modelparams.dev/llms.txt) + ## Why You're calling `claude-opus-4-7` with `frequency_penalty` set. TypeScript doesn't tell you the param doesn't exist. The provider silently ignores it. Your evals drift. Multiply by every model in your router. diff --git a/src/build/render-model.ts b/src/build/render-model.ts index bac4479..5610dc7 100644 --- a/src/build/render-model.ts +++ b/src/build/render-model.ts @@ -2,6 +2,7 @@ import path from "node:path"; import ejs from "ejs"; import { describeApplicability } from "../data/applicability.js"; import { modelLabel, paramGroupLabel, providerLabel } from "../data/display.js"; +import { modelFaq } from "../data/faq.js"; import { groupParams } from "../data/group.js"; import { VIEWS_DIR } from "../data/paths.js"; import { SITE_NAME, SITE_URL } from "../data/site.js"; @@ -82,10 +83,12 @@ export async function renderModelPage(model: Model, allModels: Model[]): Promise .filter((other) => other.provider === model.provider && modelId(other) !== modelId(model)) .sort((a, b) => modelLabel(a).localeCompare(modelLabel(b))); + const faqs = modelFaq(model); const body = await ejs.renderFile(path.join(VIEWS_DIR, "model.ejs"), { model, helpers: viewHelpers, siblings, + faqs, intro: modelIntro(model), providerName: providerLabel(model.provider), modelName: modelLabel(model), @@ -102,7 +105,7 @@ export async function renderModelPage(model: Model, allModels: Model[]): Promise title: modelPageTitle(model), description, canonicalUrl: absolute(SITE_URL, modelPagePath(model)), - structuredData: buildModelStructuredData(model, description, SITE_URL), + structuredData: buildModelStructuredData(model, description, SITE_URL, faqs), providerHubs: hubLinks(allModels), }, body, diff --git a/src/build/render.ts b/src/build/render.ts index 399292a..164fe9a 100644 --- a/src/build/render.ts +++ b/src/build/render.ts @@ -16,7 +16,7 @@ import { groupParams } from "../data/group.js"; import { usageGuideMarkdown } from "../data/llms.js"; import { logoFor } from "../data/logos.js"; import { VIEWS_DIR } from "../data/paths.js"; -import { OG_IMAGE_PATH, SITE_DESCRIPTION, SITE_NAME, SITE_URL } from "../data/site.js"; +import { OG_IMAGE_PATH, SITE_NAME, SITE_URL } from "../data/site.js"; import { absolute, modelPagePath, @@ -102,6 +102,25 @@ export interface RenderOptions { analytics?: boolean; } +/** Concrete, query-shaped homepage title — names the surface, carries the live model count. */ +export function homeTitle(modelCount: number): string { + return `Compare model parameters across ${modelCount} models · ${SITE_NAME}`; +} + +/** + * Benefit-first homepage description that names real parameters (the ones users + * actually search) plus live counts, instead of the generic site blurb. + */ +export function homeDescription( + modelCount: number, + providerCount: number, + sampleParams: string[], +): string { + const lead = + sampleParams.length > 0 ? `Compare ${sampleParams.join(", ")}, and every other ` : "Compare every "; + return `${lead}API parameter — defaults, ranges, and the conditions that gate each — across ${modelCount} models from ${providerCount} providers. An open, community-maintained catalog.`; +} + export async function renderIndex(opts: RenderOptions): Promise { const body = await ejs.renderFile(path.join(VIEWS_DIR, "index.ejs"), { models: opts.catalog.models, @@ -110,10 +129,11 @@ export async function renderIndex(opts: RenderOptions): Promise { helpers: viewHelpers, }); + const sampleParams = opts.capabilities.slice(0, 3).map((cap) => cap.path); return renderShell( { - title: `${SITE_NAME} — Open catalog of model parameters`, - description: SITE_DESCRIPTION, + title: homeTitle(opts.catalog.models.length), + description: homeDescription(opts.catalog.models.length, opts.providers.length, sampleParams), canonicalUrl: `${SITE_URL}/`, structuredData: buildHomeStructuredData( opts.catalog.models, diff --git a/src/build/structured-data.ts b/src/build/structured-data.ts index 5069614..f49ce28 100644 --- a/src/build/structured-data.ts +++ b/src/build/structured-data.ts @@ -2,6 +2,7 @@ // they can be unit-tested without touching the filesystem or the renderer. import { modelLabel, paramLabel, providerLabel } from "../data/display.js"; +import type { ModelFaq } from "../data/faq.js"; import type { GlossaryGroup } from "../data/glossary.js"; import type { ParameterDetail } from "../data/parameters.js"; import { SITE_DESCRIPTION, SITE_NAME } from "../data/site.js"; @@ -15,6 +16,9 @@ import { } from "../data/urls.js"; import { type Model } from "../schema/model.js"; +const REPO_URL = "https://github.com/mnfst/modelparams.dev"; +const NPM_URL = "https://www.npmjs.com/package/modelparams"; + interface Crumb { name: string; path: string; @@ -36,6 +40,17 @@ function graph(nodes: unknown[]): string { return JSON.stringify({ "@context": "https://schema.org", "@graph": nodes }); } +function organizationNode(siteUrl: string) { + return { + "@type": "Organization", + "@id": `${siteUrl}/#org`, + name: SITE_NAME, + url: `${siteUrl}/`, + description: SITE_DESCRIPTION, + sameAs: [REPO_URL, NPM_URL], + }; +} + function homeWebsiteNode(siteUrl: string) { return { "@type": "WebSite", @@ -43,6 +58,7 @@ function homeWebsiteNode(siteUrl: string) { url: `${siteUrl}/`, name: SITE_NAME, description: SITE_DESCRIPTION, + publisher: { "@id": `${siteUrl}/#org` }, potentialAction: { "@type": "SearchAction", target: { "@type": "EntryPoint", urlTemplate: `${siteUrl}/?q={search_term_string}` }, @@ -61,7 +77,7 @@ function homeDatasetNode(siteUrl: string, imageUrl: string) { image: imageUrl, license: "https://opensource.org/licenses/MIT", isAccessibleForFree: true, - creator: { "@type": "Organization", name: SITE_NAME, url: `${siteUrl}/` }, + creator: { "@id": `${siteUrl}/#org` }, distribution: { "@type": "DataDownload", encodingFormat: "application/json", @@ -97,16 +113,30 @@ export function buildHomeStructuredData( imageUrl: string, ): string { return graph([ + organizationNode(siteUrl), homeWebsiteNode(siteUrl), homeDatasetNode(siteUrl, imageUrl), homeItemListNode(models, siteUrl), ]); } +function faqPageNode(faqs: ModelFaq[], model: Model, siteUrl: string) { + return { + "@type": "FAQPage", + "@id": `${siteUrl}${modelPagePath(model)}#faq`, + mainEntity: faqs.map((faq) => ({ + "@type": "Question", + name: faq.question, + acceptedAnswer: { "@type": "Answer", text: faq.answer }, + })), + }; +} + export function buildModelStructuredData( model: Model, description: string, siteUrl: string, + faqs: ModelFaq[] = [], ): string { const name = `${providerLabel(model.provider)} ${modelLabel(model)} parameters`; const dataset = { @@ -136,7 +166,9 @@ export function buildModelStructuredData( { name: providerLabel(model.provider), path: providerPagePath(model.provider) }, { name: modelLabel(model), path: modelPagePath(model) }, ]); - return graph([crumbs, dataset]); + const nodes: unknown[] = [crumbs, dataset]; + if (faqs.length > 0) nodes.push(faqPageNode(faqs, model, siteUrl)); + return graph(nodes); } export function buildProviderStructuredData( diff --git a/src/data/faq.ts b/src/data/faq.ts new file mode 100644 index 0000000..ba9d088 --- /dev/null +++ b/src/data/faq.ts @@ -0,0 +1,60 @@ +// Data-driven FAQ for a model page. Pure function of the catalog entry so it can +// back both the visible Q&A section and the FAQPage JSON-LD without duplicating +// logic. The questions mirror real long-tail queries ("what is the default +// temperature for ") and every answer is derived from the tracked data. + +import { modelLabel, providerLabel } from "./display.js"; +import type { Model, Parameter } from "../schema/model.js"; + +export interface ModelFaq { + question: string; + answer: string; +} + +/** Widely-searched sampling knobs, in the order we surface them when present. */ +const FAQ_PARAMS = ["temperature", "top_p", "max_tokens", "top_k"]; +const MAX_FAQS = 5; + +function subjectOf(model: Model): string { + const auth = model.authType === "subscription" ? " (subscription)" : ""; + return `${providerLabel(model.provider)} ${modelLabel(model)}${auth}`; +} + +function defaultAnswer(param: Parameter, subject: string): string { + let answer = `The default ${param.path} for ${subject} is ${JSON.stringify(param.default)}`; + if ((param.type === "integer" || param.type === "number") && param.range) { + const { min, max } = param.range; + if (min !== undefined && max !== undefined) + answer += `, within a valid range of ${min} to ${max}`; + else if (min !== undefined) answer += `, with a minimum of ${min}`; + else if (max !== undefined) answer += `, with a maximum of ${max}`; + } + return `${answer}.`; +} + +/** Up to five Q&A pairs for a model, or none when it has no documented parameters. */ +export function modelFaq(model: Model): ModelFaq[] { + if (model.params.length === 0) return []; + const subject = subjectOf(model); + const paths = model.params.map((param) => param.path); + const faqs: ModelFaq[] = [ + { + question: `How many parameters does ${subject} accept?`, + answer: `${subject} accepts ${model.params.length} API parameter${ + model.params.length === 1 ? "" : "s" + }: ${paths.slice(0, 6).join(", ")}${paths.length > 6 ? ", and more" : ""}.`, + }, + ]; + const byPath = new Map(model.params.map((param) => [param.path, param])); + for (const path of FAQ_PARAMS) { + if (faqs.length >= MAX_FAQS) break; + const param = byPath.get(path); + if (param && param.default !== undefined) { + faqs.push({ + question: `What is the default ${path} for ${subject}?`, + answer: defaultAnswer(param, subject), + }); + } + } + return faqs; +} diff --git a/src/views/index.ejs b/src/views/index.ejs index 1669812..0658985 100644 --- a/src/views/index.ejs +++ b/src/views/index.ejs @@ -268,6 +268,29 @@

+<% if (capabilities.length > 0) { %> +
+
+

Browse by parameter

+ All parameters → +
+

+ Open any parameter to see its default, range, and every model that accepts it. +

+
+ <% for (const cap of capabilities) { %> + + <%= cap.path %> + <%= cap.count %> + + <% } %> +
+
+<% } %> +