From a264b588570c72036eba621d0d422bbe2c758044 Mon Sep 17 00:00:00 2001 From: danwood <118035379+Floating-Y@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:22:13 +0800 Subject: [PATCH 1/2] Load documentation and previews only on their own routes Signed-off-by: danwood <118035379+Floating-Y@users.noreply.github.com> --- .gitignore | 3 +- CONTRIBUTING.md | 2 + CONTRIBUTING.zh-CN.md | 2 + README.md | 2 +- scripts/build-docs.mjs | 49 ++++++++-- scripts/build-registry.mjs | 3 +- src/App.tsx | 56 +++++++++-- src/pages/block-view.tsx | 32 ++++++ src/pages/blocks.tsx | 28 +----- src/pages/dev-examples.tsx | 5 +- src/site/docs-layout.tsx | 193 ++++++++++++++++++++++++++++++++++++ src/site/docs.tsx | 194 ++++++------------------------------- src/site/pages.ts | 12 ++- 13 files changed, 366 insertions(+), 215 deletions(-) create mode 100644 src/pages/block-view.tsx create mode 100644 src/site/docs-layout.tsx diff --git a/.gitignore b/.gitignore index f191354..bc308b7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ dist *.tsbuildinfo .DS_Store public/r -src/docs/generated.json +src/docs/catalog.json +src/docs/pages/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b870c35..8ba865a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,6 +63,8 @@ npm run typecheck npm run build ``` +`npm run docs` generates `src/docs/catalog.json` (metadata only) and `src/docs/pages/
/.json` (one complete page per file). These files are ignored by Git; edit `content/` instead. Generation removes deleted pages and checks that the catalog and page files agree, including their order and parsed content. + There are no unit tests: what this repository makes is how things look. So a change is checked by looking, and the pull request says what was looked at: - `/dev/examples` reports `0 failed`. diff --git a/CONTRIBUTING.zh-CN.md b/CONTRIBUTING.zh-CN.md index ab95596..dd9bc01 100644 --- a/CONTRIBUTING.zh-CN.md +++ b/CONTRIBUTING.zh-CN.md @@ -63,6 +63,8 @@ npm run typecheck npm run build ``` +`npm run docs` 生成 `src/docs/catalog.json`(只有目录信息)和 `src/docs/pages/
/.json`(每篇完整文档一个文件)。这些文件已被 Git 忽略,请修改 `content/` 中的源文件。生成过程会清理已删除的页面,并断言目录与逐篇文件的顺序、元数据和解析后的正文一致。 + 这里没有单元测试:这个仓库产出的是"看起来怎样"。所以改动靠看来验证,PR 里要写明看了什么: - `/dev/examples` 显示 `0 failed`。 diff --git a/README.md b/README.md index 58ac97e..99d3e3e 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ npx shadcn@latest add deeplethe/solid-glass/status-glyph | `content/` | The documentation. `components/` is shadcn/ui's page for each component; `solid/` and `docs/` are Solid Glass's. | | `src/site/`, `src/pages/`, `src/home/` | The site: header, sidebar, component preview, and the wall of cards on the home page. | | `.github/` | The checks every pull request must pass (`checks.yml`), the workflow that publishes `main` (`pages.yml`), and the templates for issues and pull requests. | -| `scripts/build-docs.mjs` | Turns `content/` into `src/docs/generated.json`, which the site renders. | +| `scripts/build-docs.mjs` | Turns `content/` into the lightweight `src/docs/catalog.json` and individual `src/docs/pages/
/.json` bodies loaded on demand. | ## Run diff --git a/scripts/build-docs.mjs b/scripts/build-docs.mjs index 8615dd0..965aae7 100644 --- a/scripts/build-docs.mjs +++ b/scripts/build-docs.mjs @@ -1,5 +1,5 @@ /** - * Turn the documentation in content/ into src/docs/generated.json, which the site renders. + * Turn content/ into a lightweight src/docs/catalog.json and one JSON body per page. * * node scripts/build-docs.mjs * @@ -9,8 +9,9 @@ * block that says how to install, , , and for the * live figures in Solid Glass's own pages. Anything else that is JSX is left out. */ -import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs" -import { dirname, join } from "node:path" +import assert from "node:assert/strict" +import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs" +import { basename, dirname, join } from "node:path" import { fileURLToPath } from "node:url" const root = join(dirname(fileURLToPath(import.meta.url)), "..") @@ -25,7 +26,7 @@ const slug = (text) => const attr = (tag, name) => tag.match(new RegExp(`(?:^|[\\s<])${name}="([^"]*)"`))?.[1] function parse(file, section) { - const raw = readFileSync(file, "utf8") + const raw = readFileSync(file, "utf8").replace(/\r\n/g, "\n") const front = raw.match(/^---\n([\s\S]*?)\n---\n/) const meta = Object.fromEntries( (front?.[1] ?? "") @@ -171,7 +172,7 @@ function parse(file, section) { if (n) block.id = `${block.id}-${n}` } - const name = file.split("/").pop().replace(/\.mdx$/, "") + const name = basename(file, ".mdx") return { slug: name, section, group: meta.group, title: meta.title ?? name, description: meta.description ?? "", order: Number(meta.order ?? 0), blocks: kept } } @@ -190,6 +191,38 @@ for (const [folder, section] of [ pages.push(...found) } -mkdirSync(join(root, "src/docs"), { recursive: true }) -writeFileSync(join(root, "src/docs/generated.json"), JSON.stringify(pages)) -console.log(`docs: ${pages.length} pages -> src/docs/generated.json`) +const out = join(root, "src/docs") +const catalog = pages.map(({ blocks, ...entry }) => entry) +const keys = pages.map((page) => `${page.section}/${page.slug}`) +assert.equal(new Set(keys).size, pages.length, "Documentation page keys must be unique") +for (const page of pages) assert(!/[\\/]/.test(page.slug), `Invalid page slug: ${page.slug}`) + +mkdirSync(out, { recursive: true }) +writeFileSync(join(out, "catalog.json"), JSON.stringify(catalog)) +for (const section of ["docs", "solid", "components"]) { + const directory = join(out, "pages", section) + mkdirSync(directory, { recursive: true }) + const sectionPages = pages.filter((page) => page.section === section) + const filenames = new Set(sectionPages.map((page) => `${page.slug}.json`)) + // Deleted source pages must not remain discoverable by import.meta.glob. + for (const filename of readdirSync(directory)) { + if (filename.endsWith(".json") && !filenames.has(filename)) unlinkSync(join(directory, filename)) + } + for (const page of sectionPages) writeFileSync(join(directory, `${page.slug}.json`), JSON.stringify(page)) + assert.deepEqual(readdirSync(directory).filter((file) => file.endsWith(".json")).sort(), [...filenames].sort()) +} +const legacy = join(out, "generated.json") +if (existsSync(legacy)) unlinkSync(legacy) + +// Check the files consumers import, including metadata, order, and the parsed body. +const savedCatalog = JSON.parse(readFileSync(join(out, "catalog.json"), "utf8")) +assert.equal(savedCatalog.length, pages.length) +for (const [index, page] of pages.entries()) { + const savedPage = JSON.parse(readFileSync(join(out, "pages", page.section, `${page.slug}.json`), "utf8")) + const { blocks, ...entry } = savedPage + assert(!("blocks" in savedCatalog[index]), "The catalog must not contain document bodies") + assert(Array.isArray(blocks), `Missing body: ${keys[index]}`) + assert.deepEqual(entry, savedCatalog[index]) + assert.deepEqual(savedPage, JSON.parse(JSON.stringify(page))) +} +console.log(`docs: ${pages.length} pages -> src/docs/catalog.json + src/docs/pages/ (consistency checked)`) diff --git a/scripts/build-registry.mjs b/scripts/build-registry.mjs index fa362b8..ff226aa 100644 --- a/scripts/build-registry.mjs +++ b/scripts/build-registry.mjs @@ -108,7 +108,8 @@ const registry = { writeFileSync(join(root, "registry.json"), `${JSON.stringify(registry, null, 2)}\n`) rmSync(out, { recursive: true, force: true }) -execFileSync("npx", ["shadcn", "build", "registry.json", "--output", "public/r"], { cwd: root, stdio: ["ignore", "ignore", "inherit"] }) +// Run the installed CLI through Node: Windows cannot execute npx.cmd directly. +execFileSync(process.execPath, [join(root, "node_modules/shadcn/dist/index.js"), "build", "registry.json", "--output", "public/r"], { cwd: root, stdio: ["ignore", "ignore", "inherit"] }) // What the site serves names Solid Glass's own by the site's address instead (see the top). for (const file of readdirSync(out).filter((file) => file.endsWith(".json"))) { const path = join(out, file) diff --git a/src/App.tsx b/src/App.tsx index 9be1386..3d75d39 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,18 +4,38 @@ import { Navigate, Route, Routes, useLocation } from "react-router-dom" import { Toaster } from "@/components/ui/sonner" import { Toaster as ToastToaster } from "@/components/ui/toast" import { TooltipProvider } from "@/components/ui/tooltip" -import { Home } from "@/pages/home" import { DevBanner } from "@/pages/dev-banner" -import { DevExamples } from "@/pages/dev-examples" -import { ComponentsIndex, DocPage, DocsLayout } from "@/site/docs" +import { ComponentsIndex, DocErrorBoundary, DocLoading, DocsLayout } from "@/site/docs-layout" import { SiteFooter } from "@/site/site-footer" import { SiteHeader } from "@/site/site-header" // The preview on the Create page is a few dozen cards and every icon by name: fetched when the page is. const Create = React.lazy(() => import("@/pages/create")) +const Home = React.lazy(() => import("@/pages/home").then((page) => ({ default: page.Home }))) +const DocPage = React.lazy(() => import("@/site/docs").then((page) => ({ default: page.DocPage }))) +const DevExamples = import.meta.env.DEV + ? React.lazy(() => import("@/pages/dev-examples").then((page) => ({ default: page.DevExamples }))) + : null // The blocks carry their own source to show it: fetched with the Blocks page, not with every page. const Blocks = React.lazy(() => import("@/pages/blocks").then((page) => ({ default: page.Blocks }))) -const BlockView = React.lazy(() => import("@/pages/blocks").then((page) => ({ default: page.BlockView }))) +const BlockView = React.lazy(() => import("@/pages/block-view")) + +const loading = ( +
+ Loading… +
+) + +function DocRoute({ section }: { section: "docs" | "components" }) { + const { pathname } = useLocation() + return ( + + }> + + + + ) +} /** A new page starts at its top, unless the link was to a place on it. */ function ScrollToTop() { @@ -38,7 +58,7 @@ export function App() { + } @@ -55,7 +75,14 @@ export function App() {
- } /> + + + + } + /> }> - } /> + } /> } /> - } /> - } /> + } /> + } /> - {import.meta.env.DEV && } />} + {DevExamples && ( + + + + } + /> + )} } />
diff --git a/src/pages/block-view.tsx b/src/pages/block-view.tsx new file mode 100644 index 0000000..c1a245f --- /dev/null +++ b/src/pages/block-view.tsx @@ -0,0 +1,32 @@ +import * as React from "react" +import { useParams } from "react-router-dom" + +import { blocks } from "@/blocks" +import { Toaster } from "@/components/ui/sonner" + +const base = import.meta.env.BASE_URL.replace(/\/$/, "") + +/** A block by itself, filling the window: what the frame on the Blocks page shows. */ +export default function BlockView() { + const { name } = useParams() + const block = blocks.find((entry) => entry.name === name) + if (!block) return null + return ( + // shadcn/ui's blocks ask for their pictures from the root of the site ("/placeholder.svg"). Where + // the site lives under a path, one that was not found there is asked for again under that path. +
{ + const image = event.target + if (!(image instanceof HTMLImageElement) || image.dataset.retried) return + image.dataset.retried = "1" + image.src = `${base}/placeholder.svg` + }} + > + Loading preview…
}> + + + + + ) +} diff --git a/src/pages/blocks.tsx b/src/pages/blocks.tsx index 5dd3db1..fa2059d 100644 --- a/src/pages/blocks.tsx +++ b/src/pages/blocks.tsx @@ -1,11 +1,10 @@ import * as React from "react" import { CheckIcon, ExternalLinkIcon, MonitorIcon, RotateCwIcon, SmartphoneIcon, TabletIcon, TerminalIcon } from "lucide-react" -import { Link, useParams } from "react-router-dom" +import { Link } from "react-router-dom" import { cn } from "@/lib/utils" import { blocks, type Block } from "@/blocks" import { Button, buttonVariants } from "@/components/ui/button" -import { Toaster } from "@/components/ui/sonner" import { StatusGlyph } from "@/components/ui/status-glyph" import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard" import { CodeBlock } from "@/site/code" @@ -174,28 +173,3 @@ export function Blocks() { ) } - -/** A block by itself, filling the window: what the frame on the Blocks page shows. */ -export function BlockView() { - const { name } = useParams() - const block = blocks.find((entry) => entry.name === name) - if (!block) return null - return ( - // shadcn/ui's blocks ask for their pictures from the root of the site ("/placeholder.svg"). Where - // the site lives under a path, one that was not found there is asked for again under that path. -
{ - const image = event.target - if (!(image instanceof HTMLImageElement) || image.dataset.retried) return - image.dataset.retried = "1" - image.src = `${base}/placeholder.svg` - }} - > - - - - -
- ) -} diff --git a/src/pages/dev-examples.tsx b/src/pages/dev-examples.tsx index 5bacf5d..5a190e6 100644 --- a/src/pages/dev-examples.tsx +++ b/src/pages/dev-examples.tsx @@ -1,6 +1,9 @@ import * as React from "react" -import { pages } from "@/site/pages" +import type { Page } from "@/site/pages" + +// This module is reachable only from the development route. +const pages = Object.values(import.meta.glob("/src/docs/pages/*/*.json", { eager: true, import: "default" })) const modules = import.meta.glob>("/src/examples/*.tsx") diff --git a/src/site/docs-layout.tsx b/src/site/docs-layout.tsx new file mode 100644 index 0000000..5f995f1 --- /dev/null +++ b/src/site/docs-layout.tsx @@ -0,0 +1,193 @@ +import * as React from "react" +import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react" +import { Link, NavLink, Navigate, Outlet, useParams } from "react-router-dom" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" +import { agents, components, docs, ordered, pages, urlOf, type PageEntry } from "@/site/pages" + +/* ---- The column of names down the left ---------------------------------- */ + +function SidebarGroup({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ) +} + +function SidebarLink({ to, end, children }: { to: string; end?: boolean; children: React.ReactNode }) { + return ( + + cn( + "relative flex h-[30px] w-fit items-center rounded-md px-2 text-[0.8rem] font-medium text-foreground/90 outline-none hover:bg-accent focus-visible:bg-accent", + isActive && "glass-chosen text-foreground" + ) + } + > + {children} + + ) +} + +export function DocsLayout() { + return ( +
+
+ +
+ +
+
+
+ ) +} + +function Neighbours({ url, labelled }: { url: string; labelled?: boolean }) { + const at = ordered.findIndex((entry) => entry.url === url) + const [previous, next] = [ordered[at - 1], ordered[at + 1]] + const cls = labelled ? buttonVariants({ variant: "secondary", size: "sm" }) : cn(buttonVariants({ variant: "secondary", size: "icon" }), "size-7") + return ( + <> + {previous && ( + + {labelled ? previous.title : Previous} + + )} + {next && ( + + {labelled ? next.title : Next} + + )} + + ) +} + +export function DocShell({ title, description, url, toc, children }: { title: string; description: string; url: string; toc?: React.ReactNode; children: React.ReactNode }) { + return ( +
+
+
+
+
+
+

{title}

+
+ +
+
+ {description &&

{description}

} +
+
{children}
+
+ +
+
+
+
+
+
{toc}
+
+
+ ) +} + +export type DocSection = "docs" | "components" + +export function useDocEntry(section: DocSection) { + const { slug = "introduction" } = useParams() + return pages.find((entry) => entry.slug === slug && (section === "docs" ? entry.section === "docs" : entry.section !== "docs")) +} + +export function DocLoading({ section, failed = false }: { section: DocSection; failed?: boolean }) { + const page = useDocEntry(section) + if (!page) return + return ( + +
+ {failed ? ( + <> +

Unable to load this document. Please reload to try again.

+ + + ) : "Loading documentation…"} +
+
+ ) +} + +export class DocErrorBoundary extends React.Component<{ section: DocSection; resetKey: string; children: React.ReactNode }, { failed: boolean; resetKey: string }> { + state = { failed: false, resetKey: this.props.resetKey } + static getDerivedStateFromProps(props: { resetKey: string }, state: { resetKey: string }) { + return props.resetKey !== state.resetKey ? { failed: false, resetKey: props.resetKey } : null + } + static getDerivedStateFromError() { + return { failed: true } + } + render() { + return this.state.failed ? : this.props.children + } +} + +function ComponentGrid({ list }: { list: PageEntry[] }) { + return ( +
+ {list.map((page) => ( + + {page.title} + + ))} +
+ ) +} + +export function ComponentsIndex() { + return ( + +

+ All Components +

+ +

+ Agents +

+

A few more, for interfaces where an agent works while someone watches.

+ +
+ ) +} diff --git a/src/site/docs.tsx b/src/site/docs.tsx index 1bb3335..0ed73d2 100644 --- a/src/site/docs.tsx +++ b/src/site/docs.tsx @@ -1,84 +1,16 @@ import * as React from "react" -import { ArrowLeftIcon, ArrowRightIcon, TerminalIcon } from "lucide-react" +import { TerminalIcon } from "lucide-react" import Markdown from "react-markdown" -import { Link, NavLink, Navigate, Outlet, useLocation, useParams } from "react-router-dom" +import { Link, Navigate, useLocation } from "react-router-dom" import remarkGfm from "remark-gfm" import { cn } from "@/lib/utils" -import { buttonVariants } from "@/components/ui/button" import { CodeBlock, CopyButton, highlight } from "@/site/code" import { ComponentPreview, ComponentSource } from "@/site/component-preview" -import { agents, components, docs, ordered, pages, resolveHref, urlOf, type Block, type Page } from "@/site/pages" +import { DocLoading, DocShell, useDocEntry, type DocSection } from "@/site/docs-layout" +import { resolveHref, urlOf, type Block, type Page } from "@/site/pages" import { WIDGETS } from "@/site/widgets" -/* ---- The column of names down the left ---------------------------------- */ - -function SidebarGroup({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
-
{label}
-
{children}
-
- ) -} - -function SidebarLink({ to, end, children }: { to: string; end?: boolean; children: React.ReactNode }) { - return ( - - cn( - "relative flex h-[30px] w-fit items-center rounded-md px-2 text-[0.8rem] font-medium text-foreground/90 outline-none hover:bg-accent focus-visible:bg-accent", - isActive && "glass-chosen text-foreground" - ) - } - > - {children} - - ) -} - -export function DocsLayout() { - return ( -
-
- -
- -
-
-
- ) -} - /* ---- What a page is made of ---------------------------------------------- */ const inlineCode = "relative rounded-md bg-muted px-[0.3rem] py-[0.2rem] font-mono text-[0.8rem] break-words outline-none" @@ -306,64 +238,20 @@ function TableOfContents({ page }: { page: Page }) { ) } -/* ---- A page -------------------------------------------------------------- */ - -function Neighbours({ url, labelled }: { url: string; labelled?: boolean }) { - const at = ordered.findIndex((entry) => entry.url === url) - const [previous, next] = [ordered[at - 1], ordered[at + 1]] - const cls = labelled ? buttonVariants({ variant: "secondary", size: "sm" }) : cn(buttonVariants({ variant: "secondary", size: "icon" }), "size-7") - return ( - <> - {previous && ( - - {labelled ? previous.title : Previous} - - )} - {next && ( - - {labelled ? next.title : Next} - - )} - - ) -} - -function DocShell({ title, description, url, toc, children }: { title: string; description: string; url: string; toc?: React.ReactNode; children: React.ReactNode }) { - return ( -
-
-
-
-
-
-

{title}

-
- -
-
- {description &&

{description}

} -
-
{children}
-
- -
-
-
-
-
-
{toc}
-
-
- ) -} +// React owns each lazy import's lifetime; only the selected section/slug body is requested. +const bodies = Object.fromEntries( + Object.entries(import.meta.glob("/src/docs/pages/*/*.json", { import: "default" })).map(([path, load]) => [ + path, + React.lazy(async () => { + const page = await load() + return { default: () => } + }), + ]) +) -export function DocPage({ section }: { section: "docs" | "components" }) { - const { slug = "introduction" } = useParams() +function DocContent({ page }: { page: Page }) { const { hash, key } = useLocation() - const page = pages.find((entry) => entry.slug === slug && (section === "docs" ? entry.section === "docs" : entry.section !== "docs")) - const manager = React.useState("pnpm") - - // A link to a heading lands on it once the page is there. + // The target exists only after the requested body has committed, including on history navigation. React.useEffect(() => { if (!hash) return let id = hash.slice(1) @@ -375,45 +263,27 @@ export function DocPage({ section }: { section: "docs" | "components" }) { document.getElementById(id)?.scrollIntoView() }, [hash, key, page]) - if (!page) return return ( - // Keyed by the page, so one page's open code and running demos do not carry into the next. - - }> - - - + }> + + ) } -function ComponentGrid({ list }: { list: Page[] }) { - return ( -
- {list.map((page) => ( - - {page.title} - - ))} -
- ) -} +export function DocPage({ section }: { section: DocSection }) { + const page = useDocEntry(section) + const manager = React.useState("pnpm") + if (!page) return + const path = `/src/docs/pages/${page.section}/${page.slug}.json` + const Body = bodies[path] + if (!Body) throw new Error(`Missing document body: ${path}`) -export function ComponentsIndex() { return ( - -

- All Components -

- -

- Agents -

-

A few more, for interfaces where an agent works while someone watches.

- -
+ + {/* A new boundary immediately replaces the old body while another document loads. */} + }> + + + ) } diff --git a/src/site/pages.ts b/src/site/pages.ts index ea99da4..454a1aa 100644 --- a/src/site/pages.ts +++ b/src/site/pages.ts @@ -1,4 +1,4 @@ -import generated from "@/docs/generated.json" +import catalog from "@/docs/catalog.json" export type Block = | { type: "md"; text: string } @@ -11,7 +11,7 @@ export type Block = | { type: "callout"; text: string } | { type: "step"; text: string } -export type Page = { +export type PageEntry = { slug: string /** docs: the pages about Solid Glass itself. solid: its own components. components: shadcn/ui's. */ section: "docs" | "solid" | "components" @@ -19,10 +19,14 @@ export type Page = { group?: string title: string description: string + order: number +} + +export type Page = PageEntry & { blocks: Block[] } -export const pages = generated as Page[] +export const pages = catalog as PageEntry[] export const docs = pages.filter((page) => page.section === "docs") /** For interfaces where an agent works: listed apart, after the general components. */ export const agents = pages.filter((page) => page.section === "solid" && page.group === "agents") @@ -31,7 +35,7 @@ export const components = pages .filter((page) => page.section === "components" || (page.section === "solid" && page.group !== "agents")) .sort((a, b) => a.title.localeCompare(b.title)) -export const urlOf = (page: Page) => +export const urlOf = (page: PageEntry) => page.section === "docs" ? (page.slug === "introduction" ? "/docs" : `/docs/${page.slug}`) : `/docs/components/${page.slug}` /** In the order the sidebar lists them, with the list of all components between the docs and the components. */ From 4242e7706af4dd903003c79c8fe9bbebf9531ebf Mon Sep 17 00:00:00 2001 From: danwood <118035379+Floating-Y@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:36:09 +0800 Subject: [PATCH 2/2] Load document previews only when they approach the viewport Signed-off-by: danwood <118035379+Floating-Y@users.noreply.github.com> --- scripts/check-component-preview.html | 141 +++++++++++++++++++++++++++ src/site/component-preview.tsx | 61 ++++++++++-- src/site/docs.tsx | 1 + 3 files changed, 194 insertions(+), 9 deletions(-) create mode 100644 scripts/check-component-preview.html diff --git a/scripts/check-component-preview.html b/scripts/check-component-preview.html new file mode 100644 index 0000000..78e80bd --- /dev/null +++ b/scripts/check-component-preview.html @@ -0,0 +1,141 @@ + + + + + + ComponentPreview behavior check + + +
+

ComponentPreview behavior check

+
Running…
+
+
+ + + diff --git a/src/site/component-preview.tsx b/src/site/component-preview.tsx index a408155..7443335 100644 --- a/src/site/component-preview.tsx +++ b/src/site/component-preview.tsx @@ -31,14 +31,22 @@ function exampleOf(name: string) { /** The text of a file, once it has been fetched. */ function useSource(load: (() => Promise) | undefined) { const [text, setText] = React.useState(null) + const [error, setError] = React.useState(null) React.useEffect(() => { let live = true setText(null) - void load?.().then((value) => live && setText(value)) + setError(null) + void load?.().then( + (value) => live && setText(value), + (reason: unknown) => { + if (live) setError(reason instanceof Error ? reason : new Error(String(reason))) + } + ) return () => { live = false } }, [load]) + if (error) throw error return text } @@ -61,14 +69,43 @@ export function ComponentPreview({ wrapperClassName?: string hideCode?: boolean }) { - const Example = exampleOf(name) - const source = useSource(sources[`/src/examples/${name}.tsx`]) + const previewRef = React.useRef(null) + const [active, setActive] = React.useState(false) + const Example = active ? exampleOf(name) : null + const sourceLoader = sources[`/src/examples/${name}.tsx`] + const source = useSource(active && !hideCode ? sourceLoader : undefined) const [open, setOpen] = React.useState(false) const code = source ?? "" + const loading = ( + // The borderless interactive chart has no minimum height on narrow screens. + + + Loading example… + + ) + + React.useEffect(() => { + if (active) return + if (typeof IntersectionObserver === "undefined") { + setActive(true) + return + } + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setActive(true) + observer.disconnect() + } + }, + { rootMargin: "300px" } + ) + if (previewRef.current) observer.observe(previewRef.current) + return () => observer.disconnect() + }, [active]) return (
-
+
- }> - {Example ? :

No example named {name}.

} -
+ {active ? ( + + {Example ? :

No example named {name}.

} +
+ ) : loading}
- {!hideCode && code && ( + {!hideCode && (
- {open ? ( + {active && !sourceLoader ? ( +

No source named {name}.

+ ) : source === null ? ( +
Loading source…
+ ) : open ? ( ) : (
diff --git a/src/site/docs.tsx b/src/site/docs.tsx index 0ed73d2..4882b1b 100644 --- a/src/site/docs.tsx +++ b/src/site/docs.tsx @@ -206,6 +206,7 @@ function TableOfContents({ page }: { page: Page }) { const [active, setActive] = React.useState(null) React.useEffect(() => { + if (typeof IntersectionObserver === "undefined") return const observer = new IntersectionObserver( (entries) => { for (const entry of entries) if (entry.isIntersecting) setActive(entry.target.id)