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/.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 2722c16..4764262 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 diff --git a/package.json b/package.json index 1b30d90..249bc05 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev": "bash scripts/dev.sh", "start": "docusaurus start", "build": "docusaurus build", - "sync:appkit-docs": "node scripts/sync-appkit-docs.mjs", + "sync:appkit-docs": "node scripts/sync-appkit-docs.mjs --force", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", @@ -22,7 +22,9 @@ "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" + "prepare": "husky", + "prebuild": "node scripts/sync-appkit-docs.mjs", + "prestart": "node scripts/sync-appkit-docs.mjs" }, "dependencies": { "@base-ui/react": "^1.3.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..394ab69 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,21 +8,38 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, ".."); +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 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); + 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) { + fail(`Command killed by ${result.signal}: ${command} ${args.join(" ")}`); + } if (result.status !== 0) { fail(`Command failed: ${command} ${args.join(" ")}`); } @@ -34,8 +49,12 @@ function runCapture(command, args, cwd) { const result = spawnSync(command, args, { cwd, encoding: "utf-8", + timeout: SPAWN_TIMEOUT, }); + if (result.signal) { + fail(`Command killed by ${result.signal}: ${command} ${args.join(" ")}`); + } if (result.status !== 0) { fail(`Command failed: ${command} ${args.join(" ")}`); } @@ -63,91 +82,121 @@ function replaceDir(source, destination) { copyDirRecursive(source, destination); } -function normalizeMajorChannel(raw) { - const value = raw.trim(); - if (value.length === 0) { - fail("Version channel cannot be empty."); - } +// 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 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 (!/^v\d+$/.test(value)) { - fail("Only major channels are allowed: v0, v1, v2, ..."); + if ( + !fs.existsSync(sourceRefPath) || + !fs.existsSync(registryPath) || + !fs.existsSync(stylesPath) + ) { + return false; } - return value; -} - -function parseSemverTag(tag) { - const match = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag); - if (!match) { - return null; - } + 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 { - major: Number(match[1]), - minor: Number(match[2]), - patch: Number(match[3]), - }; + 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})...`); -function resolveLatestTagForMajor(majorChannel) { - const major = Number(majorChannel.slice(1)); - const remote = "https://github.com/databricks/appkit.git"; - const pattern = `v${major}.*`; + run( + "git", + [ + "clone", + "--depth", + "1", + "--branch", + APPKIT_BRANCH, + "--sparse", + "--filter=blob:none", + APPKIT_REMOTE, + destDir, + ], + repoRoot, + ); - const output = runCapture( + run( "git", - ["ls-remote", "--tags", "--refs", remote, pattern], + [ + "-C", + destDir, + "sparse-checkout", + "set", + "docs/docs", + "docs/versioned_docs", + "docs/versions.json", + "packages/appkit-ui/src/react/ui/examples", + ], 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); +function getHeadSha(repoDir) { + return runCapture( + "git", + ["-C", repoDir, "rev-parse", "--short", "HEAD"], + repoRoot, + ).trim(); +} - if (tags.length === 0) { - fail(`No tags found for major channel '${majorChannel}'.`); +// 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; } - tags.sort((a, b) => compareSemver(a.parsed, b.parsed)); - return tags[tags.length - 1].tag; -} + const versionDirs = fs + .readdirSync(versionedDocsDir, { withFileTypes: true }) + .filter( + (entry) => entry.isDirectory() && entry.name.startsWith("version-"), + ); -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}`); + if (versionDirs.length === 0) { + return; } - await pipeline( - Readable.fromWeb(response.body), - fs.createWriteStream(outputFile), - ); - return true; + 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"). @@ -159,17 +208,17 @@ function pascalCase(stem) { .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) { +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(extractedRoot, dir); + const srcDir = path.join(clonedRoot, dir); if (!fs.existsSync(srcDir)) continue; for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) { @@ -206,7 +255,7 @@ function syncExamples(extractedRoot) { }) .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`; + 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"); @@ -219,7 +268,7 @@ function syncExamples(extractedRoot) { // 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) { +async function syncCompiledStyles(channel) { const pkgDir = path.join( repoRoot, "node_modules", @@ -242,7 +291,7 @@ async function syncCompiledStyles(majorChannel) { fail(`dist/styles.css not found in @databricks/appkit-ui@${version}.`); } - const destDir = path.join(repoRoot, "static", "appkit-preview", majorChannel); + const destDir = path.join(repoRoot, "static", "appkit-preview", channel); fs.mkdirSync(destDir, { recursive: true }); const destCss = path.join(destDir, "styles.css"); @@ -259,7 +308,7 @@ async function syncCompiledStyles(majorChannel) { // 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`; + 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( @@ -271,72 +320,60 @@ async function syncCompiledStyles(majorChannel) { } 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 force = process.argv.includes("--force"); const docsRoot = path.join(repoRoot, "docs", "appkit"); - const majorDir = path.join(docsRoot, majorChannel); + 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-")); - 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); + try { + cloneAppKit(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"))); + const sha = getHeadSha(tempDir); + const syncDate = new Date().toISOString().slice(0, 10); + const appkitDocsSource = path.join(tempDir, "docs", "docs"); - if (!extracted) { - fail("Could not find docs/docs in downloaded AppKit source."); - } + if (!fs.existsSync(appkitDocsSource)) { + fail("Could not find docs/docs in cloned AppKit repository."); + } - const appkitDocsSource = path.join(extracted, "docs", "docs"); + // Clear existing docs and copy latest + fs.rmSync(docsRoot, { recursive: true, force: true }); + fs.mkdirSync(docsRoot, { recursive: true }); - 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 }); + replaceDir(appkitDocsSource, latestDir); + fs.writeFileSync( + path.join(latestDir, ".source-ref"), + `${syncDate} (${sha})\n`, + "utf-8", + ); - syncExamples(extracted); - const stylesVersion = await syncCompiledStyles(majorChannel); + // 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(`\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.`, + `\nAppKit docs synced from ${APPKIT_BRANCH} (${sha}), styles @ ${stylesVersion}.`, ); + console.log("Done."); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); } - console.log("Done."); } 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/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, diff --git a/src/components/DocExample.tsx b/src/components/DocExample.tsx index bd147cb..adcd7d2 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.
); } @@ -160,7 +160,7 @@ function IframePreview({ }: IframePreviewProps) { const iframeRef = useRef(null); const [mountNode, setMountNode] = useState(null); - const stylesHref = useBaseUrl("/appkit-preview/v0/styles.css"); + const stylesHref = useBaseUrl("/appkit-preview/latest/styles.css"); const height = useAutoHeight(iframeRef, customHeight); useDarkModeSync(iframeRef); diff --git a/src/theme/DocSidebar/Desktop/Content/index.tsx b/src/theme/DocSidebar/Desktop/Content/index.tsx index 978107e..8f03c7d 100644 --- a/src/theme/DocSidebar/Desktop/Content/index.tsx +++ b/src/theme/DocSidebar/Desktop/Content/index.tsx @@ -91,30 +91,35 @@ function getAppKitSidebarItems(sidebar: SidebarLikeItem[]): SidebarLikeItem[] { return appKitCategory.items; } +function extractChannel(href: string): string | null { + const match = href.match(/^\/docs\/appkit\/([^/]+)/); + return match ? match[1] : null; +} + function getAppKitChannelOptions( 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; + 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); + } } - - seen.add(normalizedHref); - options.push({ - label: item.label ?? normalizedHref, - href: normalizedHref, - }); } - return options; + collect(items); + return Array.from(channels.values()); } function getActiveChannelHref(