Skip to content
Open
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 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`.
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/` 中的源文件。生成过程会清理已删除的页面,并断言目录与逐篇文件的顺序、元数据和解析后的正文一致。

这里没有单元测试:这个仓库产出的是"看起来怎样"。所以改动靠看来验证,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
49 changes: 41 additions & 8 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,8 +9,9 @@
* <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 { 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)), "..")
Expand All @@ -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] ?? "")
Expand Down Expand Up @@ -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 }
}

Expand All @@ -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)`)
3 changes: 2 additions & 1 deletion scripts/build-registry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
141 changes: 141 additions & 0 deletions scripts/check-component-preview.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ComponentPreview behavior check</title>
</head>
<body>
<main class="mx-auto max-w-3xl p-6">
<h1 class="text-xl font-medium">ComponentPreview behavior check</h1>
<pre id="results" role="status" class="my-4 whitespace-pre-wrap">Running…</pre>
<div id="preview"></div>
</main>
<script type="module">
import * as React from "react"
import { createRoot } from "react-dom/client"
import "/src/index.css"

const output = document.getElementById("results")
const host = document.getElementById("preview")
const originalObserver = window.IntersectionObserver
const originalActEnvironment = globalThis.IS_REACT_ACT_ENVIRONMENT
const observers = []
const passed = []
let root
globalThis.IS_REACT_ACT_ENVIRONMENT = true

class ManualObserver {
constructor(callback, options) {
this.callback = callback
this.options = options
this.disconnected = false
observers.push(this)
}
observe(target) { this.target = target }
disconnect() { this.disconnected = true }
// A queued browser callback can still arrive after disconnect().
fire(isIntersecting) { this.callback([{ target: this.target, isIntersecting }], this) }
}

function check(condition, message) {
if (!condition) throw new Error(message)
passed.push(`PASS ${message}`)
output.textContent = passed.join("\n")
}

function requests(name) {
return performance.getEntriesByType("resource").map((entry) => new URL(entry.name))
.filter((url) => url.pathname === `/src/examples/${name}.tsx`)
}

async function until(predicate, message) {
const deadline = performance.now() + 15000
while (!predicate()) {
if (performance.now() > deadline) throw new Error(`Timed out: ${message}`)
await React.act(() => new Promise((resolve) => setTimeout(resolve, 25)))
}
}

try {
window.IntersectionObserver = ManualObserver
const { ComponentPreview } = await import("/src/site/component-preview.tsx")
async function mount(name, hideCode = false) {
root = createRoot(host)
await React.act(async () => {
root.render(React.createElement(React.StrictMode, null,
React.createElement(ComponentPreview, { name, hideCode })))
})
}

await mount("input-basic")
await React.act(() => new Promise(requestAnimationFrame))
check(!host.querySelector("input") && requests("input-basic").length === 0,
"before activation: no example, no example or raw-source requests")
check(observers.length === 2 && observers[0].disconnected,
"StrictMode cleans up its first observer")
check(observers.every((observer) => observer.options.rootMargin === "300px"),
"previews use a 300px root margin")
const observer = observers.at(-1)
await React.act(async () => observer.fire(false))
check(!host.querySelector("input") && requests("input-basic").length === 0,
"a non-intersecting callback does not activate the preview")

await React.act(async () => observer.fire(true))
await until(() => host.querySelector("input") &&
[...host.querySelectorAll("button")].some((button) => button.textContent === "View Code"),
"first activation loads the example and source")
await until(() => requests("input-basic").length >= 2, "example and source resource entries")
check(requests("input-basic").some((url) => url.searchParams.has("raw")) &&
requests("input-basic").some((url) => !url.searchParams.has("raw")),
"first intersection requests both the example and raw source")
const input = host.querySelector("input")
const viewCode = [...host.querySelectorAll("button")].find((button) => button.textContent === "View Code")
await React.act(async () => {
input.value = "keep this input"
input.dispatchEvent(new Event("input", { bubbles: true }))
viewCode.click()
})
const code = host.querySelector('[data-slot="code"] code')
check(code.textContent.includes("return <Input"), "View Code reveals the full source")
await React.act(async () => { observer.fire(false); observer.fire(true) })
check(host.querySelector("input") === input && input.value === "keep this input" &&
host.querySelector('[data-slot="code"] code') === code &&
![...host.querySelectorAll("button")].some((button) => button.textContent === "View Code"),
"after leaving and returning: input node, value and expanded code remain")
await React.act(async () => root.unmount())

await mount("input-demo", true)
check(!host.querySelector("input") && requests("input-demo").length === 0,
"hideCode preview also waits for its first intersection")
await React.act(async () => observers.at(-1).fire(true))
await until(() => host.querySelector("input"), "hideCode example")
await until(() => requests("input-demo").length > 0, "hideCode example resource entry")
check(!host.querySelector('[data-slot="code"]') &&
requests("input-demo").every((url) => !url.searchParams.has("raw")),
"hideCode loads the example without requesting its raw source")
await React.act(async () => root.unmount())
check(observers.every((item) => item.disconnected), "unmount disconnects every observer")

window.IntersectionObserver = undefined
const observerCount = observers.length
await mount("input-invalid")
await until(() => host.querySelector("input") && host.querySelector('[data-slot="code"] code'),
"fallback without IntersectionObserver")
check(observers.length === observerCount, "without IntersectionObserver, preview and source load immediately")
await React.act(async () => root.unmount())
output.dataset.result = "pass"
output.textContent += "\nPASS All ComponentPreview behavior checks completed."
} catch (error) {
output.dataset.result = "fail"
output.textContent = `${passed.join("\n")}\nFAIL ${error.stack ?? error}`
console.error("ComponentPreview behavior check failed", error)
throw error
} finally {
await React.act(async () => root?.unmount())
window.IntersectionObserver = originalObserver
globalThis.IS_REACT_ACT_ENVIRONMENT = originalActEnvironment
}
</script>
</body>
</html>
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>
)
}
Loading
Loading