Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/build/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export async function compileStyles(): Promise<void> {
}

export async function copyStaticAssets(): Promise<void> {
await fs.mkdir(DIST_ASSETS_DIR, { recursive: true });
await Promise.all(
["favicon.svg", "og.png", "apple-touch-icon.png"].map((name) =>
fs.copyFile(path.join(CLIENT_DIR, name), path.join(DIST_ASSETS_DIR, name)),
Expand Down
2 changes: 2 additions & 0 deletions src/build/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
modelLabel,
paramGroupIcon,
paramGroupLabel,
paramLabel,
providerLabel,
} from "../data/display.js";
import { groupParams } from "../data/group.js";
Expand All @@ -29,6 +30,7 @@ export const viewHelpers = {
authLabel,
paramGroupLabel,
paramGroupIcon,
paramLabel,
conditionIcon,
describeApplicability,
groupParams,
Expand Down
4 changes: 2 additions & 2 deletions src/build/structured-data.ts
Original file line number Diff line number Diff line change
@@ -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, providerLabel } from "../data/display.js";
import { modelLabel, paramLabel, providerLabel } from "../data/display.js";
import type { GlossaryGroup } from "../data/glossary.js";
import { SITE_DESCRIPTION, SITE_NAME } from "../data/site.js";
import {
Expand Down Expand Up @@ -125,7 +125,7 @@ export function buildModelStructuredData(
variableMeasured: model.params.map((param) => ({
"@type": "PropertyValue",
name: param.path,
alternateName: param.label,
alternateName: paramLabel(param.path, param.label),
description: param.description,
})),
};
Expand Down
207 changes: 204 additions & 3 deletions src/client/main.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import { setupWebMCP } from "./webmcp.js";

type AuthFilter = "all" | "api_key" | "subscription";
type SortMode = "provider" | "name" | "params";

interface FilterState {
query: string;
auth: AuthFilter;
providers: Set<string>;
capabilities: Set<string>;
sort: SortMode;
}

const state: FilterState = {
query: "",
auth: "all",
providers: new Set(),
capabilities: new Set(),
sort: "provider",
};

function setupHowToUseModal(): void {
Expand Down Expand Up @@ -144,6 +147,9 @@ function applyFilters(): void {

const empty = document.querySelector<HTMLElement>("[data-empty-state]");
if (empty) empty.classList.toggle("hidden", visible !== 0);

updateGroupHeaders();
syncFilterChrome();
}

function setupSearch(): void {
Expand All @@ -154,6 +160,14 @@ function setupSearch(): void {
applyFilters();
});

const clear = document.querySelector<HTMLButtonElement>("[data-search-clear]");
clear?.addEventListener("click", () => {
input.value = "";
state.query = "";
applyFilters();
input.focus();
});

// Deep link: /?q=opus pre-fills the search (backs the schema.org SearchAction).
const initial = new URLSearchParams(window.location.search).get("q");
if (initial) {
Expand All @@ -163,14 +177,19 @@ function setupSearch(): void {
}
}

function setActive(el: Element, active: boolean): void {
el.setAttribute("data-active", String(active));
el.setAttribute("aria-pressed", String(active));
}

function setupAuthFilters(): void {
const buttons = document.querySelectorAll<HTMLButtonElement>("[data-auth-filter]");
buttons.forEach((btn) => {
btn.addEventListener("click", () => {
const auth = btn.dataset.authFilter as AuthFilter | undefined;
if (!auth) return;
state.auth = auth;
buttons.forEach((b) => b.setAttribute("data-active", String(b === btn)));
buttons.forEach((b) => setActive(b, b === btn));
applyFilters();
});
});
Expand All @@ -184,23 +203,205 @@ function setupToggleChips(selector: string, datasetKey: string, bucket: Set<stri
if (!value) return;
if (bucket.has(value)) {
bucket.delete(value);
chip.setAttribute("data-active", "false");
setActive(chip, false);
} else {
bucket.add(value);
chip.setAttribute("data-active", "true");
setActive(chip, true);
}
applyFilters();
});
});
}

function hasActiveFilters(): boolean {
return (
state.query !== "" ||
state.auth !== "all" ||
state.providers.size > 0 ||
state.capabilities.size > 0
);
}

function syncFilterChrome(): void {
const clear = document.querySelector<HTMLElement>("[data-clear-filters]");
if (clear) clear.classList.toggle("hidden", !hasActiveFilters());

const searchClear = document.querySelector<HTMLElement>("[data-search-clear]");
if (searchClear) searchClear.classList.toggle("hidden", state.query === "");

// The "/" hint and the clear button share the same slot; show one at a time.
const hint = document.querySelector<HTMLElement>("[data-search-hint]");
if (hint) hint.style.display = state.query === "" ? "" : "none";
}

function setupClearFilters(): void {
const button = document.querySelector<HTMLButtonElement>("[data-clear-filters]");
if (!button) return;
button.addEventListener("click", () => {
state.query = "";
state.auth = "all";
state.providers.clear();
state.capabilities.clear();

const input = document.querySelector<HTMLInputElement>("[data-search]");
if (input) input.value = "";

document
.querySelectorAll<HTMLButtonElement>("[data-auth-filter]")
.forEach((b) => setActive(b, b.dataset.authFilter === "all"));
document
.querySelectorAll<HTMLButtonElement>("[data-provider], [data-capability]")
.forEach((c) => setActive(c, false));

applyFilters();
});
}

const modelList = (): HTMLElement | null => document.querySelector("[data-model-list]");

// Snapshot of the server-rendered order (headers + rows), used to restore grouping.
let originalOrder: Element[] = [];

function updateGroupHeaders(): void {
const list = modelList();
if (!list) return;
const headers = list.querySelectorAll<HTMLElement>("[data-group-header]");

// Provider headers only make sense in the grouped (provider) ordering.
if (state.sort !== "provider") {
headers.forEach((h) => h.classList.add("hidden"));
return;
}

// Show a header only when its group has at least one visible row.
let current: HTMLElement | null = null;
let hasVisible = false;
const finalize = (): void => {
if (current) current.classList.toggle("hidden", !hasVisible);
};
for (const child of Array.from(list.children) as HTMLElement[]) {
if (child.matches("[data-group-header]")) {
finalize();
current = child;
hasVisible = false;
} else if (child.classList.contains("model") && !child.classList.contains("hidden")) {
hasVisible = true;
}
}
finalize();
}

function reorderList(): void {
const list = modelList();
if (!list) return;
if (originalOrder.length === 0) originalOrder = Array.from(list.children);

if (state.sort === "provider") {
originalOrder.forEach((el) => list.appendChild(el));
return;
}

const rows = Array.from(list.querySelectorAll<HTMLElement>(".model"));
rows.sort((a, b) => {
const nameA = a.dataset.modelName ?? "";
const nameB = b.dataset.modelName ?? "";
if (state.sort === "name") return nameA.localeCompare(nameB);
const byParams = Number(b.dataset.modelParams ?? 0) - Number(a.dataset.modelParams ?? 0);
return byParams !== 0 ? byParams : nameA.localeCompare(nameB);
});
rows.forEach((row) => list.appendChild(row));
}

function setupSort(): void {
const select = document.querySelector<HTMLSelectElement>("[data-sort]");
if (!select) return;
originalOrder = Array.from(modelList()?.children ?? []);
select.addEventListener("change", () => {
state.sort = (select.value as SortMode) || "provider";
reorderList();
applyFilters();
});
}

function setupModelLinks(): void {
document.querySelectorAll<HTMLAnchorElement>("[data-model-link]").forEach((link) => {
link.addEventListener("click", (event) => {
// Leave modified clicks (open in new tab, etc.) to the browser.
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) {
return;
}
// A plain click navigates to the model page rather than toggling the row.
event.preventDefault();
const href = link.getAttribute("href");
if (href) window.location.href = href;
});
});
}

function setupSearchShortcut(): void {
const input = document.querySelector<HTMLInputElement>("[data-search]");
if (!input) return;
document.addEventListener("keydown", (event) => {
if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) return;
const target = event.target as HTMLElement | null;
const tag = target?.tagName.toLowerCase();
if (tag === "input" || tag === "textarea" || target?.isContentEditable) return;
event.preventDefault();
input.focus();
});
}

function setupCapabilityCollapse(): void {
const bar = document.querySelector<HTMLElement>("[data-capability-bar]");
const button = document.querySelector<HTMLButtonElement>("[data-capability-expand]");
if (!bar || !button) return;

const LIMIT = 12;
const chips = Array.from(bar.querySelectorAll<HTMLElement>("[data-capability]"));
if (chips.length <= LIMIT) return;

let expanded = false;
const render = (): void => {
chips.forEach((chip, i) => chip.classList.toggle("hidden", !expanded && i >= LIMIT));
button.textContent = expanded ? "Show fewer" : `Show all ${chips.length} parameters`;
button.setAttribute("aria-expanded", String(expanded));
};

button.classList.remove("hidden");
render();
button.addEventListener("click", () => {
expanded = !expanded;
render();
});
}

function setupMobileMenu(): void {
const menu = document.querySelector<HTMLDetailsElement>("[data-mobile-menu]");
if (!menu) return;
// Close after a selection or when clicking outside.
menu
.querySelectorAll("a, [data-open-how-to-use]")
.forEach((el) => el.addEventListener("click", () => menu.removeAttribute("open")));
document.addEventListener("click", (event) => {
if (menu.open && !menu.contains(event.target as Node)) menu.removeAttribute("open");
});
}

document.addEventListener("DOMContentLoaded", () => {
setupThemeToggle();
setupHowToUseModal();
setupCopyHowToUse();
setupMobileMenu();
setupSearch();
setupAuthFilters();
setupToggleChips("[data-provider]", "provider", state.providers);
setupToggleChips("[data-capability]", "capability", state.capabilities);
setupCapabilityCollapse();
setupClearFilters();
setupSort();
setupModelLinks();
setupSearchShortcut();
updateGroupHeaders();
syncFilterChrome();
setupWebMCP();
});
5 changes: 5 additions & 0 deletions src/client/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
details > summary::-webkit-details-marker {
display: none;
}
/* Hide the native clear control on type=search; we render our own button. */
input[type="search"]::-webkit-search-cancel-button {
-webkit-appearance: none;
appearance: none;
}
}

@layer components {
Expand Down
38 changes: 32 additions & 6 deletions src/data/display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ const AUTH_LABELS: Record<AuthType, string> = {
subscription: "Subscription",
};

// Canonical display labels for parameters whose per-model YAML labels drift.
// The raw data (and JSON API) stay untouched; this only normalizes what the
// site renders, the same way MODEL_LABEL_OVERRIDES prettifies model slugs.
const PARAM_LABEL_OVERRIDES: Record<string, string> = {
max_completion_tokens: "Max completion tokens",
};

const PARAM_GROUP_LABELS: Record<string, string> = {
generation_length: "Length",
sampling: "Sampling",
Expand Down Expand Up @@ -92,12 +99,27 @@ function titleCase(slug: string): string {
.join(" ");
}

function mergeAdjacentNumbers(parts: string[]): string[] {
// Collapses numeric slug segments into readable versions and dates:
// "4" "1" β†’ "4.1" (short version segments join with dots)
// "2024" "11" "20" β†’ "2024-11-20" (a 4-digit year + month/day reads as a date)
// "20250514" β†’ "20250514" (8-digit date stamp is left intact)
// This keeps a trailing date from fusing onto a version, e.g.
// "claude-opus-4-20250514" β†’ "Claude Opus 4 20250514", not "4.20250514".
function formatNumericRuns(parts: string[]): string[] {
const out: string[] = [];
for (const part of parts) {
const prev = out[out.length - 1];
if (prev !== undefined && /^\d+$/.test(prev) && /^\d+$/.test(part)) {
out[out.length - 1] = `${prev}.${part}`;
for (let i = 0; i < parts.length; i++) {
const part = parts[i]!;
if (/^\d{4}$/.test(part)) {
const date = [part];
while (date.length < 3 && /^\d{2}$/.test(parts[i + 1] ?? "")) date.push(parts[++i]!);
out.push(date.join("-"));
} else if (/^\d{1,2}$/.test(part)) {
const prev = out[out.length - 1];
if (prev !== undefined && /^\d{1,2}(\.\d{1,2})*$/.test(prev)) {
out[out.length - 1] = `${prev}.${part}`;
} else {
out.push(part);
}
} else {
out.push(part);
}
Expand All @@ -112,7 +134,7 @@ export function providerLabel(provider: string): string {
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 = mergeAdjacentNumbers(model.model.split("-"));
const parts = formatNumericRuns(model.model.split("-"));
return parts
.map((part) => (/^\d+(\.\d+)?$/.test(part) ? part : part[0]!.toUpperCase() + part.slice(1)))
.join(" ");
Expand All @@ -122,6 +144,10 @@ export function authLabel(authType: AuthType): string {
return AUTH_LABELS[authType];
}

export function paramLabel(path: string, fallback: string): string {
return PARAM_LABEL_OVERRIDES[path] ?? fallback;
}

export function paramGroupLabel(group: string): string {
return PARAM_GROUP_LABELS[group] ?? titleCase(group.replace(/_/g, "-"));
}
Expand Down
Loading
Loading