From 0a5c43b30b42fc53619ac49f99b6d2337f7ce93f Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Thu, 23 Apr 2026 15:47:04 +0200 Subject: [PATCH 1/6] feat(appkit-docs): sync via shallow git clone at build time Replace the manual tarball-based AppKit docs sync with a shallow git clone of the main branch that runs automatically as a prebuild step. Docs are now generated at build time instead of being committed to git. Key changes: - Rewrite sync-appkit-docs.mjs to clone from main with sparse checkout - Output docs to docs/appkit/latest/ (instead of v0/) - Skip with informational log when docs already exist locally - Support future Docusaurus versioned_docs if AppKit adopts versioning - Add prebuild/prestart lifecycle hooks for automatic sync - Update sidebar to discover latest/ and version-* directories - Add generated paths to .gitignore and .prettierignore - Add explicit sync step to CI workflow Co-authored-by: Isaac --- .github/workflows/ci.yml | 3 + .gitignore | 3 + .prettierignore | 7 + CONTRIBUTING.md | 40 +-- package.json | 178 +++++------ scripts/dev.sh | 3 + scripts/sync-appkit-docs.mjs | 574 ++++++++++++++++++----------------- sidebars.ts | 61 ++-- 8 files changed, 460 insertions(+), 409 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc88e6f..486a5c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,9 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Sync AppKit docs + run: node scripts/sync-appkit-docs.mjs + - name: Format check run: bunx prettier -c . diff --git a/.gitignore b/.gitignore index b72f84d..f04d8ae 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ bun.lock .cache-loader static/llms.txt static/raw-docs +docs/appkit/ +src/components/doc-examples/ +static/appkit-preview/ # Misc .DS_Store diff --git a/.prettierignore b/.prettierignore index f11778c..5bad624 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,13 @@ dist/ +build/ .databricks/ +.docusaurus/ **/dist/ **/.databricks/ **/node_modules/ node_modules/ + +# Generated at build time by scripts/sync-appkit-docs.mjs +docs/appkit/ +src/components/doc-examples/ +static/appkit-preview/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2722c16..7e1b51a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,6 +21,8 @@ npm run dev `npm run dev` starts Docusaurus and the Vercel Functions together at [http://localhost:3000](http://localhost:3000). The site reloads on save. +AppKit reference docs are fetched automatically on first build or dev start via a shallow git clone of the [appkit](https://github.com/databricks/appkit) repository. Run `npm run sync:appkit-docs` to force a re-sync. + You'll also need the [Vercel CLI](https://vercel.com/docs/cli) (for `vercel dev`) and the [Databricks CLI](https://dev.databricks.com/docs/tools/databricks-cli) if you plan to verify end-to-end flows against a real workspace. ### Feature Flags @@ -47,7 +49,7 @@ A flag is **enabled only when its value is exactly `"true"`** — any other valu | `npm run verify:images` | Check every image under `static/img/guides/` and `static/img/examples/` matches the 16:9 / ≥1600×900 contract | | `npm run build` | Production build via Docusaurus | | `npm run test` | Build + Vitest + Playwright smoke tests (includes sitemap, robots, llms.txt) | -| `npm run sync:appkit-docs` | Regenerate AppKit reference docs | +| `npm run sync:appkit-docs` | Force re-sync AppKit docs from main (auto-synced on first build) | ### Pre-Commit Hook @@ -174,24 +176,24 @@ Example for an example: ```ts createExample({ - id: "inventory-intelligence", - // ... - previewImageLightUrl: - "/img/examples/inventory-intelligence-dashboard-light.png", - previewImageDarkUrl: - "/img/examples/inventory-intelligence-dashboard-dark.png", - // optional carousel: - galleryImages: [ - { - lightUrl: "/img/examples/inventory-intelligence-dashboard-light.png", - darkUrl: "/img/examples/inventory-intelligence-dashboard-dark.png", - }, - { - lightUrl: - "/img/examples/inventory-intelligence-replenishment-light.png", - darkUrl: "/img/examples/inventory-intelligence-replenishment-dark.png", - }, - ], + id: "inventory-intelligence", + // ... + previewImageLightUrl: + "/img/examples/inventory-intelligence-dashboard-light.png", + previewImageDarkUrl: + "/img/examples/inventory-intelligence-dashboard-dark.png", + // optional carousel: + galleryImages: [ + { + lightUrl: "/img/examples/inventory-intelligence-dashboard-light.png", + darkUrl: "/img/examples/inventory-intelligence-dashboard-dark.png", + }, + { + lightUrl: + "/img/examples/inventory-intelligence-replenishment-light.png", + darkUrl: "/img/examples/inventory-intelligence-replenishment-dark.png", + }, + ], }); ``` diff --git a/package.json b/package.json index 1b30d90..b58b976 100644 --- a/package.json +++ b/package.json @@ -1,90 +1,92 @@ { - "name": "devhub", - "version": "0.0.0", - "private": true, - "scripts": { - "docusaurus": "docusaurus", - "dev": "bash scripts/dev.sh", - "start": "docusaurus start", - "build": "docusaurus build", - "sync:appkit-docs": "node scripts/sync-appkit-docs.mjs", - "swizzle": "docusaurus swizzle", - "deploy": "docusaurus deploy", - "clear": "docusaurus clear", - "serve": "docusaurus serve", - "write-translations": "docusaurus write-translations", - "write-heading-ids": "docusaurus write-heading-ids", - "typecheck": "tsc", - "fmt": "npx prettier -w .", - "test": "EXAMPLES_FEATURE=true npm run build && vitest run && npx playwright test", - "test:smoke": "vitest run", - "test:e2e": "npx playwright test", - "test:docs-verify": "vitest run --config vitest.docs-verify.config.ts", - "verify:images": "node scripts/verify-resource-images.mjs", - "validate:content": "node scripts/validate-content.mjs", - "prepare": "husky" - }, - "dependencies": { - "@base-ui/react": "^1.3.0", - "@databricks/appkit-ui": "^0.22.0", - "@docusaurus/core": "3.9.2", - "@docusaurus/preset-classic": "3.9.2", - "@docusaurus/theme-mermaid": "3.9.2", - "@hookform/resolvers": "^5.2.2", - "@mdx-js/react": "^3.0.0", - "@modelcontextprotocol/sdk": "^1.25.2", - "@vercel/analytics": "^2.0.1", - "@vercel/functions": "^3.4.3", - "class-variance-authority": "^0.7.1", - "clsx": "^2.0.0", - "cmdk": "^1.1.1", - "date-fns": "^4.1.0", - "embla-carousel-react": "^8.6.0", - "input-otp": "^1.4.2", - "lucide-react": "^0.577.0", - "mcp-handler": "^1.0.7", - "next-themes": "^0.4.6", - "prism-react-renderer": "^2.3.0", - "radix-ui": "^1.4.3", - "react": "^19.0.0", - "react-day-picker": "^9.14.0", - "react-dom": "^19.0.0", - "react-hook-form": "^7.71.2", - "react-resizable-panels": "^4", - "recharts": "2.15.4", - "sonner": "^2.0.7", - "tailwind-merge": "^3.5.0", - "vaul": "^1.1.2", - "zod": "^4.3.6" - }, - "devDependencies": { - "@docusaurus/module-type-aliases": "3.9.2", - "@docusaurus/tsconfig": "3.9.2", - "@docusaurus/types": "3.9.2", - "@playwright/test": "^1.58.2", - "@tailwindcss/postcss": "^4.2.2", - "@vercel/node": "^5.6.15", - "husky": "^9.1.7", - "image-size": "^2.0.2", - "postcss": "^8.5.8", - "tailwindcss": "^4.2.2", - "tw-animate-css": "^1.4.0", - "typescript": "~5.6.2", - "vitest": "^4.1.0" - }, - "browserslist": { - "production": [ - ">0.5%", - "not dead", - "not op_mini all" - ], - "development": [ - "last 3 chrome version", - "last 3 firefox version", - "last 5 safari version" - ] - }, - "engines": { - "node": ">=20.0" - } + "name": "devhub", + "version": "0.0.0", + "private": true, + "scripts": { + "docusaurus": "docusaurus", + "dev": "bash scripts/dev.sh", + "start": "docusaurus start", + "build": "docusaurus build", + "sync:appkit-docs": "node scripts/sync-appkit-docs.mjs --force", + "swizzle": "docusaurus swizzle", + "deploy": "docusaurus deploy", + "clear": "docusaurus clear", + "serve": "docusaurus serve", + "write-translations": "docusaurus write-translations", + "write-heading-ids": "docusaurus write-heading-ids", + "typecheck": "tsc", + "fmt": "npx prettier -w .", + "test": "EXAMPLES_FEATURE=true npm run build && vitest run && npx playwright test", + "test:smoke": "vitest run", + "test:e2e": "npx playwright test", + "test:docs-verify": "vitest run --config vitest.docs-verify.config.ts", + "verify:images": "node scripts/verify-resource-images.mjs", + "validate:content": "node scripts/validate-content.mjs", + "prepare": "husky", + "prebuild": "node scripts/sync-appkit-docs.mjs", + "prestart": "node scripts/sync-appkit-docs.mjs" + }, + "dependencies": { + "@base-ui/react": "^1.3.0", + "@databricks/appkit-ui": "^0.22.0", + "@docusaurus/core": "3.9.2", + "@docusaurus/preset-classic": "3.9.2", + "@docusaurus/theme-mermaid": "3.9.2", + "@hookform/resolvers": "^5.2.2", + "@mdx-js/react": "^3.0.0", + "@modelcontextprotocol/sdk": "^1.25.2", + "@vercel/analytics": "^2.0.1", + "@vercel/functions": "^3.4.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.0.0", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "embla-carousel-react": "^8.6.0", + "input-otp": "^1.4.2", + "lucide-react": "^0.577.0", + "mcp-handler": "^1.0.7", + "next-themes": "^0.4.6", + "prism-react-renderer": "^2.3.0", + "radix-ui": "^1.4.3", + "react": "^19.0.0", + "react-day-picker": "^9.14.0", + "react-dom": "^19.0.0", + "react-hook-form": "^7.71.2", + "react-resizable-panels": "^4", + "recharts": "2.15.4", + "sonner": "^2.0.7", + "tailwind-merge": "^3.5.0", + "vaul": "^1.1.2", + "zod": "^4.3.6" + }, + "devDependencies": { + "@docusaurus/module-type-aliases": "3.9.2", + "@docusaurus/tsconfig": "3.9.2", + "@docusaurus/types": "3.9.2", + "@playwright/test": "^1.58.2", + "@tailwindcss/postcss": "^4.2.2", + "@vercel/node": "^5.6.15", + "husky": "^9.1.7", + "image-size": "^2.0.2", + "postcss": "^8.5.8", + "tailwindcss": "^4.2.2", + "tw-animate-css": "^1.4.0", + "typescript": "~5.6.2", + "vitest": "^4.1.0" + }, + "browserslist": { + "production": [ + ">0.5%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 3 chrome version", + "last 3 firefox version", + "last 5 safari version" + ] + }, + "engines": { + "node": ">=20.0" + } } diff --git a/scripts/dev.sh b/scripts/dev.sh index ac27050..b7ff240 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -16,4 +16,7 @@ if [ -f "$ENV_FILE" ]; then set +a fi +# Sync AppKit docs if not already present +node scripts/sync-appkit-docs.mjs + exec vercel dev "$@" diff --git a/scripts/sync-appkit-docs.mjs b/scripts/sync-appkit-docs.mjs index 75f9a7c..e9f4668 100644 --- a/scripts/sync-appkit-docs.mjs +++ b/scripts/sync-appkit-docs.mjs @@ -1,8 +1,6 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { Readable } from "node:stream"; -import { pipeline } from "node:stream/promises"; import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; @@ -10,333 +8,339 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, ".."); +const APPKIT_REMOTE = "https://github.com/databricks/appkit.git"; +const APPKIT_BRANCH = "main"; + // Where upstream appkit stores its source-of-truth component examples. -// Paths are relative to the extracted appkit tarball root. +// Paths are relative to the cloned appkit repo root. const UPSTREAM_EXAMPLE_DIRS = ["packages/appkit-ui/src/react/ui/examples"]; function fail(message) { - console.error(`Error: ${message}`); - process.exit(1); + console.error(`Error: ${message}`); + process.exit(1); } function run(command, args, cwd) { - const result = spawnSync(command, args, { - cwd, - stdio: "inherit", - }); - - if (result.status !== 0) { - fail(`Command failed: ${command} ${args.join(" ")}`); - } + const result = spawnSync(command, args, { + cwd, + stdio: "inherit", + }); + + if (result.status !== 0) { + fail(`Command failed: ${command} ${args.join(" ")}`); + } } function runCapture(command, args, cwd) { - const result = spawnSync(command, args, { - cwd, - encoding: "utf-8", - }); + const result = spawnSync(command, args, { + cwd, + encoding: "utf-8", + }); - if (result.status !== 0) { - fail(`Command failed: ${command} ${args.join(" ")}`); - } + if (result.status !== 0) { + fail(`Command failed: ${command} ${args.join(" ")}`); + } - return result.stdout; + return result.stdout; } function copyDirRecursive(source, destination) { - fs.mkdirSync(destination, { recursive: true }); - - for (const entry of fs.readdirSync(source, { withFileTypes: true })) { - const sourcePath = path.join(source, entry.name); - const destinationPath = path.join(destination, entry.name); - - if (entry.isDirectory()) { - copyDirRecursive(sourcePath, destinationPath); - } else { - fs.copyFileSync(sourcePath, destinationPath); - } - } + fs.mkdirSync(destination, { recursive: true }); + + for (const entry of fs.readdirSync(source, { withFileTypes: true })) { + const sourcePath = path.join(source, entry.name); + const destinationPath = path.join(destination, entry.name); + + if (entry.isDirectory()) { + copyDirRecursive(sourcePath, destinationPath); + } else { + fs.copyFileSync(sourcePath, destinationPath); + } + } } function replaceDir(source, destination) { - fs.rmSync(destination, { recursive: true, force: true }); - copyDirRecursive(source, destination); -} - -function normalizeMajorChannel(raw) { - const value = raw.trim(); - if (value.length === 0) { - fail("Version channel cannot be empty."); - } - - if (!/^v\d+$/.test(value)) { - fail("Only major channels are allowed: v0, v1, v2, ..."); - } - - return value; + fs.rmSync(destination, { recursive: true, force: true }); + copyDirRecursive(source, destination); } -function parseSemverTag(tag) { - const match = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag); - if (!match) { - return null; - } - - return { - major: Number(match[1]), - minor: Number(match[2]), - patch: Number(match[3]), - }; +// Checks whether docs/appkit/latest/ already has synced content. +// If so, reads .source-ref and logs info for the developer, then returns true. +function isAlreadySynced(latestDir) { + const hasContent = + fs.existsSync(latestDir) && + fs + .readdirSync(latestDir) + .some((name) => name.endsWith(".md") || name.endsWith(".mdx")); + + if (!hasContent) { + return false; + } + + const sourceRefPath = path.join(latestDir, ".source-ref"); + if (fs.existsSync(sourceRefPath)) { + const ref = fs.readFileSync(sourceRefPath, "utf-8").trim(); + console.log( + `Using AppKit docs synced on ${ref}. Run 'npm run sync:appkit-docs' to resync.`, + ); + } else { + console.log( + "AppKit docs already present. Run 'npm run sync:appkit-docs' to resync.", + ); + } + + return true; } -function compareSemver(a, b) { - if (a.major !== b.major) { - return a.major - b.major; - } - if (a.minor !== b.minor) { - return a.minor - b.minor; - } - return a.patch - b.patch; +// Shallow-clones the appkit repo at the given branch into destDir, +// using sparse checkout to only fetch docs and examples. +function cloneAppKit(destDir) { + console.log(`Cloning ${APPKIT_REMOTE} (branch: ${APPKIT_BRANCH})...`); + + run( + "git", + [ + "clone", + "--depth", + "1", + "--branch", + APPKIT_BRANCH, + "--sparse", + "--filter=blob:none", + APPKIT_REMOTE, + destDir, + ], + repoRoot, + ); + + run( + "git", + [ + "-C", + destDir, + "sparse-checkout", + "set", + "docs/docs", + "docs/versioned_docs", + "docs/versions.json", + "packages/appkit-ui/src/react/ui/examples", + ], + repoRoot, + ); } -function resolveLatestTagForMajor(majorChannel) { - const major = Number(majorChannel.slice(1)); - const remote = "https://github.com/databricks/appkit.git"; - const pattern = `v${major}.*`; - - const output = runCapture( - "git", - ["ls-remote", "--tags", "--refs", remote, pattern], - repoRoot, - ); - - const tags = output - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .map((line) => { - const parts = line.split(/\s+/); - const ref = parts[1] ?? ""; - const tag = ref.replace("refs/tags/", ""); - const parsed = parseSemverTag(tag); - if (!parsed) { - return null; - } - return { tag, parsed }; - }) - .filter((item) => item !== null); - - if (tags.length === 0) { - fail(`No tags found for major channel '${majorChannel}'.`); - } - - tags.sort((a, b) => compareSemver(a.parsed, b.parsed)); - return tags[tags.length - 1].tag; +function getHeadSha(repoDir) { + return runCapture( + "git", + ["-C", repoDir, "rev-parse", "--short", "HEAD"], + repoRoot, + ).trim(); } -async function downloadTarball(url, outputFile) { - const response = await fetch(url); - if (!response.ok) { - return false; - } - if (!response.body) { - fail(`No response body returned from ${url}`); - } - - await pipeline( - Readable.fromWeb(response.body), - fs.createWriteStream(outputFile), - ); - return true; +// Copies versioned docs from the cloned repo if they exist. +// Currently AppKit has no versioned_docs — this is future-proofing for when +// AppKit adopts Docusaurus versioning (docs/versioned_docs/version-X/). +// +// TODO: When AppKit starts using Docusaurus versioning: +// - Read docs/versions.json to determine the latest released version +// - Copy docs/versioned_docs/version-/ → docs/appkit/latest/ (instead of docs/docs/) +// - Copy docs/docs/ → docs/appkit/next/ (unreleased dev docs) +// - Copy remaining versioned_docs/version-*/ → docs/appkit/version-*/ +function syncVersionedDocs(clonedRoot, docsRoot) { + const versionedDocsDir = path.join(clonedRoot, "docs", "versioned_docs"); + + if (!fs.existsSync(versionedDocsDir)) { + return; + } + + const versionDirs = fs + .readdirSync(versionedDocsDir, { withFileTypes: true }) + .filter( + (entry) => entry.isDirectory() && entry.name.startsWith("version-"), + ); + + if (versionDirs.length === 0) { + return; + } + + for (const entry of versionDirs) { + const src = path.join(versionedDocsDir, entry.name); + const dest = path.join(docsRoot, entry.name); + replaceDir(src, dest); + console.log(`Synced versioned docs: ${entry.name}`); + } } // Pascal-cases a kebab-cased file stem ("alert-dialog" -> "AlertDialog"). function pascalCase(stem) { - return stem - .split("-") - .filter(Boolean) - .map((part) => part[0].toUpperCase() + part.slice(1)) - .join(""); + return stem + .split("-") + .filter(Boolean) + .map((part) => part[0].toUpperCase() + part.slice(1)) + .join(""); } -// Walks the extracted appkit tree for example files and writes them under +// Walks the cloned appkit tree for example files and writes them under // src/components/doc-examples, preserving kebab-case filenames so the // contract stays stable. -function syncExamples(extractedRoot) { - const outDir = path.join(repoRoot, "src", "components", "doc-examples"); - fs.rmSync(outDir, { recursive: true, force: true }); - fs.mkdirSync(outDir, { recursive: true }); - - const collected = []; - for (const dir of UPSTREAM_EXAMPLE_DIRS) { - const srcDir = path.join(extractedRoot, dir); - if (!fs.existsSync(srcDir)) continue; - - for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) { - if (!entry.isFile()) continue; - if (!entry.name.endsWith(".example.tsx")) continue; - - const src = path.join(srcDir, entry.name); - const dest = path.join(outDir, entry.name); - fs.copyFileSync(src, dest); - - const stem = entry.name.slice(0, -".example.tsx".length); - collected.push({ stem, filename: entry.name }); - } - } - - collected.sort((a, b) => a.stem.localeCompare(b.stem)); - - const imports = collected - .map( - ({ stem, filename }) => - `import ${pascalCase(stem)}Example from "./${filename.replace(/\.tsx$/, "")}";`, - ) - .join("\n"); - - const entries = collected - .map(({ stem, filename }) => { - const sourcePath = path.join(outDir, filename); - const rawSource = fs.readFileSync(sourcePath, "utf-8"); - const escaped = rawSource - .replace(/\\/g, "\\\\") - .replace(/`/g, "\\`") - .replace(/\$/g, "\\$"); - return ` "${stem}": {\n Component: ${pascalCase(stem)}Example,\n source: \`${escaped}\`,\n }`; - }) - .join(",\n"); - - const registry = `// Auto-generated by scripts/sync-appkit-docs.mjs. Do not edit by hand.\n// Source files alongside this registry are vendored verbatim from the\n// matching appkit release tag and import from '@databricks/appkit-ui/react'.\nimport type { ComponentType } from "react";\n${imports}\n\nexport type DocExampleEntry = { Component: ComponentType; source: string };\n\nexport const docExamples = {\n${entries},\n} as const satisfies Record;\n\nexport type DocExampleKey = keyof typeof docExamples;\n`; - - fs.writeFileSync(path.join(outDir, "registry.ts"), registry, "utf-8"); - - console.log( - `Synced ${collected.length} example components to ${path.relative(repoRoot, outDir)}`, - ); +function syncExamples(clonedRoot) { + const outDir = path.join(repoRoot, "src", "components", "doc-examples"); + fs.rmSync(outDir, { recursive: true, force: true }); + fs.mkdirSync(outDir, { recursive: true }); + + const collected = []; + for (const dir of UPSTREAM_EXAMPLE_DIRS) { + const srcDir = path.join(clonedRoot, dir); + if (!fs.existsSync(srcDir)) continue; + + for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) { + if (!entry.isFile()) continue; + if (!entry.name.endsWith(".example.tsx")) continue; + + const src = path.join(srcDir, entry.name); + const dest = path.join(outDir, entry.name); + fs.copyFileSync(src, dest); + + const stem = entry.name.slice(0, -".example.tsx".length); + collected.push({ stem, filename: entry.name }); + } + } + + collected.sort((a, b) => a.stem.localeCompare(b.stem)); + + const imports = collected + .map( + ({ stem, filename }) => + `import ${pascalCase(stem)}Example from "./${filename.replace(/\.tsx$/, "")}";`, + ) + .join("\n"); + + const entries = collected + .map(({ stem, filename }) => { + const sourcePath = path.join(outDir, filename); + const rawSource = fs.readFileSync(sourcePath, "utf-8"); + const escaped = rawSource + .replace(/\\/g, "\\\\") + .replace(/`/g, "\\`") + .replace(/\$/g, "\\$"); + return ` "${stem}": {\n Component: ${pascalCase(stem)}Example,\n source: \`${escaped}\`,\n }`; + }) + .join(",\n"); + + const registry = `// Auto-generated by scripts/sync-appkit-docs.mjs. Do not edit by hand.\n// Source files alongside this registry are vendored verbatim from the\n// appkit main branch and import from '@databricks/appkit-ui/react'.\nimport type { ComponentType } from "react";\n${imports}\n\nexport type DocExampleEntry = { Component: ComponentType; source: string };\n\nexport const docExamples = {\n${entries},\n} as const satisfies Record;\n\nexport type DocExampleKey = keyof typeof docExamples;\n`; + + fs.writeFileSync(path.join(outDir, "registry.ts"), registry, "utf-8"); + + console.log( + `Synced ${collected.length} example components to ${path.relative(repoRoot, outDir)}`, + ); } // Compiles the installed @databricks/appkit-ui styles.css (Tailwind v4 source // with @import "tailwindcss" and @source directives pointing at the built // React components) into a real CSS bundle, and writes it to a public static // path so the DocExample iframe can link it directly without webpack. -async function syncCompiledStyles(majorChannel) { - const pkgDir = path.join( - repoRoot, - "node_modules", - "@databricks", - "appkit-ui", - ); - if (!fs.existsSync(pkgDir)) { - fail( - "@databricks/appkit-ui is not installed. Run `npm install` and retry.", - ); - } - - const pkgJson = JSON.parse( - fs.readFileSync(path.join(pkgDir, "package.json"), "utf-8"), - ); - const version = pkgJson.version; - - const srcCss = path.join(pkgDir, "dist", "styles.css"); - if (!fs.existsSync(srcCss)) { - fail(`dist/styles.css not found in @databricks/appkit-ui@${version}.`); - } - - const destDir = path.join(repoRoot, "static", "appkit-preview", majorChannel); - fs.mkdirSync(destDir, { recursive: true }); - const destCss = path.join(destDir, "styles.css"); - - const [{ default: postcss }, { default: tailwind }] = await Promise.all([ - import("postcss"), - import("@tailwindcss/postcss"), - ]); - - const rawCss = fs.readFileSync(srcCss, "utf-8"); - const result = await postcss([tailwind()]).process(rawCss, { - from: srcCss, - to: destCss, - }); - - // NOTE: avoid `*/` anywhere inside this banner -- it would terminate the - // CSS comment early and break the entire stylesheet's parsing. - const banner = `/* Synced from @databricks/appkit-ui@${version} (${majorChannel}).\n * Source of truth: https://github.com/databricks/appkit\n * Compiled via @tailwindcss/postcss; do not edit by hand.\n * Regenerate via: npm run sync:appkit-docs ${majorChannel}\n */\n`; - fs.writeFileSync(destCss, banner + result.css, "utf-8"); - - console.log( - `Compiled @databricks/appkit-ui@${version} styles to ` + - `${path.relative(repoRoot, destCss)} (${(result.css.length / 1024).toFixed(1)} KB).`, - ); - - return version; +async function syncCompiledStyles(channel) { + const pkgDir = path.join( + repoRoot, + "node_modules", + "@databricks", + "appkit-ui", + ); + if (!fs.existsSync(pkgDir)) { + fail( + "@databricks/appkit-ui is not installed. Run `npm install` and retry.", + ); + } + + const pkgJson = JSON.parse( + fs.readFileSync(path.join(pkgDir, "package.json"), "utf-8"), + ); + const version = pkgJson.version; + + const srcCss = path.join(pkgDir, "dist", "styles.css"); + if (!fs.existsSync(srcCss)) { + fail(`dist/styles.css not found in @databricks/appkit-ui@${version}.`); + } + + const destDir = path.join(repoRoot, "static", "appkit-preview", channel); + fs.mkdirSync(destDir, { recursive: true }); + const destCss = path.join(destDir, "styles.css"); + + const [{ default: postcss }, { default: tailwind }] = await Promise.all([ + import("postcss"), + import("@tailwindcss/postcss"), + ]); + + const rawCss = fs.readFileSync(srcCss, "utf-8"); + const result = await postcss([tailwind()]).process(rawCss, { + from: srcCss, + to: destCss, + }); + + // NOTE: avoid `*/` anywhere inside this banner -- it would terminate the + // CSS comment early and break the entire stylesheet's parsing. + const banner = `/* Synced from @databricks/appkit-ui@${version} (${channel}).\n * Source of truth: https://github.com/databricks/appkit\n * Compiled via @tailwindcss/postcss; do not edit by hand.\n * Regenerate via: npm run sync:appkit-docs\n */\n`; + fs.writeFileSync(destCss, banner + result.css, "utf-8"); + + console.log( + `Compiled @databricks/appkit-ui@${version} styles to ` + + `${path.relative(repoRoot, destCss)} (${(result.css.length / 1024).toFixed(1)} KB).`, + ); + + return version; } async function main() { - const majorArg = process.argv[2]; - if (!majorArg) { - fail("Usage: node scripts/sync-appkit-docs.mjs "); - } - - const majorChannel = normalizeMajorChannel(majorArg); - const resolvedTag = resolveLatestTagForMajor(majorChannel); - - const docsRoot = path.join(repoRoot, "docs", "appkit"); - const majorDir = path.join(docsRoot, majorChannel); - - fs.mkdirSync(docsRoot, { recursive: true }); - - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "devhub-appkit-docs-")); - const archivePath = path.join(tempDir, "appkit.tar.gz"); - - const tagUrl = `https://codeload.github.com/databricks/appkit/tar.gz/refs/tags/${resolvedTag}`; - - console.log( - `Resolved ${majorChannel} to latest AppKit tag ${resolvedTag}. Downloading source...`, - ); - const downloaded = await downloadTarball(tagUrl, archivePath); - - if (!downloaded) { - fail(`Could not download AppKit source for tag '${resolvedTag}'.`); - } - - run("tar", ["-xzf", archivePath], tempDir); - - const extracted = fs - .readdirSync(tempDir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(tempDir, entry.name)) - .find((fullPath) => fs.existsSync(path.join(fullPath, "docs", "docs"))); - - if (!extracted) { - fail("Could not find docs/docs in downloaded AppKit source."); - } - - const appkitDocsSource = path.join(extracted, "docs", "docs"); - - replaceDir(appkitDocsSource, majorDir); - fs.writeFileSync( - path.join(majorDir, ".source-ref"), - `databricks/appkit@${resolvedTag} (${majorChannel})\n`, - "utf-8", - ); - fs.rmSync(path.join(docsRoot, "current"), { recursive: true, force: true }); - - syncExamples(extracted); - const stylesVersion = await syncCompiledStyles(majorChannel); - - fs.rmSync(tempDir, { recursive: true, force: true }); - - console.log(`\nUpdated major docs: ${path.relative(repoRoot, majorDir)}`); - console.log(`AppKit docs @ ${resolvedTag}, styles @ ${stylesVersion}.`); - if (`v${stylesVersion}` !== resolvedTag) { - console.log( - `Note: docs tag (${resolvedTag}) and installed @databricks/appkit-ui ` + - `(${stylesVersion}) differ. Update the dep in package.json if you ` + - `want them aligned.`, - ); - } - console.log("Done."); + const force = process.argv.includes("--force"); + + const docsRoot = path.join(repoRoot, "docs", "appkit"); + const latestDir = path.join(docsRoot, "latest"); + + // Skip if docs already exist (unless --force) + if (!force && isAlreadySynced(latestDir)) { + return; + } + + fs.mkdirSync(docsRoot, { recursive: true }); + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "devhub-appkit-docs-")); + + cloneAppKit(tempDir); + + const sha = getHeadSha(tempDir); + const syncDate = new Date().toISOString().slice(0, 10); + const appkitDocsSource = path.join(tempDir, "docs", "docs"); + + if (!fs.existsSync(appkitDocsSource)) { + fail("Could not find docs/docs in cloned AppKit repository."); + } + + // Clear existing docs and copy latest + fs.rmSync(docsRoot, { recursive: true, force: true }); + fs.mkdirSync(docsRoot, { recursive: true }); + + replaceDir(appkitDocsSource, latestDir); + fs.writeFileSync( + path.join(latestDir, ".source-ref"), + `${syncDate} (${sha})\n`, + "utf-8", + ); + + // Copy versioned docs if present (future-proofing) + syncVersionedDocs(tempDir, docsRoot); + + syncExamples(tempDir); + const stylesVersion = await syncCompiledStyles("latest"); + + fs.rmSync(tempDir, { recursive: true, force: true }); + + console.log( + `\nAppKit docs synced from ${APPKIT_BRANCH} (${sha}), styles @ ${stylesVersion}.`, + ); + console.log("Done."); } main().catch((error) => { - fail(error instanceof Error ? error.message : String(error)); + fail(error instanceof Error ? error.message : String(error)); }); diff --git a/sidebars.ts b/sidebars.ts index 472d994..e0b3f2f 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -20,18 +20,42 @@ type AppKitDocTree = { items: AppKitSidebarItem[]; }; -function getAppKitMajorChannels(): string[] { +// Returns the list of AppKit doc channels found under docs/appkit/. +// "latest" is always first, followed by any version-* directories +// (from Docusaurus versioned_docs convention, future). +function getAppKitChannels(): string[] { const docsAppKitRoot = path.resolve(process.cwd(), "docs", "appkit"); if (!fs.existsSync(docsAppKitRoot)) { return []; } - return fs + const entries = fs .readdirSync(docsAppKitRoot, { withFileTypes: true }) - .filter((entry) => entry.isDirectory() && /^v\d+$/.test(entry.name)) + .filter((entry) => entry.isDirectory()); + + const channels: string[] = []; + + // "latest" always comes first + if (entries.some((entry) => entry.name === "latest")) { + channels.push("latest"); + } + + // Then any version-* directories (Docusaurus versioned_docs convention) + const versionDirs = entries + .filter((entry) => entry.name.startsWith("version-")) .map((entry) => entry.name) - .sort((a, b) => Number(b.slice(1)) - Number(a.slice(1))); + .sort() + .reverse(); + + channels.push(...versionDirs); + + // Also support "next" for unreleased dev docs (future) + if (entries.some((entry) => entry.name === "next")) { + channels.push("next"); + } + + return channels; } function toDocId(relativePath: string): string { @@ -139,34 +163,37 @@ function readAppKitDocTree(relativeDir: string): AppKitDocTree { }; } -const appKitMajorChannels = getAppKitMajorChannels(); +const appKitChannels = getAppKitChannels(); -const appKitVersionItems = appKitMajorChannels - .map((majorChannel, index) => { - const majorTree = readAppKitDocTree(path.join("appkit", majorChannel)); +// Each channel becomes a collapsible category whose header links to the +// channel's index doc (e.g. "Getting started"). This mirrors the old v0 +// behavior and ensures the index page is always reachable from the sidebar. +const appKitVersionItems = appKitChannels + .map((channel) => { + const tree = readAppKitDocTree(path.join("appkit", channel)); - if (!majorTree.indexDocId && majorTree.items.length === 0) { + if (!tree.indexDocId && tree.items.length === 0) { return null; } return { type: "category" as const, - label: index === 0 ? `${majorChannel} (current)` : majorChannel, - link: majorTree.indexDocId + label: channel, + link: tree.indexDocId ? { type: "doc" as const, - id: majorTree.indexDocId, + id: tree.indexDocId, } : undefined, collapsed: true, - items: majorTree.items, + items: tree.items, }; }) .filter((item): item is Exclude => item !== null); -const latestAppKitMajorChannel = appKitMajorChannels[0]; -const latestAppKitDocId = latestAppKitMajorChannel - ? readAppKitDocTree(path.join("appkit", latestAppKitMajorChannel)).indexDocId +const latestChannel = appKitChannels[0]; +const latestAppKitDocId = latestChannel + ? readAppKitDocTree(path.join("appkit", latestChannel)).indexDocId : null; const sidebars: SidebarsConfig = { @@ -223,7 +250,7 @@ const sidebars: SidebarsConfig = { label: "AppKit", link: { type: "doc", - id: latestAppKitDocId ?? "appkit/v0/index", + id: latestAppKitDocId ?? "appkit/latest/index", }, collapsed: true, items: appKitVersionItems, From 6f47af670379ee7feda81081f25484fd07473ab7 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Thu, 23 Apr 2026 17:56:49 +0200 Subject: [PATCH 2/6] fix(appkit-docs): fix version dropdown and styles path for latest/ convention - Update DocExample styles href from v0 to latest - Rewrite getAppKitChannelOptions to deduplicate by channel path segment - Hide VERSION dropdown when only one channel exists (> 1 check) Co-authored-by: Isaac --- src/components/DocExample.tsx | 546 +++++++++--------- .../DocSidebar/Desktop/Content/index.tsx | 369 ++++++------ 2 files changed, 460 insertions(+), 455 deletions(-) diff --git a/src/components/DocExample.tsx b/src/components/DocExample.tsx index bd147cb..95998b3 100644 --- a/src/components/DocExample.tsx +++ b/src/components/DocExample.tsx @@ -1,9 +1,9 @@ import { - useEffect, - useRef, - useState, - type ReactNode, - type RefObject, + useEffect, + useRef, + useState, + type ReactNode, + type RefObject, } from "react"; import { createPortal } from "react-dom"; import BrowserOnly from "@docusaurus/BrowserOnly"; @@ -15,129 +15,129 @@ import { cn } from "@/lib/utils"; import { docExamples, type DocExampleKey } from "./doc-examples/registry"; type DocExampleProps = { - name: string; + name: string; }; // Components whose previews need more vertical space than the auto-sizing can // infer (dialogs/popovers/menus that open _above_ their trigger, components // that pin content to a viewport edge). Upstream uses the same technique. const HEIGHT_OVERRIDES: Partial> = { - dialog: 600, - drawer: 700, - "hover-card": 400, - "navigation-menu": 600, - menubar: 500, - popover: 450, - sheet: 700, - "dropdown-menu": 500, - select: 450, - "alert-dialog": 500, - "context-menu": 500, - sidebar: 700, + dialog: 600, + drawer: 700, + "hover-card": 400, + "navigation-menu": 600, + menubar: 500, + popover: 450, + sheet: 700, + "dropdown-menu": 500, + select: 450, + "alert-dialog": 500, + "context-menu": 500, + sidebar: 700, }; // Which Docusaurus theme is active right now. The DocExample iframe needs this // to mirror `.dark` on its own so appkit-ui's dark-mode styles apply. function isParentDark() { - if (typeof document === "undefined") return false; - return document.documentElement.getAttribute("data-theme") === "dark"; + if (typeof document === "undefined") return false; + return document.documentElement.getAttribute("data-theme") === "dark"; } function isValidExampleName(name: string): name is DocExampleKey { - return Object.prototype.hasOwnProperty.call(docExamples, name); + return Object.prototype.hasOwnProperty.call(docExamples, name); } export function DocExample({ name }: DocExampleProps): ReactNode { - const [tab, setTab] = useState<"preview" | "code">("preview"); - - if (!isValidExampleName(name)) { - return ( -
- Missing DocExample for {name}. Re-run{" "} - npm run sync:appkit-docs v0 to sync examples from the - upstream appkit release. -
- ); - } - - const entry = docExamples[name]; - - return ( -
-
-
- setTab("preview")} - > - Preview - - setTab("code")}> - Code - -
- - {name} - -
- - {tab === "preview" ? ( - - Loading preview… - - } - > - {() => ( - - )} - - ) : ( -
- {entry.source} -
- )} -
- ); + const [tab, setTab] = useState<"preview" | "code">("preview"); + + if (!isValidExampleName(name)) { + return ( +
+ Missing DocExample for {name}. Re-run{" "} + npm run sync:appkit-docs v0 to sync examples from the + upstream appkit release. +
+ ); + } + + const entry = docExamples[name]; + + return ( +
+
+
+ setTab("preview")} + > + Preview + + setTab("code")}> + Code + +
+ + {name} + +
+ + {tab === "preview" ? ( + + Loading preview… + + } + > + {() => ( + + )} + + ) : ( +
+ {entry.source} +
+ )} +
+ ); } function TabButton({ - active, - onClick, - children, + active, + onClick, + children, }: { - active: boolean; - onClick: () => void; - children: ReactNode; + active: boolean; + onClick: () => void; + children: ReactNode; }) { - return ( - - ); + return ( + + ); } // Iframe-based preview. AppKit-UI ships a Tailwind v4 stylesheet with its own @@ -148,85 +148,85 @@ const PREVIEW_MAX_HEIGHT = 800; const PREVIEW_DEFAULT_HEIGHT = 320; type IframePreviewProps = { - exampleKey: DocExampleKey; - Component: React.ComponentType; - customHeight?: number; + exampleKey: DocExampleKey; + Component: React.ComponentType; + customHeight?: number; }; function IframePreview({ - exampleKey, - Component, - customHeight, + exampleKey, + Component, + customHeight, }: IframePreviewProps) { - const iframeRef = useRef(null); - const [mountNode, setMountNode] = useState(null); - const stylesHref = useBaseUrl("/appkit-preview/v0/styles.css"); - - const height = useAutoHeight(iframeRef, customHeight); - useDarkModeSync(iframeRef); - useSonnerStyleSync(iframeRef, exampleKey); - - useEffect(() => { - const iframe = iframeRef.current; - if (!iframe) return; - const doc = iframe.contentDocument; - if (!doc) return; - - doc.open(); - doc.write(iframeHtml(stylesHref)); - doc.close(); - - // Mirror the current Docusaurus theme immediately so we don't flash light- - // mode tokens before useDarkModeSync runs. - if (isParentDark()) { - doc.documentElement.classList.add("dark"); - } - - const setFromRoot = () => { - const root = doc.getElementById("preview-root"); - if (root) setMountNode(root); - }; - - const link = doc.querySelector('link[rel="stylesheet"]'); - if (link) { - link.addEventListener("load", setFromRoot, { once: true }); - link.addEventListener("error", setFromRoot, { once: true }); - } else { - setFromRoot(); - } - }, [stylesHref]); - - return ( - - ); + const iframeRef = useRef(null); + const [mountNode, setMountNode] = useState(null); + const stylesHref = useBaseUrl("/appkit-preview/latest/styles.css"); + + const height = useAutoHeight(iframeRef, customHeight); + useDarkModeSync(iframeRef); + useSonnerStyleSync(iframeRef, exampleKey); + + useEffect(() => { + const iframe = iframeRef.current; + if (!iframe) return; + const doc = iframe.contentDocument; + if (!doc) return; + + doc.open(); + doc.write(iframeHtml(stylesHref)); + doc.close(); + + // Mirror the current Docusaurus theme immediately so we don't flash light- + // mode tokens before useDarkModeSync runs. + if (isParentDark()) { + doc.documentElement.classList.add("dark"); + } + + const setFromRoot = () => { + const root = doc.getElementById("preview-root"); + if (root) setMountNode(root); + }; + + const link = doc.querySelector('link[rel="stylesheet"]'); + if (link) { + link.addEventListener("load", setFromRoot, { once: true }); + link.addEventListener("error", setFromRoot, { once: true }); + } else { + setFromRoot(); + } + }, [stylesHref]); + + return ( + + ); } function iframeHtml(stylesHref: string): string { - return ` + return ` @@ -259,110 +259,110 @@ function iframeHtml(stylesHref: string): string { } function useAutoHeight( - iframeRef: RefObject, - customHeight: number | undefined, + iframeRef: RefObject, + customHeight: number | undefined, ) { - const [height, setHeight] = useState( - customHeight ?? PREVIEW_DEFAULT_HEIGHT, - ); - - useEffect(() => { - const iframe = iframeRef.current; - if (!iframe?.contentDocument?.body) return; - - if (customHeight) { - setHeight(customHeight); - return; - } - - const doc = iframe.contentDocument; - const update = () => { - const scroll = doc.body.scrollHeight; - const next = Math.min( - Math.max(scroll + 20, PREVIEW_MIN_HEIGHT), - PREVIEW_MAX_HEIGHT, - ); - setHeight(next); - }; - - const initial = setTimeout(update, 100); - const observer = new ResizeObserver(update); - observer.observe(doc.body); - - return () => { - clearTimeout(initial); - observer.disconnect(); - }; - }, [iframeRef, customHeight]); - - return height; + const [height, setHeight] = useState( + customHeight ?? PREVIEW_DEFAULT_HEIGHT, + ); + + useEffect(() => { + const iframe = iframeRef.current; + if (!iframe?.contentDocument?.body) return; + + if (customHeight) { + setHeight(customHeight); + return; + } + + const doc = iframe.contentDocument; + const update = () => { + const scroll = doc.body.scrollHeight; + const next = Math.min( + Math.max(scroll + 20, PREVIEW_MIN_HEIGHT), + PREVIEW_MAX_HEIGHT, + ); + setHeight(next); + }; + + const initial = setTimeout(update, 100); + const observer = new ResizeObserver(update); + observer.observe(doc.body); + + return () => { + clearTimeout(initial); + observer.disconnect(); + }; + }, [iframeRef, customHeight]); + + return height; } function useDarkModeSync(iframeRef: RefObject) { - useEffect(() => { - const iframe = iframeRef.current; - if (!iframe) return; - - const sync = () => { - const doc = iframe.contentDocument; - if (!doc) return; - doc.documentElement.classList.toggle("dark", isParentDark()); - }; - - sync(); - - const observer = new MutationObserver(sync); - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ["data-theme", "class"], - }); - - return () => observer.disconnect(); - }, [iframeRef]); + useEffect(() => { + const iframe = iframeRef.current; + if (!iframe) return; + + const sync = () => { + const doc = iframe.contentDocument; + if (!doc) return; + doc.documentElement.classList.toggle("dark", isParentDark()); + }; + + sync(); + + const observer = new MutationObserver(sync); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme", "class"], + }); + + return () => observer.disconnect(); + }, [iframeRef]); } // Sonner injects its keyframe + toast styles into the *parent* document on // first render. When the Toaster is portaled into an iframe, those styles // never reach it. Clone them over for the sonner preview only. function useSonnerStyleSync( - iframeRef: RefObject, - exampleKey: DocExampleKey, + iframeRef: RefObject, + exampleKey: DocExampleKey, ) { - useEffect(() => { - if (exampleKey !== "sonner") return; - const iframe = iframeRef.current; - if (!iframe?.contentDocument) return; - - const doc = iframe.contentDocument; - let attempts = 0; - let timer: ReturnType | null = null; - - const cloneStyles = () => { - const sonnerStyles = Array.from( - document.querySelectorAll("style"), - ).filter( - (el) => - el.textContent?.includes("[data-sonner-toaster]") || - el.textContent?.includes("[data-sonner-toast]"), - ); - - if (sonnerStyles.length > 0) { - for (const style of sonnerStyles) { - doc.head.appendChild(style.cloneNode(true)); - } - return; - } - - if (attempts < 10) { - attempts += 1; - timer = setTimeout(cloneStyles, 100); - } - }; - - timer = setTimeout(cloneStyles, 100); - - return () => { - if (timer) clearTimeout(timer); - }; - }, [iframeRef, exampleKey]); + useEffect(() => { + if (exampleKey !== "sonner") return; + const iframe = iframeRef.current; + if (!iframe?.contentDocument) return; + + const doc = iframe.contentDocument; + let attempts = 0; + let timer: ReturnType | null = null; + + const cloneStyles = () => { + const sonnerStyles = Array.from( + document.querySelectorAll("style"), + ).filter( + (el) => + el.textContent?.includes("[data-sonner-toaster]") || + el.textContent?.includes("[data-sonner-toast]"), + ); + + if (sonnerStyles.length > 0) { + for (const style of sonnerStyles) { + doc.head.appendChild(style.cloneNode(true)); + } + return; + } + + if (attempts < 10) { + attempts += 1; + timer = setTimeout(cloneStyles, 100); + } + }; + + timer = setTimeout(cloneStyles, 100); + + return () => { + if (timer) clearTimeout(timer); + }; + }, [iframeRef, exampleKey]); } diff --git a/src/theme/DocSidebar/Desktop/Content/index.tsx b/src/theme/DocSidebar/Desktop/Content/index.tsx index 978107e..6ec11b9 100644 --- a/src/theme/DocSidebar/Desktop/Content/index.tsx +++ b/src/theme/DocSidebar/Desktop/Content/index.tsx @@ -3,8 +3,8 @@ import clsx from "clsx"; import Link from "@docusaurus/Link"; import { ThemeClassNames } from "@docusaurus/theme-common"; import { - useAnnouncementBar, - useScrollPosition, + useAnnouncementBar, + useScrollPosition, } from "@docusaurus/theme-common/internal"; import { translate } from "@docusaurus/Translate"; import useIsBrowser from "@docusaurus/useIsBrowser"; @@ -13,223 +13,228 @@ import type { Props } from "@theme/DocSidebar/Desktop/Content"; import { ChevronLeft } from "lucide-react"; import { Button } from "@/components/ui/button"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from "@/components/ui/select"; function useShowAnnouncementBar() { - const { isActive } = useAnnouncementBar(); - const [showAnnouncementBar, setShowAnnouncementBar] = useState(isActive); - useScrollPosition( - ({ scrollY }) => { - if (isActive) { - setShowAnnouncementBar(scrollY === 0); - } - }, - [isActive], - ); - return isActive && showAnnouncementBar; + const { isActive } = useAnnouncementBar(); + const [showAnnouncementBar, setShowAnnouncementBar] = useState(isActive); + useScrollPosition( + ({ scrollY }) => { + if (isActive) { + setShowAnnouncementBar(scrollY === 0); + } + }, + [isActive], + ); + return isActive && showAnnouncementBar; } type SidebarLikeItem = { - type?: string; - label?: string; - href?: string; - items?: SidebarLikeItem[]; + type?: string; + label?: string; + href?: string; + items?: SidebarLikeItem[]; }; type AppKitChannelOption = { - label: string; - href: string; + label: string; + href: string; }; function normalizePath(value: string): string { - return value.replace(/\/+$/, ""); + return value.replace(/\/+$/, ""); } function isSidebarCategory(item: SidebarLikeItem): item is SidebarLikeItem & { - items: SidebarLikeItem[]; + items: SidebarLikeItem[]; } { - return item.type === "category" && Array.isArray(item.items); + return item.type === "category" && Array.isArray(item.items); } function findCategoryByLabel( - items: SidebarLikeItem[], - label: string, + items: SidebarLikeItem[], + label: string, ): SidebarLikeItem | null { - return ( - items.find( - (item) => - isSidebarCategory(item) && - item.label?.toLocaleLowerCase() === label.toLocaleLowerCase(), - ) ?? null - ); + return ( + items.find( + (item) => + isSidebarCategory(item) && + item.label?.toLocaleLowerCase() === label.toLocaleLowerCase(), + ) ?? null + ); } function isAppKitDocsPath(path: string): boolean { - const normalizedPath = normalizePath(path); - return normalizedPath.startsWith("/docs/appkit/"); + const normalizedPath = normalizePath(path); + return normalizedPath.startsWith("/docs/appkit/"); } function getAppKitSidebarItems(sidebar: SidebarLikeItem[]): SidebarLikeItem[] { - const referencesCategory = findCategoryByLabel(sidebar, "reference"); - if (!referencesCategory || !isSidebarCategory(referencesCategory)) { - return []; - } - - const appKitCategory = findCategoryByLabel( - referencesCategory.items, - "appkit", - ); - if (!appKitCategory || !isSidebarCategory(appKitCategory)) { - return []; - } - - return appKitCategory.items; + const referencesCategory = findCategoryByLabel(sidebar, "reference"); + if (!referencesCategory || !isSidebarCategory(referencesCategory)) { + return []; + } + + const appKitCategory = findCategoryByLabel( + referencesCategory.items, + "appkit", + ); + if (!appKitCategory || !isSidebarCategory(appKitCategory)) { + return []; + } + + return appKitCategory.items; +} + +function extractChannel(href: string): string | null { + const match = href.match(/^\/docs\/appkit\/([^/]+)/); + return match ? match[1] : null; } function getAppKitChannelOptions( - items: SidebarLikeItem[], + items: SidebarLikeItem[], ): AppKitChannelOption[] { - const seen = new Set(); - const options: AppKitChannelOption[] = []; - - for (const item of items) { - if (!item.href?.startsWith("/docs/appkit/")) { - continue; - } - - const normalizedHref = normalizePath(item.href); - if (seen.has(normalizedHref)) { - continue; - } - - seen.add(normalizedHref); - options.push({ - label: item.label ?? normalizedHref, - href: normalizedHref, - }); - } - - return options; + const channels = new Map(); + + function collect(list: SidebarLikeItem[]) { + for (const item of list) { + if (item.href) { + const channel = extractChannel(item.href); + if (channel && !channels.has(channel)) { + channels.set(channel, { + label: channel, + href: `/docs/appkit/${channel}`, + }); + } + } + if (isSidebarCategory(item)) { + collect(item.items); + } + } + } + + collect(items); + return Array.from(channels.values()); } function getActiveChannelHref( - path: string, - channels: AppKitChannelOption[], + path: string, + channels: AppKitChannelOption[], ): string { - const normalizedPath = normalizePath(path); + const normalizedPath = normalizePath(path); - return ( - channels.find((channel) => normalizedPath.startsWith(channel.href))?.href ?? - channels[0]?.href ?? - "" - ); + return ( + channels.find((channel) => normalizedPath.startsWith(channel.href))?.href ?? + channels[0]?.href ?? + "" + ); } export default function DocSidebarDesktopContent({ - path, - sidebar, - className, + path, + sidebar, + className, }: Props): ReactNode { - const showAnnouncementBar = useShowAnnouncementBar(); - const isBrowser = useIsBrowser(); - - const appKitSidebarItems = useMemo( - () => getAppKitSidebarItems(sidebar as SidebarLikeItem[]), - [sidebar], - ); - const appKitChannels = useMemo( - () => getAppKitChannelOptions(appKitSidebarItems), - [appKitSidebarItems], - ); - const showAppKitReferenceShell = - isAppKitDocsPath(path) && appKitSidebarItems.length > 0; - const activeChannelHref = getActiveChannelHref(path, appKitChannels); - - return ( - - ); + const showAnnouncementBar = useShowAnnouncementBar(); + const isBrowser = useIsBrowser(); + + const appKitSidebarItems = useMemo( + () => getAppKitSidebarItems(sidebar as SidebarLikeItem[]), + [sidebar], + ); + const appKitChannels = useMemo( + () => getAppKitChannelOptions(appKitSidebarItems), + [appKitSidebarItems], + ); + const showAppKitReferenceShell = + isAppKitDocsPath(path) && appKitSidebarItems.length > 0; + const activeChannelHref = getActiveChannelHref(path, appKitChannels); + + return ( + + ); } From 44153d485ba90e911dc534f5aeeb869e9648027d Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Fri, 24 Apr 2026 10:10:29 +0200 Subject: [PATCH 3/6] fix: address review findings (stale msg, partial sync, cleanup, timeout) - Fix stale error message referencing removed `v0` argument in DocExample - Harden isAlreadySynced() to check all 3 outputs (docs, examples, styles) - Wrap temp directory in try/finally so it's cleaned up on failure - Change fail() to throw so cleanup runs before exit - Add 2-minute timeout to spawned git commands - Remove unnecessary appKitItems alias in sidebars Co-authored-by: Isaac --- scripts/sync-appkit-docs.mjs | 124 +++++++++++++++++++++------------- src/components/DocExample.tsx | 4 +- 2 files changed, 78 insertions(+), 50 deletions(-) diff --git a/scripts/sync-appkit-docs.mjs b/scripts/sync-appkit-docs.mjs index e9f4668..5ca83a0 100644 --- a/scripts/sync-appkit-docs.mjs +++ b/scripts/sync-appkit-docs.mjs @@ -16,16 +16,23 @@ const APPKIT_BRANCH = "main"; const UPSTREAM_EXAMPLE_DIRS = ["packages/appkit-ui/src/react/ui/examples"]; function fail(message) { - console.error(`Error: ${message}`); - process.exit(1); + throw new Error(message); } +const SPAWN_TIMEOUT = 120_000; // 2 minutes + function run(command, args, cwd) { const result = spawnSync(command, args, { cwd, stdio: "inherit", + timeout: SPAWN_TIMEOUT, }); + if (result.signal === "SIGTERM") { + fail( + `Command timed out after ${SPAWN_TIMEOUT / 1000}s: ${command} ${args.join(" ")}`, + ); + } if (result.status !== 0) { fail(`Command failed: ${command} ${args.join(" ")}`); } @@ -35,8 +42,14 @@ function runCapture(command, args, cwd) { const result = spawnSync(command, args, { cwd, encoding: "utf-8", + timeout: SPAWN_TIMEOUT, }); + if (result.signal === "SIGTERM") { + fail( + `Command timed out after ${SPAWN_TIMEOUT / 1000}s: ${command} ${args.join(" ")}`, + ); + } if (result.status !== 0) { fail(`Command failed: ${command} ${args.join(" ")}`); } @@ -64,30 +77,40 @@ function replaceDir(source, destination) { copyDirRecursive(source, destination); } -// Checks whether docs/appkit/latest/ already has synced content. -// If so, reads .source-ref and logs info for the developer, then returns true. +// Checks whether all three sync outputs are present: +// 1. docs/appkit/latest/.source-ref (docs were synced) +// 2. src/components/doc-examples/registry.ts (examples were synced) +// 3. static/appkit-preview/latest/styles.css (styles were compiled) +// If any is missing, returns false so the sync re-runs. function isAlreadySynced(latestDir) { - const hasContent = - fs.existsSync(latestDir) && - fs - .readdirSync(latestDir) - .some((name) => name.endsWith(".md") || name.endsWith(".mdx")); + const sourceRefPath = path.join(latestDir, ".source-ref"); + const registryPath = path.join( + repoRoot, + "src", + "components", + "doc-examples", + "registry.ts", + ); + const stylesPath = path.join( + repoRoot, + "static", + "appkit-preview", + "latest", + "styles.css", + ); - if (!hasContent) { + if ( + !fs.existsSync(sourceRefPath) || + !fs.existsSync(registryPath) || + !fs.existsSync(stylesPath) + ) { return false; } - const sourceRefPath = path.join(latestDir, ".source-ref"); - if (fs.existsSync(sourceRefPath)) { - const ref = fs.readFileSync(sourceRefPath, "utf-8").trim(); - console.log( - `Using AppKit docs synced on ${ref}. Run 'npm run sync:appkit-docs' to resync.`, - ); - } else { - console.log( - "AppKit docs already present. Run 'npm run sync:appkit-docs' to resync.", - ); - } + const ref = fs.readFileSync(sourceRefPath, "utf-8").trim(); + console.log( + `Using AppKit docs synced on ${ref}. Run 'npm run sync:appkit-docs' to resync.`, + ); return true; } @@ -306,41 +329,46 @@ async function main() { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "devhub-appkit-docs-")); - cloneAppKit(tempDir); + try { + cloneAppKit(tempDir); - const sha = getHeadSha(tempDir); - const syncDate = new Date().toISOString().slice(0, 10); - const appkitDocsSource = path.join(tempDir, "docs", "docs"); + const sha = getHeadSha(tempDir); + const syncDate = new Date().toISOString().slice(0, 10); + const appkitDocsSource = path.join(tempDir, "docs", "docs"); - if (!fs.existsSync(appkitDocsSource)) { - fail("Could not find docs/docs in cloned AppKit repository."); - } - - // Clear existing docs and copy latest - fs.rmSync(docsRoot, { recursive: true, force: true }); - fs.mkdirSync(docsRoot, { recursive: true }); + if (!fs.existsSync(appkitDocsSource)) { + fail("Could not find docs/docs in cloned AppKit repository."); + } - replaceDir(appkitDocsSource, latestDir); - fs.writeFileSync( - path.join(latestDir, ".source-ref"), - `${syncDate} (${sha})\n`, - "utf-8", - ); + // Clear existing docs and copy latest + fs.rmSync(docsRoot, { recursive: true, force: true }); + fs.mkdirSync(docsRoot, { recursive: true }); - // Copy versioned docs if present (future-proofing) - syncVersionedDocs(tempDir, docsRoot); + replaceDir(appkitDocsSource, latestDir); + fs.writeFileSync( + path.join(latestDir, ".source-ref"), + `${syncDate} (${sha})\n`, + "utf-8", + ); - syncExamples(tempDir); - const stylesVersion = await syncCompiledStyles("latest"); + // Copy versioned docs if present (future-proofing) + syncVersionedDocs(tempDir, docsRoot); - fs.rmSync(tempDir, { recursive: true, force: true }); + syncExamples(tempDir); + const stylesVersion = await syncCompiledStyles("latest"); - console.log( - `\nAppKit docs synced from ${APPKIT_BRANCH} (${sha}), styles @ ${stylesVersion}.`, - ); - console.log("Done."); + console.log( + `\nAppKit docs synced from ${APPKIT_BRANCH} (${sha}), styles @ ${stylesVersion}.`, + ); + console.log("Done."); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } } main().catch((error) => { - fail(error instanceof Error ? error.message : String(error)); + console.error( + `Error: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); }); diff --git a/src/components/DocExample.tsx b/src/components/DocExample.tsx index 95998b3..d099a1e 100644 --- a/src/components/DocExample.tsx +++ b/src/components/DocExample.tsx @@ -54,8 +54,8 @@ export function DocExample({ name }: DocExampleProps): ReactNode { return (
Missing DocExample for {name}. Re-run{" "} - npm run sync:appkit-docs v0 to sync examples from the - upstream appkit release. + npm run sync:appkit-docs to sync examples from the upstream + appkit repo.
); } From 11b85e708ce1929303c1b82e37f5a73eb3f9de1c Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Fri, 24 Apr 2026 11:11:16 +0200 Subject: [PATCH 4/6] chore: add .prettierrc to pin 2-space indentation Prettier 3.8 changed the default for JSON to tabs. Pin useTabs: false and tabWidth: 2 to maintain consistent formatting across the repo. Co-authored-by: Isaac --- .prettierrc | 4 + CONTRIBUTING.md | 36 +- package.json | 180 +++--- scripts/sync-appkit-docs.mjs | 556 +++++++++--------- src/components/DocExample.tsx | 546 ++++++++--------- .../DocSidebar/Desktop/Content/index.tsx | 368 ++++++------ 6 files changed, 847 insertions(+), 843 deletions(-) create mode 100644 .prettierrc diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..8973894 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "useTabs": false, + "tabWidth": 2 +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7e1b51a..4764262 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -176,24 +176,24 @@ Example for an example: ```ts createExample({ - id: "inventory-intelligence", - // ... - previewImageLightUrl: - "/img/examples/inventory-intelligence-dashboard-light.png", - previewImageDarkUrl: - "/img/examples/inventory-intelligence-dashboard-dark.png", - // optional carousel: - galleryImages: [ - { - lightUrl: "/img/examples/inventory-intelligence-dashboard-light.png", - darkUrl: "/img/examples/inventory-intelligence-dashboard-dark.png", - }, - { - lightUrl: - "/img/examples/inventory-intelligence-replenishment-light.png", - darkUrl: "/img/examples/inventory-intelligence-replenishment-dark.png", - }, - ], + id: "inventory-intelligence", + // ... + previewImageLightUrl: + "/img/examples/inventory-intelligence-dashboard-light.png", + previewImageDarkUrl: + "/img/examples/inventory-intelligence-dashboard-dark.png", + // optional carousel: + galleryImages: [ + { + lightUrl: "/img/examples/inventory-intelligence-dashboard-light.png", + darkUrl: "/img/examples/inventory-intelligence-dashboard-dark.png", + }, + { + lightUrl: + "/img/examples/inventory-intelligence-replenishment-light.png", + darkUrl: "/img/examples/inventory-intelligence-replenishment-dark.png", + }, + ], }); ``` diff --git a/package.json b/package.json index b58b976..249bc05 100644 --- a/package.json +++ b/package.json @@ -1,92 +1,92 @@ { - "name": "devhub", - "version": "0.0.0", - "private": true, - "scripts": { - "docusaurus": "docusaurus", - "dev": "bash scripts/dev.sh", - "start": "docusaurus start", - "build": "docusaurus build", - "sync:appkit-docs": "node scripts/sync-appkit-docs.mjs --force", - "swizzle": "docusaurus swizzle", - "deploy": "docusaurus deploy", - "clear": "docusaurus clear", - "serve": "docusaurus serve", - "write-translations": "docusaurus write-translations", - "write-heading-ids": "docusaurus write-heading-ids", - "typecheck": "tsc", - "fmt": "npx prettier -w .", - "test": "EXAMPLES_FEATURE=true npm run build && vitest run && npx playwright test", - "test:smoke": "vitest run", - "test:e2e": "npx playwright test", - "test:docs-verify": "vitest run --config vitest.docs-verify.config.ts", - "verify:images": "node scripts/verify-resource-images.mjs", - "validate:content": "node scripts/validate-content.mjs", - "prepare": "husky", - "prebuild": "node scripts/sync-appkit-docs.mjs", - "prestart": "node scripts/sync-appkit-docs.mjs" - }, - "dependencies": { - "@base-ui/react": "^1.3.0", - "@databricks/appkit-ui": "^0.22.0", - "@docusaurus/core": "3.9.2", - "@docusaurus/preset-classic": "3.9.2", - "@docusaurus/theme-mermaid": "3.9.2", - "@hookform/resolvers": "^5.2.2", - "@mdx-js/react": "^3.0.0", - "@modelcontextprotocol/sdk": "^1.25.2", - "@vercel/analytics": "^2.0.1", - "@vercel/functions": "^3.4.3", - "class-variance-authority": "^0.7.1", - "clsx": "^2.0.0", - "cmdk": "^1.1.1", - "date-fns": "^4.1.0", - "embla-carousel-react": "^8.6.0", - "input-otp": "^1.4.2", - "lucide-react": "^0.577.0", - "mcp-handler": "^1.0.7", - "next-themes": "^0.4.6", - "prism-react-renderer": "^2.3.0", - "radix-ui": "^1.4.3", - "react": "^19.0.0", - "react-day-picker": "^9.14.0", - "react-dom": "^19.0.0", - "react-hook-form": "^7.71.2", - "react-resizable-panels": "^4", - "recharts": "2.15.4", - "sonner": "^2.0.7", - "tailwind-merge": "^3.5.0", - "vaul": "^1.1.2", - "zod": "^4.3.6" - }, - "devDependencies": { - "@docusaurus/module-type-aliases": "3.9.2", - "@docusaurus/tsconfig": "3.9.2", - "@docusaurus/types": "3.9.2", - "@playwright/test": "^1.58.2", - "@tailwindcss/postcss": "^4.2.2", - "@vercel/node": "^5.6.15", - "husky": "^9.1.7", - "image-size": "^2.0.2", - "postcss": "^8.5.8", - "tailwindcss": "^4.2.2", - "tw-animate-css": "^1.4.0", - "typescript": "~5.6.2", - "vitest": "^4.1.0" - }, - "browserslist": { - "production": [ - ">0.5%", - "not dead", - "not op_mini all" - ], - "development": [ - "last 3 chrome version", - "last 3 firefox version", - "last 5 safari version" - ] - }, - "engines": { - "node": ">=20.0" - } + "name": "devhub", + "version": "0.0.0", + "private": true, + "scripts": { + "docusaurus": "docusaurus", + "dev": "bash scripts/dev.sh", + "start": "docusaurus start", + "build": "docusaurus build", + "sync:appkit-docs": "node scripts/sync-appkit-docs.mjs --force", + "swizzle": "docusaurus swizzle", + "deploy": "docusaurus deploy", + "clear": "docusaurus clear", + "serve": "docusaurus serve", + "write-translations": "docusaurus write-translations", + "write-heading-ids": "docusaurus write-heading-ids", + "typecheck": "tsc", + "fmt": "npx prettier -w .", + "test": "EXAMPLES_FEATURE=true npm run build && vitest run && npx playwright test", + "test:smoke": "vitest run", + "test:e2e": "npx playwright test", + "test:docs-verify": "vitest run --config vitest.docs-verify.config.ts", + "verify:images": "node scripts/verify-resource-images.mjs", + "validate:content": "node scripts/validate-content.mjs", + "prepare": "husky", + "prebuild": "node scripts/sync-appkit-docs.mjs", + "prestart": "node scripts/sync-appkit-docs.mjs" + }, + "dependencies": { + "@base-ui/react": "^1.3.0", + "@databricks/appkit-ui": "^0.22.0", + "@docusaurus/core": "3.9.2", + "@docusaurus/preset-classic": "3.9.2", + "@docusaurus/theme-mermaid": "3.9.2", + "@hookform/resolvers": "^5.2.2", + "@mdx-js/react": "^3.0.0", + "@modelcontextprotocol/sdk": "^1.25.2", + "@vercel/analytics": "^2.0.1", + "@vercel/functions": "^3.4.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.0.0", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "embla-carousel-react": "^8.6.0", + "input-otp": "^1.4.2", + "lucide-react": "^0.577.0", + "mcp-handler": "^1.0.7", + "next-themes": "^0.4.6", + "prism-react-renderer": "^2.3.0", + "radix-ui": "^1.4.3", + "react": "^19.0.0", + "react-day-picker": "^9.14.0", + "react-dom": "^19.0.0", + "react-hook-form": "^7.71.2", + "react-resizable-panels": "^4", + "recharts": "2.15.4", + "sonner": "^2.0.7", + "tailwind-merge": "^3.5.0", + "vaul": "^1.1.2", + "zod": "^4.3.6" + }, + "devDependencies": { + "@docusaurus/module-type-aliases": "3.9.2", + "@docusaurus/tsconfig": "3.9.2", + "@docusaurus/types": "3.9.2", + "@playwright/test": "^1.58.2", + "@tailwindcss/postcss": "^4.2.2", + "@vercel/node": "^5.6.15", + "husky": "^9.1.7", + "image-size": "^2.0.2", + "postcss": "^8.5.8", + "tailwindcss": "^4.2.2", + "tw-animate-css": "^1.4.0", + "typescript": "~5.6.2", + "vitest": "^4.1.0" + }, + "browserslist": { + "production": [ + ">0.5%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 3 chrome version", + "last 3 firefox version", + "last 5 safari version" + ] + }, + "engines": { + "node": ">=20.0" + } } diff --git a/scripts/sync-appkit-docs.mjs b/scripts/sync-appkit-docs.mjs index 5ca83a0..27ac8f6 100644 --- a/scripts/sync-appkit-docs.mjs +++ b/scripts/sync-appkit-docs.mjs @@ -16,65 +16,65 @@ const APPKIT_BRANCH = "main"; const UPSTREAM_EXAMPLE_DIRS = ["packages/appkit-ui/src/react/ui/examples"]; function fail(message) { - throw new Error(message); + throw new Error(message); } const SPAWN_TIMEOUT = 120_000; // 2 minutes function run(command, args, cwd) { - const result = spawnSync(command, args, { - cwd, - stdio: "inherit", - timeout: SPAWN_TIMEOUT, - }); - - if (result.signal === "SIGTERM") { - fail( - `Command timed out after ${SPAWN_TIMEOUT / 1000}s: ${command} ${args.join(" ")}`, - ); - } - if (result.status !== 0) { - fail(`Command failed: ${command} ${args.join(" ")}`); - } + const result = spawnSync(command, args, { + cwd, + stdio: "inherit", + timeout: SPAWN_TIMEOUT, + }); + + if (result.signal === "SIGTERM") { + fail( + `Command timed out after ${SPAWN_TIMEOUT / 1000}s: ${command} ${args.join(" ")}`, + ); + } + if (result.status !== 0) { + fail(`Command failed: ${command} ${args.join(" ")}`); + } } function runCapture(command, args, cwd) { - const result = spawnSync(command, args, { - cwd, - encoding: "utf-8", - timeout: SPAWN_TIMEOUT, - }); - - if (result.signal === "SIGTERM") { - fail( - `Command timed out after ${SPAWN_TIMEOUT / 1000}s: ${command} ${args.join(" ")}`, - ); - } - if (result.status !== 0) { - fail(`Command failed: ${command} ${args.join(" ")}`); - } - - return result.stdout; + const result = spawnSync(command, args, { + cwd, + encoding: "utf-8", + timeout: SPAWN_TIMEOUT, + }); + + if (result.signal === "SIGTERM") { + fail( + `Command timed out after ${SPAWN_TIMEOUT / 1000}s: ${command} ${args.join(" ")}`, + ); + } + if (result.status !== 0) { + fail(`Command failed: ${command} ${args.join(" ")}`); + } + + return result.stdout; } function copyDirRecursive(source, destination) { - fs.mkdirSync(destination, { recursive: true }); - - for (const entry of fs.readdirSync(source, { withFileTypes: true })) { - const sourcePath = path.join(source, entry.name); - const destinationPath = path.join(destination, entry.name); - - if (entry.isDirectory()) { - copyDirRecursive(sourcePath, destinationPath); - } else { - fs.copyFileSync(sourcePath, destinationPath); - } - } + fs.mkdirSync(destination, { recursive: true }); + + for (const entry of fs.readdirSync(source, { withFileTypes: true })) { + const sourcePath = path.join(source, entry.name); + const destinationPath = path.join(destination, entry.name); + + if (entry.isDirectory()) { + copyDirRecursive(sourcePath, destinationPath); + } else { + fs.copyFileSync(sourcePath, destinationPath); + } + } } function replaceDir(source, destination) { - fs.rmSync(destination, { recursive: true, force: true }); - copyDirRecursive(source, destination); + fs.rmSync(destination, { recursive: true, force: true }); + copyDirRecursive(source, destination); } // Checks whether all three sync outputs are present: @@ -83,81 +83,81 @@ function replaceDir(source, destination) { // 3. static/appkit-preview/latest/styles.css (styles were compiled) // If any is missing, returns false so the sync re-runs. function isAlreadySynced(latestDir) { - const sourceRefPath = path.join(latestDir, ".source-ref"); - const registryPath = path.join( - repoRoot, - "src", - "components", - "doc-examples", - "registry.ts", - ); - const stylesPath = path.join( - repoRoot, - "static", - "appkit-preview", - "latest", - "styles.css", - ); - - if ( - !fs.existsSync(sourceRefPath) || - !fs.existsSync(registryPath) || - !fs.existsSync(stylesPath) - ) { - return false; - } - - const ref = fs.readFileSync(sourceRefPath, "utf-8").trim(); - console.log( - `Using AppKit docs synced on ${ref}. Run 'npm run sync:appkit-docs' to resync.`, - ); - - return true; + const sourceRefPath = path.join(latestDir, ".source-ref"); + const registryPath = path.join( + repoRoot, + "src", + "components", + "doc-examples", + "registry.ts", + ); + const stylesPath = path.join( + repoRoot, + "static", + "appkit-preview", + "latest", + "styles.css", + ); + + if ( + !fs.existsSync(sourceRefPath) || + !fs.existsSync(registryPath) || + !fs.existsSync(stylesPath) + ) { + return false; + } + + const ref = fs.readFileSync(sourceRefPath, "utf-8").trim(); + console.log( + `Using AppKit docs synced on ${ref}. Run 'npm run sync:appkit-docs' to resync.`, + ); + + return true; } // Shallow-clones the appkit repo at the given branch into destDir, // using sparse checkout to only fetch docs and examples. function cloneAppKit(destDir) { - console.log(`Cloning ${APPKIT_REMOTE} (branch: ${APPKIT_BRANCH})...`); - - run( - "git", - [ - "clone", - "--depth", - "1", - "--branch", - APPKIT_BRANCH, - "--sparse", - "--filter=blob:none", - APPKIT_REMOTE, - destDir, - ], - repoRoot, - ); - - run( - "git", - [ - "-C", - destDir, - "sparse-checkout", - "set", - "docs/docs", - "docs/versioned_docs", - "docs/versions.json", - "packages/appkit-ui/src/react/ui/examples", - ], - repoRoot, - ); + console.log(`Cloning ${APPKIT_REMOTE} (branch: ${APPKIT_BRANCH})...`); + + run( + "git", + [ + "clone", + "--depth", + "1", + "--branch", + APPKIT_BRANCH, + "--sparse", + "--filter=blob:none", + APPKIT_REMOTE, + destDir, + ], + repoRoot, + ); + + run( + "git", + [ + "-C", + destDir, + "sparse-checkout", + "set", + "docs/docs", + "docs/versioned_docs", + "docs/versions.json", + "packages/appkit-ui/src/react/ui/examples", + ], + repoRoot, + ); } function getHeadSha(repoDir) { - return runCapture( - "git", - ["-C", repoDir, "rev-parse", "--short", "HEAD"], - repoRoot, - ).trim(); + return runCapture( + "git", + ["-C", repoDir, "rev-parse", "--short", "HEAD"], + repoRoot, + ).trim(); } // Copies versioned docs from the cloned repo if they exist. @@ -170,93 +170,93 @@ function getHeadSha(repoDir) { // - Copy docs/docs/ → docs/appkit/next/ (unreleased dev docs) // - Copy remaining versioned_docs/version-*/ → docs/appkit/version-*/ function syncVersionedDocs(clonedRoot, docsRoot) { - const versionedDocsDir = path.join(clonedRoot, "docs", "versioned_docs"); - - if (!fs.existsSync(versionedDocsDir)) { - return; - } - - const versionDirs = fs - .readdirSync(versionedDocsDir, { withFileTypes: true }) - .filter( - (entry) => entry.isDirectory() && entry.name.startsWith("version-"), - ); - - if (versionDirs.length === 0) { - return; - } - - for (const entry of versionDirs) { - const src = path.join(versionedDocsDir, entry.name); - const dest = path.join(docsRoot, entry.name); - replaceDir(src, dest); - console.log(`Synced versioned docs: ${entry.name}`); - } + const versionedDocsDir = path.join(clonedRoot, "docs", "versioned_docs"); + + if (!fs.existsSync(versionedDocsDir)) { + return; + } + + const versionDirs = fs + .readdirSync(versionedDocsDir, { withFileTypes: true }) + .filter( + (entry) => entry.isDirectory() && entry.name.startsWith("version-"), + ); + + if (versionDirs.length === 0) { + return; + } + + for (const entry of versionDirs) { + const src = path.join(versionedDocsDir, entry.name); + const dest = path.join(docsRoot, entry.name); + replaceDir(src, dest); + console.log(`Synced versioned docs: ${entry.name}`); + } } // Pascal-cases a kebab-cased file stem ("alert-dialog" -> "AlertDialog"). function pascalCase(stem) { - return stem - .split("-") - .filter(Boolean) - .map((part) => part[0].toUpperCase() + part.slice(1)) - .join(""); + return stem + .split("-") + .filter(Boolean) + .map((part) => part[0].toUpperCase() + part.slice(1)) + .join(""); } // Walks the cloned appkit tree for example files and writes them under // src/components/doc-examples, preserving kebab-case filenames so the // contract stays stable. function syncExamples(clonedRoot) { - const outDir = path.join(repoRoot, "src", "components", "doc-examples"); - fs.rmSync(outDir, { recursive: true, force: true }); - fs.mkdirSync(outDir, { recursive: true }); - - const collected = []; - for (const dir of UPSTREAM_EXAMPLE_DIRS) { - const srcDir = path.join(clonedRoot, dir); - if (!fs.existsSync(srcDir)) continue; - - for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) { - if (!entry.isFile()) continue; - if (!entry.name.endsWith(".example.tsx")) continue; - - const src = path.join(srcDir, entry.name); - const dest = path.join(outDir, entry.name); - fs.copyFileSync(src, dest); - - const stem = entry.name.slice(0, -".example.tsx".length); - collected.push({ stem, filename: entry.name }); - } - } - - collected.sort((a, b) => a.stem.localeCompare(b.stem)); - - const imports = collected - .map( - ({ stem, filename }) => - `import ${pascalCase(stem)}Example from "./${filename.replace(/\.tsx$/, "")}";`, - ) - .join("\n"); - - const entries = collected - .map(({ stem, filename }) => { - const sourcePath = path.join(outDir, filename); - const rawSource = fs.readFileSync(sourcePath, "utf-8"); - const escaped = rawSource - .replace(/\\/g, "\\\\") - .replace(/`/g, "\\`") - .replace(/\$/g, "\\$"); - return ` "${stem}": {\n Component: ${pascalCase(stem)}Example,\n source: \`${escaped}\`,\n }`; - }) - .join(",\n"); - - const registry = `// Auto-generated by scripts/sync-appkit-docs.mjs. Do not edit by hand.\n// Source files alongside this registry are vendored verbatim from the\n// appkit main branch and import from '@databricks/appkit-ui/react'.\nimport type { ComponentType } from "react";\n${imports}\n\nexport type DocExampleEntry = { Component: ComponentType; source: string };\n\nexport const docExamples = {\n${entries},\n} as const satisfies Record;\n\nexport type DocExampleKey = keyof typeof docExamples;\n`; - - fs.writeFileSync(path.join(outDir, "registry.ts"), registry, "utf-8"); - - console.log( - `Synced ${collected.length} example components to ${path.relative(repoRoot, outDir)}`, - ); + const outDir = path.join(repoRoot, "src", "components", "doc-examples"); + fs.rmSync(outDir, { recursive: true, force: true }); + fs.mkdirSync(outDir, { recursive: true }); + + const collected = []; + for (const dir of UPSTREAM_EXAMPLE_DIRS) { + const srcDir = path.join(clonedRoot, dir); + if (!fs.existsSync(srcDir)) continue; + + for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) { + if (!entry.isFile()) continue; + if (!entry.name.endsWith(".example.tsx")) continue; + + const src = path.join(srcDir, entry.name); + const dest = path.join(outDir, entry.name); + fs.copyFileSync(src, dest); + + const stem = entry.name.slice(0, -".example.tsx".length); + collected.push({ stem, filename: entry.name }); + } + } + + collected.sort((a, b) => a.stem.localeCompare(b.stem)); + + const imports = collected + .map( + ({ stem, filename }) => + `import ${pascalCase(stem)}Example from "./${filename.replace(/\.tsx$/, "")}";`, + ) + .join("\n"); + + const entries = collected + .map(({ stem, filename }) => { + const sourcePath = path.join(outDir, filename); + const rawSource = fs.readFileSync(sourcePath, "utf-8"); + const escaped = rawSource + .replace(/\\/g, "\\\\") + .replace(/`/g, "\\`") + .replace(/\$/g, "\\$"); + return ` "${stem}": {\n Component: ${pascalCase(stem)}Example,\n source: \`${escaped}\`,\n }`; + }) + .join(",\n"); + + const registry = `// Auto-generated by scripts/sync-appkit-docs.mjs. Do not edit by hand.\n// Source files alongside this registry are vendored verbatim from the\n// appkit main branch and import from '@databricks/appkit-ui/react'.\nimport type { ComponentType } from "react";\n${imports}\n\nexport type DocExampleEntry = { Component: ComponentType; source: string };\n\nexport const docExamples = {\n${entries},\n} as const satisfies Record;\n\nexport type DocExampleKey = keyof typeof docExamples;\n`; + + fs.writeFileSync(path.join(outDir, "registry.ts"), registry, "utf-8"); + + console.log( + `Synced ${collected.length} example components to ${path.relative(repoRoot, outDir)}`, + ); } // Compiles the installed @databricks/appkit-ui styles.css (Tailwind v4 source @@ -264,111 +264,111 @@ function syncExamples(clonedRoot) { // React components) into a real CSS bundle, and writes it to a public static // path so the DocExample iframe can link it directly without webpack. async function syncCompiledStyles(channel) { - const pkgDir = path.join( - repoRoot, - "node_modules", - "@databricks", - "appkit-ui", - ); - if (!fs.existsSync(pkgDir)) { - fail( - "@databricks/appkit-ui is not installed. Run `npm install` and retry.", - ); - } - - const pkgJson = JSON.parse( - fs.readFileSync(path.join(pkgDir, "package.json"), "utf-8"), - ); - const version = pkgJson.version; - - const srcCss = path.join(pkgDir, "dist", "styles.css"); - if (!fs.existsSync(srcCss)) { - fail(`dist/styles.css not found in @databricks/appkit-ui@${version}.`); - } - - const destDir = path.join(repoRoot, "static", "appkit-preview", channel); - fs.mkdirSync(destDir, { recursive: true }); - const destCss = path.join(destDir, "styles.css"); - - const [{ default: postcss }, { default: tailwind }] = await Promise.all([ - import("postcss"), - import("@tailwindcss/postcss"), - ]); - - const rawCss = fs.readFileSync(srcCss, "utf-8"); - const result = await postcss([tailwind()]).process(rawCss, { - from: srcCss, - to: destCss, - }); - - // NOTE: avoid `*/` anywhere inside this banner -- it would terminate the - // CSS comment early and break the entire stylesheet's parsing. - const banner = `/* Synced from @databricks/appkit-ui@${version} (${channel}).\n * Source of truth: https://github.com/databricks/appkit\n * Compiled via @tailwindcss/postcss; do not edit by hand.\n * Regenerate via: npm run sync:appkit-docs\n */\n`; - fs.writeFileSync(destCss, banner + result.css, "utf-8"); - - console.log( - `Compiled @databricks/appkit-ui@${version} styles to ` + - `${path.relative(repoRoot, destCss)} (${(result.css.length / 1024).toFixed(1)} KB).`, - ); - - return version; + const pkgDir = path.join( + repoRoot, + "node_modules", + "@databricks", + "appkit-ui", + ); + if (!fs.existsSync(pkgDir)) { + fail( + "@databricks/appkit-ui is not installed. Run `npm install` and retry.", + ); + } + + const pkgJson = JSON.parse( + fs.readFileSync(path.join(pkgDir, "package.json"), "utf-8"), + ); + const version = pkgJson.version; + + const srcCss = path.join(pkgDir, "dist", "styles.css"); + if (!fs.existsSync(srcCss)) { + fail(`dist/styles.css not found in @databricks/appkit-ui@${version}.`); + } + + const destDir = path.join(repoRoot, "static", "appkit-preview", channel); + fs.mkdirSync(destDir, { recursive: true }); + const destCss = path.join(destDir, "styles.css"); + + const [{ default: postcss }, { default: tailwind }] = await Promise.all([ + import("postcss"), + import("@tailwindcss/postcss"), + ]); + + const rawCss = fs.readFileSync(srcCss, "utf-8"); + const result = await postcss([tailwind()]).process(rawCss, { + from: srcCss, + to: destCss, + }); + + // NOTE: avoid `*/` anywhere inside this banner -- it would terminate the + // CSS comment early and break the entire stylesheet's parsing. + const banner = `/* Synced from @databricks/appkit-ui@${version} (${channel}).\n * Source of truth: https://github.com/databricks/appkit\n * Compiled via @tailwindcss/postcss; do not edit by hand.\n * Regenerate via: npm run sync:appkit-docs\n */\n`; + fs.writeFileSync(destCss, banner + result.css, "utf-8"); + + console.log( + `Compiled @databricks/appkit-ui@${version} styles to ` + + `${path.relative(repoRoot, destCss)} (${(result.css.length / 1024).toFixed(1)} KB).`, + ); + + return version; } async function main() { - const force = process.argv.includes("--force"); + const force = process.argv.includes("--force"); - const docsRoot = path.join(repoRoot, "docs", "appkit"); - const latestDir = path.join(docsRoot, "latest"); + const docsRoot = path.join(repoRoot, "docs", "appkit"); + const latestDir = path.join(docsRoot, "latest"); - // Skip if docs already exist (unless --force) - if (!force && isAlreadySynced(latestDir)) { - return; - } + // Skip if docs already exist (unless --force) + if (!force && isAlreadySynced(latestDir)) { + return; + } - fs.mkdirSync(docsRoot, { recursive: true }); + fs.mkdirSync(docsRoot, { recursive: true }); - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "devhub-appkit-docs-")); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "devhub-appkit-docs-")); - try { - cloneAppKit(tempDir); + try { + cloneAppKit(tempDir); - const sha = getHeadSha(tempDir); - const syncDate = new Date().toISOString().slice(0, 10); - const appkitDocsSource = path.join(tempDir, "docs", "docs"); + const sha = getHeadSha(tempDir); + const syncDate = new Date().toISOString().slice(0, 10); + const appkitDocsSource = path.join(tempDir, "docs", "docs"); - if (!fs.existsSync(appkitDocsSource)) { - fail("Could not find docs/docs in cloned AppKit repository."); - } + if (!fs.existsSync(appkitDocsSource)) { + fail("Could not find docs/docs in cloned AppKit repository."); + } - // Clear existing docs and copy latest - fs.rmSync(docsRoot, { recursive: true, force: true }); - fs.mkdirSync(docsRoot, { recursive: true }); + // Clear existing docs and copy latest + fs.rmSync(docsRoot, { recursive: true, force: true }); + fs.mkdirSync(docsRoot, { recursive: true }); - replaceDir(appkitDocsSource, latestDir); - fs.writeFileSync( - path.join(latestDir, ".source-ref"), - `${syncDate} (${sha})\n`, - "utf-8", - ); + replaceDir(appkitDocsSource, latestDir); + fs.writeFileSync( + path.join(latestDir, ".source-ref"), + `${syncDate} (${sha})\n`, + "utf-8", + ); - // Copy versioned docs if present (future-proofing) - syncVersionedDocs(tempDir, docsRoot); + // Copy versioned docs if present (future-proofing) + syncVersionedDocs(tempDir, docsRoot); - syncExamples(tempDir); - const stylesVersion = await syncCompiledStyles("latest"); + syncExamples(tempDir); + const stylesVersion = await syncCompiledStyles("latest"); - console.log( - `\nAppKit docs synced from ${APPKIT_BRANCH} (${sha}), styles @ ${stylesVersion}.`, - ); - console.log("Done."); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } + console.log( + `\nAppKit docs synced from ${APPKIT_BRANCH} (${sha}), styles @ ${stylesVersion}.`, + ); + console.log("Done."); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } } main().catch((error) => { - console.error( - `Error: ${error instanceof Error ? error.message : String(error)}`, - ); - process.exit(1); + console.error( + `Error: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); }); diff --git a/src/components/DocExample.tsx b/src/components/DocExample.tsx index d099a1e..adcd7d2 100644 --- a/src/components/DocExample.tsx +++ b/src/components/DocExample.tsx @@ -1,9 +1,9 @@ import { - useEffect, - useRef, - useState, - type ReactNode, - type RefObject, + useEffect, + useRef, + useState, + type ReactNode, + type RefObject, } from "react"; import { createPortal } from "react-dom"; import BrowserOnly from "@docusaurus/BrowserOnly"; @@ -15,129 +15,129 @@ import { cn } from "@/lib/utils"; import { docExamples, type DocExampleKey } from "./doc-examples/registry"; type DocExampleProps = { - name: string; + name: string; }; // Components whose previews need more vertical space than the auto-sizing can // infer (dialogs/popovers/menus that open _above_ their trigger, components // that pin content to a viewport edge). Upstream uses the same technique. const HEIGHT_OVERRIDES: Partial> = { - dialog: 600, - drawer: 700, - "hover-card": 400, - "navigation-menu": 600, - menubar: 500, - popover: 450, - sheet: 700, - "dropdown-menu": 500, - select: 450, - "alert-dialog": 500, - "context-menu": 500, - sidebar: 700, + dialog: 600, + drawer: 700, + "hover-card": 400, + "navigation-menu": 600, + menubar: 500, + popover: 450, + sheet: 700, + "dropdown-menu": 500, + select: 450, + "alert-dialog": 500, + "context-menu": 500, + sidebar: 700, }; // Which Docusaurus theme is active right now. The DocExample iframe needs this // to mirror `.dark` on its own so appkit-ui's dark-mode styles apply. function isParentDark() { - if (typeof document === "undefined") return false; - return document.documentElement.getAttribute("data-theme") === "dark"; + if (typeof document === "undefined") return false; + return document.documentElement.getAttribute("data-theme") === "dark"; } function isValidExampleName(name: string): name is DocExampleKey { - return Object.prototype.hasOwnProperty.call(docExamples, name); + return Object.prototype.hasOwnProperty.call(docExamples, name); } export function DocExample({ name }: DocExampleProps): ReactNode { - const [tab, setTab] = useState<"preview" | "code">("preview"); - - if (!isValidExampleName(name)) { - return ( -
- Missing DocExample for {name}. Re-run{" "} - npm run sync:appkit-docs to sync examples from the upstream - appkit repo. -
- ); - } - - const entry = docExamples[name]; - - return ( -
-
-
- setTab("preview")} - > - Preview - - setTab("code")}> - Code - -
- - {name} - -
- - {tab === "preview" ? ( - - Loading preview… - - } - > - {() => ( - - )} - - ) : ( -
- {entry.source} -
- )} -
- ); + const [tab, setTab] = useState<"preview" | "code">("preview"); + + if (!isValidExampleName(name)) { + return ( +
+ Missing DocExample for {name}. Re-run{" "} + npm run sync:appkit-docs to sync examples from the upstream + appkit repo. +
+ ); + } + + const entry = docExamples[name]; + + return ( +
+
+
+ setTab("preview")} + > + Preview + + setTab("code")}> + Code + +
+ + {name} + +
+ + {tab === "preview" ? ( + + Loading preview… + + } + > + {() => ( + + )} + + ) : ( +
+ {entry.source} +
+ )} +
+ ); } function TabButton({ - active, - onClick, - children, + active, + onClick, + children, }: { - active: boolean; - onClick: () => void; - children: ReactNode; + active: boolean; + onClick: () => void; + children: ReactNode; }) { - return ( - - ); + return ( + + ); } // Iframe-based preview. AppKit-UI ships a Tailwind v4 stylesheet with its own @@ -148,85 +148,85 @@ const PREVIEW_MAX_HEIGHT = 800; const PREVIEW_DEFAULT_HEIGHT = 320; type IframePreviewProps = { - exampleKey: DocExampleKey; - Component: React.ComponentType; - customHeight?: number; + exampleKey: DocExampleKey; + Component: React.ComponentType; + customHeight?: number; }; function IframePreview({ - exampleKey, - Component, - customHeight, + exampleKey, + Component, + customHeight, }: IframePreviewProps) { - const iframeRef = useRef(null); - const [mountNode, setMountNode] = useState(null); - const stylesHref = useBaseUrl("/appkit-preview/latest/styles.css"); - - const height = useAutoHeight(iframeRef, customHeight); - useDarkModeSync(iframeRef); - useSonnerStyleSync(iframeRef, exampleKey); - - useEffect(() => { - const iframe = iframeRef.current; - if (!iframe) return; - const doc = iframe.contentDocument; - if (!doc) return; - - doc.open(); - doc.write(iframeHtml(stylesHref)); - doc.close(); - - // Mirror the current Docusaurus theme immediately so we don't flash light- - // mode tokens before useDarkModeSync runs. - if (isParentDark()) { - doc.documentElement.classList.add("dark"); - } - - const setFromRoot = () => { - const root = doc.getElementById("preview-root"); - if (root) setMountNode(root); - }; - - const link = doc.querySelector('link[rel="stylesheet"]'); - if (link) { - link.addEventListener("load", setFromRoot, { once: true }); - link.addEventListener("error", setFromRoot, { once: true }); - } else { - setFromRoot(); - } - }, [stylesHref]); - - return ( - - ); + const iframeRef = useRef(null); + const [mountNode, setMountNode] = useState(null); + const stylesHref = useBaseUrl("/appkit-preview/latest/styles.css"); + + const height = useAutoHeight(iframeRef, customHeight); + useDarkModeSync(iframeRef); + useSonnerStyleSync(iframeRef, exampleKey); + + useEffect(() => { + const iframe = iframeRef.current; + if (!iframe) return; + const doc = iframe.contentDocument; + if (!doc) return; + + doc.open(); + doc.write(iframeHtml(stylesHref)); + doc.close(); + + // Mirror the current Docusaurus theme immediately so we don't flash light- + // mode tokens before useDarkModeSync runs. + if (isParentDark()) { + doc.documentElement.classList.add("dark"); + } + + const setFromRoot = () => { + const root = doc.getElementById("preview-root"); + if (root) setMountNode(root); + }; + + const link = doc.querySelector('link[rel="stylesheet"]'); + if (link) { + link.addEventListener("load", setFromRoot, { once: true }); + link.addEventListener("error", setFromRoot, { once: true }); + } else { + setFromRoot(); + } + }, [stylesHref]); + + return ( + + ); } function iframeHtml(stylesHref: string): string { - return ` + return ` @@ -259,110 +259,110 @@ function iframeHtml(stylesHref: string): string { } function useAutoHeight( - iframeRef: RefObject, - customHeight: number | undefined, + iframeRef: RefObject, + customHeight: number | undefined, ) { - const [height, setHeight] = useState( - customHeight ?? PREVIEW_DEFAULT_HEIGHT, - ); - - useEffect(() => { - const iframe = iframeRef.current; - if (!iframe?.contentDocument?.body) return; - - if (customHeight) { - setHeight(customHeight); - return; - } - - const doc = iframe.contentDocument; - const update = () => { - const scroll = doc.body.scrollHeight; - const next = Math.min( - Math.max(scroll + 20, PREVIEW_MIN_HEIGHT), - PREVIEW_MAX_HEIGHT, - ); - setHeight(next); - }; - - const initial = setTimeout(update, 100); - const observer = new ResizeObserver(update); - observer.observe(doc.body); - - return () => { - clearTimeout(initial); - observer.disconnect(); - }; - }, [iframeRef, customHeight]); - - return height; + const [height, setHeight] = useState( + customHeight ?? PREVIEW_DEFAULT_HEIGHT, + ); + + useEffect(() => { + const iframe = iframeRef.current; + if (!iframe?.contentDocument?.body) return; + + if (customHeight) { + setHeight(customHeight); + return; + } + + const doc = iframe.contentDocument; + const update = () => { + const scroll = doc.body.scrollHeight; + const next = Math.min( + Math.max(scroll + 20, PREVIEW_MIN_HEIGHT), + PREVIEW_MAX_HEIGHT, + ); + setHeight(next); + }; + + const initial = setTimeout(update, 100); + const observer = new ResizeObserver(update); + observer.observe(doc.body); + + return () => { + clearTimeout(initial); + observer.disconnect(); + }; + }, [iframeRef, customHeight]); + + return height; } function useDarkModeSync(iframeRef: RefObject) { - useEffect(() => { - const iframe = iframeRef.current; - if (!iframe) return; - - const sync = () => { - const doc = iframe.contentDocument; - if (!doc) return; - doc.documentElement.classList.toggle("dark", isParentDark()); - }; - - sync(); - - const observer = new MutationObserver(sync); - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ["data-theme", "class"], - }); - - return () => observer.disconnect(); - }, [iframeRef]); + useEffect(() => { + const iframe = iframeRef.current; + if (!iframe) return; + + const sync = () => { + const doc = iframe.contentDocument; + if (!doc) return; + doc.documentElement.classList.toggle("dark", isParentDark()); + }; + + sync(); + + const observer = new MutationObserver(sync); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme", "class"], + }); + + return () => observer.disconnect(); + }, [iframeRef]); } // Sonner injects its keyframe + toast styles into the *parent* document on // first render. When the Toaster is portaled into an iframe, those styles // never reach it. Clone them over for the sonner preview only. function useSonnerStyleSync( - iframeRef: RefObject, - exampleKey: DocExampleKey, + iframeRef: RefObject, + exampleKey: DocExampleKey, ) { - useEffect(() => { - if (exampleKey !== "sonner") return; - const iframe = iframeRef.current; - if (!iframe?.contentDocument) return; - - const doc = iframe.contentDocument; - let attempts = 0; - let timer: ReturnType | null = null; - - const cloneStyles = () => { - const sonnerStyles = Array.from( - document.querySelectorAll("style"), - ).filter( - (el) => - el.textContent?.includes("[data-sonner-toaster]") || - el.textContent?.includes("[data-sonner-toast]"), - ); - - if (sonnerStyles.length > 0) { - for (const style of sonnerStyles) { - doc.head.appendChild(style.cloneNode(true)); - } - return; - } - - if (attempts < 10) { - attempts += 1; - timer = setTimeout(cloneStyles, 100); - } - }; - - timer = setTimeout(cloneStyles, 100); - - return () => { - if (timer) clearTimeout(timer); - }; - }, [iframeRef, exampleKey]); + useEffect(() => { + if (exampleKey !== "sonner") return; + const iframe = iframeRef.current; + if (!iframe?.contentDocument) return; + + const doc = iframe.contentDocument; + let attempts = 0; + let timer: ReturnType | null = null; + + const cloneStyles = () => { + const sonnerStyles = Array.from( + document.querySelectorAll("style"), + ).filter( + (el) => + el.textContent?.includes("[data-sonner-toaster]") || + el.textContent?.includes("[data-sonner-toast]"), + ); + + if (sonnerStyles.length > 0) { + for (const style of sonnerStyles) { + doc.head.appendChild(style.cloneNode(true)); + } + return; + } + + if (attempts < 10) { + attempts += 1; + timer = setTimeout(cloneStyles, 100); + } + }; + + timer = setTimeout(cloneStyles, 100); + + return () => { + if (timer) clearTimeout(timer); + }; + }, [iframeRef, exampleKey]); } diff --git a/src/theme/DocSidebar/Desktop/Content/index.tsx b/src/theme/DocSidebar/Desktop/Content/index.tsx index 6ec11b9..8f03c7d 100644 --- a/src/theme/DocSidebar/Desktop/Content/index.tsx +++ b/src/theme/DocSidebar/Desktop/Content/index.tsx @@ -3,8 +3,8 @@ import clsx from "clsx"; import Link from "@docusaurus/Link"; import { ThemeClassNames } from "@docusaurus/theme-common"; import { - useAnnouncementBar, - useScrollPosition, + useAnnouncementBar, + useScrollPosition, } from "@docusaurus/theme-common/internal"; import { translate } from "@docusaurus/Translate"; import useIsBrowser from "@docusaurus/useIsBrowser"; @@ -13,228 +13,228 @@ import type { Props } from "@theme/DocSidebar/Desktop/Content"; import { ChevronLeft } from "lucide-react"; import { Button } from "@/components/ui/button"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from "@/components/ui/select"; function useShowAnnouncementBar() { - const { isActive } = useAnnouncementBar(); - const [showAnnouncementBar, setShowAnnouncementBar] = useState(isActive); - useScrollPosition( - ({ scrollY }) => { - if (isActive) { - setShowAnnouncementBar(scrollY === 0); - } - }, - [isActive], - ); - return isActive && showAnnouncementBar; + const { isActive } = useAnnouncementBar(); + const [showAnnouncementBar, setShowAnnouncementBar] = useState(isActive); + useScrollPosition( + ({ scrollY }) => { + if (isActive) { + setShowAnnouncementBar(scrollY === 0); + } + }, + [isActive], + ); + return isActive && showAnnouncementBar; } type SidebarLikeItem = { - type?: string; - label?: string; - href?: string; - items?: SidebarLikeItem[]; + type?: string; + label?: string; + href?: string; + items?: SidebarLikeItem[]; }; type AppKitChannelOption = { - label: string; - href: string; + label: string; + href: string; }; function normalizePath(value: string): string { - return value.replace(/\/+$/, ""); + return value.replace(/\/+$/, ""); } function isSidebarCategory(item: SidebarLikeItem): item is SidebarLikeItem & { - items: SidebarLikeItem[]; + items: SidebarLikeItem[]; } { - return item.type === "category" && Array.isArray(item.items); + return item.type === "category" && Array.isArray(item.items); } function findCategoryByLabel( - items: SidebarLikeItem[], - label: string, + items: SidebarLikeItem[], + label: string, ): SidebarLikeItem | null { - return ( - items.find( - (item) => - isSidebarCategory(item) && - item.label?.toLocaleLowerCase() === label.toLocaleLowerCase(), - ) ?? null - ); + return ( + items.find( + (item) => + isSidebarCategory(item) && + item.label?.toLocaleLowerCase() === label.toLocaleLowerCase(), + ) ?? null + ); } function isAppKitDocsPath(path: string): boolean { - const normalizedPath = normalizePath(path); - return normalizedPath.startsWith("/docs/appkit/"); + const normalizedPath = normalizePath(path); + return normalizedPath.startsWith("/docs/appkit/"); } function getAppKitSidebarItems(sidebar: SidebarLikeItem[]): SidebarLikeItem[] { - const referencesCategory = findCategoryByLabel(sidebar, "reference"); - if (!referencesCategory || !isSidebarCategory(referencesCategory)) { - return []; - } - - const appKitCategory = findCategoryByLabel( - referencesCategory.items, - "appkit", - ); - if (!appKitCategory || !isSidebarCategory(appKitCategory)) { - return []; - } - - return appKitCategory.items; + const referencesCategory = findCategoryByLabel(sidebar, "reference"); + if (!referencesCategory || !isSidebarCategory(referencesCategory)) { + return []; + } + + const appKitCategory = findCategoryByLabel( + referencesCategory.items, + "appkit", + ); + if (!appKitCategory || !isSidebarCategory(appKitCategory)) { + return []; + } + + return appKitCategory.items; } function extractChannel(href: string): string | null { - const match = href.match(/^\/docs\/appkit\/([^/]+)/); - return match ? match[1] : null; + const match = href.match(/^\/docs\/appkit\/([^/]+)/); + return match ? match[1] : null; } function getAppKitChannelOptions( - items: SidebarLikeItem[], + items: SidebarLikeItem[], ): AppKitChannelOption[] { - const channels = new Map(); - - function collect(list: SidebarLikeItem[]) { - for (const item of list) { - if (item.href) { - const channel = extractChannel(item.href); - if (channel && !channels.has(channel)) { - channels.set(channel, { - label: channel, - href: `/docs/appkit/${channel}`, - }); - } - } - if (isSidebarCategory(item)) { - collect(item.items); - } - } - } - - collect(items); - return Array.from(channels.values()); + const channels = new Map(); + + function collect(list: SidebarLikeItem[]) { + for (const item of list) { + if (item.href) { + const channel = extractChannel(item.href); + if (channel && !channels.has(channel)) { + channels.set(channel, { + label: channel, + href: `/docs/appkit/${channel}`, + }); + } + } + if (isSidebarCategory(item)) { + collect(item.items); + } + } + } + + collect(items); + return Array.from(channels.values()); } function getActiveChannelHref( - path: string, - channels: AppKitChannelOption[], + path: string, + channels: AppKitChannelOption[], ): string { - const normalizedPath = normalizePath(path); + const normalizedPath = normalizePath(path); - return ( - channels.find((channel) => normalizedPath.startsWith(channel.href))?.href ?? - channels[0]?.href ?? - "" - ); + return ( + channels.find((channel) => normalizedPath.startsWith(channel.href))?.href ?? + channels[0]?.href ?? + "" + ); } export default function DocSidebarDesktopContent({ - path, - sidebar, - className, + path, + sidebar, + className, }: Props): ReactNode { - const showAnnouncementBar = useShowAnnouncementBar(); - const isBrowser = useIsBrowser(); - - const appKitSidebarItems = useMemo( - () => getAppKitSidebarItems(sidebar as SidebarLikeItem[]), - [sidebar], - ); - const appKitChannels = useMemo( - () => getAppKitChannelOptions(appKitSidebarItems), - [appKitSidebarItems], - ); - const showAppKitReferenceShell = - isAppKitDocsPath(path) && appKitSidebarItems.length > 0; - const activeChannelHref = getActiveChannelHref(path, appKitChannels); - - return ( - - ); + const showAnnouncementBar = useShowAnnouncementBar(); + const isBrowser = useIsBrowser(); + + const appKitSidebarItems = useMemo( + () => getAppKitSidebarItems(sidebar as SidebarLikeItem[]), + [sidebar], + ); + const appKitChannels = useMemo( + () => getAppKitChannelOptions(appKitSidebarItems), + [appKitSidebarItems], + ); + const showAppKitReferenceShell = + isAppKitDocsPath(path) && appKitSidebarItems.length > 0; + const activeChannelHref = getActiveChannelHref(path, appKitChannels); + + return ( + + ); } From 234b6fa98f9a8d6a6767623096f9dfff18e3f580 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Fri, 24 Apr 2026 11:37:21 +0200 Subject: [PATCH 5/6] feat(appkit-docs): support APPKIT_REMOTE and APPKIT_BRANCH env vars Allow overriding the appkit git remote URL and branch via environment variables. Defaults remain unchanged (github.com/databricks/appkit, main). This enables CI pipelines in the appkit repo to validate that docs changes on a PR branch don't break the devhub build, and supports forks where the remote URL differs. Co-authored-by: Isaac --- scripts/sync-appkit-docs.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/sync-appkit-docs.mjs b/scripts/sync-appkit-docs.mjs index 27ac8f6..9718a04 100644 --- a/scripts/sync-appkit-docs.mjs +++ b/scripts/sync-appkit-docs.mjs @@ -8,8 +8,9 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, ".."); -const APPKIT_REMOTE = "https://github.com/databricks/appkit.git"; -const APPKIT_BRANCH = "main"; +const APPKIT_REMOTE = + process.env.APPKIT_REMOTE || "https://github.com/databricks/appkit.git"; +const APPKIT_BRANCH = process.env.APPKIT_BRANCH || "main"; // Where upstream appkit stores its source-of-truth component examples. // Paths are relative to the cloned appkit repo root. From 0b4356a75cc56cfab980d4a2509f2299e53c3172 Mon Sep 17 00:00:00 2001 From: Pawel Kosiec Date: Fri, 24 Apr 2026 12:07:37 +0200 Subject: [PATCH 6/6] fix: validate env vars and broaden signal handling in sync script - Validate APPKIT_BRANCH and APPKIT_REMOTE before passing to git clone to prevent flag injection (e.g. --upload-pack=/bin/sh). - Replace SIGTERM-only checks with generic signal check in run() and runCapture() to catch SIGKILL, SIGSEGV, etc. Co-authored-by: Isaac --- scripts/sync-appkit-docs.mjs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/scripts/sync-appkit-docs.mjs b/scripts/sync-appkit-docs.mjs index 9718a04..394ab69 100644 --- a/scripts/sync-appkit-docs.mjs +++ b/scripts/sync-appkit-docs.mjs @@ -12,6 +12,14 @@ const APPKIT_REMOTE = process.env.APPKIT_REMOTE || "https://github.com/databricks/appkit.git"; const APPKIT_BRANCH = process.env.APPKIT_BRANCH || "main"; +if (!/^[\w.\-/]+$/.test(APPKIT_BRANCH)) { + throw new Error(`Invalid APPKIT_BRANCH: ${APPKIT_BRANCH}`); +} + +if (!/^https?:\/\//.test(APPKIT_REMOTE) && !/^git@/.test(APPKIT_REMOTE)) { + throw new Error(`Invalid APPKIT_REMOTE: must be an HTTPS or SSH git URL`); +} + // Where upstream appkit stores its source-of-truth component examples. // Paths are relative to the cloned appkit repo root. const UPSTREAM_EXAMPLE_DIRS = ["packages/appkit-ui/src/react/ui/examples"]; @@ -29,10 +37,8 @@ function run(command, args, cwd) { timeout: SPAWN_TIMEOUT, }); - if (result.signal === "SIGTERM") { - fail( - `Command timed out after ${SPAWN_TIMEOUT / 1000}s: ${command} ${args.join(" ")}`, - ); + if (result.signal) { + fail(`Command killed by ${result.signal}: ${command} ${args.join(" ")}`); } if (result.status !== 0) { fail(`Command failed: ${command} ${args.join(" ")}`); @@ -46,10 +52,8 @@ function runCapture(command, args, cwd) { timeout: SPAWN_TIMEOUT, }); - if (result.signal === "SIGTERM") { - fail( - `Command timed out after ${SPAWN_TIMEOUT / 1000}s: ${command} ${args.join(" ")}`, - ); + if (result.signal) { + fail(`Command killed by ${result.signal}: ${command} ${args.join(" ")}`); } if (result.status !== 0) { fail(`Command failed: ${command} ${args.join(" ")}`);