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/.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/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 350f4f6fee0b..6a5226b5b5e4 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "docusaurus": "docusaurus", + "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", @@ -14,7 +15,7 @@ "deploy": "docusaurus deploy", "clear": "docusaurus clear", "serve": "docusaurus serve", - "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.js b/scripts/generate-catalogs.js new file mode 100644 index 000000000000..69cb0e4bd711 --- /dev/null +++ b/scripts/generate-catalogs.js @@ -0,0 +1,169 @@ +#!/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. +// +// 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. + +const fs = require('node:fs'); +const path = require('node:path'); + +const siteDir = path.join(__dirname, '..'); +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); +} + +/** 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 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)'}`); + } + + return catalogs; +} + +module.exports = { generateCatalogs }; + +if (require.main === module) { + try { + generateCatalogs(); + } catch (error) { + console.error(error.message); + process.exit(1); + } +} diff --git a/src/components/fiber-landscape/index.tsx b/src/components/fiber-landscape/index.tsx index fb20dd83b052..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; @@ -24,14 +29,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 = { @@ -62,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. @@ -128,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>({}); @@ -278,6 +245,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) { @@ -317,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 (
@@ -382,11 +354,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)} ), )} @@ -474,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 4eb6a94fe46d..2e4136114ed9 100644 --- a/src/components/home/Ecosystem.tsx +++ b/src/components/home/Ecosystem.tsx @@ -1,75 +1,69 @@ // src/components/home/Ecosystem.tsx import React from 'react'; import Heading from '@theme/Heading'; +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; - 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; title: string; description: string; - items: string[]; - more?: string; href: string; cta: string; }; -// Keep counts rough ("30+") so the homepage doesn't go stale with every new package. +// 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: '🧬', - badge: '30+ middleware', + catalog: 'middleware', + noun: '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', - ], - more: '+ many more', href: 'https://docs.gofiber.io/category/-middleware', cta: 'Explore middleware', }, { icon: '🗄️', - badge: '30+ drivers', + catalog: 'storage', + noun: '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', - ], - 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', 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', - ], - 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', 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', - ], - more: '+ many more', - href: 'https://docs.gofiber.io/contrib/next/', + href: 'https://docs.gofiber.io/contrib/', cta: 'Discover contrib', }, ]; @@ -88,33 +82,36 @@ export default function Ecosystem() {

diff --git a/src/types/catalogs.ts b/src/types/catalogs.ts new file mode 100644 index 000000000000..08a78e12817b --- /dev/null +++ b/src/types/catalogs.ts @@ -0,0 +1,23 @@ +// 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'; + +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; + /** 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; +};