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
1 change: 1 addition & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
],
"ignorePaths": [
"src/components/route-playground/match-vectors.json",
"src/data/catalogs.json",
"**/*.svg",
"**/*.png",
"**/*.jpg",
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docusaurus.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
169 changes: 169 additions & 0 deletions scripts/generate-catalogs.js
Original file line number Diff line number Diff line change
@@ -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 /<routeBasePath>/<id>.
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);
}
}
94 changes: 35 additions & 59 deletions src/components/fiber-landscape/index.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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;
Expand All @@ -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 = {
Expand Down Expand Up @@ -62,58 +70,17 @@ const CHIP_DETAILS: Record<string, ChipDetail> = {
},
};

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<string, CatalogChip[]> {
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<string, CatalogChip[]> = {};

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<string>();
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.
Expand All @@ -128,7 +95,7 @@ export default function FiberLandscape(): JSX.Element {
const [selectedKey, setSelectedKey] = useState("core");
const [hoverKey, setHoverKey] = useState<string | null>(null);
const [edges, setEdges] = useState<EdgeGeometry[]>([]);
const catalogs = useCatalogs();
const catalogHref = useCatalogHref();
const containerRef = useRef<HTMLDivElement | null>(null);
const appRef = useRef<HTMLDivElement | null>(null);
const cardRefs = useRef<Record<string, HTMLElement | null>>({});
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 (
<div className={styles.landscape}>
Expand Down Expand Up @@ -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)}
</button>
) : (
<span key={chip.label} className={styles.blockChip}>
{chip.label}
{chipText(chip)}
</span>
),
)}
Expand Down Expand Up @@ -474,7 +446,11 @@ export default function FiberLandscape(): JSX.Element {
{detailCatalog.length > 0 ? (
<div className={styles.detailChips}>
{detailCatalog.map((chip) => (
<Link key={chip.to} className={styles.chipLink} to={chip.to}>
<Link
key={chip.id}
className={styles.chipLink}
to={catalogHref(detailCatalogKey as CatalogKey, chip)}
>
{chip.label}
</Link>
))}
Expand Down
6 changes: 0 additions & 6 deletions src/components/home/Ecosystem.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading