Skip to content
Merged
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
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
216 changes: 216 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 7 additions & 6 deletions scripts/gen-images.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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);
3 changes: 2 additions & 1 deletion src/build/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ export async function compileStyles(): Promise<void> {

export async function copyStaticAssets(): Promise<void> {
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)),
),
);
Expand Down
30 changes: 21 additions & 9 deletions src/build/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
await fs.rm(DIST_DIR, { recursive: true, force: true });
Expand Down Expand Up @@ -66,21 +68,23 @@ async function writeRobotsAndSitemap(models: Model[]): Promise<void> {
// /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),
Expand Down Expand Up @@ -120,6 +124,7 @@ async function writeHtmlPages(models: Model[]): Promise<void> {

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<void> {
Expand Down Expand Up @@ -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);

Expand Down
18 changes: 18 additions & 0 deletions src/build/lastmod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,21 @@ export function gitLastmodMap(repoRoot: string): Map<string, string> {
export function modelLastmod(model: Model, dates: Map<string, string>, 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<string, string>,
fallback: string,
): string {
let newest = "";
for (const model of models) {
const date = modelLastmod(model, dates, fallback);
if (date > newest) newest = date;
}
return newest || fallback;
}
Loading
Loading