From fc2717a45db10fbf4be46fdf0eec67ad79c15f68 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 16:48:03 +0000 Subject: [PATCH 1/4] feat(home): derive ecosystem counts and package chips from the docs The homepage ecosystem cards listed middleware, storage drivers, template engines, and contrib packages from hardcoded arrays with rounded counts ("30+ drivers"), so every new package needed a manual edit. Add a fiber-catalogs plugin that reads the four catalogs from the synced docs at build time and publishes them as global data. The homepage now renders exact counts and the package names from the docs themselves; the per-card lists keep a featured ordering hint so recognizable packages lead, while everything else follows alphabetically and the overflow chip shows the real remainder ("+ 22 more"). Unknown ids in the hint are ignored, so adding a package requires no homepage change. The plugin is needed because the homepage build ships without any docs plugin, so it cannot read the docs plugin global data the ecosystem landscape uses. That landscape now derives its "30+ Middleware" core chip from the catalog as well, and the storage/template/contrib card links point at the canonical URLs instead of the /next/ redirects. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LX25C5g5L5D8AgDtBScJA6 --- docusaurus.config.ts | 5 + fiber-catalogs-plugin.ts | 148 +++++++++++++++++++++ project-words.txt | 2 + src/components/fiber-landscape/index.tsx | 18 ++- src/components/home/Ecosystem.tsx | 157 +++++++++++++++-------- src/types/catalogs.ts | 17 +++ 6 files changed, 289 insertions(+), 58 deletions(-) create mode 100644 fiber-catalogs-plugin.ts create mode 100644 src/types/catalogs.ts diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 15e5e41fd95c..8d74e355d93b 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -16,6 +16,11 @@ function plugins(): PluginConfig[] { require.resolve('./simple-analytics-plugin'), + // Publishes the official package catalogs as global data so counts and + // package lists are derived from the docs instead of hardcoded. Needed + // in both targets: the homepage build ships without any docs plugin. + require.resolve('./fiber-catalogs-plugin'), + 'docusaurus-plugin-sass', // Suppress webpack "Critical dependency" warning from vscode-languageserver-types diff --git a/fiber-catalogs-plugin.ts b/fiber-catalogs-plugin.ts new file mode 100644 index 000000000000..359e2782a0b3 --- /dev/null +++ b/fiber-catalogs-plugin.ts @@ -0,0 +1,148 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import type { LoadContext, Plugin } from '@docusaurus/types'; +import { + CATALOG_PLUGIN_NAME, + type CatalogEntry, + type CatalogKey, + type FiberCatalogs, +} from './src/types/catalogs'; + +// Reads the official package catalogs (middleware, contrib, storage, template) +// straight from the synced docs at build time and publishes them as global +// data. Anything rendering counts or package names can therefore derive them +// instead of hardcoding: a package added to the docs shows up on its own. +// +// The docs build already has this information in the docs plugin global data +// (see src/components/fiber-landscape), but the homepage build ships without +// any docs plugin, so the catalogs are read from the file system here. The +// rule matches what the landscape does: one entry per package page directly +// below the catalog root, nested pages (contrib/socketio/legacy, ...) are not +// packages of their own. + +const CATALOG_ROOTS: Record, string> = { + contrib: 'docs/contrib', + storage: 'docs/storage', + template: 'docs/template', +}; + +const INDEX_FILES = ['README.md', 'README.mdx', 'index.md', 'index.mdx']; + +const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---/; +const FRONT_MATTER_TITLE = /^title:[ \t]*(.+?)[ \t]*$/m; +const FIRST_HEADING = /^#[ \t]+(.+?)[ \t]*$/m; + +/** + * The middleware catalog of the docs version served at the docs site root, + * which is the newest entry of versions.json. Falls back to the unreleased + * docs when no version has been cut yet. + */ +function coreMiddlewareDir(siteDir: string): string { + try { + const versions = JSON.parse( + fs.readFileSync(path.join(siteDir, 'versions.json'), 'utf8'), + ) as string[]; + const dir = path.join(siteDir, 'versioned_docs', `version-${versions[0]}`, 'middleware'); + if (fs.existsSync(dir)) { + return dir; + } + } catch { + // No versions.json (or an unreadable one): use the current docs. + } + return path.join(siteDir, 'docs/core/middleware'); +} + +/** Title of a doc: front matter `title`, else its first heading, else the id. */ +function readLabel(file: string, id: string): string { + let raw: string; + try { + // Some synced READMEs carry a BOM, which would hide the front matter. + raw = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''); + } catch { + return id; + } + + const frontMatter = raw.match(FRONT_MATTER); + const title = frontMatter?.[1].match(FRONT_MATTER_TITLE)?.[1]; + if (title) { + return cleanLabel(title.replace(/^['"]|['"]$/g, '')); + } + + const body = frontMatter ? raw.slice(frontMatter[0].length) : raw; + const heading = body.match(FIRST_HEADING)?.[1]; + return heading ? cleanLabel(heading) : id; +} + +/** Strips the markdown a heading may carry: links, code spans, emphasis. */ +function cleanLabel(label: string): string { + return label + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/[`*_]/g, '') + .trim(); +} + +function byLabel(a: CatalogEntry, b: CatalogEntry): number { + return a.label.localeCompare(b.label); +} + +/** Catalogs whose packages are a directory with an index doc. */ +function readPackageDirs(root: string): CatalogEntry[] { + if (!fs.existsSync(root)) { + return []; + } + return fs + .readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && !entry.name.startsWith('_')) + .map((entry) => { + const index = INDEX_FILES.map((name) => path.join(root, entry.name, name)).find( + (file) => fs.existsSync(file), + ); + return index ? { id: entry.name, label: readLabel(index, entry.name) } : null; + }) + .filter((entry): entry is CatalogEntry => entry !== null) + .sort(byLabel); +} + +/** Catalogs whose packages are a single doc file, such as the middleware. */ +function readPackageFiles(root: string): CatalogEntry[] { + if (!fs.existsSync(root)) { + return []; + } + return fs + .readdirSync(root, { withFileTypes: true }) + .filter( + (entry) => entry.isFile() && /\.mdx?$/.test(entry.name) && !entry.name.startsWith('_'), + ) + .map((entry) => { + const id = entry.name.replace(/\.mdx?$/, ''); + return { id, label: readLabel(path.join(root, entry.name), id) }; + }) + .sort(byLabel); +} + +function readCatalogs(siteDir: string): FiberCatalogs { + return { + middleware: readPackageFiles(coreMiddlewareDir(siteDir)), + contrib: readPackageDirs(path.join(siteDir, CATALOG_ROOTS.contrib)), + storage: readPackageDirs(path.join(siteDir, CATALOG_ROOTS.storage)), + template: readPackageDirs(path.join(siteDir, CATALOG_ROOTS.template)), + }; +} + +export default function fiberCatalogsPlugin(context: LoadContext): Plugin { + return { + name: CATALOG_PLUGIN_NAME, + async loadContent() { + return readCatalogs(context.siteDir); + }, + async contentLoaded({ content, actions }) { + actions.setGlobalData(content); + }, + getPathsToWatch() { + return [ + coreMiddlewareDir(context.siteDir), + ...Object.values(CATALOG_ROOTS).map((root) => path.join(context.siteDir, root)), + ]; + }, + }; +} diff --git a/project-words.txt b/project-words.txt index 683ce1c7692f..ca1de51ca1f6 100644 --- a/project-words.txt +++ b/project-words.txt @@ -41,3 +41,5 @@ expressjs swaggerui valkey azureblob +clickhouse +circuitbreaker diff --git a/src/components/fiber-landscape/index.tsx b/src/components/fiber-landscape/index.tsx index fb20dd83b052..0a750d964dd3 100644 --- a/src/components/fiber-landscape/index.tsx +++ b/src/components/fiber-landscape/index.tsx @@ -24,14 +24,17 @@ type EdgeGeometry = { }; // Core building blocks; the ones with an id are docking targets for edges -// and selectable for their own detail view. -const CORE_CHIPS: { id: "bind" | "middleware" | null; label: string }[] = [ +// and selectable for their own detail view. A chip with a catalog gets the +// catalog size prefixed to its label, so the count can never go stale. +type CoreChip = { id: "bind" | "middleware" | null; label: string; catalog?: "middleware" }; + +const CORE_CHIPS: CoreChip[] = [ { id: null, label: "Router" }, { id: null, label: "fiber.Ctx" }, { id: "bind", label: "Bind & Validation" }, { id: null, label: "HTTP Client" }, { id: null, label: "Hooks" }, - { id: "middleware", label: "30+ Middleware" }, + { id: "middleware", label: "Middleware", catalog: "middleware" }, ]; type ChipDetail = { @@ -278,6 +281,11 @@ export default function FiberLandscape(): JSX.Element { ? styles.edgeFoundation : styles.edgeExtension; + const chipText = (chip: CoreChip) => { + const catalog = chip.catalog ? catalogs[chip.catalog] : undefined; + return catalog && catalog.length > 0 ? `${catalog.length} ${chip.label}` : chip.label; + }; + const badgeText = (node: LandscapeNode) => { const catalog = node.catalog ? catalogs[node.catalog] : undefined; if (node.badgeNoun && catalog && catalog.length > 0) { @@ -382,11 +390,11 @@ export default function FiberLandscape(): JSX.Element { aria-pressed={selectedKey === `chip:${chip.id}`} onClick={() => select(`chip:${chip.id}`)} > - {chip.label} + {chipText(chip)} ) : ( - {chip.label} + {chipText(chip)} ), )} diff --git a/src/components/home/Ecosystem.tsx b/src/components/home/Ecosystem.tsx index 4eb6a94fe46d..4a2d56c09bec 100644 --- a/src/components/home/Ecosystem.tsx +++ b/src/components/home/Ecosystem.tsx @@ -1,80 +1,116 @@ // src/components/home/Ecosystem.tsx import React from 'react'; import Heading from '@theme/Heading'; +import { usePluginData } from '@docusaurus/useGlobalData'; +import { + CATALOG_PLUGIN_NAME, + type CatalogEntry, + type CatalogKey, + type FiberCatalogs, +} from '../../types/catalogs'; import styles from './Ecosystem.module.scss'; import shared from './shared.module.scss'; type EcosystemCategory = { icon: string; - badge: string; + /** Catalog this card counts and lists, read from the docs at build time. */ + catalog: CatalogKey; + /** Noun of the count badge, as in "34 drivers". */ + noun: string; + /** Badge text used when the catalog cannot be read. */ + fallbackBadge: string; title: string; description: string; - items: string[]; - more?: string; + /** + * Ordering hint only: these packages lead the chip list so the most + * recognizable names show up first, everything else follows alphabetically. + * Unknown ids are ignored and new packages need no entry here. + */ + featured: string[]; href: string; cta: string; }; -// Keep counts rough ("30+") so the homepage doesn't go stale with every new package. +// Number of example chips per card; the rest is summarized as "+ N more". +const MAX_CHIPS = 12; + const categories: EcosystemCategory[] = [ { icon: '🧬', - badge: '30+ middleware', + catalog: 'middleware', + noun: 'middleware', + fallbackBadge: '30+ middleware', title: 'Core Middleware', description: 'The deepest catalog in the box: authentication, caching, compression, rate limiting, security headers, sessions, and more, each one app.Use away.', - items: [ - 'Logger', 'CORS', 'CSRF', 'Helmet', 'Limiter', 'Cache', - 'Compress', 'Session', 'Proxy', 'Static', 'RequestID', 'SSE', + featured: [ + 'logger', 'cors', 'csrf', 'helmet', 'limiter', 'cache', + 'compress', 'session', 'proxy', 'static', 'requestid', 'sse', ], - more: '+ many more', href: 'https://docs.gofiber.io/category/-middleware', cta: 'Explore middleware', }, { icon: '🗄️', - badge: '30+ drivers', + catalog: 'storage', + noun: 'drivers', + fallbackBadge: '30+ drivers', title: 'Storage Drivers', description: 'One unified interface for every major database and key-value store. Plug them into sessions, caching, or rate limiting without changing your code.', - items: [ - 'Redis', 'PostgreSQL', 'MySQL', 'MongoDB', 'SQLite', 'S3', - 'DynamoDB', 'Memcache', 'NATS', 'etcd', 'Badger', 'ClickHouse', + featured: [ + 'redis', 'postgres', 'mysql', 'mongodb', 'sqlite3', 's3', + 'dynamodb', 'memcache', 'nats', 'etcd', 'badger', 'clickhouse', ], - more: '+ many more', - href: 'https://docs.gofiber.io/storage/next/', + href: 'https://docs.gofiber.io/storage/', cta: 'Browse storage drivers', }, { icon: '📝', - badge: '9 engines', + catalog: 'template', + noun: 'engines', + fallbackBadge: '9 engines', title: 'Template Engines', description: 'Server-side rendering with the syntax you already know. One official package, one interface, your choice of engine.', - items: [ - 'HTML', 'Django', 'Handlebars', 'Pug', 'Jet', - 'Mustache', 'Ace', 'Amber', 'Slim', + featured: [ + 'html', 'django', 'handlebars', 'pug', 'jet', + 'mustache', 'ace', 'amber', 'slim', ], - href: 'https://docs.gofiber.io/template/next/', + href: 'https://docs.gofiber.io/template/', cta: 'Pick your engine', }, { icon: '🧩', - badge: '20+ packages', + catalog: 'contrib', + noun: 'packages', + fallbackBadge: '20+ packages', title: 'Contrib Packages', description: 'Officially maintained integrations with the wider ecosystem: tracing, logging, authentication, API documentation, and real-time communication.', - items: [ - 'JWT', 'WebSocket', 'OpenTelemetry', 'Swagger', 'Casbin', 'Sentry', - 'Zap', 'Zerolog', 'Socket.io', 'Circuit Breaker', 'i18n', 'Paseto', + featured: [ + 'jwt', 'websocket', 'otel', 'swaggerui', 'casbin', 'sentry', + 'zap', 'zerolog', 'socketio', 'circuitbreaker', 'i18n', 'paseto', ], - more: '+ many more', - href: 'https://docs.gofiber.io/contrib/next/', + href: 'https://docs.gofiber.io/contrib/', cta: 'Discover contrib', }, ]; +function orderByFeatured(entries: CatalogEntry[], featured: string[]): CatalogEntry[] { + const rank = new Map(featured.map((id, index) => [id, index])); + return [...entries].sort((a, b) => { + const rankA = rank.get(a.id) ?? Number.MAX_SAFE_INTEGER; + const rankB = rank.get(b.id) ?? Number.MAX_SAFE_INTEGER; + return rankA === rankB ? a.label.localeCompare(b.label) : rankA - rankB; + }); +} + export default function Ecosystem() { + // Counts and chips come from the docs catalogs, so a new middleware, + // driver, engine, or contrib package shows up here without an edit. + const catalogs = usePluginData(CATALOG_PLUGIN_NAME) as FiberCatalogs | undefined; + return (
@@ -88,33 +124,48 @@ export default function Ecosystem() {

- {categories.map((cat) => ( - -
- {cat.icon} -

{cat.title}

- {cat.badge} -
-

{cat.description}

-
- {cat.items.map((item) => ( - {item} - ))} - {cat.more && ( - {cat.more} - )} -
- - {cat.cta} - -
- ))} + {categories.map((cat) => { + const entries = orderByFeatured( + catalogs?.[cat.catalog] ?? [], + cat.featured, + ); + const chips = entries.slice(0, MAX_CHIPS); + const remaining = entries.length - chips.length; + const badge = + entries.length > 0 + ? `${entries.length} ${cat.noun}` + : cat.fallbackBadge; + + return ( + +
+ {cat.icon} +

{cat.title}

+ {badge} +
+

{cat.description}

+
+ {chips.map((item) => ( + {item.label} + ))} + {remaining > 0 && ( + + + {remaining} more + + )} +
+ + {cat.cta} + +
+ ); + })}
diff --git a/src/types/catalogs.ts b/src/types/catalogs.ts new file mode 100644 index 000000000000..908dc09ecac0 --- /dev/null +++ b/src/types/catalogs.ts @@ -0,0 +1,17 @@ +// Shared contract between the build-time catalog plugin +// (fiber-catalogs-plugin.ts) and the components that render the catalogs. +// Kept free of Node imports so it can be pulled into the client bundle. + +export const CATALOG_PLUGIN_NAME = 'fiber-catalogs'; + +export type CatalogKey = 'middleware' | 'contrib' | 'storage' | 'template'; + +export type CatalogEntry = { + /** Doc id, i.e. the directory or file name such as "redis". */ + id: string; + /** Display name taken from the doc itself, such as "Redis". */ + label: string; +}; + +/** Every official package catalog, alphabetically sorted by label. */ +export type FiberCatalogs = Record; From 33e82dd1eb2957d7cd9fbaa01af2f27bd0954bde Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 17:28:12 +0000 Subject: [PATCH 2/4] refactor(catalogs): generate the package catalogs into a JSON both pages read Replaces the docusaurus plugin from the previous commit with a plain node script: scripts/generate-catalogs.mjs reads the docs folder and writes src/data/catalogs.json (id, display name, and doc path per package). It runs from the preinstall and pre-build hooks, so every install, dev start, and build refreshes it, and it needs no dependencies. Both surfaces now read that one file: - The homepage ecosystem cards render the exact count and every package of a catalog, so the featured lists and the "+ N more" cap are gone. Nothing about a package is written by hand anymore, only the card wording. - The ecosystem landscape drops its docs-plugin lookup (~55 lines of global data plumbing and URL regexes) for the same JSON, which also gives its chips the proper display names instead of raw doc ids. Middleware links keep the version prefix of the page the reader is on. The generated file is committed so the imports resolve without running the script first, and is excluded from the spell check. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LX25C5g5L5D8AgDtBScJA6 --- .cspell.json | 1 + docusaurus.config.ts | 5 - fiber-catalogs-plugin.ts | 148 ------- package.json | 6 + project-words.txt | 2 - scripts/generate-catalogs.mjs | 154 +++++++ src/components/fiber-landscape/index.tsx | 76 +--- src/components/home/Ecosystem.module.scss | 6 - src/components/home/Ecosystem.tsx | 82 +--- src/data/catalogs.json | 498 ++++++++++++++++++++++ src/types/catalogs.ts | 16 +- 11 files changed, 706 insertions(+), 288 deletions(-) delete mode 100644 fiber-catalogs-plugin.ts create mode 100644 scripts/generate-catalogs.mjs create mode 100644 src/data/catalogs.json diff --git a/.cspell.json b/.cspell.json index 7606493f8893..008e3daa1c2f 100644 --- a/.cspell.json +++ b/.cspell.json @@ -73,6 +73,7 @@ ], "ignorePaths": [ "src/components/route-playground/match-vectors.json", + "src/data/catalogs.json", "**/*.svg", "**/*.png", "**/*.jpg", diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 8d74e355d93b..15e5e41fd95c 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -16,11 +16,6 @@ function plugins(): PluginConfig[] { require.resolve('./simple-analytics-plugin'), - // Publishes the official package catalogs as global data so counts and - // package lists are derived from the docs instead of hardcoded. Needed - // in both targets: the homepage build ships without any docs plugin. - require.resolve('./fiber-catalogs-plugin'), - 'docusaurus-plugin-sass', // Suppress webpack "Critical dependency" warning from vscode-languageserver-types diff --git a/fiber-catalogs-plugin.ts b/fiber-catalogs-plugin.ts deleted file mode 100644 index 359e2782a0b3..000000000000 --- a/fiber-catalogs-plugin.ts +++ /dev/null @@ -1,148 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import type { LoadContext, Plugin } from '@docusaurus/types'; -import { - CATALOG_PLUGIN_NAME, - type CatalogEntry, - type CatalogKey, - type FiberCatalogs, -} from './src/types/catalogs'; - -// Reads the official package catalogs (middleware, contrib, storage, template) -// straight from the synced docs at build time and publishes them as global -// data. Anything rendering counts or package names can therefore derive them -// instead of hardcoding: a package added to the docs shows up on its own. -// -// The docs build already has this information in the docs plugin global data -// (see src/components/fiber-landscape), but the homepage build ships without -// any docs plugin, so the catalogs are read from the file system here. The -// rule matches what the landscape does: one entry per package page directly -// below the catalog root, nested pages (contrib/socketio/legacy, ...) are not -// packages of their own. - -const CATALOG_ROOTS: Record, string> = { - contrib: 'docs/contrib', - storage: 'docs/storage', - template: 'docs/template', -}; - -const INDEX_FILES = ['README.md', 'README.mdx', 'index.md', 'index.mdx']; - -const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---/; -const FRONT_MATTER_TITLE = /^title:[ \t]*(.+?)[ \t]*$/m; -const FIRST_HEADING = /^#[ \t]+(.+?)[ \t]*$/m; - -/** - * The middleware catalog of the docs version served at the docs site root, - * which is the newest entry of versions.json. Falls back to the unreleased - * docs when no version has been cut yet. - */ -function coreMiddlewareDir(siteDir: string): string { - try { - const versions = JSON.parse( - fs.readFileSync(path.join(siteDir, 'versions.json'), 'utf8'), - ) as string[]; - const dir = path.join(siteDir, 'versioned_docs', `version-${versions[0]}`, 'middleware'); - if (fs.existsSync(dir)) { - return dir; - } - } catch { - // No versions.json (or an unreadable one): use the current docs. - } - return path.join(siteDir, 'docs/core/middleware'); -} - -/** Title of a doc: front matter `title`, else its first heading, else the id. */ -function readLabel(file: string, id: string): string { - let raw: string; - try { - // Some synced READMEs carry a BOM, which would hide the front matter. - raw = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''); - } catch { - return id; - } - - const frontMatter = raw.match(FRONT_MATTER); - const title = frontMatter?.[1].match(FRONT_MATTER_TITLE)?.[1]; - if (title) { - return cleanLabel(title.replace(/^['"]|['"]$/g, '')); - } - - const body = frontMatter ? raw.slice(frontMatter[0].length) : raw; - const heading = body.match(FIRST_HEADING)?.[1]; - return heading ? cleanLabel(heading) : id; -} - -/** Strips the markdown a heading may carry: links, code spans, emphasis. */ -function cleanLabel(label: string): string { - return label - .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') - .replace(/[`*_]/g, '') - .trim(); -} - -function byLabel(a: CatalogEntry, b: CatalogEntry): number { - return a.label.localeCompare(b.label); -} - -/** Catalogs whose packages are a directory with an index doc. */ -function readPackageDirs(root: string): CatalogEntry[] { - if (!fs.existsSync(root)) { - return []; - } - return fs - .readdirSync(root, { withFileTypes: true }) - .filter((entry) => entry.isDirectory() && !entry.name.startsWith('_')) - .map((entry) => { - const index = INDEX_FILES.map((name) => path.join(root, entry.name, name)).find( - (file) => fs.existsSync(file), - ); - return index ? { id: entry.name, label: readLabel(index, entry.name) } : null; - }) - .filter((entry): entry is CatalogEntry => entry !== null) - .sort(byLabel); -} - -/** Catalogs whose packages are a single doc file, such as the middleware. */ -function readPackageFiles(root: string): CatalogEntry[] { - if (!fs.existsSync(root)) { - return []; - } - return fs - .readdirSync(root, { withFileTypes: true }) - .filter( - (entry) => entry.isFile() && /\.mdx?$/.test(entry.name) && !entry.name.startsWith('_'), - ) - .map((entry) => { - const id = entry.name.replace(/\.mdx?$/, ''); - return { id, label: readLabel(path.join(root, entry.name), id) }; - }) - .sort(byLabel); -} - -function readCatalogs(siteDir: string): FiberCatalogs { - return { - middleware: readPackageFiles(coreMiddlewareDir(siteDir)), - contrib: readPackageDirs(path.join(siteDir, CATALOG_ROOTS.contrib)), - storage: readPackageDirs(path.join(siteDir, CATALOG_ROOTS.storage)), - template: readPackageDirs(path.join(siteDir, CATALOG_ROOTS.template)), - }; -} - -export default function fiberCatalogsPlugin(context: LoadContext): Plugin { - return { - name: CATALOG_PLUGIN_NAME, - async loadContent() { - return readCatalogs(context.siteDir); - }, - async contentLoaded({ content, actions }) { - actions.setGlobalData(content); - }, - getPathsToWatch() { - return [ - coreMiddlewareDir(context.siteDir), - ...Object.values(CATALOG_ROOTS).map((root) => path.join(context.siteDir, root)), - ]; - }, - }; -} diff --git a/package.json b/package.json index 350f4f6fee0b..c246e1fb78d7 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,17 @@ "private": true, "scripts": { "docusaurus": "docusaurus", + "generate:catalogs": "node scripts/generate-catalogs.mjs", + "preinstall": "node scripts/generate-catalogs.mjs", + "prestart": "npm run generate:catalogs", "start": "docusaurus start", "preview:docs": "npm run build:docs && docusaurus serve", "preview:home": "npm run build:home && docusaurus serve", + "prebuild": "npm run generate:catalogs", "build": "docusaurus build", + "prebuild:home": "npm run generate:catalogs", "build:home": "cross-env BUILD_TARGET=home docusaurus build", + "prebuild:docs": "npm run generate:catalogs", "build:docs": "cross-env BUILD_TARGET=docs docusaurus build", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", diff --git a/project-words.txt b/project-words.txt index ca1de51ca1f6..683ce1c7692f 100644 --- a/project-words.txt +++ b/project-words.txt @@ -41,5 +41,3 @@ expressjs swaggerui valkey azureblob -clickhouse -circuitbreaker diff --git a/scripts/generate-catalogs.mjs b/scripts/generate-catalogs.mjs new file mode 100644 index 000000000000..ae46f63c8a4e --- /dev/null +++ b/scripts/generate-catalogs.mjs @@ -0,0 +1,154 @@ +#!/usr/bin/env node +// Generates src/data/catalogs.json from the docs folder: the official package +// catalogs (middleware, contrib, storage, template) with their display names +// and doc paths. Both the homepage and the ecosystem landscape read that file, +// so counts and package lists never have to be maintained by hand. +// +// Runs from the pre* hooks of the start and build scripts (see package.json) +// and uses nothing but Node builtins, so it also works before an install. +// +// A package is one page directly below a catalog root; nested pages such as +// contrib/socketio/legacy are part of their package, not packages of their own. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const siteDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const outFile = path.join(siteDir, 'src/data/catalogs.json'); + +// Catalogs of one directory per package, served at //. +const DIR_CATALOGS = { + contrib: { dir: 'docs/contrib', routeBasePath: 'contrib' }, + storage: { dir: 'docs/storage', routeBasePath: 'storage' }, + template: { dir: 'docs/template', routeBasePath: 'template' }, +}; + +const INDEX_FILES = ['README.md', 'README.mdx', 'index.md', 'index.mdx']; + +const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---/; +const FRONT_MATTER_TITLE = /^title:[ \t]*(.+?)[ \t]*$/m; +const FIRST_HEADING = /^#[ \t]+(.+?)[ \t]*$/m; + +/** + * The middleware of the docs version served at the docs site root, which is + * the newest entry of versions.json. Falls back to the unreleased docs when + * no version has been cut yet. + */ +function coreMiddlewareDir() { + try { + const versions = JSON.parse(fs.readFileSync(path.join(siteDir, 'versions.json'), 'utf8')); + const dir = path.join(siteDir, 'versioned_docs', `version-${versions[0]}`, 'middleware'); + if (fs.existsSync(dir)) { + return dir; + } + } catch { + // No versions.json (or an unreadable one): use the current docs. + } + return path.join(siteDir, 'docs/core/middleware'); +} + +/** Title of a doc: front matter `title`, else its first heading, else the id. */ +function readLabel(file, id) { + // Some synced READMEs carry a BOM, which would hide the front matter. + const raw = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''); + + const frontMatter = raw.match(FRONT_MATTER); + const title = frontMatter?.[1].match(FRONT_MATTER_TITLE)?.[1]; + if (title) { + return cleanLabel(title.replace(/^['"]|['"]$/g, '')); + } + + const body = frontMatter ? raw.slice(frontMatter[0].length) : raw; + const heading = body.match(FIRST_HEADING)?.[1]; + return heading ? cleanLabel(heading) : id; +} + +/** Strips the markdown a heading may carry: links, code spans, emphasis. */ +function cleanLabel(label) { + return label + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/[`*_]/g, '') + .trim(); +} + +function byLabel(a, b) { + return a.label.localeCompare(b.label, 'en'); +} + +/** Catalogs whose packages are a directory with an index doc. */ +function readPackageDirs(root, routeBasePath) { + return fs + .readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && !entry.name.startsWith('_')) + .map((entry) => { + const index = INDEX_FILES.map((name) => path.join(root, entry.name, name)).find((file) => + fs.existsSync(file), + ); + if (!index) { + return null; + } + return { + id: entry.name, + label: readLabel(index, entry.name), + path: `/${routeBasePath}/${entry.name}`, + }; + }) + .filter((entry) => entry !== null) + .sort(byLabel); +} + +/** Catalogs whose packages are a single doc file, such as the middleware. */ +function readPackageFiles(root, routeBasePath) { + return fs + .readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isFile() && /\.mdx?$/.test(entry.name) && !entry.name.startsWith('_')) + .map((entry) => { + const id = entry.name.replace(/\.mdx?$/, ''); + return { + id, + label: readLabel(path.join(root, entry.name), id), + path: `/${routeBasePath}/${id}`, + }; + }) + .sort(byLabel); +} + +const catalogs = { + middleware: readPackageFiles(coreMiddlewareDir(), 'middleware'), + ...Object.fromEntries( + Object.entries(DIR_CATALOGS).map(([key, { dir, routeBasePath }]) => [ + key, + readPackageDirs(path.join(siteDir, dir), routeBasePath), + ]), + ), +}; + +const empty = Object.keys(catalogs).filter((key) => catalogs[key].length === 0); +if (empty.length > 0) { + console.error( + `generate-catalogs: no packages found for ${empty.join(', ')}. Are the docs synced?`, + ); + process.exit(1); +} + +const contents = `${JSON.stringify( + { + generatedBy: 'scripts/generate-catalogs.mjs, do not edit by hand', + catalogs, + }, + null, + 2, +)}\n`; + +// Only touch the file when it actually changed, so watchers stay quiet. +const changed = !fs.existsSync(outFile) || fs.readFileSync(outFile, 'utf8') !== contents; +if (changed) { + fs.mkdirSync(path.dirname(outFile), { recursive: true }); + fs.writeFileSync(outFile, contents); +} + +const summary = Object.entries(catalogs) + .map(([key, entries]) => `${key} ${entries.length}`) + .join(', '); +console.log(`generate-catalogs: ${summary}${changed ? '' : ' (unchanged)'}`); diff --git a/src/components/fiber-landscape/index.tsx b/src/components/fiber-landscape/index.tsx index 0a750d964dd3..98cbf5a2d09a 100644 --- a/src/components/fiber-landscape/index.tsx +++ b/src/components/fiber-landscape/index.tsx @@ -1,7 +1,9 @@ import React, { useLayoutEffect, useRef, useState } from "react"; import CodeBlock from "@theme/CodeBlock"; import Link from "@docusaurus/Link"; -import { useActiveDocContext, useAllDocsData } from "@docusaurus/plugin-content-docs/client"; +import { useActiveDocContext } from "@docusaurus/plugin-content-docs/client"; +import catalogsFile from "../../data/catalogs.json"; +import type { CatalogEntry, CatalogKey, CatalogsFile } from "../../types/catalogs"; import { nodes, type LandscapeNode } from "./nodes"; import styles from "./styles.module.css"; @@ -12,7 +14,10 @@ import styles from "./styles.module.css"; // Selecting a card lights up its connection and fills the detail panel; the // middleware and bind blocks are selectable themselves and light up every // edge that docks onto them. Hovering previews a connection. All package -// chips come from the docs plugin global data, never from a hardcoded list. +// chips come from the docs, never from a hardcoded list: catalogs.json is +// generated from the docs folder by scripts/generate-catalogs.mjs and is the +// same file the homepage renders. +const { catalogs } = catalogsFile as CatalogsFile; type EdgeGeometry = { nodeKey: string; @@ -65,58 +70,17 @@ const CHIP_DETAILS: Record = { }, }; -type CatalogChip = { label: string; to: string }; - -type GlobalDocLite = { id: string; path: string }; -type GlobalVersionLite = { name: string; isLast: boolean; docs: GlobalDocLite[] }; - -// Reads the package catalogs from the docs plugin global data, so the chips -// always mirror the synced docs and link to the real pages. The middleware -// catalog comes from the docs version the reader is currently on; contrib, -// storage, and template come from their own plugin instances. Packages are -// recognized by their public URL (one path segment below the instance root), -// because many upstream READMEs override the doc id via front matter. -function useCatalogs(): Record { - const allDocs = useAllDocsData() as unknown as Record< - string, - { versions: GlobalVersionLite[] } | undefined - >; +// The core docs are versioned, so a middleware link keeps the version the +// reader is on. Contrib, storage, and template only serve their current +// version, their generated paths already point at it. +function useCatalogHref(): (catalog: CatalogKey, entry: CatalogEntry) => string { const { activeVersion } = useActiveDocContext(undefined) as unknown as { - activeVersion?: GlobalVersionLite; + activeVersion?: { path: string }; }; + const versionPath = (activeVersion?.path ?? "").replace(/\/$/, ""); - const catalogs: Record = {}; - - if (activeVersion) { - catalogs.middleware = activeVersion.docs - .filter((doc) => doc.id.startsWith("middleware/")) - .map((doc) => ({ label: doc.id.slice("middleware/".length), to: doc.path })) - .sort((a, b) => a.label.localeCompare(b.label)); - } - - for (const key of ["contrib", "storage", "template"] as const) { - const versions = allDocs[key]?.versions ?? []; - const version = versions.find((v) => v.name === "current") ?? versions.find((v) => v.isLast); - if (!version) { - continue; - } - const pattern = new RegExp(`^(?:.*)?/${key}/([^/]+)/?$`); - const seen = new Set(); - catalogs[key] = version.docs - .map((doc) => { - const match = doc.path.match(pattern); - return match ? { label: match[1], to: doc.path } : null; - }) - .filter((chip): chip is CatalogChip => { - if (chip === null || seen.has(chip.label)) { - return false; - } - seen.add(chip.label); - return true; - }) - .sort((a, b) => a.label.localeCompare(b.label)); - } - return catalogs; + return (catalog, entry) => + catalog === "middleware" ? `${versionPath}${entry.path}` : entry.path; } // Cubic bezier point at t = 0.5, used to place the edge label pills. @@ -131,7 +95,7 @@ export default function FiberLandscape(): JSX.Element { const [selectedKey, setSelectedKey] = useState("core"); const [hoverKey, setHoverKey] = useState(null); const [edges, setEdges] = useState([]); - const catalogs = useCatalogs(); + const catalogHref = useCatalogHref(); const containerRef = useRef(null); const appRef = useRef(null); const cardRefs = useRef>({}); @@ -325,7 +289,7 @@ export default function FiberLandscape(): JSX.Element { ); const detailCatalogKey = chipDetail ? chipDetail.catalog : selectedNode?.catalog; - const detailCatalog = detailCatalogKey ? (catalogs[detailCatalogKey] ?? []) : []; + const detailCatalog: CatalogEntry[] = detailCatalogKey ? catalogs[detailCatalogKey] : []; return (
@@ -482,7 +446,11 @@ export default function FiberLandscape(): JSX.Element { {detailCatalog.length > 0 ? (
{detailCatalog.map((chip) => ( - + {chip.label} ))} diff --git a/src/components/home/Ecosystem.module.scss b/src/components/home/Ecosystem.module.scss index 01dad1f1e8c3..a0911e247d3f 100644 --- a/src/components/home/Ecosystem.module.scss +++ b/src/components/home/Ecosystem.module.scss @@ -122,12 +122,6 @@ white-space: nowrap; } -.chipMore { - background: transparent; - border: 1px dashed var(--ifm-color-emphasis-400); - color: var(--ifm-color-emphasis-600); -} - .cardCta { margin-top: auto; padding-top: 14px; diff --git a/src/components/home/Ecosystem.tsx b/src/components/home/Ecosystem.tsx index 4a2d56c09bec..2e4136114ed9 100644 --- a/src/components/home/Ecosystem.tsx +++ b/src/components/home/Ecosystem.tsx @@ -1,52 +1,38 @@ // src/components/home/Ecosystem.tsx import React from 'react'; import Heading from '@theme/Heading'; -import { usePluginData } from '@docusaurus/useGlobalData'; -import { - CATALOG_PLUGIN_NAME, - type CatalogEntry, - type CatalogKey, - type FiberCatalogs, -} from '../../types/catalogs'; +import catalogsFile from '../../data/catalogs.json'; +import type { CatalogKey, CatalogsFile } from '../../types/catalogs'; import styles from './Ecosystem.module.scss'; import shared from './shared.module.scss'; +// Package names and counts come from the docs, generated into catalogs.json +// by scripts/generate-catalogs.mjs before every build. +const { catalogs } = catalogsFile as CatalogsFile; + type EcosystemCategory = { icon: string; /** Catalog this card counts and lists, read from the docs at build time. */ catalog: CatalogKey; /** Noun of the count badge, as in "34 drivers". */ noun: string; - /** Badge text used when the catalog cannot be read. */ - fallbackBadge: string; title: string; description: string; - /** - * Ordering hint only: these packages lead the chip list so the most - * recognizable names show up first, everything else follows alphabetically. - * Unknown ids are ignored and new packages need no entry here. - */ - featured: string[]; href: string; cta: string; }; -// Number of example chips per card; the rest is summarized as "+ N more". -const MAX_CHIPS = 12; - +// Only the wording of a card lives here. Its badge count and its package +// chips come from the docs, so a new middleware, driver, engine, or contrib +// package appears on the homepage without touching this file. const categories: EcosystemCategory[] = [ { icon: '🧬', catalog: 'middleware', noun: 'middleware', - fallbackBadge: '30+ middleware', title: 'Core Middleware', description: 'The deepest catalog in the box: authentication, caching, compression, rate limiting, security headers, sessions, and more, each one app.Use away.', - featured: [ - 'logger', 'cors', 'csrf', 'helmet', 'limiter', 'cache', - 'compress', 'session', 'proxy', 'static', 'requestid', 'sse', - ], href: 'https://docs.gofiber.io/category/-middleware', cta: 'Explore middleware', }, @@ -54,14 +40,9 @@ const categories: EcosystemCategory[] = [ icon: '🗄️', catalog: 'storage', noun: 'drivers', - fallbackBadge: '30+ drivers', title: 'Storage Drivers', description: 'One unified interface for every major database and key-value store. Plug them into sessions, caching, or rate limiting without changing your code.', - featured: [ - 'redis', 'postgres', 'mysql', 'mongodb', 'sqlite3', 's3', - 'dynamodb', 'memcache', 'nats', 'etcd', 'badger', 'clickhouse', - ], href: 'https://docs.gofiber.io/storage/', cta: 'Browse storage drivers', }, @@ -69,14 +50,9 @@ const categories: EcosystemCategory[] = [ icon: '📝', catalog: 'template', noun: 'engines', - fallbackBadge: '9 engines', title: 'Template Engines', description: 'Server-side rendering with the syntax you already know. One official package, one interface, your choice of engine.', - featured: [ - 'html', 'django', 'handlebars', 'pug', 'jet', - 'mustache', 'ace', 'amber', 'slim', - ], href: 'https://docs.gofiber.io/template/', cta: 'Pick your engine', }, @@ -84,33 +60,15 @@ const categories: EcosystemCategory[] = [ icon: '🧩', catalog: 'contrib', noun: 'packages', - fallbackBadge: '20+ packages', title: 'Contrib Packages', description: 'Officially maintained integrations with the wider ecosystem: tracing, logging, authentication, API documentation, and real-time communication.', - featured: [ - 'jwt', 'websocket', 'otel', 'swaggerui', 'casbin', 'sentry', - 'zap', 'zerolog', 'socketio', 'circuitbreaker', 'i18n', 'paseto', - ], href: 'https://docs.gofiber.io/contrib/', cta: 'Discover contrib', }, ]; -function orderByFeatured(entries: CatalogEntry[], featured: string[]): CatalogEntry[] { - const rank = new Map(featured.map((id, index) => [id, index])); - return [...entries].sort((a, b) => { - const rankA = rank.get(a.id) ?? Number.MAX_SAFE_INTEGER; - const rankB = rank.get(b.id) ?? Number.MAX_SAFE_INTEGER; - return rankA === rankB ? a.label.localeCompare(b.label) : rankA - rankB; - }); -} - export default function Ecosystem() { - // Counts and chips come from the docs catalogs, so a new middleware, - // driver, engine, or contrib package shows up here without an edit. - const catalogs = usePluginData(CATALOG_PLUGIN_NAME) as FiberCatalogs | undefined; - return (
@@ -125,16 +83,7 @@ export default function Ecosystem() {
{categories.map((cat) => { - const entries = orderByFeatured( - catalogs?.[cat.catalog] ?? [], - cat.featured, - ); - const chips = entries.slice(0, MAX_CHIPS); - const remaining = entries.length - chips.length; - const badge = - entries.length > 0 - ? `${entries.length} ${cat.noun}` - : cat.fallbackBadge; + const entries = catalogs[cat.catalog]; return ( {cat.icon}

{cat.title}

- {badge} + + {entries.length} {cat.noun} +

{cat.description}

- {chips.map((item) => ( + {entries.map((item) => ( {item.label} ))} - {remaining > 0 && ( - - + {remaining} more - - )}
{cat.cta} diff --git a/src/data/catalogs.json b/src/data/catalogs.json new file mode 100644 index 000000000000..89a0aa29d0ce --- /dev/null +++ b/src/data/catalogs.json @@ -0,0 +1,498 @@ +{ + "generatedBy": "scripts/generate-catalogs.mjs, do not edit by hand", + "catalogs": { + "middleware": [ + { + "id": "adaptor", + "label": "Adaptor", + "path": "/middleware/adaptor" + }, + { + "id": "basicauth", + "label": "BasicAuth", + "path": "/middleware/basicauth" + }, + { + "id": "cache", + "label": "Cache", + "path": "/middleware/cache" + }, + { + "id": "compress", + "label": "Compress", + "path": "/middleware/compress" + }, + { + "id": "cors", + "label": "CORS", + "path": "/middleware/cors" + }, + { + "id": "csrf", + "label": "CSRF", + "path": "/middleware/csrf" + }, + { + "id": "earlydata", + "label": "EarlyData", + "path": "/middleware/earlydata" + }, + { + "id": "encryptcookie", + "label": "Encrypt Cookie", + "path": "/middleware/encryptcookie" + }, + { + "id": "envvar", + "label": "EnvVar", + "path": "/middleware/envvar" + }, + { + "id": "etag", + "label": "ETag", + "path": "/middleware/etag" + }, + { + "id": "expvar", + "label": "ExpVar", + "path": "/middleware/expvar" + }, + { + "id": "favicon", + "label": "Favicon", + "path": "/middleware/favicon" + }, + { + "id": "healthcheck", + "label": "Health Check", + "path": "/middleware/healthcheck" + }, + { + "id": "helmet", + "label": "Helmet", + "path": "/middleware/helmet" + }, + { + "id": "hostauthorization", + "label": "Host Authorization", + "path": "/middleware/hostauthorization" + }, + { + "id": "idempotency", + "label": "Idempotency", + "path": "/middleware/idempotency" + }, + { + "id": "keyauth", + "label": "KeyAuth", + "path": "/middleware/keyauth" + }, + { + "id": "limiter", + "label": "Limiter", + "path": "/middleware/limiter" + }, + { + "id": "logger", + "label": "Logger", + "path": "/middleware/logger" + }, + { + "id": "paginate", + "label": "Paginate", + "path": "/middleware/paginate" + }, + { + "id": "pprof", + "label": "Pprof", + "path": "/middleware/pprof" + }, + { + "id": "proxy", + "label": "Proxy", + "path": "/middleware/proxy" + }, + { + "id": "recover", + "label": "Recover", + "path": "/middleware/recover" + }, + { + "id": "redirect", + "label": "Redirect", + "path": "/middleware/redirect" + }, + { + "id": "requestid", + "label": "RequestID", + "path": "/middleware/requestid" + }, + { + "id": "responsetime", + "label": "ResponseTime", + "path": "/middleware/responsetime" + }, + { + "id": "rewrite", + "label": "Rewrite", + "path": "/middleware/rewrite" + }, + { + "id": "session", + "label": "Session", + "path": "/middleware/session" + }, + { + "id": "skip", + "label": "Skip", + "path": "/middleware/skip" + }, + { + "id": "sse", + "label": "SSE", + "path": "/middleware/sse" + }, + { + "id": "static", + "label": "Static", + "path": "/middleware/static" + }, + { + "id": "timeout", + "label": "Timeout", + "path": "/middleware/timeout" + } + ], + "contrib": [ + { + "id": "casbin", + "label": "Casbin", + "path": "/contrib/casbin" + }, + { + "id": "circuitbreaker", + "label": "Circuit Breaker", + "path": "/contrib/circuitbreaker" + }, + { + "id": "coraza", + "label": "Coraza", + "path": "/contrib/coraza" + }, + { + "id": "fgprof", + "label": "Fgprof", + "path": "/contrib/fgprof" + }, + { + "id": "hcaptcha", + "label": "HCaptcha", + "path": "/contrib/hcaptcha" + }, + { + "id": "i18n", + "label": "I18n", + "path": "/contrib/i18n" + }, + { + "id": "jwt", + "label": "JWT", + "path": "/contrib/jwt" + }, + { + "id": "loadshed", + "label": "LoadShed", + "path": "/contrib/loadshed" + }, + { + "id": "monitor", + "label": "Monitor", + "path": "/contrib/monitor" + }, + { + "id": "newrelic", + "label": "New Relic", + "path": "/contrib/newrelic" + }, + { + "id": "opa", + "label": "OPA", + "path": "/contrib/opa" + }, + { + "id": "otel", + "label": "OTel", + "path": "/contrib/otel" + }, + { + "id": "paseto", + "label": "Paseto", + "path": "/contrib/paseto" + }, + { + "id": "sentry", + "label": "Sentry", + "path": "/contrib/sentry" + }, + { + "id": "socketio", + "label": "Socket.io", + "path": "/contrib/socketio" + }, + { + "id": "swaggerui", + "label": "Swagger UI", + "path": "/contrib/swaggerui" + }, + { + "id": "swaggo", + "label": "Swaggo", + "path": "/contrib/swaggo" + }, + { + "id": "testcontainers", + "label": "Testcontainers", + "path": "/contrib/testcontainers" + }, + { + "id": "uptime", + "label": "Uptime", + "path": "/contrib/uptime" + }, + { + "id": "websocket", + "label": "Websocket", + "path": "/contrib/websocket" + }, + { + "id": "zap", + "label": "Zap", + "path": "/contrib/zap" + }, + { + "id": "zerolog", + "label": "Zerolog", + "path": "/contrib/zerolog" + } + ], + "storage": [ + { + "id": "aerospike", + "label": "Aerospike", + "path": "/storage/aerospike" + }, + { + "id": "arangodb", + "label": "ArangoDB", + "path": "/storage/arangodb" + }, + { + "id": "azureblob", + "label": "Azure Blob", + "path": "/storage/azureblob" + }, + { + "id": "badger", + "label": "Badger", + "path": "/storage/badger" + }, + { + "id": "bbolt", + "label": "Bbolt", + "path": "/storage/bbolt" + }, + { + "id": "cassandra", + "label": "Cassandra", + "path": "/storage/cassandra" + }, + { + "id": "clickhouse", + "label": "Clickhouse", + "path": "/storage/clickhouse" + }, + { + "id": "cloudflarekv", + "label": "Cloudflare KV", + "path": "/storage/cloudflarekv" + }, + { + "id": "coherence", + "label": "Coherence", + "path": "/storage/coherence" + }, + { + "id": "couchbase", + "label": "Couchbase", + "path": "/storage/couchbase" + }, + { + "id": "dynamodb", + "label": "DynamoDB", + "path": "/storage/dynamodb" + }, + { + "id": "etcd", + "label": "Etcd", + "path": "/storage/etcd" + }, + { + "id": "firestore", + "label": "Firestore", + "path": "/storage/firestore" + }, + { + "id": "leveldb", + "label": "LevelDB", + "path": "/storage/leveldb" + }, + { + "id": "memcache", + "label": "Memcache", + "path": "/storage/memcache" + }, + { + "id": "memory", + "label": "Memory", + "path": "/storage/memory" + }, + { + "id": "minio", + "label": "Minio", + "path": "/storage/minio" + }, + { + "id": "mockstorage", + "label": "MockStorage", + "path": "/storage/mockstorage" + }, + { + "id": "mongodb", + "label": "MongoDB", + "path": "/storage/mongodb" + }, + { + "id": "mssql", + "label": "MSSQL", + "path": "/storage/mssql" + }, + { + "id": "mysql", + "label": "MySQL", + "path": "/storage/mysql" + }, + { + "id": "nats", + "label": "Nats", + "path": "/storage/nats" + }, + { + "id": "neo4j", + "label": "Neo4j", + "path": "/storage/neo4j" + }, + { + "id": "pebble", + "label": "Pebble", + "path": "/storage/pebble" + }, + { + "id": "postgres", + "label": "Postgres", + "path": "/storage/postgres" + }, + { + "id": "redis", + "label": "Redis", + "path": "/storage/redis" + }, + { + "id": "ristretto", + "label": "Ristretto", + "path": "/storage/ristretto" + }, + { + "id": "rueidis", + "label": "Rueidis", + "path": "/storage/rueidis" + }, + { + "id": "s3", + "label": "S3", + "path": "/storage/s3" + }, + { + "id": "scylladb", + "label": "ScyllaDb", + "path": "/storage/scylladb" + }, + { + "id": "sqlite3", + "label": "SQLite3", + "path": "/storage/sqlite3" + }, + { + "id": "surrealdb", + "label": "SurrealDB", + "path": "/storage/surrealdb" + }, + { + "id": "testhelpers", + "label": "Test Helpers", + "path": "/storage/testhelpers" + }, + { + "id": "valkey", + "label": "Valkey", + "path": "/storage/valkey" + } + ], + "template": [ + { + "id": "ace", + "label": "Ace", + "path": "/template/ace" + }, + { + "id": "amber", + "label": "Amber", + "path": "/template/amber" + }, + { + "id": "django", + "label": "Django", + "path": "/template/django" + }, + { + "id": "handlebars", + "label": "Handlebars", + "path": "/template/handlebars" + }, + { + "id": "html", + "label": "HTML", + "path": "/template/html" + }, + { + "id": "jet", + "label": "Jet", + "path": "/template/jet" + }, + { + "id": "mustache", + "label": "Mustache", + "path": "/template/mustache" + }, + { + "id": "pug", + "label": "Pug", + "path": "/template/pug" + }, + { + "id": "slim", + "label": "Slim", + "path": "/template/slim" + } + ] + } +} diff --git a/src/types/catalogs.ts b/src/types/catalogs.ts index 908dc09ecac0..08a78e12817b 100644 --- a/src/types/catalogs.ts +++ b/src/types/catalogs.ts @@ -1,8 +1,7 @@ -// Shared contract between the build-time catalog plugin -// (fiber-catalogs-plugin.ts) and the components that render the catalogs. -// Kept free of Node imports so it can be pulled into the client bundle. - -export const CATALOG_PLUGIN_NAME = 'fiber-catalogs'; +// Shape of src/data/catalogs.json, the package catalogs generated from the +// docs folder by scripts/generate-catalogs.mjs. Both the homepage and the +// ecosystem landscape read that file, so neither has to carry package lists +// or counts of its own. export type CatalogKey = 'middleware' | 'contrib' | 'storage' | 'template'; @@ -11,7 +10,14 @@ export type CatalogEntry = { id: string; /** Display name taken from the doc itself, such as "Redis". */ label: string; + /** Path of the doc page on the docs site, such as "/storage/redis". */ + path: string; }; /** Every official package catalog, alphabetically sorted by label. */ export type FiberCatalogs = Record; + +export type CatalogsFile = { + generatedBy: string; + catalogs: FiberCatalogs; +}; From 9672ea6df0ef3d03cec74bb2702f0d194f82ebb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 17:35:44 +0000 Subject: [PATCH 3/4] build(catalogs): keep the generated catalogs.json out of version control The file is derived from the docs folder, so tracking it only produces diff noise and can go stale against the docs it mirrors. It is now gitignored and written on the fly instead: the generator already ran from preinstall and the pre-build hooks, and typecheck gets one too so tsc can resolve the JSON import in a fresh checkout. Regenerating from scratch was verified for npm ci, npm install, typecheck, build:home and build:docs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LX25C5g5L5D8AgDtBScJA6 --- .gitignore | 2 + package.json | 1 + src/data/catalogs.json | 498 ----------------------------------------- 3 files changed, 3 insertions(+), 498 deletions(-) delete mode 100644 src/data/catalogs.json diff --git a/.gitignore b/.gitignore index 84f55e20e3a5..50064e6d3720 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ # Generated files .docusaurus .cache-loader +# Package catalogs, written from the docs folder by scripts/generate-catalogs.mjs +/src/data/catalogs.json # Misc .DS_Store diff --git a/package.json b/package.json index c246e1fb78d7..e5356ed32ef2 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "deploy": "docusaurus deploy", "clear": "docusaurus clear", "serve": "docusaurus serve", + "pretypecheck": "npm run generate:catalogs", "typecheck": "tsc --noEmit", "test:matcher": "node --test src/components/route-playground/matcher.test.mts", "check": "npm run typecheck && npm run test:matcher && npm run build:docs && npm run build:home", diff --git a/src/data/catalogs.json b/src/data/catalogs.json deleted file mode 100644 index 89a0aa29d0ce..000000000000 --- a/src/data/catalogs.json +++ /dev/null @@ -1,498 +0,0 @@ -{ - "generatedBy": "scripts/generate-catalogs.mjs, do not edit by hand", - "catalogs": { - "middleware": [ - { - "id": "adaptor", - "label": "Adaptor", - "path": "/middleware/adaptor" - }, - { - "id": "basicauth", - "label": "BasicAuth", - "path": "/middleware/basicauth" - }, - { - "id": "cache", - "label": "Cache", - "path": "/middleware/cache" - }, - { - "id": "compress", - "label": "Compress", - "path": "/middleware/compress" - }, - { - "id": "cors", - "label": "CORS", - "path": "/middleware/cors" - }, - { - "id": "csrf", - "label": "CSRF", - "path": "/middleware/csrf" - }, - { - "id": "earlydata", - "label": "EarlyData", - "path": "/middleware/earlydata" - }, - { - "id": "encryptcookie", - "label": "Encrypt Cookie", - "path": "/middleware/encryptcookie" - }, - { - "id": "envvar", - "label": "EnvVar", - "path": "/middleware/envvar" - }, - { - "id": "etag", - "label": "ETag", - "path": "/middleware/etag" - }, - { - "id": "expvar", - "label": "ExpVar", - "path": "/middleware/expvar" - }, - { - "id": "favicon", - "label": "Favicon", - "path": "/middleware/favicon" - }, - { - "id": "healthcheck", - "label": "Health Check", - "path": "/middleware/healthcheck" - }, - { - "id": "helmet", - "label": "Helmet", - "path": "/middleware/helmet" - }, - { - "id": "hostauthorization", - "label": "Host Authorization", - "path": "/middleware/hostauthorization" - }, - { - "id": "idempotency", - "label": "Idempotency", - "path": "/middleware/idempotency" - }, - { - "id": "keyauth", - "label": "KeyAuth", - "path": "/middleware/keyauth" - }, - { - "id": "limiter", - "label": "Limiter", - "path": "/middleware/limiter" - }, - { - "id": "logger", - "label": "Logger", - "path": "/middleware/logger" - }, - { - "id": "paginate", - "label": "Paginate", - "path": "/middleware/paginate" - }, - { - "id": "pprof", - "label": "Pprof", - "path": "/middleware/pprof" - }, - { - "id": "proxy", - "label": "Proxy", - "path": "/middleware/proxy" - }, - { - "id": "recover", - "label": "Recover", - "path": "/middleware/recover" - }, - { - "id": "redirect", - "label": "Redirect", - "path": "/middleware/redirect" - }, - { - "id": "requestid", - "label": "RequestID", - "path": "/middleware/requestid" - }, - { - "id": "responsetime", - "label": "ResponseTime", - "path": "/middleware/responsetime" - }, - { - "id": "rewrite", - "label": "Rewrite", - "path": "/middleware/rewrite" - }, - { - "id": "session", - "label": "Session", - "path": "/middleware/session" - }, - { - "id": "skip", - "label": "Skip", - "path": "/middleware/skip" - }, - { - "id": "sse", - "label": "SSE", - "path": "/middleware/sse" - }, - { - "id": "static", - "label": "Static", - "path": "/middleware/static" - }, - { - "id": "timeout", - "label": "Timeout", - "path": "/middleware/timeout" - } - ], - "contrib": [ - { - "id": "casbin", - "label": "Casbin", - "path": "/contrib/casbin" - }, - { - "id": "circuitbreaker", - "label": "Circuit Breaker", - "path": "/contrib/circuitbreaker" - }, - { - "id": "coraza", - "label": "Coraza", - "path": "/contrib/coraza" - }, - { - "id": "fgprof", - "label": "Fgprof", - "path": "/contrib/fgprof" - }, - { - "id": "hcaptcha", - "label": "HCaptcha", - "path": "/contrib/hcaptcha" - }, - { - "id": "i18n", - "label": "I18n", - "path": "/contrib/i18n" - }, - { - "id": "jwt", - "label": "JWT", - "path": "/contrib/jwt" - }, - { - "id": "loadshed", - "label": "LoadShed", - "path": "/contrib/loadshed" - }, - { - "id": "monitor", - "label": "Monitor", - "path": "/contrib/monitor" - }, - { - "id": "newrelic", - "label": "New Relic", - "path": "/contrib/newrelic" - }, - { - "id": "opa", - "label": "OPA", - "path": "/contrib/opa" - }, - { - "id": "otel", - "label": "OTel", - "path": "/contrib/otel" - }, - { - "id": "paseto", - "label": "Paseto", - "path": "/contrib/paseto" - }, - { - "id": "sentry", - "label": "Sentry", - "path": "/contrib/sentry" - }, - { - "id": "socketio", - "label": "Socket.io", - "path": "/contrib/socketio" - }, - { - "id": "swaggerui", - "label": "Swagger UI", - "path": "/contrib/swaggerui" - }, - { - "id": "swaggo", - "label": "Swaggo", - "path": "/contrib/swaggo" - }, - { - "id": "testcontainers", - "label": "Testcontainers", - "path": "/contrib/testcontainers" - }, - { - "id": "uptime", - "label": "Uptime", - "path": "/contrib/uptime" - }, - { - "id": "websocket", - "label": "Websocket", - "path": "/contrib/websocket" - }, - { - "id": "zap", - "label": "Zap", - "path": "/contrib/zap" - }, - { - "id": "zerolog", - "label": "Zerolog", - "path": "/contrib/zerolog" - } - ], - "storage": [ - { - "id": "aerospike", - "label": "Aerospike", - "path": "/storage/aerospike" - }, - { - "id": "arangodb", - "label": "ArangoDB", - "path": "/storage/arangodb" - }, - { - "id": "azureblob", - "label": "Azure Blob", - "path": "/storage/azureblob" - }, - { - "id": "badger", - "label": "Badger", - "path": "/storage/badger" - }, - { - "id": "bbolt", - "label": "Bbolt", - "path": "/storage/bbolt" - }, - { - "id": "cassandra", - "label": "Cassandra", - "path": "/storage/cassandra" - }, - { - "id": "clickhouse", - "label": "Clickhouse", - "path": "/storage/clickhouse" - }, - { - "id": "cloudflarekv", - "label": "Cloudflare KV", - "path": "/storage/cloudflarekv" - }, - { - "id": "coherence", - "label": "Coherence", - "path": "/storage/coherence" - }, - { - "id": "couchbase", - "label": "Couchbase", - "path": "/storage/couchbase" - }, - { - "id": "dynamodb", - "label": "DynamoDB", - "path": "/storage/dynamodb" - }, - { - "id": "etcd", - "label": "Etcd", - "path": "/storage/etcd" - }, - { - "id": "firestore", - "label": "Firestore", - "path": "/storage/firestore" - }, - { - "id": "leveldb", - "label": "LevelDB", - "path": "/storage/leveldb" - }, - { - "id": "memcache", - "label": "Memcache", - "path": "/storage/memcache" - }, - { - "id": "memory", - "label": "Memory", - "path": "/storage/memory" - }, - { - "id": "minio", - "label": "Minio", - "path": "/storage/minio" - }, - { - "id": "mockstorage", - "label": "MockStorage", - "path": "/storage/mockstorage" - }, - { - "id": "mongodb", - "label": "MongoDB", - "path": "/storage/mongodb" - }, - { - "id": "mssql", - "label": "MSSQL", - "path": "/storage/mssql" - }, - { - "id": "mysql", - "label": "MySQL", - "path": "/storage/mysql" - }, - { - "id": "nats", - "label": "Nats", - "path": "/storage/nats" - }, - { - "id": "neo4j", - "label": "Neo4j", - "path": "/storage/neo4j" - }, - { - "id": "pebble", - "label": "Pebble", - "path": "/storage/pebble" - }, - { - "id": "postgres", - "label": "Postgres", - "path": "/storage/postgres" - }, - { - "id": "redis", - "label": "Redis", - "path": "/storage/redis" - }, - { - "id": "ristretto", - "label": "Ristretto", - "path": "/storage/ristretto" - }, - { - "id": "rueidis", - "label": "Rueidis", - "path": "/storage/rueidis" - }, - { - "id": "s3", - "label": "S3", - "path": "/storage/s3" - }, - { - "id": "scylladb", - "label": "ScyllaDb", - "path": "/storage/scylladb" - }, - { - "id": "sqlite3", - "label": "SQLite3", - "path": "/storage/sqlite3" - }, - { - "id": "surrealdb", - "label": "SurrealDB", - "path": "/storage/surrealdb" - }, - { - "id": "testhelpers", - "label": "Test Helpers", - "path": "/storage/testhelpers" - }, - { - "id": "valkey", - "label": "Valkey", - "path": "/storage/valkey" - } - ], - "template": [ - { - "id": "ace", - "label": "Ace", - "path": "/template/ace" - }, - { - "id": "amber", - "label": "Amber", - "path": "/template/amber" - }, - { - "id": "django", - "label": "Django", - "path": "/template/django" - }, - { - "id": "handlebars", - "label": "Handlebars", - "path": "/template/handlebars" - }, - { - "id": "html", - "label": "HTML", - "path": "/template/html" - }, - { - "id": "jet", - "label": "Jet", - "path": "/template/jet" - }, - { - "id": "mustache", - "label": "Mustache", - "path": "/template/mustache" - }, - { - "id": "pug", - "label": "Pug", - "path": "/template/pug" - }, - { - "id": "slim", - "label": "Slim", - "path": "/template/slim" - } - ] - } -} From 738e2dd348fb0c819a09342ebb74bc75f3ecd427 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 17:49:41 +0000 Subject: [PATCH 4/4] build(catalogs): generate from the docusaurus config instead of npm hooks npm matches pre* hooks on the exact script name, so covering the builds took one hook per entry point (prebuild, prebuild:home, prebuild:docs) plus prestart and preinstall. Loading the generator from docusaurus.config.ts replaces all of them: the config is read at the start of every docusaurus command and long before webpack resolves the JSON import, so start, build, build:home, build:docs and a bare npx docusaurus call are all covered by one call that cannot be forgotten when a script is added. The script becomes a CommonJS module exporting generateCatalogs() and keeps working as a CLI. Only typecheck still generates on its own, because tsc never loads the config and would not resolve the import in a fresh checkout. Verified from a deleted catalogs.json: npm run check (typecheck, tests, both builds) regenerates it and passes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LX25C5g5L5D8AgDtBScJA6 --- docusaurus.config.ts | 7 ++ package.json | 10 +- ...rate-catalogs.mjs => generate-catalogs.js} | 95 +++++++++++-------- 3 files changed, 64 insertions(+), 48 deletions(-) rename scripts/{generate-catalogs.mjs => generate-catalogs.js} (66%) diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 15e5e41fd95c..24537a226c99 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -2,6 +2,13 @@ import type { Config, Plugin, PluginConfig, PluginModule } from '@docusaurus/typ import type { Options } from '@docusaurus/preset-classic'; import { themes } from 'prism-react-renderer'; +// src/data/catalogs.json is derived from the docs folder and not tracked in +// git. Writing it here covers every docusaurus command (start, build, serve) +// in one place, before webpack resolves the import. `npm run typecheck` does +// not load this config and has its own hook. +const { generateCatalogs } = require('./scripts/generate-catalogs'); +generateCatalogs(); + const lightCodeTheme = themes.github; const darkCodeTheme = themes.dracula; diff --git a/package.json b/package.json index e5356ed32ef2..6a5226b5b5e4 100644 --- a/package.json +++ b/package.json @@ -4,24 +4,18 @@ "private": true, "scripts": { "docusaurus": "docusaurus", - "generate:catalogs": "node scripts/generate-catalogs.mjs", - "preinstall": "node scripts/generate-catalogs.mjs", - "prestart": "npm run generate:catalogs", + "generate:catalogs": "node scripts/generate-catalogs.js", "start": "docusaurus start", "preview:docs": "npm run build:docs && docusaurus serve", "preview:home": "npm run build:home && docusaurus serve", - "prebuild": "npm run generate:catalogs", "build": "docusaurus build", - "prebuild:home": "npm run generate:catalogs", "build:home": "cross-env BUILD_TARGET=home docusaurus build", - "prebuild:docs": "npm run generate:catalogs", "build:docs": "cross-env BUILD_TARGET=docs docusaurus build", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", "serve": "docusaurus serve", - "pretypecheck": "npm run generate:catalogs", - "typecheck": "tsc --noEmit", + "typecheck": "npm run generate:catalogs && tsc --noEmit", "test:matcher": "node --test src/components/route-playground/matcher.test.mts", "check": "npm run typecheck && npm run test:matcher && npm run build:docs && npm run build:home", "write-translations": "docusaurus write-translations", diff --git a/scripts/generate-catalogs.mjs b/scripts/generate-catalogs.js similarity index 66% rename from scripts/generate-catalogs.mjs rename to scripts/generate-catalogs.js index ae46f63c8a4e..69cb0e4bd711 100644 --- a/scripts/generate-catalogs.mjs +++ b/scripts/generate-catalogs.js @@ -4,17 +4,18 @@ // and doc paths. Both the homepage and the ecosystem landscape read that file, // so counts and package lists never have to be maintained by hand. // -// Runs from the pre* hooks of the start and build scripts (see package.json) -// and uses nothing but Node builtins, so it also works before an install. +// The output is not tracked in git. docusaurus.config.ts calls this on load, +// which covers every docusaurus command in one place; package.json wires it +// into the typecheck, which does not load the config. Uses nothing but node +// builtins, so it also runs before an install. // // A package is one page directly below a catalog root; nested pages such as // contrib/socketio/legacy are part of their package, not packages of their own. -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +const fs = require('node:fs'); +const path = require('node:path'); -const siteDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const siteDir = path.join(__dirname, '..'); const outFile = path.join(siteDir, 'src/data/catalogs.json'); // Catalogs of one directory per package, served at //. @@ -114,41 +115,55 @@ function readPackageFiles(root, routeBasePath) { .sort(byLabel); } -const catalogs = { - middleware: readPackageFiles(coreMiddlewareDir(), 'middleware'), - ...Object.fromEntries( - Object.entries(DIR_CATALOGS).map(([key, { dir, routeBasePath }]) => [ - key, - readPackageDirs(path.join(siteDir, dir), routeBasePath), - ]), - ), -}; +/** Writes src/data/catalogs.json and returns the catalogs it wrote. */ +function generateCatalogs({ silent = false } = {}) { + const catalogs = { + middleware: readPackageFiles(coreMiddlewareDir(), 'middleware'), + ...Object.fromEntries( + Object.entries(DIR_CATALOGS).map(([key, { dir, routeBasePath }]) => [ + key, + readPackageDirs(path.join(siteDir, dir), routeBasePath), + ]), + ), + }; + + const empty = Object.keys(catalogs).filter((key) => catalogs[key].length === 0); + if (empty.length > 0) { + throw new Error( + `generate-catalogs: no packages found for ${empty.join(', ')}. Are the docs synced?`, + ); + } -const empty = Object.keys(catalogs).filter((key) => catalogs[key].length === 0); -if (empty.length > 0) { - console.error( - `generate-catalogs: no packages found for ${empty.join(', ')}. Are the docs synced?`, - ); - process.exit(1); -} + const contents = `${JSON.stringify( + { generatedBy: 'scripts/generate-catalogs.js, do not edit by hand', catalogs }, + null, + 2, + )}\n`; + + // Only touch the file when it actually changed, so watchers stay quiet. + const changed = !fs.existsSync(outFile) || fs.readFileSync(outFile, 'utf8') !== contents; + if (changed) { + fs.mkdirSync(path.dirname(outFile), { recursive: true }); + fs.writeFileSync(outFile, contents); + } + + if (!silent) { + const summary = Object.entries(catalogs) + .map(([key, entries]) => `${key} ${entries.length}`) + .join(', '); + console.log(`generate-catalogs: ${summary}${changed ? '' : ' (unchanged)'}`); + } -const contents = `${JSON.stringify( - { - generatedBy: 'scripts/generate-catalogs.mjs, do not edit by hand', - catalogs, - }, - null, - 2, -)}\n`; - -// Only touch the file when it actually changed, so watchers stay quiet. -const changed = !fs.existsSync(outFile) || fs.readFileSync(outFile, 'utf8') !== contents; -if (changed) { - fs.mkdirSync(path.dirname(outFile), { recursive: true }); - fs.writeFileSync(outFile, contents); + return catalogs; } -const summary = Object.entries(catalogs) - .map(([key, entries]) => `${key} ${entries.length}`) - .join(', '); -console.log(`generate-catalogs: ${summary}${changed ? '' : ' (unchanged)'}`); +module.exports = { generateCatalogs }; + +if (require.main === module) { + try { + generateCatalogs(); + } catch (error) { + console.error(error.message); + process.exit(1); + } +}