From e4889da9cfdd4535748d055c070276b9f92c540c Mon Sep 17 00:00:00 2001 From: danwood <118035379+Floating-Y@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:07:08 +0800 Subject: [PATCH] Preserve installation steps so converted documentation remains usable. Signed-off-by: danwood <118035379+Floating-Y@users.noreply.github.com> --- .github/workflows/checks.yml | 2 +- scripts/build-docs.mjs | 62 +++++++++++++++++++++---------- scripts/docs-install.test.mjs | 69 +++++++++++++++++++++++++++++++++++ src/site/docs.tsx | 25 +++++-------- src/site/pages.ts | 3 +- 5 files changed, 123 insertions(+), 38 deletions(-) create mode 100644 scripts/docs-install.test.mjs diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 39def38..bd1f76f 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -26,7 +26,7 @@ jobs: node-version: 22 cache: npm - run: npm ci - - run: node --test scripts/block-metadata.test.mjs + - run: node --test scripts/block-metadata.test.mjs scripts/docs-install.test.mjs # the documentation and the registry are generated: the types of what renders them depend on them - run: npm run docs - name: The registry is as committed diff --git a/scripts/build-docs.mjs b/scripts/build-docs.mjs index 285dac5..c7a512e 100644 --- a/scripts/build-docs.mjs +++ b/scripts/build-docs.mjs @@ -25,17 +25,8 @@ const slug = (text) => // The whole name: "className" must not be found inside "previewClassName". const attr = (tag, name) => tag.match(new RegExp(`(?:^|[\\s<])${name}="([^"]*)"`))?.[1] -function parse(file, section) { - const raw = readFileSync(file, "utf8") - const front = raw.match(/^---\n([\s\S]*?)\n---\n/) - const meta = Object.fromEntries( - (front?.[1] ?? "") - .split("\n") - .map((line) => line.match(/^(\w+):\s*(.*)$/)) - .filter(Boolean) - .map(([, key, value]) => [key, value.replace(/^["']|["']$/g, "")]) - ) - const lines = raw.slice(front?.[0].length ?? 0).split("\n") +function parseBlocks(body, installation = false) { + const lines = body.replace(/\r\n/g, "\n").split("\n") const blocks = [] let text = [] const flush = () => { @@ -81,18 +72,29 @@ function parse(file, section) { flush() while (i < lines.length - 1 && !/from\s+["'][^"']+["'];?\s*$/.test(lines[i]) && lines[i].trim() !== "") i++ } else if (/^/.test(lines[end])) end++ - const inner = lines.slice(i, end).join("\n") - const fences = [...inner.matchAll(/```bash\n([\s\S]*?)```/g)].map((m) => m[1].trim()) - blocks.push({ - type: "install", - cli: fences.find((code) => code.includes("shadcn")), - packages: fences.find((code) => /^(npm|pnpm) (install|add)/.test(code)), - source: attr(inner.match(//)?.[0] ?? "", "name"), - }) + const inner = lines.slice(i + 1, end).join("\n") + const tabs = [...inner.matchAll(/([\s\S]*?)<\/TabsContent>/g)] + if (tabs.length) { + const install = { type: "install", cli: [], manual: [] } + for (const [, tab, body] of tabs) install[tab] = parseBlocks(body, true) + blocks.push(install) + } else { + // Solid's compact form places the CLI first, followed by manual dependencies and source. + const content = parseBlocks(inner) + const cli = content.filter((block) => block.type === "code" && block.code.includes("shadcn")) + const manual = content.filter((block) => !cli.includes(block)) + const steps = [] + for (const block of manual) { + if (block.type === "code") steps.push({ type: "step", text: "Install the following dependencies:" }) + if (block.type === "source") steps.push({ type: "step", text: "Copy and paste the following code into your project." }) + steps.push(block) + } + steps.push({ type: "step", text: "Update the import paths to match your project setup." }) + blocks.push({ type: "install", cli, manual: [{ type: "steps", blocks: steps }] }) + } i = end } else if (/^]*>/g, "").trim() if (body) blocks.push({ type: "callout", text: body }) i = end + } else if (installation && /^)/.test(line)) { + flush() + let end = i + 1 + while (end < lines.length && !/^<\/Steps>/.test(lines[end])) end++ + blocks.push({ type: "steps", blocks: parseBlocks(lines.slice(i + 1, end).join("\n"), true) }) + i = end } else if (/^/.test(line)) { flush() blocks.push({ type: "step", text: line.replace(/<\/?Step>/g, "").trim() }) @@ -148,6 +156,20 @@ function parse(file, section) { } flush() + return blocks +} + +function parse(file, section) { + const raw = readFileSync(file, "utf8") + const front = raw.match(/^---\n([\s\S]*?)\n---\n/) + const meta = Object.fromEntries( + (front?.[1] ?? "") + .split("\n") + .map((line) => line.match(/^(\w+):\s*(.*)$/)) + .filter(Boolean) + .map(([, key, value]) => [key, value.replace(/^["']|["']$/g, "")]) + ) + const blocks = parseBlocks(raw.slice(front?.[0].length ?? 0)) // What cannot be shown here goes, heading and all: right-to-left examples need the site's // language switcher, and a heading whose previews were all dropped has nothing under it. const kept = [] diff --git a/scripts/docs-install.test.mjs b/scripts/docs-install.test.mjs new file mode 100644 index 0000000..8acff4d --- /dev/null +++ b/scripts/docs-install.test.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict" +import { readFileSync } from "node:fs" +import test from "node:test" +import { execFileSync } from "node:child_process" +import { fileURLToPath } from "node:url" +execFileSync(process.execPath, [fileURLToPath(new URL("./build-docs.mjs", import.meta.url))]) + +const step = (text) => ({ type: "step", text }) +const code = (code, lang = "bash", title) => ({ type: "code", lang, ...(title ? { title } : {}), code }) +const source = (name) => ({ type: "source", name, title: `components/ui/${name}.tsx` }) +const copy = step("Copy and paste the following code into your project.") +const imports = step("Update the import paths to match your project setup.") +function installation(name, section = "components") { + const { blocks } = JSON.parse(readFileSync(new URL(`../src/docs/pages/${section}/${name}.json`, import.meta.url), "utf8")) + const block = blocks.find((block) => block.type === "install") + assert(Array.isArray(block.cli), "Command must preserve an ordered list of installation blocks") + assert(Array.isArray(block.manual), "Manual must preserve its own ordered list of installation blocks") + const flatten = (blocks) => blocks.flatMap((item) => item.type === "steps" ? flatten(item.blocks) : [item]) + return { cli: flatten(block.cli), manual: flatten(block.manual) } +} + +for (const name of ["attachment", "calendar", "button"]) { + test(`${name} preserves command and manual instructions in their original order`, () => { + const block = installation(name) + assert.deepEqual(block.cli, [code(`npx shadcn@latest add ${name}`)]) + const dependencies = { + attachment: [ + step("Install the required shadcn/ui dependencies:"), + code("npx shadcn@latest add button"), + ], + calendar: [ + step("Install the following dependencies:"), + code("npm install react-day-picker date-fns"), + step("Add the `Button` component to your project."), + { type: "md", text: "The `Calendar` component uses the `Button` component. Make sure you have it installed in your project." }, + ], + button: [ + step("Install the following dependencies:"), + code("npm install @base-ui/react"), + ], + }[name] + assert.deepEqual(block.manual, [...dependencies, copy, source(name), imports]) + }) +} + +test("Toast preserves each tab's distinct layout code, language and filename", () => { + const block = installation("toast") + const layout = (children) => code(`import { Toaster } from "@/components/ui/toast" + +export default function RootLayout({ children }) { + return ( + + + ${children} + + + + ) +}`, "tsx", "app/layout.tsx") + const mount = step("Add the Toaster component.") + assert.deepEqual(block.cli, [step("Run the following command:"), code("npx shadcn@latest add toast"), mount, layout("
{children}
")]) + assert.deepEqual(block.manual, [step("Install the following dependency:"), code("npm install @base-ui/react"), copy, source("toast"), imports, mount, layout("{children}")]) +}) + +test("compact Solid Glass installation keeps its existing manual fallback", () => { + const block = installation("ask-card", "solid") + assert.deepEqual(block.cli, [code("npx shadcn@latest add deeplethe/solid-glass/ask-card")]) + assert.deepEqual(block.manual, [copy, { type: "source", name: "ask-card" }, imports]) +}) diff --git a/src/site/docs.tsx b/src/site/docs.tsx index 6c54c84..1fa2096 100644 --- a/src/site/docs.tsx +++ b/src/site/docs.tsx @@ -129,26 +129,12 @@ function Install({ block }: { block: Extract }) { ))} - {tab === "cli" ? ( - block.cli && - ) : ( -
- {block.packages && ( - <> - Install the following dependencies: - - - )} - Copy and paste the following code into your project. - {block.source && } - Update the import paths to match your project setup. -
- )} + ) } -function Blocks({ blocks }: { blocks: Block[] }) { +function Blocks({ blocks, numbered = false }: { blocks: Block[]; numbered?: boolean }) { return ( <> {blocks.map((block, index) => { @@ -187,7 +173,14 @@ function Blocks({ blocks }: { blocks: Block[] }) { {block.text} ) + case "steps": + return ( +
+ +
+ ) case "step": + if (numbered) return {block.text} return (

{block.text} diff --git a/src/site/pages.ts b/src/site/pages.ts index 454a1aa..08bfd0f 100644 --- a/src/site/pages.ts +++ b/src/site/pages.ts @@ -5,7 +5,8 @@ export type Block = | { type: "heading"; depth: number; text: string; id: string } | { type: "code"; lang: string; title?: string; code: string } | { type: "preview"; name: string; align?: string; className?: string; wrapperClassName?: string; hideCode?: boolean } - | { type: "install"; cli?: string; packages?: string; source?: string } + | { type: "install"; cli: Block[]; manual: Block[] } + | { type: "steps"; blocks: Block[] } | { type: "source"; name?: string; title?: string } | { type: "widget"; name?: string } | { type: "callout"; text: string }