diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c4b0e04..32bec9d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -169,8 +169,8 @@ The website code lives under `src/`: - `src/schema/` — Zod types (single source of truth) and JSON Schema generator. - `src/data/` — YAML loader, catalog builder, display helpers, applicability formatter. - `src/views/` — EJS templates (layout, partials, index page). -- `src/client/` — browser-side TypeScript (search, filter, dark mode) and Tailwind entry. -- `src/build/` — SSG pipeline (renders pages, compiles assets, emits JSON API). +- `src/client/` — browser-side TypeScript (search, filter, dark mode), Tailwind entry, and the vendored Outfit fonts under `fonts/` that social cards are rendered with. +- `src/build/` — SSG pipeline (renders pages, compiles assets, emits JSON API, generates a social card per page). - `src/server/` — Express dev server. Conventions: diff --git a/package-lock.json b/package-lock.json index b679c0c..a316e72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ ], "dependencies": { "@lobehub/icons-static-svg": "^1.91.0", + "@resvg/resvg-js": "^2.6.2", "ejs": "^3.1.10", "express": "^4.19.2", "js-yaml": "^4.1.0", @@ -775,6 +776,221 @@ "node": ">= 8" } }, + "node_modules/@resvg/resvg-js": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz", + "integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==", + "license": "MPL-2.0", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@resvg/resvg-js-android-arm-eabi": "2.6.2", + "@resvg/resvg-js-android-arm64": "2.6.2", + "@resvg/resvg-js-darwin-arm64": "2.6.2", + "@resvg/resvg-js-darwin-x64": "2.6.2", + "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", + "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", + "@resvg/resvg-js-linux-arm64-musl": "2.6.2", + "@resvg/resvg-js-linux-x64-gnu": "2.6.2", + "@resvg/resvg-js-linux-x64-musl": "2.6.2", + "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", + "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", + "@resvg/resvg-js-win32-x64-msvc": "2.6.2" + } + }, + "node_modules/@resvg/resvg-js-android-arm-eabi": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz", + "integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-android-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz", + "integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz", + "integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-x64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz", + "integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm-gnueabihf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz", + "integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz", + "integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz", + "integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz", + "integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz", + "integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-arm64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz", + "integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-ia32-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz", + "integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==", + "cpu": [ + "ia32" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-x64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz", + "integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.4", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", diff --git a/package.json b/package.json index 8d10a9d..ba8c062 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ }, "dependencies": { "@lobehub/icons-static-svg": "^1.91.0", + "@resvg/resvg-js": "^2.6.2", "ejs": "^3.1.10", "express": "^4.19.2", "js-yaml": "^4.1.0", diff --git a/scripts/gen-images.mjs b/scripts/gen-images.mjs index 982ef1d..f084f1a 100644 --- a/scripts/gen-images.mjs +++ b/scripts/gen-images.mjs @@ -1,11 +1,13 @@ -// Rasterizes the brand SVGs into the PNG assets that social platforms and iOS -// require (they don't reliably accept SVG). Run manually after editing -// `og.svg` or `favicon.svg` — `sharp` is intentionally NOT a project -// dependency, so pull it in ad hoc: +// Rasterizes favicon.svg into the apple-touch-icon PNG that iOS requires (it +// doesn't accept SVG). Run manually after editing `favicon.svg` — `sharp` is +// intentionally NOT a project dependency, so pull it in ad hoc: // // npx -y -p sharp node scripts/gen-images.mjs // -// Outputs are committed under src/client/ and copied into dist/assets at build. +// The output is committed under src/client/ and copied into dist/assets at build. +// +// Social cards are NOT generated here: `npm run build` renders one per page from +// the catalog data (src/build/og.ts), so there is nothing to regenerate by hand. import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -23,5 +25,4 @@ async function rasterize(srcSvg, outPng, width, height, density) { console.log(`wrote src/client/${outPng} (${width}x${height})`); } -await rasterize("og.svg", "og.png", 1200, 630, 144); await rasterize("favicon.svg", "apple-touch-icon.png", 180, 180, 600); diff --git a/src/build/assets.ts b/src/build/assets.ts index d8cf2bf..43f50f2 100644 --- a/src/build/assets.ts +++ b/src/build/assets.ts @@ -30,8 +30,9 @@ export async function compileStyles(): Promise { export async function copyStaticAssets(): Promise { await fs.mkdir(DIST_ASSETS_DIR, { recursive: true }); + // og.png is not copied — the build generates it, along with a card per page. await Promise.all( - ["favicon.svg", "og.png", "apple-touch-icon.png"].map((name) => + ["favicon.svg", "apple-touch-icon.png"].map((name) => fs.copyFile(path.join(CLIENT_DIR, name), path.join(DIST_ASSETS_DIR, name)), ), ); diff --git a/src/build/build.ts b/src/build/build.ts index 2c2be7d..ffb4ad5 100644 --- a/src/build/build.ts +++ b/src/build/build.ts @@ -29,13 +29,15 @@ import { import { modelId, type Model } from "../schema/model.js"; import { buildModelJsonSchema } from "../schema/generate.js"; import { bundleClientScript, compileStyles, copyStaticAssets } from "./assets.js"; -import { gitLastmodMap, modelLastmod } from "./lastmod.js"; +import { gitLastmodMap, modelLastmod, newestLastmod } from "./lastmod.js"; import { renderIndex } from "./render.js"; import { renderApiPage } from "./render-api.js"; import { renderGlossaryPage } from "./render-glossary.js"; import { renderModelPage } from "./render-model.js"; -import { renderParameterPage } from "./render-parameter.js"; +import { defaultSummary,renderParameterPage, rangeSummary } from "./render-parameter.js"; +import { renderNotFoundPage } from "./render-not-found.js"; import { renderProviderPage } from "./render-provider.js"; +import { writeOgImages } from "./og.js"; async function cleanDist(): Promise { await fs.rm(DIST_DIR, { recursive: true, force: true }); @@ -66,21 +68,23 @@ async function writeRobotsAndSitemap(models: Model[]): Promise { // /api is the HTML documentation page, so it belongs here. const today = new Date().toISOString().slice(0, 10); const dates = gitLastmodMap(REPO_ROOT); - // Aggregate pages (home, glossary, providers, parameters) track the build date; - // each model URL carries the commit date of its own YAML so lastmod stays honest. + // Every URL's lastmod comes from the commit dates of the model YAML behind it, + // so a rebuild that changed nothing doesn't claim the whole site is fresh. An + // aggregate page is as new as the newest model it lists. + const freshest = (subset: Model[]): string => newestLastmod(subset, dates, today); const entries: { path: string; priority: string; lastmod: string }[] = [ - { path: "/", priority: "1.0", lastmod: today }, - { path: GLOSSARY_PATH, priority: "0.7", lastmod: today }, - { path: API_PATH, priority: "0.5", lastmod: today }, + { path: "/", priority: "1.0", lastmod: freshest(models) }, + { path: GLOSSARY_PATH, priority: "0.7", lastmod: freshest(models) }, + { path: API_PATH, priority: "0.5", lastmod: freshest(models) }, ...uniqueProviders(models).map((provider) => ({ path: providerPagePath(provider), priority: "0.8", - lastmod: today, + lastmod: freshest(models.filter((model) => model.provider === provider)), })), ...buildParameterIndex(models).map((detail) => ({ path: parameterPagePath(detail.path), priority: "0.7", - lastmod: today, + lastmod: freshest(detail.usages.map((usage) => usage.model)), })), ...models.map((model) => ({ path: modelPagePath(model), @@ -120,6 +124,7 @@ async function writeHtmlPages(models: Model[]): Promise { await fs.writeFile(path.join(DIST_DIR, "glossary.html"), await renderGlossaryPage(models), "utf8"); await fs.writeFile(path.join(DIST_DIR, "api.html"), await renderApiPage(models), "utf8"); + await fs.writeFile(path.join(DIST_DIR, "404.html"), await renderNotFoundPage(models), "utf8"); } async function writeParameterPages(models: Model[]): Promise { @@ -205,6 +210,13 @@ export async function build(): Promise<{ models: number }> { await writeHtmlPages(models); await writeParameterPages(models); + console.log("Generating social cards..."); + const cards = await writeOgImages(models, uniqueProviders(models), { + defaultSummary, + rangeSummary, + }); + console.log(` ${cards} social cards written.`); + await writeLlmsFiles(models); await writeRobotsAndSitemap(models); diff --git a/src/build/lastmod.ts b/src/build/lastmod.ts index f9ca721..1f54677 100644 --- a/src/build/lastmod.ts +++ b/src/build/lastmod.ts @@ -43,3 +43,21 @@ export function gitLastmodMap(repoRoot: string): Map { export function modelLastmod(model: Model, dates: Map, fallback: string): string { return dates.get(modelSourcePath(model)) ?? fallback; } + +/** + * The most recent lastmod across a set of models — the honest date for an + * aggregate page (home, a provider hub, a parameter page), which is only as + * fresh as the newest entry it lists. + */ +export function newestLastmod( + models: Model[], + dates: Map, + fallback: string, +): string { + let newest = ""; + for (const model of models) { + const date = modelLastmod(model, dates, fallback); + if (date > newest) newest = date; + } + return newest || fallback; +} diff --git a/src/build/meta.ts b/src/build/meta.ts new file mode 100644 index 0000000..a116156 --- /dev/null +++ b/src/build/meta.ts @@ -0,0 +1,59 @@ +// Length budgets for the and <meta name="description"> the renderers +// emit. Google truncates titles around 60 characters and descriptions around +// 160, so anything past those limits is markup nobody reads. The helpers here +// let each renderer offer a few phrasings, longest first, and take the richest +// one that still fits — instead of hardcoding a shape that overflows on the +// longest model names and nested parameter paths. + +/** Titles are cut near 60 characters in the SERP. */ +export const TITLE_MAX = 60; + +/** Descriptions are cut near 160; 158 leaves room for the trailing period. */ +export const DESCRIPTION_MAX = 158; + +/** The first candidate that fits `max`, or the last (shortest) one if none do. */ +export function fitText(candidates: string[], max: number): string { + return candidates.find((candidate) => candidate.length <= max) ?? candidates.at(-1)!; +} + +/** Pick the richest title phrasing that survives truncation. */ +export function fitTitle(candidates: string[]): string { + return fitText(candidates, TITLE_MAX); +} + +/** Pick the richest description phrasing that survives truncation. */ +export function fitDescription(candidates: string[]): string { + return fitText(candidates, DESCRIPTION_MAX); +} + +/** + * As many comma-separated items as fit `budget` characters, plus whether any + * were left out — so a description can size its parameter sample to the space + * actually remaining after the surrounding prose. + */ +export function fitList(items: string[], budget: number): { text: string; truncated: boolean } { + const kept: string[] = []; + let width = 0; + for (const item of items) { + const next = width === 0 ? item.length : width + 2 + item.length; + if (next > budget) break; + kept.push(item); + width = next; + } + return { text: kept.join(", "), truncated: kept.length < items.length }; +} + +const MORE = ", and more"; + +/** + * A comma-separated sample of `items` that fits `budget`, appending ", and more" + * when the list had to be cut. Always names at least one item, even if that + * nudges past the budget — a bare "and more" tells the reader nothing. + */ +export function sampleList(items: string[], budget: number): string { + if (items.length === 0) return ""; + const full = fitList(items, budget); + if (!full.truncated) return full.text; + const trimmed = fitList(items, budget - MORE.length); + return trimmed.text.length > 0 ? `${trimmed.text}${MORE}` : `${items[0]!}${MORE}`; +} diff --git a/src/build/og-card.ts b/src/build/og-card.ts new file mode 100644 index 0000000..6f298b5 --- /dev/null +++ b/src/build/og-card.ts @@ -0,0 +1,140 @@ +// Composes the social-share card for a page as an SVG string. Pure — no I/O, no +// rasterizer — so the layout can be unit-tested and the same markup feeds both +// the PNG writer and any future preview tooling. +// +// Text is laid out by estimating advance widths from the font size, because +// resvg has no text-measurement API and the card only needs to avoid overflow, +// not typeset precisely. + +const WIDTH = 1200; +const HEIGHT = 630; +const MARGIN = 80; +const CONTENT = WIDTH - MARGIN * 2; + +const INK = "#0f172a"; +const MUTED = "#475569"; +const FAINT = "#94a3b8"; +const ACCENT = "#6366f1"; +const CHIP_BG = "#f1f5f9"; + +/** Rough advance width per character as a fraction of font size, by weight. */ +const ADVANCE = { bold: 0.58, regular: 0.52 }; + +export interface OgCard { + /** Small accent line above the headline, e.g. the provider or parameter group. */ + eyebrow?: string; + /** The page subject, set as large as it fits on up to two lines. */ + headline: string; + /** One line of supporting facts, e.g. "18 API parameters". */ + subline?: string; + /** Monospace-ish pills, e.g. sample parameter paths. Dropped when they overflow. */ + chips?: string[]; +} + +function escapeXml(value: string): string { + return value + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """); +} + +function widthOf(text: string, size: number, weight: keyof typeof ADVANCE): number { + return text.length * size * ADVANCE[weight]; +} + +/** Largest size in [min, max] at which `text` fits `maxWidth` on one line. */ +function fitSize(text: string, maxWidth: number, max: number, min: number): number { + if (text.length === 0) return max; + const ideal = maxWidth / (text.length * ADVANCE.bold); + return Math.max(min, Math.min(max, Math.floor(ideal))); +} + +/** Split into at most `maxLines` lines that each fit `maxWidth`, ellipsising the rest. */ +function wrap(text: string, size: number, maxWidth: number, maxLines: number): string[] { + const words = text.split(/\s+/).filter(Boolean); + const lines: string[] = []; + let line = ""; + for (const word of words) { + const candidate = line.length === 0 ? word : `${line} ${word}`; + if (widthOf(candidate, size, "bold") <= maxWidth || line.length === 0) { + line = candidate; + } else { + lines.push(line); + line = word; + if (lines.length === maxLines) break; + } + } + if (lines.length < maxLines && line.length > 0) lines.push(line); + if (lines.length === maxLines && words.join(" ") !== lines.join(" ")) { + const last = lines[maxLines - 1]!; + lines[maxLines - 1] = `${last.slice(0, Math.max(0, last.length - 1))}…`; + } + return lines; +} + +function text( + content: string, + x: number, + y: number, + size: number, + weight: number, + fill: string, +): string { + return `<text x="${x}" y="${y}" font-family="Outfit" font-size="${size}" font-weight="${weight}" fill="${fill}">${escapeXml(content)}</text>`; +} + +/** The brand lockup: accent tile with three rules, then the wordmark. */ +function brandMark(): string { + return [ + `<rect x="${MARGIN}" y="70" width="56" height="56" rx="12" fill="${ACCENT}" />`, + `<path d="M${MARGIN + 14} ${70 + 19}h28M${MARGIN + 14} ${70 + 28}h20M${MARGIN + 14} ${70 + 37}h28" stroke="#ffffff" stroke-width="4.5" stroke-linecap="round" fill="none" />`, + text("modelparams.dev", MARGIN + 74, 116, 30, 600, INK), + ].join(""); +} + +/** Pills for sample parameter paths, laid out left to right until the row is full. */ +function chipRow(chips: string[], y: number): string { + const size = 24; + const padX = 16; + const height = 44; + const gap = 12; + const out: string[] = []; + let x = MARGIN; + for (const chip of chips) { + const width = Math.round(widthOf(chip, size, "regular")) + padX * 2; + if (x + width > MARGIN + CONTENT) break; + out.push( + `<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="8" fill="${CHIP_BG}" />`, + text(chip, x + padX, y + 30, size, 400, MUTED), + ); + x += width + gap; + } + return out.join(""); +} + +/** + * The full 1200×630 card. Blocks are stacked from a fixed baseline so cards with + * and without an eyebrow or chip row still read as the same template. + */ +export function renderOgCard(card: OgCard): string { + const headSize = fitSize(card.headline, CONTENT, 84, 40); + const lines = wrap(card.headline, headSize, CONTENT, 2); + const headTop = lines.length > 1 ? 268 : 300; + + const parts = [ + `<rect width="${WIDTH}" height="${HEIGHT}" fill="#ffffff" />`, + `<rect width="${WIDTH}" height="12" fill="${ACCENT}" />`, + brandMark(), + ]; + if (card.eyebrow) parts.push(text(card.eyebrow, MARGIN, 214, 28, 600, ACCENT)); + lines.forEach((line, i) => { + parts.push(text(line, MARGIN, headTop + i * (headSize + 10), headSize, 700, INK)); + }); + const afterHead = headTop + (lines.length - 1) * (headSize + 10); + if (card.subline) parts.push(text(card.subline, MARGIN, afterHead + 68, 32, 400, MUTED)); + if (card.chips && card.chips.length > 0) parts.push(chipRow(card.chips, 486)); + parts.push(text("Open catalog of model parameters · MIT licensed", MARGIN, 588, 22, 400, FAINT)); + + return `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}" viewBox="0 0 ${WIDTH} ${HEIGHT}">${parts.join("")}</svg>`; +} diff --git a/src/build/og.ts b/src/build/og.ts new file mode 100644 index 0000000..9940c53 --- /dev/null +++ b/src/build/og.ts @@ -0,0 +1,156 @@ +// Per-page social-share images. Every page gets its own 1200×630 PNG built from +// the same catalog data the page renders, so a shared model or parameter link +// previews with its actual name and facts instead of one generic site card. +// +// Rasterization is deterministic: resvg renders with the three vendored Outfit +// TTFs and `loadSystemFonts: false`, so the output never depends on whatever +// fonts happen to exist on the build machine. + +import fs from "node:fs/promises"; +import path from "node:path"; +import { Resvg } from "@resvg/resvg-js"; +import { modelLabel, paramGroupLabel, providerLabel } from "../data/display.js"; +import { buildParameterIndex, type ParameterDetail } from "../data/parameters.js"; +import { CLIENT_DIR, DIST_ASSETS_DIR, DIST_DIR } from "../data/paths.js"; +import { + API_PATH, + GLOSSARY_PATH, + modelPagePath, + ogImagePath, + providerPagePath, +} from "../data/urls.js"; +import type { Model } from "../schema/model.js"; +import { renderOgCard, type OgCard } from "./og-card.js"; + +const FONT_FILES = ["outfit-400.ttf", "outfit-600.ttf", "outfit-700.ttf"].map((name) => + path.join(CLIENT_DIR, "fonts", name), +); + +const CHIP_LIMIT = 4; + +function toPng(svg: string): Buffer { + const resvg = new Resvg(svg, { + font: { fontFiles: FONT_FILES, loadSystemFonts: false, defaultFontFamily: "Outfit" }, + fitTo: { mode: "width", value: 1200 }, + }); + return Buffer.from(resvg.render().asPng()); +} + +/** Distinct parameter paths across a set of models, in first-seen order. */ +function paramPaths(models: Model[]): string[] { + const seen = new Set<string>(); + for (const model of models) { + for (const param of model.params) seen.add(param.path); + } + return [...seen]; +} + +export function homeCard(models: Model[], providerCount: number): OgCard { + return { + headline: "Every parameter, for every model.", + subline: `${models.length} models · ${providerCount} providers · open JSON API`, + chips: paramPaths(models).slice(0, CHIP_LIMIT), + }; +} + +export function modelCard(model: Model): OgCard { + const subscription = model.authType === "subscription"; + const count = model.params.length; + return { + eyebrow: `${providerLabel(model.provider)}${subscription ? " · Subscription" : ""}`, + headline: modelLabel(model), + subline: `${count} API parameter${count === 1 ? "" : "s"} · type, default, range, conditions`, + chips: model.params.slice(0, CHIP_LIMIT).map((param) => param.path), + }; +} + +export function providerCard(provider: string, models: Model[]): OgCard { + const paths = paramPaths(models); + return { + eyebrow: "Provider", + headline: `${providerLabel(provider)} model parameters`, + subline: `${models.length} model${models.length === 1 ? "" : "s"} · ${paths.length} parameters tracked`, + chips: paths.slice(0, CHIP_LIMIT), + }; +} + +export function parameterCard(detail: ParameterDetail, facts: string[]): OgCard { + const count = detail.modelCount; + return { + eyebrow: `${paramGroupLabel(detail.group)} parameter`, + headline: detail.path, + subline: [...facts, `${count} model${count === 1 ? "" : "s"}`].join(" · "), + chips: [...new Set(detail.usages.map((usage) => usage.providerName))].slice(0, CHIP_LIMIT), + }; +} + +export function glossaryCard(parameterCount: number): OgCard { + return { + eyebrow: "Glossary", + headline: "LLM parameter glossary", + subline: `${parameterCount} parameters defined, with defaults and ranges`, + }; +} + +export function apiCard(modelCount: number): OgCard { + return { + eyebrow: "Documentation", + headline: "modelparams.dev API", + subline: `${modelCount} models · static JSON · CORS-enabled · MIT licensed`, + }; +} + +/** Writes one card to the dist path that `ogImagePath` advertises for `pagePath`. */ +async function writeCard(pagePath: string, card: OgCard): Promise<void> { + const file = path.join(DIST_DIR, ogImagePath(pagePath).replace(/^\//, "")); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, toPng(renderOgCard(card))); +} + +/** Short "Default 1 · Range 0 – 2" facts for a parameter card. */ +function parameterFacts(detail: ParameterDetail, summaries: ParameterSummaries): string[] { + const facts: string[] = []; + const fallback = summaries.defaultSummary(detail); + if (fallback !== "no default") facts.push(`Default ${fallback}`); + const range = summaries.rangeSummary(detail); + if (range) facts.push(`Range ${range}`); + return facts; +} + +export interface ParameterSummaries { + defaultSummary: (detail: ParameterDetail) => string; + rangeSummary: (detail: ParameterDetail) => string; +} + +/** + * Generates every page's card plus the site-wide default at /assets/og.png, + * which remains the fallback for any surface that hasn't got a page of its own. + */ +export async function writeOgImages( + models: Model[], + providers: string[], + summaries: ParameterSummaries, +): Promise<number> { + const details = buildParameterIndex(models); + + await fs.mkdir(DIST_ASSETS_DIR, { recursive: true }); + const home = homeCard(models, providers.length); + await fs.writeFile(path.join(DIST_ASSETS_DIR, "og.png"), toPng(renderOgCard(home))); + + await writeCard("/", home); + await writeCard(GLOSSARY_PATH, glossaryCard(details.length)); + await writeCard(API_PATH, apiCard(models.length)); + + for (const provider of providers) { + const providerModels = models.filter((model) => model.provider === provider); + await writeCard(providerPagePath(provider), providerCard(provider, providerModels)); + } + for (const detail of details) { + await writeCard(`/parameters/${detail.slug}`, parameterCard(detail, parameterFacts(detail, summaries))); + } + for (const model of models) { + await writeCard(modelPagePath(model), modelCard(model)); + } + + return 3 + providers.length + details.length + models.length; +} diff --git a/src/build/render-api.ts b/src/build/render-api.ts index 50e88f0..57126ac 100644 --- a/src/build/render-api.ts +++ b/src/build/render-api.ts @@ -2,7 +2,7 @@ import path from "node:path"; import ejs from "ejs"; import { VIEWS_DIR } from "../data/paths.js"; import { SITE_NAME, SITE_URL } from "../data/site.js"; -import { API_PATH, absolute } from "../data/urls.js"; +import { API_PATH, absolute, ogImagePath } from "../data/urls.js"; import { type Model } from "../schema/model.js"; import { hubLinks, renderShell, viewHelpers } from "./render.js"; @@ -20,6 +20,7 @@ export async function renderApiPage(allModels: Model[]): Promise<string> { title: API_TITLE, description: API_DESCRIPTION, canonicalUrl: absolute(SITE_URL, API_PATH), + ogImage: ogImagePath(API_PATH), structuredData: "{}", providerHubs: hubLinks(allModels), }, diff --git a/src/build/render-glossary.ts b/src/build/render-glossary.ts index 2a71c5b..b676165 100644 --- a/src/build/render-glossary.ts +++ b/src/build/render-glossary.ts @@ -3,7 +3,7 @@ import ejs from "ejs"; import { buildGlossary, type GlossaryGroup } from "../data/glossary.js"; import { VIEWS_DIR } from "../data/paths.js"; import { SITE_NAME, SITE_URL } from "../data/site.js"; -import { GLOSSARY_PATH, absolute } from "../data/urls.js"; +import { GLOSSARY_PATH, absolute, ogImagePath } from "../data/urls.js"; import { type Model } from "../schema/model.js"; import { buildGlossaryStructuredData } from "./structured-data.js"; import { hubLinks, renderShell, viewHelpers } from "./render.js"; @@ -11,7 +11,7 @@ import { hubLinks, renderShell, viewHelpers } from "./render.js"; const GLOSSARY_TITLE = `LLM parameter glossary · ${SITE_NAME}`; const GLOSSARY_DESCRIPTION = - "Every LLM API parameter in the catalog, defined: what temperature, top_p, max_tokens, reasoning effort and the rest do. Open any parameter for its default and range on every model that accepts it."; + "Every LLM API parameter defined: what temperature, top_p, max_tokens and reasoning effort do. Open any parameter for its default and range on every model."; function glossaryIntro(groups: GlossaryGroup[]): string { const total = groups.reduce((sum, groupItem) => sum + groupItem.entries.length, 0); @@ -32,6 +32,7 @@ export async function renderGlossaryPage(allModels: Model[]): Promise<string> { title: GLOSSARY_TITLE, description: GLOSSARY_DESCRIPTION, canonicalUrl: absolute(SITE_URL, GLOSSARY_PATH), + ogImage: ogImagePath(GLOSSARY_PATH), structuredData: buildGlossaryStructuredData(groups, SITE_URL), providerHubs: hubLinks(allModels), }, diff --git a/src/build/render-model.ts b/src/build/render-model.ts index 5610dc7..c69506d 100644 --- a/src/build/render-model.ts +++ b/src/build/render-model.ts @@ -1,14 +1,21 @@ import path from "node:path"; import ejs from "ejs"; import { describeApplicability } from "../data/applicability.js"; -import { modelLabel, paramGroupLabel, providerLabel } from "../data/display.js"; +import { modelFullLabel, 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"; -import { absolute, modelJsonPath, modelPagePath, providerPagePath } from "../data/urls.js"; +import { + absolute, + modelJsonPath, + modelPagePath, + ogImagePath, + providerPagePath, +} from "../data/urls.js"; import { modelId, type Model, type Parameter } from "../schema/model.js"; import { buildModelStructuredData } from "./structured-data.js"; +import { DESCRIPTION_MAX, fitDescription, fitTitle, sampleList } from "./meta.js"; import { hubLinks, renderShell, viewHelpers } from "./render.js"; function authNote(model: Model): string { @@ -16,20 +23,31 @@ function authNote(model: Model): string { } export function modelPageTitle(model: Model): string { + // The auth variant is never dropped: the API-key and subscription pages are + // separate URLs, so a fallback without it would give them the same title. const variant = model.authType === "subscription" ? " (subscription)" : ""; - return `${providerLabel(model.provider)} ${modelLabel(model)}${variant} parameters · ${SITE_NAME}`; + const who = `${modelFullLabel(model)}${variant}`; + return fitTitle([`${who} parameters · ${SITE_NAME}`, `${who} parameters`, who]); } +const PARAM_TAIL = ". Type, default, range, and gating conditions for each."; + export function modelPageDescription(model: Model): string { - const who = `${providerLabel(model.provider)} ${modelLabel(model)}${authNote(model)}`; + const who = `${modelFullLabel(model)}${authNote(model)}`; if (model.params.length === 0) { return `${who}: no parameters documented yet. Browse the open catalog of model parameters on ${SITE_NAME}.`; } - const paths = model.params.map((param) => param.path); - const sample = paths.slice(0, 4).join(", "); - const more = paths.length > 4 ? ", and more" : ""; const count = `${model.params.length} API parameter${model.params.length === 1 ? "" : "s"}`; - return `All ${count} for ${who}: ${sample}${more}. See each type, default, range, and the conditions that gate it.`; + const head = `All ${count} for ${who}`; + const paths = model.params.map((param) => param.path); + const sample = sampleList(paths, DESCRIPTION_MAX - head.length - PARAM_TAIL.length - 2); + // A long model name can leave no room for even one parameter path; when that + // happens the sample is dropped rather than allowed to overflow. + return fitDescription([ + `${head}: ${sample}${PARAM_TAIL}`, + `${head}${PARAM_TAIL}`, + `${who}: ${count}, with type, default, range, and gating conditions.`, + ]); } /** A short, factual clause describing a parameter's default, range, values, and gate. */ @@ -67,7 +85,7 @@ export function modelParamProse(model: Model): ParamProseGroup[] { } export function modelIntro(model: Model): string { - const who = `${providerLabel(model.provider)} ${modelLabel(model)}`; + const who = modelFullLabel(model); if (model.params.length === 0) { return `No parameters are documented yet for ${who}. The data is community-maintained, so this page fills in as entries land.`; } @@ -92,6 +110,7 @@ export async function renderModelPage(model: Model, allModels: Model[]): Promise intro: modelIntro(model), providerName: providerLabel(model.provider), modelName: modelLabel(model), + fullName: modelFullLabel(model), providerPath: providerPagePath(model.provider), jsonPath: modelJsonPath(model), modelJson: JSON.stringify({ $schema: "https://modelparams.dev/api/v1/schema.json", ...model }, null, 2), @@ -105,6 +124,8 @@ export async function renderModelPage(model: Model, allModels: Model[]): Promise title: modelPageTitle(model), description, canonicalUrl: absolute(SITE_URL, modelPagePath(model)), + ogImage: ogImagePath(modelPagePath(model)), + ogType: "article", structuredData: buildModelStructuredData(model, description, SITE_URL, faqs), providerHubs: hubLinks(allModels), }, diff --git a/src/build/render-not-found.ts b/src/build/render-not-found.ts new file mode 100644 index 0000000..8a72a21 --- /dev/null +++ b/src/build/render-not-found.ts @@ -0,0 +1,37 @@ +import path from "node:path"; +import ejs from "ejs"; +import { VIEWS_DIR } from "../data/paths.js"; +import { SITE_NAME, SITE_URL } from "../data/site.js"; +import { GLOSSARY_PATH, absolute } from "../data/urls.js"; +import { type Model } from "../schema/model.js"; +import { hubLinks, renderShell, viewHelpers } from "./render.js"; + +const NOT_FOUND_TITLE = `Page not found · ${SITE_NAME}`; +const NOT_FOUND_DESCRIPTION = + "That page isn't in the catalog. Browse every tracked model, provider, and LLM API parameter on modelparams.dev."; + +/** + * Branded 404 with real navigation, so a bad link lands on something useful + * instead of the host's default page. Carries `noindex` — a soft-404 in the + * index is worse than no page at all — but stays crawlable so the links work. + */ +export async function renderNotFoundPage(allModels: Model[]): Promise<string> { + const providerHubs = hubLinks(allModels); + const body = await ejs.renderFile(path.join(VIEWS_DIR, "not-found.ejs"), { + helpers: viewHelpers, + providerHubs, + glossaryPath: GLOSSARY_PATH, + }); + + return renderShell( + { + title: NOT_FOUND_TITLE, + description: NOT_FOUND_DESCRIPTION, + canonicalUrl: absolute(SITE_URL, "/404"), + structuredData: "{}", + providerHubs, + robots: "noindex, follow", + }, + body, + ); +} diff --git a/src/build/render-parameter.ts b/src/build/render-parameter.ts index 3685dd3..cf4b2fa 100644 --- a/src/build/render-parameter.ts +++ b/src/build/render-parameter.ts @@ -8,21 +8,43 @@ import { } from "../data/parameters.js"; import { VIEWS_DIR } from "../data/paths.js"; import { SITE_NAME, SITE_URL } from "../data/site.js"; -import { GLOSSARY_PATH, absolute, parameterPagePath } from "../data/urls.js"; +import { GLOSSARY_PATH, absolute, ogImagePath, parameterPagePath } from "../data/urls.js"; import { type Model, type Parameter } from "../schema/model.js"; import { buildParameterStructuredData } from "./structured-data.js"; +import { fitDescription, fitTitle } from "./meta.js"; import { hubLinks, renderShell, viewHelpers } from "./render.js"; const RELATED_LIMIT = 12; +/** Last segment of a dotted path, e.g. generationConfig.topP → topP. */ +function shortPath(path: string): string { + return path.split(".").at(-1)!; +} + export function parameterPageTitle(detail: ParameterDetail): string { - return `${detail.label} (${detail.path}) parameter — defaults & ranges · ${SITE_NAME}`; + const { label, path } = detail; + return fitTitle([ + `${label} (${path}) parameter — defaults & ranges · ${SITE_NAME}`, + `${label} (${path}) — defaults & ranges · ${SITE_NAME}`, + `${label} (${path}) — defaults & ranges`, + `${label} (${path}) parameter`, + `${label} (${shortPath(path)}) parameter — defaults & ranges`, + `${label} (${shortPath(path)}) parameter`, + `${label} parameter — defaults & ranges`, + ]); } export function parameterPageDescription(detail: ParameterDetail): string { const group = paramGroupLabel(detail.group).toLowerCase(); - const models = `${detail.modelCount} model${detail.modelCount === 1 ? "" : "s"}`; - return `${detail.label} (${detail.path}) is an LLM ${group} parameter. Compare its type, default, and valid range across the ${models} in the catalog that accept it.`; + const one = detail.modelCount === 1; + const models = `${detail.modelCount} model${one ? "" : "s"} that ${one ? "accepts" : "accept"} it`; + const range = "Compare its type, default, and valid range across the"; + return fitDescription([ + `${detail.label} (${detail.path}) is an LLM ${group} parameter. ${range} ${models}.`, + `${detail.path} is an LLM ${group} parameter. ${range} ${models}.`, + `${detail.label} (${shortPath(detail.path)}) is an LLM ${group} parameter. ${range} ${models}.`, + `${detail.label}: LLM ${group} parameter. Type, default, and range across ${models}.`, + ]); } function rangeOf(param: Parameter): { min?: number; max?: number } | undefined { @@ -32,7 +54,7 @@ function rangeOf(param: Parameter): { min?: number; max?: number } | undefined { } /** Most common default across the models that set one, or a "varies" note. */ -function defaultSummary(detail: ParameterDetail): string { +export function defaultSummary(detail: ParameterDetail): string { const defaults = detail.usages .map((u) => u.param.default) .filter((d): d is NonNullable<typeof d> => d !== undefined) @@ -43,7 +65,7 @@ function defaultSummary(detail: ParameterDetail): string { } /** Widest numeric span any model allows, e.g. "0 – 2". */ -function rangeSummary(detail: ParameterDetail): string { +export function rangeSummary(detail: ParameterDetail): string { let min: number | undefined; let max: number | undefined; for (const usage of detail.usages) { @@ -82,6 +104,8 @@ export async function renderParameterPage( title: parameterPageTitle(detail), description, canonicalUrl: absolute(SITE_URL, parameterPagePath(detail.path)), + ogImage: ogImagePath(parameterPagePath(detail.path)), + ogType: "article", structuredData: buildParameterStructuredData(detail, description, SITE_URL), providerHubs: hubLinks(allModels), }, diff --git a/src/build/render-provider.ts b/src/build/render-provider.ts index bd0147c..a82e997 100644 --- a/src/build/render-provider.ts +++ b/src/build/render-provider.ts @@ -3,19 +3,30 @@ import ejs from "ejs"; import { modelLabel, providerLabel } from "../data/display.js"; import { VIEWS_DIR } from "../data/paths.js"; import { SITE_NAME, SITE_URL } from "../data/site.js"; -import { absolute, providerPagePath } from "../data/urls.js"; +import { absolute, ogImagePath, providerPagePath } from "../data/urls.js"; import { modelId, type Model } from "../schema/model.js"; import { buildProviderStructuredData } from "./structured-data.js"; +import { DESCRIPTION_MAX, fitDescription, sampleList } from "./meta.js"; import { hubLinks, renderShell, viewHelpers } from "./render.js"; export function providerPageTitle(provider: string): string { return `${providerLabel(provider)} model parameters · ${SITE_NAME}`; } +const PARAM_TAIL = ". Type, default, range, and gating conditions for each."; + export function providerPageDescription(provider: string, models: Model[]): string { const count = `${models.length} ${providerLabel(provider)} model${models.length === 1 ? "" : "s"}`; - const sample = sampleParams(models).slice(0, 4).join(", "); - return `Parameters for ${count}${sample ? `: ${sample}` : ""}. Each model lists its types, defaults, ranges, and the conditions that gate every parameter.`; + const head = `Parameters for ${count}`; + const sample = sampleList( + sampleParams(models), + DESCRIPTION_MAX - head.length - PARAM_TAIL.length - 2, + ); + return fitDescription([ + `${head}: ${sample}${PARAM_TAIL}`, + `${head}${PARAM_TAIL}`, + `${head} — type, default, range, and gating conditions.`, + ]); } function sampleParams(models: Model[]): string[] { @@ -55,6 +66,7 @@ export async function renderProviderPage( title: providerPageTitle(provider), description, canonicalUrl: absolute(SITE_URL, providerPagePath(provider)), + ogImage: ogImagePath(providerPagePath(provider)), structuredData: buildProviderStructuredData(provider, models, description, SITE_URL), providerHubs: hubLinks(allModels), }, diff --git a/src/build/render.ts b/src/build/render.ts index 164fe9a..e9586db 100644 --- a/src/build/render.ts +++ b/src/build/render.ts @@ -20,11 +20,13 @@ import { OG_IMAGE_PATH, SITE_NAME, SITE_URL } from "../data/site.js"; import { absolute, modelPagePath, + ogImagePath, parameterAnchorId, parameterPagePath, providerPagePath, } from "../data/urls.js"; import { modelId, type Catalog, type Model } from "../schema/model.js"; +import { fitDescription, fitTitle } from "./meta.js"; import { buildHomeStructuredData } from "./structured-data.js"; const LAYOUT_PATH = path.join(VIEWS_DIR, "layout.ejs"); @@ -72,6 +74,12 @@ export interface ShellMeta { canonicalUrl: string; structuredData: string; providerHubs: HubLink[]; + /** Root-relative path of this page's share image; falls back to the site card. */ + ogImage?: string; + /** og:type — "website" for hubs, "article" for the content pages. */ + ogType?: string; + /** Robots directive; omitted (index, follow) for every indexable page. */ + robots?: string; initialThemeClass?: string; analytics?: boolean; } @@ -83,7 +91,9 @@ export async function renderShell(meta: ShellMeta, body: string): Promise<string description: meta.description, canonicalUrl: meta.canonicalUrl, structuredData: meta.structuredData, - ogImageUrl: absolute(SITE_URL, OG_IMAGE_PATH), + ogImageUrl: absolute(SITE_URL, meta.ogImage ?? OG_IMAGE_PATH), + ogType: meta.ogType ?? "website", + robots: meta.robots ?? "", providerHubs: meta.providerHubs, helpers: viewHelpers, usageGuide: usageGuideMarkdown(SITE_URL), @@ -102,23 +112,42 @@ export interface RenderOptions { analytics?: boolean; } -/** Concrete, query-shaped homepage title — names the surface, carries the live model count. */ +/** + * Brand-first homepage title. Interior pages read "<page> · modelparams.dev"; + * the homepage inverts that so the brand leads, which is what a branded search + * ("modelparams") and a link in a feed both want to see first. "LLM parameters" + * is the head term people actually type — "model parameters" reads as weights + * to an ML audience, so the descriptive half says LLM. The live count carries + * the scale that makes the result worth clicking. + */ export function homeTitle(modelCount: number): string { - return `Compare model parameters across ${modelCount} models · ${SITE_NAME}`; + return fitTitle([ + `${SITE_NAME} — LLM Parameters for ${modelCount} Models`, + `${SITE_NAME} — LLM Parameters`, + ]); } /** - * Benefit-first homepage description that names real parameters (the ones users - * actually search) plus live counts, instead of the generic site blurb. + * Description in sentence case (Title Case here reads as spam), opening on the + * brand and the category it owns, then the specific parameter names users + * search for plus the live counts. */ 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.`; + const knobs = + sampleParams.length > 0 + ? `Compare ${sampleParams.join(", ")} and every other knob` + : "Compare every knob you can turn"; + const reach = `across ${modelCount} models from ${providerCount} providers`; + const lead = `${SITE_NAME} is the open catalog of LLM API parameters.`; + return fitDescription([ + `${lead} ${knobs} ${reach}, with defaults, ranges and gating conditions.`, + `${lead} ${knobs} ${reach}.`, + `${lead} ${knobs}.`, + ]); } export async function renderIndex(opts: RenderOptions): Promise<string> { @@ -135,6 +164,7 @@ export async function renderIndex(opts: RenderOptions): Promise<string> { title: homeTitle(opts.catalog.models.length), description: homeDescription(opts.catalog.models.length, opts.providers.length, sampleParams), canonicalUrl: `${SITE_URL}/`, + ogImage: ogImagePath("/"), structuredData: buildHomeStructuredData( opts.catalog.models, SITE_URL, diff --git a/src/build/structured-data.ts b/src/build/structured-data.ts index f49ce28..7741c46 100644 --- a/src/build/structured-data.ts +++ b/src/build/structured-data.ts @@ -1,7 +1,7 @@ // JSON-LD builders for every page type. Pure functions of (data, siteUrl) so // they can be unit-tested without touching the filesystem or the renderer. -import { modelLabel, paramLabel, providerLabel } from "../data/display.js"; +import { modelFullLabel, 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"; @@ -138,7 +138,7 @@ export function buildModelStructuredData( siteUrl: string, faqs: ModelFaq[] = [], ): string { - const name = `${providerLabel(model.provider)} ${modelLabel(model)} parameters`; + const name = `${modelFullLabel(model)} parameters`; const dataset = { "@type": "Dataset", "@id": `${siteUrl}${modelPagePath(model)}#dataset`, @@ -219,7 +219,7 @@ export function buildParameterStructuredData( "@type": "ListItem", position: index + 1, url: absolute(siteUrl, modelPagePath(usage.model)), - name: `${usage.providerName} ${usage.modelName}`, + name: modelFullLabel(usage.model), })), }; const crumbs = breadcrumb(siteUrl, [ diff --git a/src/client/fonts/OFL.txt b/src/client/fonts/OFL.txt new file mode 100644 index 0000000..723cd44 --- /dev/null +++ b/src/client/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2021 The Outfit Project Authors (https://github.com/Outfitio/Outfit-Fonts) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/client/fonts/outfit-400.ttf b/src/client/fonts/outfit-400.ttf new file mode 100644 index 0000000..658e4c1 Binary files /dev/null and b/src/client/fonts/outfit-400.ttf differ diff --git a/src/client/fonts/outfit-600.ttf b/src/client/fonts/outfit-600.ttf new file mode 100644 index 0000000..a812c87 Binary files /dev/null and b/src/client/fonts/outfit-600.ttf differ diff --git a/src/client/fonts/outfit-700.ttf b/src/client/fonts/outfit-700.ttf new file mode 100644 index 0000000..3fd2d7a Binary files /dev/null and b/src/client/fonts/outfit-700.ttf differ diff --git a/src/client/og.png b/src/client/og.png deleted file mode 100644 index cbce043..0000000 Binary files a/src/client/og.png and /dev/null differ diff --git a/src/client/og.svg b/src/client/og.svg deleted file mode 100644 index 0171311..0000000 --- a/src/client/og.svg +++ /dev/null @@ -1,16 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630"> - <rect width="1200" height="630" fill="#ffffff" /> - <rect width="1200" height="12" fill="#6366f1" /> - - <g transform="translate(80,86)"> - <rect width="68" height="68" rx="15" fill="#6366f1" /> - <path d="M17 24h34M17 34h25M17 44h34" stroke="#ffffff" stroke-width="5.5" stroke-linecap="round" fill="none" /> - </g> - <text x="168" y="133" font-family="DejaVu Sans" font-weight="bold" font-size="36" fill="#0f172a">modelparams.dev</text> - - <text x="80" y="320" font-family="DejaVu Sans" font-weight="bold" font-size="76" fill="#0f172a">Every parameter,</text> - <text x="80" y="408" font-family="DejaVu Sans" font-weight="bold" font-size="76" fill="#6366f1">for every model.</text> - - <text x="80" y="486" font-family="DejaVu Sans" font-size="32" fill="#475569">An open catalog of model parameters</text> - <text x="80" y="556" font-family="DejaVu Sans" font-size="23" fill="#94a3b8">Search · filter · JSON API · llms.txt · MIT licensed</text> -</svg> diff --git a/src/data/display.ts b/src/data/display.ts index 29fcec2..8c14cc5 100644 --- a/src/data/display.ts +++ b/src/data/display.ts @@ -11,6 +11,7 @@ const PROVIDER_LABELS: Record<string, string> = { mistral: "Mistral", deepseek: "DeepSeek", minimax: "MiniMax", + nvidia: "NVIDIA", cohere: "Cohere", perplexity: "Perplexity", "z-ai": "Z.ai", @@ -22,9 +23,13 @@ const MODEL_LABEL_OVERRIDES: Record<string, string> = { "openai/gpt-4o-mini": "GPT-4o mini", "openai/o1": "o1", "openai/o1-mini": "o1-mini", + "openai/o1-preview": "o1-preview", "openai/o3": "o3", "openai/o3-mini": "o3-mini", + "openai/o3-pro": "o3-pro", "openai/o4-mini": "o4-mini", + "deepseek/deepseek-v4-flash": "DeepSeek-V4 Flash", + "deepseek/deepseek-v4-pro": "DeepSeek-V4 Pro", "minimax/minimax-m2": "MiniMax M2", "minimax/minimax-m2.1": "MiniMax M2.1", "minimax/minimax-m2.1-highspeed": "MiniMax M2.1 Highspeed", @@ -51,8 +56,42 @@ const MODEL_LABEL_OVERRIDES: Record<string, string> = { "moonshot/moonshot-v1-32k": "Moonshot v1 32K", "moonshot/moonshot-v1-128k": "Moonshot v1 128K", "alibaba/qwen3.5": "Qwen3.5", + "mistral/open-mistral-nemo": "Mistral NeMo", }; +// Slug tokens whose canonical casing can't be derived by title-casing, keyed by +// the lowercased token. Without these, `gpt-5.1` renders as "Gpt 5.1" — which +// misses the exact-match spelling every search for the model actually uses. +const TOKEN_CASING: Record<string, string> = { + gpt: "GPT", + chatgpt: "ChatGPT", + oss: "OSS", + glm: "GLM", + qwq: "QwQ", + minimax: "MiniMax", + deepseek: "DeepSeek", + mimo: "MiMo", + gliner: "GLiNER", + nemoguard: "NemoGuard", + usdcode: "USDCode", + fp8: "FP8", + it: "IT", + pii: "PII", + ai: "AI", + o1: "o1", + o3: "o3", + o4: "o4", +}; + +/** Families written joined to the version that follows: GPT-5.1, GLM-4.7. */ +const HYPHENATED_FAMILIES = new Set(["GPT", "GLM"]); + +/** Size/variant tokens that are simply uppercased: 70b → 70B, a4b → A4B, 128e → 128E. */ +const SIZE_TOKEN = /^(?:\d+(?:\.\d+)?[bkm]|[aer]\d+(?:\.\d+)?b|\d+e)$/i; + +/** Revision tokens keep their lowercase v: v1, v1.1, v2.5. */ +const REVISION_TOKEN = /^v\d+(?:\.\d+)*$/i; + const AUTH_LABELS: Record<AuthType, string> = { api_key: "API key", subscription: "Subscription", @@ -142,13 +181,44 @@ export function providerLabel(provider: string): string { return PROVIDER_LABELS[provider] ?? titleCase(provider); } +/** Canonical casing for one slug token: brand map first, then shape rules. */ +function castToken(token: string): string { + if (token.length === 0) return token; + const known = TOKEN_CASING[token.toLowerCase()]; + if (known) return known; + if (SIZE_TOKEN.test(token)) return token.toUpperCase(); + if (REVISION_TOKEN.test(token)) return token.toLowerCase(); + if (/^\d+(\.\d+)?$/.test(token)) return token; + return token[0]!.toUpperCase() + token.slice(1); +} + +/** Joins cast tokens, binding a family prefix to what follows: ["GPT","5.1"] → "GPT-5.1". */ +function joinTokens(tokens: string[]): string { + return tokens.reduce( + (acc, token, i) => + i === 0 ? token : acc + (HYPHENATED_FAMILIES.has(tokens[i - 1]!) ? "-" : " ") + token, + "", + ); +} + export function modelLabel(model: Pick<Model, "provider" | "model">): string { const key = `${model.provider}/${model.model}`; if (MODEL_LABEL_OVERRIDES[key]) return MODEL_LABEL_OVERRIDES[key]; - const parts = formatNumericRuns(model.model.split("-")); - return parts - .map((part) => (/^\d+(\.\d+)?$/.test(part) ? part : part[0]!.toUpperCase() + part.slice(1))) - .join(" "); + return joinTokens(formatNumericRuns(model.model.split("-")).map(castToken)); +} + +/** + * Provider + model as one phrase for titles, descriptions, and prose — without + * stuttering when the model name already carries the brand ("DeepSeek Chat", + * not "DeepSeek DeepSeek Chat"; "Mistral Large", not "Mistral Mistral Large"). + */ +export function modelFullLabel(model: Pick<Model, "provider" | "model">): string { + const provider = providerLabel(model.provider); + const label = modelLabel(model); + const carriesBrand = + label.slice(0, provider.length).toLowerCase() === provider.toLowerCase() && + !/[a-z0-9]/i.test(label.charAt(provider.length)); + return carriesBrand ? label : `${provider} ${label}`; } export function authLabel(authType: AuthType): string { diff --git a/src/data/faq.ts b/src/data/faq.ts index ba9d088..181a917 100644 --- a/src/data/faq.ts +++ b/src/data/faq.ts @@ -3,7 +3,7 @@ // logic. The questions mirror real long-tail queries ("what is the default // temperature for <model>") and every answer is derived from the tracked data. -import { modelLabel, providerLabel } from "./display.js"; +import { modelFullLabel } from "./display.js"; import type { Model, Parameter } from "../schema/model.js"; export interface ModelFaq { @@ -17,7 +17,7 @@ const MAX_FAQS = 5; function subjectOf(model: Model): string { const auth = model.authType === "subscription" ? " (subscription)" : ""; - return `${providerLabel(model.provider)} ${modelLabel(model)}${auth}`; + return `${modelFullLabel(model)}${auth}`; } function defaultAnswer(param: Parameter, subject: string): string { diff --git a/src/data/llms.ts b/src/data/llms.ts index 3fa2078..63cb02d 100644 --- a/src/data/llms.ts +++ b/src/data/llms.ts @@ -1,6 +1,6 @@ import { describeApplicability } from "./applicability.js"; import { buildProviderFacets } from "./catalog.js"; -import { authLabel, modelLabel, paramGroupLabel, providerLabel } from "./display.js"; +import { authLabel, modelFullLabel, paramGroupLabel, providerLabel } from "./display.js"; import { groupParams } from "./group.js"; import { buildParameterIndex } from "./parameters.js"; import { parameterPagePath } from "./urls.js"; @@ -14,7 +14,7 @@ function modelJsonUrl(siteUrl: string, model: Model): string { function modelTitle(model: Model): string { const variant = model.authType === "subscription" ? ` (${authLabel(model.authType)})` : ""; - return `${providerLabel(model.provider)} ${modelLabel(model)}${variant}`; + return `${modelFullLabel(model)}${variant}`; } function plural(n: number, word: string): string { diff --git a/src/data/urls.ts b/src/data/urls.ts index d8d21ee..f710387 100644 --- a/src/data/urls.ts +++ b/src/data/urls.ts @@ -45,6 +45,15 @@ export function parameterAnchorId(path: string): string { return `param-${parameterSlug(path)}`; } +/** + * Social-share image for a page, mirroring the page's own URL: + * /models/openai/gpt-5.1 → /assets/og/models/openai/gpt-5.1.png. The build + * writes a PNG at each of these; /assets/og.png stays the site-wide fallback. + */ +export function ogImagePath(pagePath: string): string { + return pagePath === "/" ? "/assets/og/home.png" : `/assets/og${pagePath}.png`; +} + /** Existing JSON API endpoint for a model (unchanged; referenced for linking). */ export function modelJsonPath(model: ModelRef): string { return `/api/v1/models/${modelId(model)}.json`; diff --git a/src/views/layout.ejs b/src/views/layout.ejs index fcef0e4..3757402 100644 --- a/src/views/layout.ejs +++ b/src/views/layout.ejs @@ -6,21 +6,25 @@ <title><%= title %> + <% if (robots) { %><% } %> - + + + - + + diff --git a/src/views/model.ejs b/src/views/model.ejs index 032d66d..0723209 100644 --- a/src/views/model.ejs +++ b/src/views/model.ejs @@ -21,7 +21,7 @@ <%= model.params.length %> param<%= model.params.length === 1 ? "" : "s" %>

- <%= providerName %> <%= modelName %><% if (isSubscription) { %> (subscription)<% } %> parameters + <%= fullName %><% if (isSubscription) { %> (subscription)<% } %> parameters

<%= intro %> diff --git a/src/views/not-found.ejs b/src/views/not-found.ejs new file mode 100644 index 0000000..725030e --- /dev/null +++ b/src/views/not-found.ejs @@ -0,0 +1,36 @@ +

+

404

+

+ That page isn't in the catalog. +

+

+ The model, provider, or parameter you asked for either moved or was never tracked here. + The catalog is community-maintained, so if something is genuinely missing you can add it. +

+ + +
+ +
+

+ Jump to a provider +

+
+ <% for (const hub of providerHubs) { %> + + <%= hub.label %> + <%= hub.count %> + + <% } %> +
+
diff --git a/tests/catalog.test.ts b/tests/catalog.test.ts index ff1af93..9c1f9da 100644 --- a/tests/catalog.test.ts +++ b/tests/catalog.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import { buildCapabilityFacets, buildCatalog, uniqueProviders } from "../src/data/catalog.js"; import { describeApplicability } from "../src/data/applicability.js"; -import { modelLabel, paramLabel, providerLabel } from "../src/data/display.js"; +import { modelFullLabel, modelLabel, paramLabel, providerLabel } from "../src/data/display.js"; import { findModelParams, listModelParamsResponses, @@ -180,9 +180,33 @@ describe("display helpers", () => { ); // A hyphenated YYYY-MM-DD date reads as a date, not a dotted version. expect(modelLabel({ provider: "openai", model: "gpt-4-turbo-2024-04-09" })).toBe( - "Gpt 4 Turbo 2024-04-09", + "GPT-4 Turbo 2024-04-09", ); }); + + it("spells brand tokens the way the vendor does", () => { + expect(modelLabel({ provider: "openai", model: "gpt-5.1" })).toBe("GPT-5.1"); + expect(modelLabel({ provider: "openai", model: "gpt-oss-120b" })).toBe("GPT-OSS 120B"); + expect(modelLabel({ provider: "alibaba", model: "qwq-plus" })).toBe("QwQ Plus"); + expect(modelLabel({ provider: "google", model: "gemma-3-4b-it" })).toBe("Gemma 3 4B IT"); + expect(modelLabel({ provider: "nvidia", model: "nemotron-3-nano-30b-a3b" })).toBe( + "Nemotron 3 Nano 30B A3B", + ); + expect(modelLabel({ provider: "nvidia", model: "llama-3.3-nemotron-super-49b-v1.5" })).toBe( + "Llama 3.3 Nemotron Super 49B v1.5", + ); + }); + + it("does not repeat the provider when the model name already carries it", () => { + expect(modelFullLabel({ provider: "deepseek", model: "deepseek-chat" })).toBe("DeepSeek Chat"); + expect(modelFullLabel({ provider: "minimax", model: "minimax-m3" })).toBe("MiniMax M3"); + expect(modelFullLabel({ provider: "mistral", model: "mistral-large-latest" })).toBe( + "Mistral Large Latest", + ); + // ...but a model that doesn't carry the brand still gets it prefixed. + expect(modelFullLabel({ provider: "openai", model: "gpt-5.1" })).toBe("OpenAI GPT-5.1"); + expect(modelFullLabel({ provider: "z-ai", model: "glm-5.2" })).toBe("Z.ai GLM-5.2"); + }); }); describe("describeApplicability", () => { diff --git a/tests/render-meta.test.ts b/tests/render-meta.test.ts index 7d2dfb6..304841d 100644 --- a/tests/render-meta.test.ts +++ b/tests/render-meta.test.ts @@ -7,7 +7,10 @@ import { } from "../src/build/render-model.js"; import { providerPageDescription, providerPageTitle } from "../src/build/render-provider.js"; import { parameterPageDescription, parameterPageTitle } from "../src/build/render-parameter.js"; +import { uniqueProviders } from "../src/data/catalog.js"; +import { loadAllModels } from "../src/data/load.js"; import { buildParameterIndex } from "../src/data/parameters.js"; +import { DESCRIPTION_MAX, TITLE_MAX } from "../src/build/meta.js"; import { modelJsonPath, modelPagePath, @@ -56,11 +59,36 @@ describe("parameter page meta", () => { it("titles and describes a parameter with its path and model count", () => { const detail = buildParameterIndex([model()])[0]!; expect(parameterPageTitle(detail)).toContain("temperature"); - expect(parameterPageTitle(detail)).toContain("modelparams.dev"); const desc = parameterPageDescription(detail); expect(desc).toContain("temperature"); expect(desc).toContain("1 model"); }); + + it("agrees the verb with the model count", () => { + const one = buildParameterIndex([model()])[0]!; + expect(parameterPageDescription(one)).toContain("1 model that accepts it"); + const many = buildParameterIndex([model(), model({ model: "claude-opus-4-8" })])[0]!; + expect(parameterPageDescription(many)).toContain("2 models that accept it"); + }); + + it("keeps a deeply nested path inside the title budget", () => { + const nested = buildParameterIndex([ + model({ + params: [ + { + path: "generationConfig.thinkingConfig.includeThoughts", + type: "boolean", + label: "Include thoughts", + description: "x", + group: "reasoning", + }, + ], + } as Partial), + ])[0]!; + expect(parameterPageTitle(nested).length).toBeLessThanOrEqual(TITLE_MAX); + // The short form still names the parameter, just without the full path. + expect(parameterPageTitle(nested)).toContain("includeThoughts"); + }); }); describe("modelParamProse", () => { @@ -85,18 +113,27 @@ describe("model page meta", () => { }); describe("home page meta", () => { - it("names the surface and carries the live model count in the title", () => { - expect(homeTitle(198)).toBe("Compare model parameters across 198 models · modelparams.dev"); + it("leads with the brand and carries the live model count in the title", () => { + expect(homeTitle(198)).toBe("modelparams.dev — LLM Parameters for 198 Models"); }); - it("leads the description with real parameters and live counts", () => { + it("opens the description on the brand, then real parameters and live counts", () => { const desc = homeDescription(198, 15, ["temperature", "top_p", "max_tokens"]); + expect(desc.startsWith("modelparams.dev is the open catalog of LLM API parameters.")).toBe( + true, + ); expect(desc).toContain("Compare temperature, top_p, max_tokens"); expect(desc).toContain("198 models from 15 providers"); }); it("still reads cleanly when no sample parameters are available", () => { - expect(homeDescription(198, 15, [])).toContain("Compare every API parameter"); + expect(homeDescription(198, 15, [])).toContain("Compare every knob you can turn"); + }); + + it("keeps the brand-first title and description inside the SERP budget", () => { + expect(homeTitle(1000).length).toBeLessThanOrEqual(TITLE_MAX); + const wide = homeDescription(1000, 40, ["temperature", "top_p", "max_tokens"]); + expect(wide.length).toBeLessThanOrEqual(DESCRIPTION_MAX); }); }); @@ -108,3 +145,66 @@ describe("provider page meta", () => { ); }); }); + +// The templates above are budget-aware, but only the real catalog has the model +// names and nested parameter paths long enough to blow the budget. This walks +// every page the build emits so an overflowing title can't ship unnoticed. +describe("every page in the real catalog fits the SERP budget", async () => { + const { models } = await loadAllModels(); + const details = buildParameterIndex(models); + const providers = uniqueProviders(models); + + const pages: { page: string; title: string; description: string }[] = [ + { + page: "/", + title: homeTitle(models.length), + description: homeDescription(models.length, providers.length, ["temperature", "top_p"]), + }, + ...providers.map((provider) => { + const owned = models.filter((m) => m.provider === provider); + return { + page: `/providers/${provider}`, + title: providerPageTitle(provider), + description: providerPageDescription(provider, owned), + }; + }), + ...details.map((detail) => ({ + page: `/parameters/${detail.slug}`, + title: parameterPageTitle(detail), + description: parameterPageDescription(detail), + })), + ...models.map((m) => ({ + page: modelPagePath(m), + title: modelPageTitle(m), + description: modelPageDescription(m), + })), + ]; + + it("has a catalog to check", () => { + expect(pages.length).toBeGreaterThan(100); + }); + + it("keeps every title within the truncation limit", () => { + const over = pages.filter((p) => p.title.length > TITLE_MAX); + expect(over.map((p) => `${p.page} (${p.title.length}): ${p.title}`)).toEqual([]); + }); + + it("keeps every description within the truncation limit", () => { + const over = pages.filter((p) => p.description.length > DESCRIPTION_MAX); + expect(over.map((p) => `${p.page} (${p.description.length})`)).toEqual([]); + }); + + it("keeps every title and description unique", () => { + const titles = new Set(pages.map((p) => p.title)); + const descriptions = new Set(pages.map((p) => p.description)); + expect(titles.size).toBe(pages.length); + expect(descriptions.size).toBe(pages.length); + }); + + it("never repeats the provider name inside a model title", () => { + const stutter = models + .map((m) => modelPageTitle(m)) + .filter((title) => /^(\S+)\s+\1\b/i.test(title)); + expect(stutter).toEqual([]); + }); +}); diff --git a/tests/server.test.ts b/tests/server.test.ts index 2429fb7..212e1f5 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -163,7 +163,7 @@ describe("GET / (home)", () => { it("carries a concrete title and a crawlable browse-by-parameter section", async () => { const body = await get("/").then((r) => r.text()); - expect(body).toContain("Compare model parameters across 3 models"); + expect(body).toContain("modelparams.dev — LLM Parameters for 3 Models"); expect(body).toContain("Browse by parameter"); expect(body).toContain('href="/parameters/temperature"'); expect(body).toContain('href="/parameters/max_tokens"');