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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ dist
*.tsbuildinfo
.DS_Store
public/r
src/docs/generated.json
src/docs/catalog.json
src/docs/pages/
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ npm run typecheck
npm run build
```

`npm run docs` generates `src/docs/catalog.json` (metadata only) and `src/docs/pages/<section>/<slug>.json` (one complete page per file). These files are ignored by Git; edit `content/` instead. Generation checks page keys and slugs and removes deleted pages.

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`.
Expand Down
2 changes: 2 additions & 0 deletions CONTRIBUTING.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ npm run typecheck
npm run build
```

`npm run docs` 生成 `src/docs/catalog.json`(只有目录信息)和 `src/docs/pages/<section>/<slug>.json`(每篇完整文档一个文件)。这些文件已被 Git 忽略,请修改 `content/` 中的源文件。生成过程会检查页面键与 slug,并清理已删除的页面。

这里没有单元测试:这个仓库产出的是"看起来怎样"。所以改动靠看来验证,PR 里要写明看了什么:

- `/dev/examples` 显示 `0 failed`。
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<section>/<slug>.json` bodies loaded on demand. |

## Run

Expand Down
2 changes: 1 addition & 1 deletion content/components/chart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ component: true
<ComponentPreview
styleName="base-nova"
name="chart-demo"
className="theme-blue [&_.preview]:h-auto [&_.preview]:p-0 [&_.preview]:lg:min-h-[404px] [&_.preview>div]:w-full [&_.preview>div]:border-none [&_.preview>div]:shadow-none"
className="theme-blue [&_.preview]:h-auto [&_.preview]:p-0 [&_.preview]:min-h-[404px] [&_.preview>div]:w-full [&_.preview>div]:border-none [&_.preview>div]:shadow-none"
hideCode
/>

Expand Down
31 changes: 26 additions & 5 deletions scripts/build-docs.mjs
Original file line number Diff line number Diff line change
@@ -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
*
Expand All @@ -9,7 +9,8 @@
* <CodeTabs> block that says how to install, <Callout>, <Step>, and <Widget name="…" /> 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 assert from "node:assert/strict"
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"

Expand Down Expand Up @@ -190,6 +191,26 @@ 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))
}
const legacy = join(out, "generated.json")
if (existsSync(legacy)) unlinkSync(legacy)

console.log(`docs: ${pages.length} pages -> src/docs/catalog.json + src/docs/pages/`)
56 changes: 46 additions & 10 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
<div role="status" className="flex min-h-[50svh] flex-1 items-center justify-center text-sm text-muted-foreground">
Loading…
</div>
)

function DocRoute({ section }: { section: "docs" | "components" }) {
const { pathname } = useLocation()
return (
<DocErrorBoundary resetKey={pathname} section={section}>
<React.Suspense fallback={<DocLoading section={section} />}>
<DocPage section={section} />
</React.Suspense>
</DocErrorBoundary>
)
}

/** A new page starts at its top, unless the link was to a place on it. */
function ScrollToTop() {
Expand All @@ -38,7 +58,7 @@ export function App() {
<Route
path="view/:name"
element={
<React.Suspense fallback={null}>
<React.Suspense fallback={loading}>
<BlockView />
</React.Suspense>
}
Expand All @@ -55,7 +75,14 @@ export function App() {
<SiteHeader />
<main className="flex min-h-0 flex-1 flex-col">
<Routes>
<Route index element={<Home />} />
<Route
index
element={
<React.Suspense fallback={loading}>
<Home />
</React.Suspense>
}
/>
<Route
path="create"
element={
Expand All @@ -73,12 +100,21 @@ export function App() {
}
/>
<Route path="docs" element={<DocsLayout />}>
<Route index element={<DocPage section="docs" />} />
<Route index element={<DocRoute section="docs" />} />
<Route path="components" element={<ComponentsIndex />} />
<Route path="components/:slug" element={<DocPage section="components" />} />
<Route path=":slug" element={<DocPage section="docs" />} />
<Route path="components/:slug" element={<DocRoute section="components" />} />
<Route path=":slug" element={<DocRoute section="docs" />} />
</Route>
{import.meta.env.DEV && <Route path="dev/examples" element={<DevExamples />} />}
{DevExamples && (
<Route
path="dev/examples"
element={
<React.Suspense fallback={loading}>
<DevExamples />
</React.Suspense>
}
/>
)}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
Expand Down
32 changes: 32 additions & 0 deletions src/pages/block-view.tsx
Original file line number Diff line number Diff line change
@@ -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.
<div
className="contents"
onErrorCapture={(event) => {
const image = event.target
if (!(image instanceof HTMLImageElement) || image.dataset.retried) return
image.dataset.retried = "1"
image.src = `${base}/placeholder.svg`
}}
>
<React.Suspense fallback={<div role="status" className="flex min-h-svh items-center justify-center text-sm text-muted-foreground">Loading preview…</div>}>
<block.component />
</React.Suspense>
<Toaster />
</div>
)
}
28 changes: 1 addition & 27 deletions src/pages/blocks.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -174,28 +173,3 @@ export function Blocks() {
</div>
)
}

/** 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.
<div
className="contents"
onErrorCapture={(event) => {
const image = event.target
if (!(image instanceof HTMLImageElement) || image.dataset.retried) return
image.dataset.retried = "1"
image.src = `${base}/placeholder.svg`
}}
>
<React.Suspense fallback={null}>
<block.component />
</React.Suspense>
<Toaster />
</div>
)
}
5 changes: 4 additions & 1 deletion src/pages/dev-examples.tsx
Original file line number Diff line number Diff line change
@@ -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<Page>("/src/docs/pages/*/*.json", { eager: true, import: "default" }))

const modules = import.meta.glob<Record<string, unknown>>("/src/examples/*.tsx")

Expand Down
66 changes: 55 additions & 11 deletions src/site/component-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,22 @@ function exampleOf(name: string) {
/** The text of a file, once it has been fetched. */
function useSource(load: (() => Promise<string>) | undefined) {
const [text, setText] = React.useState<string | null>(null)
const [error, setError] = React.useState<Error | null>(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])
return text
return { text, error }
}

/**
Expand All @@ -61,29 +68,65 @@ export function ComponentPreview({
wrapperClassName?: string
hideCode?: boolean
}) {
const Example = exampleOf(name)
const source = useSource(sources[`/src/examples/${name}.tsx`])
const previewRef = React.useRef<HTMLDivElement>(null)
const [active, setActive] = React.useState(false)
const Example = active ? exampleOf(name) : null
const sourceLoader = sources[`/src/examples/${name}.tsx`]
const { text: source, error: sourceError } = useSource(active && !hideCode ? sourceLoader : undefined)
const [open, setOpen] = React.useState(false)
const code = source ?? ""
const loading = (
<span role="status" className="flex h-52 max-h-full items-center justify-center text-muted-foreground">
<Spinner />
<span className="sr-only">Loading example…</span>
</span>
)

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 (
<div data-slot="component-preview" className={cn("group relative mt-4 mb-12 flex flex-col overflow-hidden rounded-2xl border", wrapperClassName)}>
<div data-slot="preview">
<div ref={previewRef} data-slot="preview">
<div
data-align={align}
className={cn(
"preview relative flex h-72 w-full justify-center p-10 data-[align=center]:items-center data-[align=end]:items-end data-[align=start]:items-start",
className
)}
>
<React.Suspense fallback={<Spinner className="text-muted-foreground" />}>
{Example ? <Example /> : <p className="text-sm text-muted-foreground">No example named {name}.</p>}
</React.Suspense>
{active ? (
<React.Suspense fallback={loading}>
{Example ? <Example /> : <p className="text-sm text-muted-foreground">No example named {name}.</p>}
</React.Suspense>
) : loading}
</div>
</div>
{!hideCode && code && (
{!hideCode && (
<div data-slot="code" className="relative overflow-hidden border-t">
{open ? (
{active && !sourceLoader ? (
<p role="alert" className="p-4 text-sm text-destructive">No source named {name}.</p>
) : sourceError ? (
<p role="alert" className="p-4 text-sm text-destructive">Unable to load this source. Please reload to try again.</p>
) : source === null ? (
<div role="status" className="flex h-23 items-center justify-center text-sm text-muted-foreground">Loading source…</div>
) : open ? (
<CodeBlock code={code} className="my-0 rounded-none" />
) : (
<div className="relative">
Expand All @@ -104,7 +147,8 @@ export function ComponentPreview({

/** A component's own source, as it is installed. */
export function ComponentSource({ name, title }: { name: string; title?: string }) {
const source = useSource(uiSources[`/src/components/ui/${name}.tsx`])
const { text: source, error } = useSource(uiSources[`/src/components/ui/${name}.tsx`])
if (error) return <p role="alert" className="my-4 text-sm text-destructive">Unable to load this source. Please reload to try again.</p>
if (!source) return null
return <CodeBlock code={source} title={title ?? `components/ui/${name}.tsx`} />
}
Loading
Loading