From 0b300f1f947c08c6c8ebee26e45242103ce82aa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 1 Oct 2026 12:01:42 -0400 Subject: [PATCH] =?UTF-8?q?T48a=20Docs=20=C2=B7=20Frame:=20guides=20render?= =?UTF-8?q?=20at=20build=20in=20a=20Vite=20plugin,=20so=20a=20bad=20one=20?= =?UTF-8?q?fails=20the=20build;=20focus=20rings=20whole=20in=20the=20rail?= =?UTF-8?q?=20and=20list;=20plain=20heading=20words,=20unique=20ids,=20cap?= =?UTF-8?q?tions=20only=20when=20named,=20code=20under=20example=3D=20refu?= =?UTF-8?q?sed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- frontend/package.json | 2 +- frontend/pnpm-lock.yaml | 6 ++--- frontend/src/docs/guides.ts | 45 ++++++++++++++++++++++------------ frontend/src/docs/virtual.d.ts | 3 +++ frontend/src/pages/Docs.tsx | 3 ++- frontend/src/styles.css | 5 ++-- frontend/vite.config.ts | 30 +++++++++++++++++++++-- scripts/check.sh | 1 + 9 files changed, 71 insertions(+), 26 deletions(-) create mode 100644 frontend/src/docs/virtual.d.ts diff --git a/AGENTS.md b/AGENTS.md index 3e4900d..8c3b6b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Pictures: `image` 0.25 (MIT OR Apache-2.0), the crate gpui decodes with, default features off; it turns decoded frames and rims map tiles. - Maps: Natural Earth's 110m countries (public domain). `assets/maps/countries.geojson`, trimmed by `scripts/countries.py` to an ISO code, a name and positions to 0.01°, backs `maps::WorldMap`; the gallery's tiles are cut from the same file, z0 to z3, by `scripts/tiles.py` with Pillow. - Web: on wasm32, `web-time` 1.1 (MIT OR Apache-2.0) gives the `Instant` gpui's executor returns, std's own natively; jiff takes `js`, rust-embed `debug-embed`, similar `wasm32_web_time`. The gallery's web entry takes `gpui_web`, `wasm-bindgen`, `js-sys` and `web-sys` as wasm-only dev-dependencies, and a Hebrew cut of Noto Sans Hebrew 2.003 (OFL, `examples/gallery/fonts`): gpui_web asks the browser for emoji and CJK alone. The `wasm-bindgen` CLI at Cargo.lock's version and binaryen's `wasm-opt` finish the bundle. -- Gallery: `examples/gallery`. Website: `frontend/` (Vite 8.3, pnpm 11, TypeScript, React 19.3, TanStack Router 1.170 in code-based routes, Lenis 1.3), in serro.ai's language: heavy titles over thin faint text, hairlines, one accent, full-screen sections, isometric line miniatures on a canvas. Pages name a story by its title's first part (`src/data.ts`'s `name`); scroll bars stay hidden; layouts step at 1024 and 768. Each story runs live from the gallery built for wasm32 through gpui_web, in a same-origin iframe, or, where a browser cannot run it, as a native capture. The Docs tab is Markdown in `frontend/src/docs/`, read in its `ORDER` and rendered at build by `marked` 18 (MIT); a fenced block tagged `example=NAME` shows `examples/docs/NAME.rs`, an example `scripts/check.sh` compiles as `docs_NAME`, so every full Rust sample on the site builds. Below 360px the header drops the sound switch. Geist and Geist Mono (OFL, `@fontsource-variable`), Reicon icons (MIT, based on Solar Icons, CC BY 4.0, credited in the footer), simple-icons' GitHub mark (CC0). It deploys to Cloudflare Workers static assets through `npx wrangler` (4.143). +- Gallery: `examples/gallery`. Website: `frontend/` (Vite 8.3, pnpm 11, TypeScript, React 19.3, TanStack Router 1.170 in code-based routes, Lenis 1.3), in serro.ai's language: heavy titles over thin faint text, hairlines, one accent, full-screen sections, isometric line miniatures on a canvas. Pages name a story by its title's first part (`src/data.ts`'s `name`); scroll bars stay hidden; layouts step at 1024 and 768. Each story runs live from the gallery built for wasm32 through gpui_web, in a same-origin iframe, or, where a browser cannot run it, as a native capture. The Docs tab is Markdown in `frontend/src/docs/`, read in its `ORDER` and rendered by `marked` 18 (MIT) inside `vite.config.ts`'s `docs` plugin, so a bad guide fails `pnpm build` and the browser gets HTML alone; a fenced block tagged `example=NAME` shows `examples/docs/NAME.rs`, an example `scripts/check.sh` compiles as `docs_NAME`, so every full Rust sample on the site builds. Below 360px the header drops the sound switch. Geist and Geist Mono (OFL, `@fontsource-variable`), Reicon icons (MIT, based on Solar Icons, CC BY 4.0, credited in the footer), simple-icons' GitHub mark (CC0). It deploys to Cloudflare Workers static assets through `npx wrangler` (4.143). ## Commands diff --git a/frontend/package.json b/frontend/package.json index 8ac7423..c8c7c02 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "@vitejs/plugin-react": "6.1.1", + "marked": "18.0.14", "typescript": "^7.0.2", "vite": "8.3.1", "wrangler": "4.143.0" @@ -27,7 +28,6 @@ "@fontsource-variable/geist-mono": "5.3.0", "@tanstack/react-router": "1.170.40", "lenis": "1.3.26", - "marked": "18.0.14", "react": "19.3.0", "react-dom": "19.3.0", "reicon": "1.2.4" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 5ca7417..545377f 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -20,9 +20,6 @@ importers: lenis: specifier: 1.3.26 version: 1.3.26(react@19.3.0) - marked: - specifier: 18.0.14 - version: 18.0.14 react: specifier: 19.3.0 version: 19.3.0 @@ -48,6 +45,9 @@ importers: '@vitejs/plugin-react': specifier: 6.1.1 version: 6.1.1(vite@8.3.1(@types/node@24.19.0)(esbuild@0.28.1)) + marked: + specifier: 18.0.14 + version: 18.0.14 typescript: specifier: ^7.0.2 version: 7.0.2 diff --git a/frontend/src/docs/guides.ts b/frontend/src/docs/guides.ts index df5fab1..ee874e9 100644 --- a/frontend/src/docs/guides.ts +++ b/frontend/src/docs/guides.ts @@ -1,7 +1,9 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; import { Marked, type Tokens } from "marked"; -const sources = import.meta.glob("./*.md", { query: "?raw", import: "default", eager: true }); -const examples = import.meta.glob("../../../examples/docs/*.rs", { query: "?raw", import: "default", eager: true }); +// Runs in Vite's config at build time, never in the browser. +const at = (relative: string) => fileURLToPath(new URL(relative, import.meta.url)); /** The guides in reading order, by file name. */ const ORDER = ["introduction"]; @@ -23,14 +25,20 @@ const slugify = (text: string) => .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); -/** A heading's words: no backticks, links as their text. */ -const plain = (markdown: string) => markdown.replace(/`/g, "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1"); +/** A heading's words: no code, emphasis or link marks. */ +const plain = (markdown: string) => + markdown + .replace(/`/g, "") + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/(\*\*|\*)([^*]+)\1/g, "$2"); -function read(file: string): Guide { - const source = sources[`./${file}.md`]; - if (source === undefined) throw new Error(`docs: no ${file}.md`); +function read(file: string, files: string[]): Guide { + const path = at(`./${file}.md`); + files.push(path); + const source = readFileSync(path, "utf8"); let title = ""; const toc: Guide["toc"] = []; + const ids = new Set(); const marked = new Marked({ renderer: { heading({ tokens, depth, text }: Tokens.Heading) { @@ -42,10 +50,9 @@ function read(file: string): Guide { } const html = this.parser.parseInline(tokens); const id = slugify(words); - if (depth === 2) { - if (toc.some((h) => h.id === id)) throw new Error(`docs: ${file}.md repeats heading ${id}`); - toc.push({ id, text: words }); - } + if (ids.has(id)) throw new Error(`docs: ${file}.md repeats heading ${id}`); + ids.add(id); + if (depth === 2) toc.push({ id, text: words }); return `${html}`; }, code({ text, lang }: Tokens.Code) { @@ -54,12 +61,14 @@ function read(file: string): Guide { let code = text; let caption = language; if (example) { - const body = examples[`../../../examples/docs/${example}.rs`]; - if (body === undefined) throw new Error(`docs: ${file}.md names a missing example ${example}`); - code = body.trimEnd(); + if (text.trim()) throw new Error(`docs: ${file}.md puts code under example=${example}`); + const path = at(`../../../examples/docs/${example}.rs`); + files.push(path); + code = readFileSync(path, "utf8").trimEnd(); caption = `examples/docs/${example}.rs · cargo run --example docs_${example}`; } - return `
${escape(caption)}
${escape(code)}
`; + const head = caption ? `
${escape(caption)}
` : ""; + return `
${head}
${escape(code)}
`; }, }, }); @@ -68,4 +77,8 @@ function read(file: string): Guide { return { slug: file === "introduction" ? "" : file, title, html, toc }; } -export const guides: Guide[] = ORDER.map(read); +/** Every guide, and every file read to make them. */ +export function render(): { guides: Guide[]; files: string[] } { + const files: string[] = []; + return { guides: ORDER.map((file) => read(file, files)), files }; +} diff --git a/frontend/src/docs/virtual.d.ts b/frontend/src/docs/virtual.d.ts new file mode 100644 index 0000000..dc51607 --- /dev/null +++ b/frontend/src/docs/virtual.d.ts @@ -0,0 +1,3 @@ +declare module "virtual:guides" { + export const guides: import("./guides").Guide[]; +} diff --git a/frontend/src/pages/Docs.tsx b/frontend/src/pages/Docs.tsx index d998c79..d291d62 100644 --- a/frontend/src/pages/Docs.tsx +++ b/frontend/src/pages/Docs.tsx @@ -1,6 +1,7 @@ import { Link, useNavigate, useParams } from "@tanstack/react-router"; import { useEffect, type MouseEvent } from "react"; -import { guides, type Guide } from "../docs/guides"; +import type { Guide } from "../docs/guides"; +import { guides } from "virtual:guides"; import { NotFound } from "./NotFound"; const to = (guide: Guide) => (guide.slug ? `/docs/${guide.slug}/` : "/docs/"); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index e2e7ac3..21659e9 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -165,7 +165,7 @@ img.shot { display: block; max-width: 100%; height: auto; } .pager .next { margin-left: auto; text-align: right; } .docs { display: grid; grid-template-columns: 200px minmax(0, 720px) 200px; justify-content: center; gap: 64px; padding: 0 64px; } -.docs-rail, .doc-toc { position: sticky; top: 64px; height: calc(100vh - 64px); overflow-y: auto; padding: 96px 0 64px; } +.docs-rail, .doc-toc { position: sticky; top: 64px; height: calc(100vh - 64px); overflow-y: auto; padding: 96px 8px 64px; margin: 0 -8px; } .docs-rail ol, .doc-toc ol { list-style: none; margin: 0; padding: 0; } .docs-rail li { padding: 5px 0; font-size: 15px; } .doc-toc li { padding: 4px 0; font-size: 13px; line-height: 20px; } @@ -191,6 +191,7 @@ img.shot { display: block; max-width: 100%; height: auto; } .code { position: relative; margin: 28px 0; background: var(--code); border-radius: 12px; } .code figcaption { padding: 14px 88px 0 20px; font: 400 12px/18px var(--mono); color: var(--faint); overflow-wrap: anywhere; } .code pre { margin: 0; padding: 14px 20px 20px; overflow-x: auto; } +.code button + pre { padding-top: 20px; } .code code { display: block; font: 400 13.5px/22px var(--mono); background: none; padding: 0; color: var(--fg); } .code .copy { position: absolute; top: 10px; right: 12px; padding: 4px 8px; font-size: 12px; color: var(--muted); transition: color 160ms ease-out; } .code .copy:hover { color: var(--fg); } @@ -208,7 +209,7 @@ img.shot { display: block; max-width: 100%; height: auto; } @media (max-width: 1023px) { .docs { grid-template-columns: minmax(0, 1fr); gap: 24px; } - .docs-rail { position: static; height: auto; padding: 96px 0 0; } + .docs-rail { position: static; height: auto; overflow: visible; padding: 96px 0 0; margin: 0; } .docs-rail ol { display: flex; flex-wrap: wrap; gap: 4px 20px; } .doc { padding-top: 48px; } .reader { grid-template-columns: minmax(0, 1fr); } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 023d9ee..5f32d0c 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,8 +1,34 @@ import react from "@vitejs/plugin-react"; -import { defineConfig } from "vite"; +import { defineConfig, type Plugin } from "vite"; +import { render } from "./src/docs/guides"; + +const GUIDES = "\0virtual:guides"; + +/** The guides, rendered at build; a bad one fails the build. */ +function docs(): Plugin { + let files: string[] = []; + return { + name: "ely-docs", + resolveId: (id) => (id === "virtual:guides" ? GUIDES : undefined), + load(id) { + if (id !== GUIDES) return; + const out = render(); + files = out.files; + files.forEach((file) => this.addWatchFile(file)); + return `export const guides = ${JSON.stringify(out.guides)};`; + }, + handleHotUpdate({ file, server }) { + if (!files.includes(file)) return; + const module = server.moduleGraph.getModuleById(GUIDES); + if (module) server.moduleGraph.invalidateModule(module); + server.ws.send({ type: "full-reload" }); + return []; + }, + }; +} // The manifest lives beside the gallery, one folder up. export default defineConfig({ - plugins: [react()], + plugins: [react(), docs()], server: { fs: { allow: [".."] } }, }); diff --git a/scripts/check.sh b/scripts/check.sh index cda9ada..5677722 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -28,6 +28,7 @@ deferred=$(grep -rn 'deferred(' src --include='*.rs' | grep -v '^src/primitives/ instant=$(grep -rnE '(^|[^_[:alnum:]])time::(Instant|\{[^}]*Instant)' src examples || true) [ -z "$instant" ] || { echo "std Instant panics in the browser, use web_time::Instant:"; echo "$instant"; fail=1; } for f in examples/docs/*.rs; do + [ -e "$f" ] || continue n=$(basename "$f" .rs) grep -qx "path = \"$f\"" Cargo.toml && grep -qx "name = \"docs_$n\"" Cargo.toml || { echo "$f needs [[example]] docs_$n in Cargo.toml"; fail=1; } done