diff --git a/.changeset/brave-nimbus-docs.md b/.changeset/brave-nimbus-docs.md new file mode 100644 index 0000000..6b3632c --- /dev/null +++ b/.changeset/brave-nimbus-docs.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/polystella": patch +--- + +Document the requirement to add a Changesets entry for package-affecting changes and record the docs migration to Nimbus. diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a14f3b4..c027d4c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -27,7 +27,7 @@ concurrency: jobs: build: - name: Build Starlight site + name: Build Nimbus docs site runs-on: ubuntu-latest timeout-minutes: 15 steps: diff --git a/AGENTS.md b/AGENTS.md index 47ebd37..d8f695b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,7 @@ section numbers. Inserting new sections never breaks links. | `pnpm test:watch` | Vitest in watch mode. | | `pnpm build` | Compile `src/` → `dist/` via `tsc -p tsconfig.build.json` (mirrored layout, `.js` + `.d.ts` + sourcemaps + declaration maps). Produces the standalone `polystella` CLI at `dist/cli.js` and library entries. | | `pnpm exec tsc --noEmit` | Typecheck against the root `tsconfig.json` (which includes tests). The build config (`tsconfig.build.json`) sets `noEmit: false` and narrows `include` to `src/**`. | +| `pnpm changeset` | Add a Changesets entry for package-affecting work. Use `pnpm changeset add --empty` only for changes that intentionally do not need a package release. | No lint step yet. @@ -85,6 +86,12 @@ to the explanatory section when adding code that touches one. ### Always - Run `pnpm test` before pushing. Tests must stay green. +- Add a Changesets entry for every change that affects the published + package, its documented behaviour, or release-facing contributor + guidance. Use `pnpm changeset` for release notes; use + `pnpm changeset add --empty` only when the change deliberately does + not require a package release (for example, docs-site-only or CI-only + maintenance). - Bump the package version in `package.json` only — `POLYSTELLA_VERSION` (in `src/version.ts`) reads it at module-load time via a JSON import attribute, so the constant flows automatically through to diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 583b476..f3b463e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,7 +43,7 @@ Required: | `pnpm exec tsc --noEmit` | Typecheck the package. | | `pnpm build` | Compile `src/` → `dist/` (library + CLI, JS + `.d.ts`). | | `pnpm build:llms` | Regenerate `llms-full.txt` from canonical agent docs. | -| `pnpm --filter polystella-docs dev` | Run the Starlight docs site locally. | +| `pnpm --filter polystella-docs dev` | Run the Nimbus docs site locally. | | `pnpm --filter polystella-docs build` | Build the docs site (includes auto-generated config reference). | | `pnpm --filter polystella-docs check` | Astro check over docs content. | diff --git a/README.md b/README.md index 7330f69..f2c5870 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ collection APIs. ## Documentation -Full documentation lives at the Starlight docs site (under `docs/` in this repo): +Full documentation lives at the Nimbus docs site (under `docs/` in this repo): - [Getting started](https://polystella-docs.pcx-team.workers.dev/getting-started/install/) — install, quick start, mental model - [Concepts](https://polystella-docs.pcx-team.workers.dev/concepts/how-it-works/) — pipeline, cache, overrides, runtime bridge diff --git a/docs/.nimbus/lint.json b/docs/.nimbus/lint.json new file mode 100644 index 0000000..e1f00b6 --- /dev/null +++ b/docs/.nimbus/lint.json @@ -0,0 +1,9 @@ +{ + "version": 1, + "rules": { + "nimbus/frontmatter-shape": "error", + "nimbus/internal-link": "error" + }, + "collections": {}, + "site": "https://polystella-docs.pcx-team.workers.dev" +} diff --git a/docs/.nimbus/routes.json b/docs/.nimbus/routes.json new file mode 100644 index 0000000..8471902 --- /dev/null +++ b/docs/.nimbus/routes.json @@ -0,0 +1,50 @@ +{ + "version": 1, + "base": "/", + "knownRoutes": [ + "/", + "/404", + "/adapters/custom-loader", + "/adapters/markdown", + "/adapters/mdx", + "/adapters/toml", + "/cli", + "/cli/audit-mdx", + "/cli/check-ui", + "/cli/sync-ui", + "/cli/translate", + "/cli/translate-ui", + "/concepts/ai-marker", + "/concepts/glossaries", + "/concepts/how-it-works", + "/concepts/mode-boundary", + "/concepts/overrides", + "/concepts/r2-cache", + "/concepts/runtime-bridge", + "/configuration", + "/configuration/reference", + "/cookbook/locale-picker", + "/getting-started/install", + "/getting-started/mental-model", + "/getting-started/quick-start", + "/operations/branch-dispatch", + "/operations/ci", + "/operations/preview-isolation", + "/providers/anthropic", + "/providers/batching", + "/providers/model-selection", + "/providers/permanent-errors", + "/providers/workers-ai", + "/reference/breaking-changes", + "/reference/exports", + "/roadmap", + "/routing/configuration", + "/routing/shims", + "/runtime-api/explicit-imports", + "/runtime-api/locals", + "/runtime-api/middleware", + "/runtime-api/react-hooks", + "/troubleshooting/missing-translations" + ], + "opaqueNamespaces": [] +} diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs deleted file mode 100644 index bca382b..0000000 --- a/docs/astro.config.mjs +++ /dev/null @@ -1,136 +0,0 @@ -// @ts-check -import { defineConfig } from "astro/config"; -import starlight from "@astrojs/starlight"; - -/** - * Starlight configuration for the PolyStella docs site. - * - * Single-language (en-US) for v0.x. Dogfooding polystella's own - * translation pipeline on the docs is queued for a later release. - */ -export default defineConfig({ - site: "https://polystella-docs.pcx-team.workers.dev", - integrations: [ - starlight({ - title: "PolyStella", - description: "AI-driven content localization for Astro", - logo: { - src: "./src/assets/wordmark.svg", - replacesTitle: true, - }, - social: [ - { - icon: "github", - label: "GitHub", - href: "https://github.com/cloudflare/polystella", - }, - ], - editLink: { - baseUrl: "https://github.com/cloudflare/polystella/edit/main/docs/", - }, - sidebar: [ - { - label: "Getting started", - items: [ - { label: "Install", slug: "getting-started/install" }, - { label: "Quick start", slug: "getting-started/quick-start" }, - { label: "Mental model", slug: "getting-started/mental-model" }, - ], - }, - { - label: "Concepts", - items: [ - { label: "How it works", slug: "concepts/how-it-works" }, - { label: "R2 cache", slug: "concepts/r2-cache" }, - { label: "Glossaries", slug: "concepts/glossaries" }, - { label: "Overrides", slug: "concepts/overrides" }, - { label: "Mode boundary", slug: "concepts/mode-boundary" }, - { label: "Runtime bridge", slug: "concepts/runtime-bridge" }, - { label: "AI marker", slug: "concepts/ai-marker" }, - ], - }, - { - label: "Configuration", - items: [ - { label: "Overview", slug: "configuration" }, - { label: "Full reference", slug: "configuration/reference" }, - ], - }, - { - label: "Adapters", - items: [ - { label: "Markdown", slug: "adapters/markdown" }, - { label: "MDX", slug: "adapters/mdx" }, - { label: "TOML", slug: "adapters/toml" }, - { label: "Custom loader", slug: "adapters/custom-loader" }, - ], - }, - { - label: "Providers", - items: [ - { label: "Workers AI", slug: "providers/workers-ai" }, - { label: "Anthropic", slug: "providers/anthropic" }, - { label: "Model selection", slug: "providers/model-selection" }, - { label: "Batching", slug: "providers/batching" }, - { label: "Permanent errors", slug: "providers/permanent-errors" }, - ], - }, - { - label: "Routing", - items: [ - { label: "Standalone shims", slug: "routing/shims" }, - { label: "Route configuration", slug: "routing/configuration" }, - ], - }, - { - label: "Runtime API", - items: [ - { label: "Astro.locals", slug: "runtime-api/locals" }, - { label: "Middleware", slug: "runtime-api/middleware" }, - { label: "Explicit imports", slug: "runtime-api/explicit-imports" }, - { label: "React hooks", slug: "runtime-api/react-hooks" }, - ], - }, - { - label: "CLI", - items: [ - { label: "Overview", slug: "cli" }, - { label: "translate", slug: "cli/translate" }, - { label: "check-ui", slug: "cli/check-ui" }, - { label: "sync-ui", slug: "cli/sync-ui" }, - { label: "translate-ui", slug: "cli/translate-ui" }, - { label: "audit-mdx", slug: "cli/audit-mdx" }, - ], - }, - { - label: "Operations", - items: [ - { label: "CI / Workers Builds", slug: "operations/ci" }, - { label: "Branch dispatch", slug: "operations/branch-dispatch" }, - { label: "Preview isolation", slug: "operations/preview-isolation" }, - ], - }, - { - label: "Cookbook", - items: [{ autogenerate: { directory: "cookbook" } }], - }, - { - label: "Troubleshooting", - items: [{ autogenerate: { directory: "troubleshooting" } }], - }, - { - label: "Reference", - items: [ - { label: "Public exports", slug: "reference/exports" }, - { label: "Breaking changes", slug: "reference/breaking-changes" }, - ], - }, - { - label: "Roadmap", - slug: "roadmap", - }, - ], - customCss: ["./src/styles/custom.css"], - }), - ], -}); diff --git a/docs/astro.config.ts b/docs/astro.config.ts new file mode 100644 index 0000000..f6696c8 --- /dev/null +++ b/docs/astro.config.ts @@ -0,0 +1,94 @@ +import { defineConfig } from "astro/config"; +import icon from "astro-icon"; +import nimbus, { defineConfig as defineNimbusConfig } from "nimbus-docs"; + +const nimbusConfig = defineNimbusConfig({ + site: "https://polystella-docs.pcx-team.workers.dev", + title: "PolyStella", + description: "AI-driven content localization for Astro", + locale: "en", + github: "https://github.com/cloudflare/polystella", + editPattern: "https://github.com/cloudflare/polystella/edit/main/docs/{path}", + socialImageAlt: "PolyStella documentation preview", + sidebar: { + items: [ + { + label: "Getting started", + items: ["getting-started/install", "getting-started/quick-start", "getting-started/mental-model"], + }, + { + label: "Concepts", + items: [ + "concepts/how-it-works", + "concepts/r2-cache", + "concepts/glossaries", + "concepts/overrides", + "concepts/mode-boundary", + "concepts/runtime-bridge", + "concepts/ai-marker", + ], + }, + { + label: "Configuration", + items: ["configuration", "configuration/reference"], + }, + { + label: "Adapters", + items: ["adapters/markdown", "adapters/mdx", "adapters/toml", "adapters/custom-loader"], + }, + { + label: "Providers", + items: [ + "providers/workers-ai", + "providers/anthropic", + "providers/model-selection", + "providers/batching", + "providers/permanent-errors", + ], + }, + { + label: "Routing", + items: ["routing/shims", "routing/configuration"], + }, + { + label: "Runtime API", + items: ["runtime-api/locals", "runtime-api/middleware", "runtime-api/explicit-imports", "runtime-api/react-hooks"], + }, + { + label: "CLI", + items: ["cli", "cli/translate", "cli/check-ui", "cli/sync-ui", "cli/translate-ui", "cli/audit-mdx"], + }, + { + label: "Operations", + items: ["operations/ci", "operations/branch-dispatch", "operations/preview-isolation"], + }, + { label: "Cookbook", autogenerate: { directory: "cookbook" } }, + { + label: "Troubleshooting", + autogenerate: { directory: "troubleshooting" }, + }, + { + label: "Reference", + items: ["reference/exports", "reference/breaking-changes"], + }, + "roadmap", + ], + }, +}); + +export default defineConfig({ + output: "static", + prefetch: { + prefetchAll: true, + defaultStrategy: "hover", + }, + integrations: [ + icon(), + nimbus(nimbusConfig, { + rules: { + "nimbus/frontmatter-shape": "error", + "nimbus/internal-link": "error", + }, + }), + ], +}); diff --git a/docs/package.json b/docs/package.json index 73031e1..bb19bb2 100644 --- a/docs/package.json +++ b/docs/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "type": "module", - "description": "Starlight-based documentation site for PolyStella.", + "description": "Nimbus-based documentation site for PolyStella.", "scripts": { "dev": "pnpm prebuild && astro dev", "start": "pnpm prebuild && astro dev", @@ -17,12 +17,24 @@ "deploy": "wrangler deploy" }, "dependencies": { - "@astrojs/starlight": "^0.41.3", + "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@iconify-json/ph": "^1.2.0", "astro": "^7.0.0", + "astro-icon": "^1.1.0", + "astro-og-canvas": "^0.13.0", + "canvaskit-wasm": "^0.41.1", + "clsx": "^2.1.1", + "nimbus-docs": "^0.1.22", + "shiki": "^4.2.0", + "tailwind-merge": "^3.5.0", "sharp": "^0.35.3" }, "devDependencies": { "@astrojs/check": "^0.9.9", + "@tailwindcss/postcss": "^4.1.4", + "pagefind": "^1.5.2", + "tailwindcss": "^4.1.4", "@types/node": "^26.1.0", "tsx": "^4.19.0", "typescript": "^6.0.3", diff --git a/docs/postcss.config.mjs b/docs/postcss.config.mjs new file mode 100644 index 0000000..c2ddf74 --- /dev/null +++ b/docs/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; diff --git a/docs/public/_headers b/docs/public/_headers index a2fbdbb..ede26c5 100644 --- a/docs/public/_headers +++ b/docs/public/_headers @@ -4,8 +4,8 @@ # # Syntax: https://developers.cloudflare.com/workers/static-assets/headers/ # -# CSP is intentionally omitted: Starlight ships inline -# ` + + diff --git a/docs/src/components/ui/banner/banner.client.ts b/docs/src/components/ui/banner/banner.client.ts new file mode 100644 index 0000000..4ee2fda --- /dev/null +++ b/docs/src/components/ui/banner/banner.client.ts @@ -0,0 +1,49 @@ +/** + * Storage key: `nb-banner-dismissed-{id}`. Value is "0" for permanent, + * or a future timestamp (ms) for time-limited dismissal. + */ + +import { mount } from "nimbus-docs/client"; + +const KEY_PREFIX = "nb-banner-dismissed-"; + +function initBanner(banner: HTMLElement): () => void { + const id = banner.dataset.nbBannerDismiss; + if (!id) return () => {}; + + const key = `${KEY_PREFIX}${id}`; + + try { + const stored = localStorage.getItem(key); + if (stored) { + const expiry = Number(stored); + if (expiry === 0 || expiry > Date.now()) { + banner.remove(); + return () => {}; + } + localStorage.removeItem(key); + } + } catch { + // localStorage unavailable; show without persistence. + } + + const btn = banner.querySelector("[data-nb-banner-close]"); + if (!btn) return () => {}; + + function handleClick() { + const days = Number(banner.dataset.nbBannerDays) || 0; + const value = days > 0 ? String(Date.now() + days * 86400000) : "0"; + try { + localStorage.setItem(key, value); + } catch { + // localStorage unavailable; dismissal is session-only. + } + banner.remove(); + } + + btn.addEventListener("click", handleClick); + + return () => btn.removeEventListener("click", handleClick); +} + +mount("[data-nb-banner-dismiss]", initBanner); diff --git a/docs/src/components/ui/banner/index.ts b/docs/src/components/ui/banner/index.ts new file mode 100644 index 0000000..9b90dcf --- /dev/null +++ b/docs/src/components/ui/banner/index.ts @@ -0,0 +1 @@ +export { default as Banner } from "./Banner.astro"; diff --git a/docs/src/components/ui/breadcrumbs/Breadcrumbs.astro b/docs/src/components/ui/breadcrumbs/Breadcrumbs.astro new file mode 100644 index 0000000..53da5f3 --- /dev/null +++ b/docs/src/components/ui/breadcrumbs/Breadcrumbs.astro @@ -0,0 +1,84 @@ +--- +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; +import type { Breadcrumb } from "nimbus-docs/types"; + +interface Props extends HTMLAttributes<"nav"> { + items: Breadcrumb[]; + /** Max visible crumbs before collapsing middle items (default: 4) */ + maxVisible?: number; +} + +const { items, maxVisible = 4, class: className, ...attrs } = Astro.props; + +// Determine if we need to collapse the middle +const shouldCollapse = items.length > maxVisible; +// When collapsed: Home + first segment + ... + last 2 (parent + current) +const headCount = 2; // Home + first segment +const tailCount = 2; // parent + current page +const headItems = shouldCollapse ? items.slice(0, headCount) : items; +const collapsedItems = shouldCollapse ? items.slice(headCount, items.length - tailCount) : []; +const tailItems = shouldCollapse ? items.slice(items.length - tailCount) : []; +--- + +{items.length > 1 && ( + + + {headItems.map((crumb, i) => ( + + {i > 0 && /} + {(!shouldCollapse && i === items.length - 1) || !crumb.href ? ( + {crumb.label} + ) : ( + + {crumb.label} + + )} + + ))} + + {shouldCollapse && collapsedItems.length > 0 && ( + + / + + + … + + + {collapsedItems.map((crumb) => ( + crumb.href ? ( + + {crumb.label} + + ) : ( + + {crumb.label} + + ) + ))} + + + + )} + + {shouldCollapse && tailItems.map((crumb, i) => ( + + / + {i === tailItems.length - 1 || !crumb.href ? ( + {crumb.label} + ) : ( + + {crumb.label} + + )} + + ))} + + +)} diff --git a/docs/src/components/ui/breadcrumbs/index.ts b/docs/src/components/ui/breadcrumbs/index.ts new file mode 100644 index 0000000..734e208 --- /dev/null +++ b/docs/src/components/ui/breadcrumbs/index.ts @@ -0,0 +1 @@ +export { default as Breadcrumbs } from "./Breadcrumbs.astro"; diff --git a/docs/src/components/ui/button/Button.astro b/docs/src/components/ui/button/Button.astro new file mode 100644 index 0000000..4503785 --- /dev/null +++ b/docs/src/components/ui/button/Button.astro @@ -0,0 +1,69 @@ +--- +/** + * Button — primary action trigger. + * + * Save + * Create + * Saving… + * + * + * Variants: primary · secondary (default) · ghost · destructive · + * secondary-destructive · outline. + * Sizes: xs · sm · base (default) · lg. + * Shapes: base (default) · square · circle (icon-only — pass `aria-label`). + * + * `icon` takes an iconify name (astro-icon) rendered before the label; + * `loading` swaps it for a spinner and disables the button. + * + * Styling lives in `./variants` (shared with LinkButton). For an anchor + * styled as a button, use `~/components/ui/link-button`. + */ +import { cn } from "@/lib/cn"; +import { Icon } from "astro-icon/components"; +import type { HTMLAttributes } from "astro/types"; +import { + buttonVariants, + buttonIconSize, + type ButtonVariant, + type ButtonSize, + type ButtonShape, +} from "./variants"; + +interface Props extends Omit, "size"> { + variant?: ButtonVariant; + size?: ButtonSize; + shape?: ButtonShape; + /** Iconify name rendered before the label, e.g. "ph:plus". */ + icon?: string; + /** Show a spinner and disable interaction. */ + loading?: boolean; +} + +const { + variant = "secondary", + size = "base", + shape = "base", + icon, + loading = false, + type = "button", + disabled, + class: className, + ...attrs +} = Astro.props; +--- + + + { + loading ? ( + + ) : ( + icon && + ) + } + + diff --git a/docs/src/components/ui/button/index.ts b/docs/src/components/ui/button/index.ts new file mode 100644 index 0000000..32440d6 --- /dev/null +++ b/docs/src/components/ui/button/index.ts @@ -0,0 +1,13 @@ +export { default as Button } from "./Button.astro"; +export { + buttonVariants, + buttonBase, + buttonVariantClasses, + buttonSizeText, + buttonSizeCompact, + buttonIconSize, + type ButtonVariant, + type ButtonSize, + type ButtonShape, + type ButtonVariantsOptions, +} from "./variants"; diff --git a/docs/src/components/ui/button/variants.ts b/docs/src/components/ui/button/variants.ts new file mode 100644 index 0000000..f98c1c6 --- /dev/null +++ b/docs/src/components/ui/button/variants.ts @@ -0,0 +1,65 @@ +/** + * Shared button styling — the single source of truth for both + * (a real button) and (an anchor styled as a button), so the + * two stay visually identical. + * + * Token-mapped to Nimbus. Import `buttonVariants()` to compose the trigger + * classes for a button-shaped element; `buttonIconSize` sizes a leading/ + * trailing icon for a given size. + */ +import { cn } from "@/lib/cn"; + +export type ButtonVariant = "primary" | "secondary" | "ghost" | "destructive" | "secondary-destructive" | "outline"; +export type ButtonSize = "xs" | "sm" | "base" | "lg"; +export type ButtonShape = "base" | "square" | "circle"; + +// `rounded-lg` is the default radius for every button; `circle` overrides +// it to `rounded-full` (see `buttonVariants`), `square` keeps it. +export const buttonBase = + "group inline-flex w-max shrink-0 items-center justify-center rounded-lg font-medium whitespace-nowrap no-underline transition-all cursor-pointer select-none focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:opacity-50"; + +export const buttonVariantClasses: Record = { + primary: "bg-primary text-primary-foreground shadow-sm hover:bg-primary-hover hover:shadow", + secondary: "bg-card text-foreground ring ring-border shadow-sm hover:bg-accent hover:ring-border-strong", + ghost: "bg-transparent text-foreground shadow-none hover:bg-accent", + destructive: "bg-danger text-white shadow-sm hover:bg-danger/90", + "secondary-destructive": "bg-card text-danger ring ring-border shadow-sm hover:bg-accent hover:ring-danger/40", + outline: "bg-transparent text-foreground ring ring-border hover:ring-border-strong", +}; + +// Rectangular sizing (shape="base"). Radius comes from `buttonBase`. +export const buttonSizeText: Record = { + xs: "gap-1 px-2 py-1 text-xs", + sm: "gap-1 px-3 py-1.5 text-xs", + base: "gap-1.5 px-4 py-2 text-sm", + lg: "gap-2 px-5 py-2.5 text-sm", +}; + +// Square/circle sizing (icon-only): equal dimensions, no padding. +export const buttonSizeCompact: Record = { + xs: "size-7", + sm: "size-8", + base: "size-9", + lg: "size-10", +}; + +export const buttonIconSize: Record = { + xs: "h-3.5 w-3.5", + sm: "h-3.5 w-3.5", + base: "h-4 w-4", + lg: "h-[1.125rem] w-[1.125rem]", +}; + +export interface ButtonVariantsOptions { + variant?: ButtonVariant; + size?: ButtonSize; + shape?: ButtonShape; +} + +/** Compose the base + variant + size/shape classes for a button-shaped element. */ +export function buttonVariants({ variant = "secondary", size = "base", shape = "base" }: ButtonVariantsOptions = {}): string { + // base + square inherit `rounded-lg` from buttonBase; circle overrides it + // to a full pill. + const dims = shape === "base" ? buttonSizeText[size] : cn(buttonSizeCompact[size], "p-0", shape === "circle" && "rounded-full"); + return cn(buttonBase, buttonVariantClasses[variant], dims); +} diff --git a/docs/src/components/ui/card-grid/CardGrid.astro b/docs/src/components/ui/card-grid/CardGrid.astro new file mode 100644 index 0000000..a7c50fb --- /dev/null +++ b/docs/src/components/ui/card-grid/CardGrid.astro @@ -0,0 +1,15 @@ +--- +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +type Props = HTMLAttributes<"div">; + +const { class: className, ...attrs } = Astro.props; +--- + +*]:my-0", className)} + {...attrs} +> + + diff --git a/docs/src/components/ui/card-grid/index.ts b/docs/src/components/ui/card-grid/index.ts new file mode 100644 index 0000000..417cc1f --- /dev/null +++ b/docs/src/components/ui/card-grid/index.ts @@ -0,0 +1 @@ +export { default as CardGrid } from "./CardGrid.astro"; diff --git a/docs/src/components/ui/card/Card.astro b/docs/src/components/ui/card/Card.astro new file mode 100644 index 0000000..f4f268a --- /dev/null +++ b/docs/src/components/ui/card/Card.astro @@ -0,0 +1,21 @@ +--- +import { Icon } from "astro-icon/components"; +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"article"> { + title: string; + /** Iconify icon name, e.g. `ph:lightning`. */ + icon?: string; +} + +const { title, icon, class: className, ...attrs } = Astro.props; +--- + + + {icon && } + {title} + + + + diff --git a/docs/src/components/ui/card/index.ts b/docs/src/components/ui/card/index.ts new file mode 100644 index 0000000..b491741 --- /dev/null +++ b/docs/src/components/ui/card/index.ts @@ -0,0 +1 @@ +export { default as Card } from "./Card.astro"; diff --git a/docs/src/components/ui/code/Code.astro b/docs/src/components/ui/code/Code.astro new file mode 100644 index 0000000..627c877 --- /dev/null +++ b/docs/src/components/ui/code/Code.astro @@ -0,0 +1,38 @@ +--- +/** + * Code — syntax-highlighted code block from a string prop. + * + * + * + */ +import { Code as AstroCode } from "astro:components"; +import { defaultCodeTransformers } from "nimbus-docs"; + +type Props = Parameters[0]; +type NimbusCodeTransformerOptions = NonNullable[0]>; +const rawProps = Astro.props as Props; +const usesNimbusDefaultThemes = !("theme" in rawProps) && !("themes" in rawProps); +const themed = usesNimbusDefaultThemes + ? { + ...rawProps, + themes: { light: "github-light", dark: "github-dark" }, + defaultColor: false, + } + : rawProps; +const userTransformers = themed.transformers ?? []; +const props = { + ...themed, + transformers: defaultCodeTransformers({ + beforeTitleTransformers: + userTransformers as NimbusCodeTransformerOptions["beforeTitleTransformers"], + }), +}; +// One boundary cast back to Astro's own `` props. Everything above is +// runtime-valid; the only type friction is nominal — nimbus-docs and Astro +// can resolve different `@shikijs/types` copies (e.g. 4.2.x vs 4.1.x), so the +// `ShikiTransformer`/`ThemePresets` shapes differ on paper while matching at +// runtime. `Astro.props` was already `as Props`; re-asserting here keeps the +// component compiling regardless of which shiki types version dedupes in. +--- + + diff --git a/docs/src/components/ui/code/index.ts b/docs/src/components/ui/code/index.ts new file mode 100644 index 0000000..152859d --- /dev/null +++ b/docs/src/components/ui/code/index.ts @@ -0,0 +1 @@ +export { default as Code } from "./Code.astro"; diff --git a/docs/src/components/ui/collapsible/Collapsible.astro b/docs/src/components/ui/collapsible/Collapsible.astro new file mode 100644 index 0000000..24b51c2 --- /dev/null +++ b/docs/src/components/ui/collapsible/Collapsible.astro @@ -0,0 +1,25 @@ +--- +/** Collapsible — disclosure. Compose with CollapsibleTrigger + CollapsibleContent. */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> { + /** Start open. Default false. */ + open?: boolean; +} + +const { open = false, class: className, ...attrs } = Astro.props; +--- + + + + + + diff --git a/docs/src/components/ui/collapsible/CollapsibleContent.astro b/docs/src/components/ui/collapsible/CollapsibleContent.astro new file mode 100644 index 0000000..e8b7686 --- /dev/null +++ b/docs/src/components/ui/collapsible/CollapsibleContent.astro @@ -0,0 +1,27 @@ +--- +/** + * CollapsibleContent — the panel that animates open/closed. + * Uses `grid-template-rows: 0fr → 1fr` for smooth height transition. + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> {} + +const { class: className, ...attrs } = Astro.props; +--- + + + + + + diff --git a/docs/src/components/ui/collapsible/CollapsibleTrigger.astro b/docs/src/components/ui/collapsible/CollapsibleTrigger.astro new file mode 100644 index 0000000..d5f8cea --- /dev/null +++ b/docs/src/components/ui/collapsible/CollapsibleTrigger.astro @@ -0,0 +1,21 @@ +--- +/** + * CollapsibleTrigger — the button that toggles the Collapsible. + * Slot accepts arbitrary content; component author provides full visuals. + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"button"> {} + +const { class: className, type, ...attrs } = Astro.props; +--- + + + + diff --git a/docs/src/components/ui/collapsible/collapsible.client.ts b/docs/src/components/ui/collapsible/collapsible.client.ts new file mode 100644 index 0000000..9e93213 --- /dev/null +++ b/docs/src/components/ui/collapsible/collapsible.client.ts @@ -0,0 +1,22 @@ +/** Wires Collapsible via the disclosure module. */ + +import { mount, makeDisclosure } from "nimbus-docs/client"; + +function initCollapsible(root: HTMLElement): () => void { + const trigger = root.querySelector("[data-nb-collapsible-trigger]"); + const content = root.querySelector("[data-nb-collapsible-content]"); + + if (!trigger || !content) return () => {}; + + const defaultOpen = root.dataset.nbDefaultOpen === "true"; + + const disclosure = makeDisclosure({ + trigger, + content, + defaultOpen, + }); + + return () => disclosure.destroy(); +} + +mount("[data-nb-collapsible]", initCollapsible); diff --git a/docs/src/components/ui/collapsible/index.ts b/docs/src/components/ui/collapsible/index.ts new file mode 100644 index 0000000..a4c4221 --- /dev/null +++ b/docs/src/components/ui/collapsible/index.ts @@ -0,0 +1,3 @@ +export { default as Collapsible } from "./Collapsible.astro"; +export { default as CollapsibleTrigger } from "./CollapsibleTrigger.astro"; +export { default as CollapsibleContent } from "./CollapsibleContent.astro"; diff --git a/docs/src/components/ui/dialog/Dialog.astro b/docs/src/components/ui/dialog/Dialog.astro new file mode 100644 index 0000000..24e3309 --- /dev/null +++ b/docs/src/components/ui/dialog/Dialog.astro @@ -0,0 +1,53 @@ +--- +/** + * Dialog — modal overlay built on the native . + * + * Open with `el.showModal()`, close with `el.close()` or Escape. + * Scroll-lock and backdrop-click-to-close are handled automatically. + * + * + * + * Are you sure? + * Cancel + * + * + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"dialog"> {} + +const { class: className, ...attrs } = Astro.props; +--- + + + + + + diff --git a/docs/src/components/ui/dialog/DialogClose.astro b/docs/src/components/ui/dialog/DialogClose.astro new file mode 100644 index 0000000..bc6e81f --- /dev/null +++ b/docs/src/components/ui/dialog/DialogClose.astro @@ -0,0 +1,35 @@ +--- +/** + * DialogClose — button that closes the nearest ancestor . + * Consumer provides the visual (icon, text, kbd hint) via default slot. + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"button"> {} + +const { class: className, ...attrs } = Astro.props; +--- + + + + + + diff --git a/docs/src/components/ui/dialog/DialogContent.astro b/docs/src/components/ui/dialog/DialogContent.astro new file mode 100644 index 0000000..462448a --- /dev/null +++ b/docs/src/components/ui/dialog/DialogContent.astro @@ -0,0 +1,21 @@ +--- +/** + * DialogContent — inner frame of a Dialog. Centered, constrained, styled. + * Consumer controls max-width/height via class override. + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> {} + +const { class: className, ...attrs } = Astro.props; +--- + + + + + + diff --git a/docs/src/components/ui/dialog/index.ts b/docs/src/components/ui/dialog/index.ts new file mode 100644 index 0000000..fe7fb64 --- /dev/null +++ b/docs/src/components/ui/dialog/index.ts @@ -0,0 +1,3 @@ +export { default as Dialog } from "./Dialog.astro"; +export { default as DialogContent } from "./DialogContent.astro"; +export { default as DialogClose } from "./DialogClose.astro"; diff --git a/docs/src/components/ui/layer-card/LayerCard.astro b/docs/src/components/ui/layer-card/LayerCard.astro new file mode 100644 index 0000000..8473c7b --- /dev/null +++ b/docs/src/components/ui/layer-card/LayerCard.astro @@ -0,0 +1,23 @@ +--- +/** + * LayerCard — two-layer card (recessed header + raised content). + * + * + * Title or tabs + * Main content + * + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> {} + +const { class: className, ...attrs } = Astro.props; +--- + + + + diff --git a/docs/src/components/ui/layer-card/LayerCardContent.astro b/docs/src/components/ui/layer-card/LayerCardContent.astro new file mode 100644 index 0000000..12bdaea --- /dev/null +++ b/docs/src/components/ui/layer-card/LayerCardContent.astro @@ -0,0 +1,16 @@ +--- +/** LayerCardContent — raised content layer of a LayerCard. */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> {} + +const { class: className, ...attrs } = Astro.props; +--- + + + + diff --git a/docs/src/components/ui/layer-card/LayerCardHeader.astro b/docs/src/components/ui/layer-card/LayerCardHeader.astro new file mode 100644 index 0000000..b9be404 --- /dev/null +++ b/docs/src/components/ui/layer-card/LayerCardHeader.astro @@ -0,0 +1,16 @@ +--- +/** LayerCardHeader — recessed header layer of a LayerCard. */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> {} + +const { class: className, ...attrs } = Astro.props; +--- + + + + diff --git a/docs/src/components/ui/layer-card/index.ts b/docs/src/components/ui/layer-card/index.ts new file mode 100644 index 0000000..dd347fe --- /dev/null +++ b/docs/src/components/ui/layer-card/index.ts @@ -0,0 +1,3 @@ +export { default as LayerCard } from "./LayerCard.astro"; +export { default as LayerCardHeader } from "./LayerCardHeader.astro"; +export { default as LayerCardContent } from "./LayerCardContent.astro"; diff --git a/docs/src/components/ui/link-button/LinkButton.astro b/docs/src/components/ui/link-button/LinkButton.astro new file mode 100644 index 0000000..e1eb918 --- /dev/null +++ b/docs/src/components/ui/link-button/LinkButton.astro @@ -0,0 +1,75 @@ +--- +/** + * LinkButton — an anchor styled as a button. + * + * Get started + * Read the docs + * Learn more + * … + * + * The `icon` prop appends a right-caret that nudges on hover. + * + * Styling is delegated to the shared `ui/button/variants`, so LinkButton and + * Button stay visually identical. The original props are preserved (used by + * MDX): the LinkButton-only aliases map onto the shared vocabulary — + * `minimal → ghost`, `md → base`. + */ +import { cn } from "@/lib/cn"; +import { Icon } from "astro-icon/components"; +import type { HTMLAttributes } from "astro/types"; +import { + buttonVariants, + buttonIconSize, + type ButtonVariant, + type ButtonSize, + type ButtonShape, +} from "../button/variants"; + +interface Props extends HTMLAttributes<"a"> { + href: string; + /** Original `primary | secondary | minimal`, plus Button's variants. */ + variant?: ButtonVariant | "minimal"; + /** Original `sm | md | lg`, plus Button's `xs | base`. */ + size?: ButtonSize | "md"; + /** `base` (default) · `square` · `circle` (icon-only — pass `aria-label`). */ + shape?: ButtonShape; + /** Append a caret-right that nudges on hover. */ + icon?: boolean; +} + +const { + href, + variant = "primary", + size = "md", + shape = "base", + icon = false, + class: className, + ...attrs +} = Astro.props; + +// Map the LinkButton-only aliases onto the shared Button vocabulary. +const resolvedVariant: ButtonVariant = variant === "minimal" ? "ghost" : variant; +const resolvedSize: ButtonSize = size === "md" ? "base" : size; +--- + + + + { + icon && ( + + ) + } + diff --git a/docs/src/components/ui/link-button/index.ts b/docs/src/components/ui/link-button/index.ts new file mode 100644 index 0000000..71411b5 --- /dev/null +++ b/docs/src/components/ui/link-button/index.ts @@ -0,0 +1 @@ +export { default as LinkButton } from "./LinkButton.astro"; diff --git a/docs/src/components/ui/package-managers/PackageManagers.astro b/docs/src/components/ui/package-managers/PackageManagers.astro new file mode 100644 index 0000000..4e203af --- /dev/null +++ b/docs/src/components/ui/package-managers/PackageManagers.astro @@ -0,0 +1,123 @@ +--- +/** + * PackageManagers — code block with a tab per package manager (npm, + * pnpm, yarn, bun). Selection syncs across instances via sessionStorage; + * an inline custom element restores the saved tab before paint. + */ +import { createHash } from "node:crypto"; +import { Icon } from "astro-icon/components"; +import { getTabs } from "nimbus-docs/lib/pkgm"; +import type { CommandType, CommandOptions } from "nimbus-docs/lib/pkgm"; +import { LayerCard, LayerCardHeader } from "@/components/ui/layer-card"; + +interface Props extends CommandOptions { + pkg?: string; + type?: CommandType; +} + +const { pkg, type = "add", args, dev, comment } = Astro.props; +const tabs = getTabs(type, pkg, { args, dev, comment }); +// Deterministic ID — hash the props so two builds with identical content +// produce identical IDs. Required for incremental builds: a per-render +// `crypto.randomUUID()` would make warm output differ from cold. +// Two instances with identical props on the same page would collide; in +// practice this is a rare authoring pattern (same package + type + args). +const uid = + "pm-" + + createHash("sha256") + .update(JSON.stringify({ pkg, type, args, dev, comment })) + .digest("hex") + .slice(0, 12); +--- + + + + + + + {tabs.map((tab, i) => ( + + {tab.mgr} + + ))} + + + {tabs.map((tab, i) => { + const cmdLines = tab.cmd.split("\n"); + const commentPrefix = cmdLines.length > 1 ? cmdLines.slice(0, -1).join("\n") + "\n" : ""; + const codeLine = cmdLines[cmdLines.length - 1]; + const spaceIdx = codeLine.indexOf(" "); + const codeFirst = spaceIdx === -1 ? codeLine : codeLine.slice(0, spaceIdx); + const codeRest = spaceIdx === -1 ? "" : codeLine.slice(spaceIdx); + return ( + + + {commentPrefix && {commentPrefix}}{codeFirst}{codeRest} + + + + + + ); + })} + + + + + + + diff --git a/docs/src/components/ui/package-managers/index.ts b/docs/src/components/ui/package-managers/index.ts new file mode 100644 index 0000000..248021d --- /dev/null +++ b/docs/src/components/ui/package-managers/index.ts @@ -0,0 +1 @@ +export { default as PackageManagers } from "./PackageManagers.astro"; diff --git a/docs/src/components/ui/package-managers/package-managers.client.ts b/docs/src/components/ui/package-managers/package-managers.client.ts new file mode 100644 index 0000000..a7c1152 --- /dev/null +++ b/docs/src/components/ui/package-managers/package-managers.client.ts @@ -0,0 +1,55 @@ +/** + * Sync key `ui-pm-tab` (sessionStorage) is shared with the + * `` early-paint element to avoid flash across navigations. + */ + +import { mount, initTabs } from "nimbus-docs/client"; + +function cloneIcon(tpl: HTMLTemplateElement | null): Node { + return tpl ? tpl.content.cloneNode(true) : document.createTextNode(""); +} + +function initPackageManager(container: HTMLElement): () => void { + const copyTpl = container.querySelector("[data-nb-pm-icon-copy]"); + const checkTpl = container.querySelector("[data-nb-pm-icon-check]"); + + const tabs = initTabs({ + container, + tabSelector: "[data-nb-pm-tab]", + panelSelector: "[data-nb-pm-panel]", + rovingTabindex: true, + sync: { key: "ui-pm-tab", storage: "session" }, + }); + + const copyHandlers: Array<{ btn: HTMLButtonElement; handler: () => void; timer?: number }> = []; + + container.querySelectorAll("[data-nb-pm-copy]").forEach((btn) => { + const handlerInfo: { btn: HTMLButtonElement; handler: () => void; timer?: number } = { + btn, + handler: async () => { + try { + await navigator.clipboard.writeText(btn.dataset.nbCommand ?? ""); + } catch { + return; + } + btn.replaceChildren(cloneIcon(checkTpl)); + if (handlerInfo.timer) window.clearTimeout(handlerInfo.timer); + handlerInfo.timer = window.setTimeout(() => { + btn.replaceChildren(cloneIcon(copyTpl)); + }, 1500); + }, + }; + btn.addEventListener("click", handlerInfo.handler); + copyHandlers.push(handlerInfo); + }); + + return () => { + tabs.destroy(); + copyHandlers.forEach(({ btn, handler, timer }) => { + btn.removeEventListener("click", handler); + if (timer) window.clearTimeout(timer); + }); + }; +} + +mount("[data-nb-pm]", initPackageManager); diff --git a/docs/src/components/ui/page-actions/PageActions.astro b/docs/src/components/ui/page-actions/PageActions.astro new file mode 100644 index 0000000..09719d6 --- /dev/null +++ b/docs/src/components/ui/page-actions/PageActions.astro @@ -0,0 +1,82 @@ +--- +import { Icon } from "astro-icon/components"; +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> { + markdownUrl?: string; + lastUpdated?: Date; + /** Page was drafted by an AI agent and not yet reviewed by a human. */ + aiGenerated?: boolean; +} + +const { markdownUrl, lastUpdated, aiGenerated, class: className, ...attrs } = Astro.props; + +const baseBtn = + "inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-transparent px-2 py-1 text-muted-foreground no-underline transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2"; + +const formattedDate = lastUpdated + ? new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "numeric" }).format(lastUpdated) + : null; +--- + +{(markdownUrl || lastUpdated || aiGenerated) && ( + + {aiGenerated && ( + <> + + + AI-generated · awaiting review + + {(lastUpdated || markdownUrl) && ( + | + )} + > + )} + + {lastUpdated && ( + + + Updated {formattedDate} + + )} + + {markdownUrl && lastUpdated && ( + | + )} + + {markdownUrl && ( + <> + + + + Copy page + + + | + + + + View as Markdown + + > + )} + +)} + + diff --git a/docs/src/components/ui/page-actions/index.ts b/docs/src/components/ui/page-actions/index.ts new file mode 100644 index 0000000..3d7bcd1 --- /dev/null +++ b/docs/src/components/ui/page-actions/index.ts @@ -0,0 +1 @@ +export { default as PageActions } from "./PageActions.astro"; diff --git a/docs/src/components/ui/page-actions/page-actions.client.ts b/docs/src/components/ui/page-actions/page-actions.client.ts new file mode 100644 index 0000000..56c9d59 --- /dev/null +++ b/docs/src/components/ui/page-actions/page-actions.client.ts @@ -0,0 +1,55 @@ +import { mount } from "nimbus-docs/client"; + +function initPageActions(root: HTMLElement): () => void { + const copyBtn = root.querySelector("[data-nb-page-actions-copy]"); + const copyIcon = root.querySelector("[data-nb-page-actions-copy-icon]"); + const checkIcon = root.querySelector("[data-nb-page-actions-check-icon]"); + const label = root.querySelector("[data-nb-page-actions-label]"); + const mdUrl = root.dataset.mdUrl; + + if (!copyBtn || !mdUrl) return () => {}; + const markdownUrl = mdUrl; + + let resetTimer: number | undefined; + + function showState(state: "copied" | "error") { + if (!copyIcon || !checkIcon || !label) return; + if (state === "copied") { + copyIcon.classList.add("hidden"); + checkIcon.classList.remove("hidden"); + label.textContent = "Copied"; + } else { + label.textContent = "Couldn't copy"; + } + if (resetTimer) window.clearTimeout(resetTimer); + resetTimer = window.setTimeout(() => { + copyIcon.classList.remove("hidden"); + checkIcon.classList.add("hidden"); + label.textContent = "Copy page"; + }, 1500); + } + + async function handleCopyPage() { + try { + const res = await fetch(markdownUrl); + if (!res.ok) { + showState("error"); + return; + } + const text = await res.text(); + await navigator.clipboard.writeText(text); + showState("copied"); + } catch { + showState("error"); + } + } + + copyBtn.addEventListener("click", handleCopyPage); + + return () => { + if (resetTimer) window.clearTimeout(resetTimer); + copyBtn.removeEventListener("click", handleCopyPage); + }; +} + +mount("[data-nb-page-actions]", initPageActions); diff --git a/docs/src/components/ui/pagination/Pagination.astro b/docs/src/components/ui/pagination/Pagination.astro new file mode 100644 index 0000000..6768078 --- /dev/null +++ b/docs/src/components/ui/pagination/Pagination.astro @@ -0,0 +1,36 @@ +--- +import { Icon } from "astro-icon/components"; +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; +import type { PrevNext } from "nimbus-docs/types"; + +interface Props extends HTMLAttributes<"nav"> { + prevNext: PrevNext; +} + +const { prevNext, class: className, ...attrs } = Astro.props; +const { prev, next } = prevNext; +--- + +{(prev || next) && ( + + {prev ? ( + + + + Previous + {prev.label} + + + ) : } + {next ? ( + + + Next + {next.label} + + + + ) : } + +)} diff --git a/docs/src/components/ui/pagination/index.ts b/docs/src/components/ui/pagination/index.ts new file mode 100644 index 0000000..8972ed7 --- /dev/null +++ b/docs/src/components/ui/pagination/index.ts @@ -0,0 +1 @@ +export { default as Pagination } from "./Pagination.astro"; diff --git a/docs/src/components/ui/search/SearchDialog.astro b/docs/src/components/ui/search/SearchDialog.astro new file mode 100644 index 0000000..1dd0847 --- /dev/null +++ b/docs/src/components/ui/search/SearchDialog.astro @@ -0,0 +1,112 @@ +--- +import { Icon } from "astro-icon/components"; +import { Dialog, DialogClose, DialogContent } from "@/components/ui/dialog"; +--- + + + + + + + + + Esc + + + + + + Type to search… + + + + + + ↑↓ navigate + ↵ select + Esc close + + + + + diff --git a/docs/src/components/ui/search/SearchTrigger.astro b/docs/src/components/ui/search/SearchTrigger.astro new file mode 100644 index 0000000..4126e75 --- /dev/null +++ b/docs/src/components/ui/search/SearchTrigger.astro @@ -0,0 +1,40 @@ +--- +/** Button that opens the search dialog. Cmd+K on macOS, Ctrl+K elsewhere. */ +import { Icon } from "astro-icon/components"; +--- + + + + Search + + CtrlK + + + + diff --git a/docs/src/components/ui/search/index.ts b/docs/src/components/ui/search/index.ts new file mode 100644 index 0000000..9f7c86a --- /dev/null +++ b/docs/src/components/ui/search/index.ts @@ -0,0 +1,2 @@ +export { default as SearchDialog } from "./SearchDialog.astro"; +export { default as SearchTrigger } from "./SearchTrigger.astro"; diff --git a/docs/src/components/ui/search/providers/pagefind.ts b/docs/src/components/ui/search/providers/pagefind.ts new file mode 100644 index 0000000..6a8d8e9 --- /dev/null +++ b/docs/src/components/ui/search/providers/pagefind.ts @@ -0,0 +1,75 @@ +import type { SearchProvider, SearchResult } from "nimbus-docs/types"; +import { config } from "virtual:nimbus/config"; + +interface PagefindSubResult { + title?: string; + url?: string; +} + +interface PagefindResultData { + url: string; + excerpt?: string; + meta?: { title?: string }; + sub_results?: PagefindSubResult[]; +} + +interface PagefindSearchResponse { + results: Array<{ data(): Promise }>; +} + +interface PagefindFilters { + [key: string]: string | string[] | { none?: string | string[]; any?: string | string[] }; +} + +interface PagefindApi { + init(): Promise; + search(query: string, options?: { filters?: PagefindFilters }): Promise; +} + +let pagefind: PagefindApi | undefined; + +/** + * Default Pagefind filters applied to every search. + * + * Versioning: when the site has a `versions.deprecated` list, the + * layout emits `data-pagefind-filter="status:deprecated"` on every + * deprecated-version page. Search defaults to excluding those results + * (readers searching for "auth" want the current version's auth page, + * not the deprecated one). Future UI work can expose a "include + * deprecated" toggle; for now the default is current + non-deprecated. + * + * Versions are still searchable individually — readers on a v0 page + * who explicitly search from there can opt the UI into a version-scoped + * filter. The default exclusion is just for the top-level search. + * + * Computed at module-import time so we don't pay the config lookup on + * every keystroke. + */ +const defaultFilters: PagefindFilters | undefined = + config.versions && config.versions.deprecated && config.versions.deprecated.length > 0 ? { status: { none: "deprecated" } } : undefined; + +export const provider: SearchProvider = { + async init() { + if (pagefind) return; + const baseUrl = new URL(import.meta.env.BASE_URL ?? "/", window.location.origin); + const pagefindUrl = new URL("pagefind/pagefind.js", baseUrl); + pagefind = (await import(/* @vite-ignore */ pagefindUrl.href)) as PagefindApi; + await pagefind.init(); + }, + + async search(query) { + if (!pagefind) await this.init?.(); + if (!pagefind) return []; + + const search = await pagefind.search(query, defaultFilters ? { filters: defaultFilters } : undefined); + const results = await Promise.all(search.results.slice(0, 10).map((result) => result.data())); + return results.map((result): SearchResult => ({ + title: result.meta?.title ?? "Untitled", + url: result.url, + snippet: result.excerpt, + subResults: result.sub_results + ?.filter((sub): sub is Required => Boolean(sub.title && sub.url)) + .map((sub) => ({ title: sub.title, url: sub.url })), + })); + }, +}; diff --git a/docs/src/components/ui/search/search.client.ts b/docs/src/components/ui/search/search.client.ts new file mode 100644 index 0000000..96f76f5 --- /dev/null +++ b/docs/src/components/ui/search/search.client.ts @@ -0,0 +1,208 @@ +import type { SearchProvider, SearchResult } from "nimbus-docs/types"; + +export interface SearchConfig { + input: HTMLInputElement; + resultsContainer: HTMLElement; + emptyState: HTMLElement; + provider: SearchProvider; + onNavigate?: () => void; +} + +export interface SearchInstance { + reset(): Promise; + destroy(): void; +} + +export function initSearch(config: SearchConfig): SearchInstance { + const { input, resultsContainer, emptyState, provider, onNavigate } = config; + + let initialized = false; + let activeIndex = -1; + let resultIdCounter = 0; + let debounceTimer: ReturnType | undefined; + let activeController: AbortController | undefined; + + function getOptions(): HTMLElement[] { + return Array.from(resultsContainer.querySelectorAll("[role='option']")); + } + + function updateActive(newIndex: number): void { + const options = getOptions(); + if (options.length === 0) { + activeIndex = -1; + input.removeAttribute("aria-activedescendant"); + return; + } + activeIndex = Math.max(-1, Math.min(newIndex, options.length - 1)); + options.forEach((option, index) => { + if (index === activeIndex) { + option.setAttribute("data-highlighted", ""); + option.scrollIntoView({ block: "nearest" }); + input.setAttribute("aria-activedescendant", option.id); + } else { + option.removeAttribute("data-highlighted"); + } + }); + if (activeIndex < 0) input.removeAttribute("aria-activedescendant"); + } + + function clearResults(): void { + for (const result of resultsContainer.querySelectorAll("[role='option']")) result.remove(); + input.setAttribute("aria-expanded", "false"); + input.removeAttribute("aria-activedescendant"); + } + + function resultLink(title: string, href: string, className: string): HTMLAnchorElement { + const link = document.createElement("a"); + link.href = href; + link.className = className; + link.textContent = title; + link.addEventListener("click", () => onNavigate?.()); + return link; + } + + function buildResult(result: SearchResult): HTMLElement { + const option = document.createElement("div"); + option.id = `search-result-${resultIdCounter++}`; + option.setAttribute("role", "option"); + option.className = + "rounded-lg px-2 py-2 transition-colors cursor-pointer hover:bg-accent focus-within:bg-accent data-[highlighted]:bg-accent"; + + const link = resultLink( + result.title, + result.url, + "block truncate text-sm font-medium text-foreground no-underline focus-visible:outline-none", + ); + option.appendChild(link); + + if (result.snippet) { + const snippet = document.createElement("p"); + snippet.className = "mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground"; + snippet.innerHTML = result.snippet; + option.appendChild(snippet); + } + + if (result.subResults?.length) { + const subList = document.createElement("div"); + subList.className = "mt-2 border-l border-border pl-3"; + for (const sub of result.subResults.slice(0, 3)) { + subList.appendChild( + resultLink(sub.title, sub.url, "block truncate py-0.5 text-xs text-muted-foreground no-underline hover:text-foreground"), + ); + } + option.appendChild(subList); + } + + option.addEventListener("click", (event) => { + if ((event.target as Element | null)?.closest("a")) return; + link.click(); + }); + + return option; + } + + async function ensureInitialized(): Promise { + if (initialized) return true; + try { + await provider.init?.(); + initialized = true; + return true; + } catch { + emptyState.textContent = "Search is available after a production build."; + return false; + } + } + + async function runSearch(query: string): Promise { + activeController?.abort(); + activeController = new AbortController(); + const signal = activeController.signal; + + emptyState.style.display = ""; + emptyState.textContent = "Searching…"; + clearResults(); + + if (!(await ensureInitialized()) || signal.aborted) return; + + try { + const results = await provider.search(query, { signal }); + if (signal.aborted) return; + + clearResults(); + activeIndex = -1; + + if (results.length === 0) { + emptyState.style.display = ""; + emptyState.textContent = "No results found."; + return; + } + + emptyState.style.display = "none"; + input.setAttribute("aria-expanded", "true"); + for (const result of results) resultsContainer.appendChild(buildResult(result)); + } catch { + if (signal.aborted) return; + clearResults(); + emptyState.style.display = ""; + emptyState.textContent = "Search is temporarily unavailable."; + } + } + + function handleInput(): void { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + const query = input.value.trim(); + if (!query) { + activeController?.abort(); + clearResults(); + emptyState.style.display = ""; + emptyState.textContent = "Type to search…"; + return; + } + void runSearch(query); + }, 150); + } + + function handleKeydown(event: KeyboardEvent): void { + const options = getOptions(); + if (event.key === "ArrowDown") { + event.preventDefault(); + updateActive(activeIndex + 1); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + updateActive(activeIndex - 1); + } else if (event.key === "Home") { + event.preventDefault(); + updateActive(0); + } else if (event.key === "End") { + event.preventDefault(); + updateActive(options.length - 1); + } else if (event.key === "Enter" && activeIndex >= 0) { + event.preventDefault(); + options[activeIndex]?.querySelector("a")?.click(); + } + } + + input.addEventListener("input", handleInput); + input.closest("dialog")?.addEventListener("keydown", handleKeydown); + + return { + async reset() { + activeController?.abort(); + if (debounceTimer) clearTimeout(debounceTimer); + input.value = ""; + input.focus(); + activeIndex = -1; + clearResults(); + emptyState.style.display = ""; + emptyState.textContent = "Type to search…"; + await ensureInitialized(); + }, + destroy() { + activeController?.abort(); + if (debounceTimer) clearTimeout(debounceTimer); + input.removeEventListener("input", handleInput); + input.closest("dialog")?.removeEventListener("keydown", handleKeydown); + }, + }; +} diff --git a/docs/src/components/ui/sidebar/Sidebar.astro b/docs/src/components/ui/sidebar/Sidebar.astro new file mode 100644 index 0000000..1ca6bcd --- /dev/null +++ b/docs/src/components/ui/sidebar/Sidebar.astro @@ -0,0 +1,64 @@ +--- +/** + * Sidebar — recursive navigation tree from a `SidebarItem[]`. Composes + * SidebarGroup + SidebarLink. Pass `persist` to opt into sessionStorage + * for open/scroll state (desktop only). + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; +import SidebarGroup from "./SidebarGroup.astro"; +import SidebarLink from "./SidebarLink.astro"; +import type { SidebarItem } from "nimbus-docs/types"; +import { sidebarHash } from "nimbus-docs"; + +interface Props extends HTMLAttributes<"div"> { + items: SidebarItem[]; + /** Persist open/scroll state to sessionStorage. Desktop sidebar only. */ + persist?: boolean; +} + +const { items, persist = false, class: className, ...attrs } = Astro.props; +const hash = sidebarHash(items); +--- + + + + {items.map((item) => + item.type === "group" ? ( + + + + ) : item.type === "external" ? ( + + + + ) : ( + + + + ), + )} + + + + + + diff --git a/docs/src/components/ui/sidebar/SidebarFilter.astro b/docs/src/components/ui/sidebar/SidebarFilter.astro new file mode 100644 index 0000000..be9cb97 --- /dev/null +++ b/docs/src/components/ui/sidebar/SidebarFilter.astro @@ -0,0 +1,20 @@ +--- +/** SidebarFilter — text input that filters the adjacent Sidebar. Press "/" to focus. */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> {} + +const { class: className, ...attrs } = Astro.props; +--- + + + + diff --git a/docs/src/components/ui/sidebar/SidebarGroup.astro b/docs/src/components/ui/sidebar/SidebarGroup.astro new file mode 100644 index 0000000..fa22b55 --- /dev/null +++ b/docs/src/components/ui/sidebar/SidebarGroup.astro @@ -0,0 +1,209 @@ +--- +/** + * SidebarGroup — autogenerated section header in the sidebar rail. + * + * Renders in one of two shapes depending on whether the group has a + * landing page (`indexHref`): + * + * - Has landing: the label is an `` linking to indexHref. The + * collapse caret sits next to it as a separate ``. Matches + * the structural-separation pattern used by Fumadocs / Fern / + * Docusaurus: the group label IS the link to the landing page; + * children are listed separately below. + * - No landing: the entire row is a single `` that toggles + * the collapse. Label is non-interactive. Used for directories + * without an `index.mdx`, where the group is a pure visual section + * divider over its children. + */ +import { Icon } from "astro-icon/components"; +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; +import SidebarLink from "./SidebarLink.astro"; +import { Badge } from "@/components/ui/badge"; +import type { SidebarItem, SidebarBadge } from "nimbus-docs/types"; + +interface Props extends HTMLAttributes<"div"> { + label: string; + items: SidebarItem[]; + collapsed?: boolean; + badge?: SidebarBadge; + /** Landing-page URL when the group has an `index.mdx`. Renders the label as a link. */ + indexHref?: string; + /** True when the landing page is the current route. */ + indexIsCurrent?: boolean; + /** True when `indexHref` is an off-site URL — render with target="_blank" rel="noopener". */ + indexIsExternal?: boolean; +} + +const { + label, + items, + collapsed, + badge, + indexHref, + indexIsCurrent, + indexIsExternal, + class: className, + ...attrs +} = Astro.props; + +function hasActiveDescendant(items: SidebarItem[]): boolean { + return items.some((item) => + item.type === "link" + ? Boolean(item.isCurrent) + : item.type === "group" + ? Boolean(item.indexIsCurrent) || hasActiveDescendant(item.children) + : false, + ); +} + +const hasActive = Boolean(indexIsCurrent) || hasActiveDescendant(items); +const isOpen = Boolean(hasActive || collapsed === false || collapsed === undefined); + +// Shared classes for the row that holds the label + caret in both +// rendering modes (landing-as-link vs. label-as-trigger). +const rowClass = cn( + "group/expander flex min-h-[2rem] items-center rounded-lg px-3 py-1 text-[0.8125rem] no-underline transition-colors duration-150 focus-visible:outline-offset-[-2px]", + "hover:bg-accent hover:text-foreground", + hasActive ? "font-semibold text-foreground" : "font-medium text-muted-foreground", +); +// Chevron icon classes. Rotation is driven by an explicit CSS rule in +// ` diff --git a/docs/src/components/ui/sidebar/SidebarLink.astro b/docs/src/components/ui/sidebar/SidebarLink.astro new file mode 100644 index 0000000..62769dc --- /dev/null +++ b/docs/src/components/ui/sidebar/SidebarLink.astro @@ -0,0 +1,37 @@ +--- +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; +import { Badge } from "@/components/ui/badge"; +import type { SidebarBadge } from "nimbus-docs/types"; + +interface Props extends HTMLAttributes<"a"> { + label: string; + href: string; + isCurrent?: boolean; + badge?: SidebarBadge; +} + +const { label, href, isCurrent, badge, class: className, ...attrs } = Astro.props; +--- + + + {label} + {badge && + (typeof badge === "string" ? ( + + ) : ( + + ))} + diff --git a/docs/src/components/ui/sidebar/index.ts b/docs/src/components/ui/sidebar/index.ts new file mode 100644 index 0000000..8b4c73a --- /dev/null +++ b/docs/src/components/ui/sidebar/index.ts @@ -0,0 +1,4 @@ +export { default as Sidebar } from "./Sidebar.astro"; +export { default as SidebarFilter } from "./SidebarFilter.astro"; +export { default as SidebarGroup } from "./SidebarGroup.astro"; +export { default as SidebarLink } from "./SidebarLink.astro"; diff --git a/docs/src/components/ui/sidebar/sidebar.client.ts b/docs/src/components/ui/sidebar/sidebar.client.ts new file mode 100644 index 0000000..84edeb4 --- /dev/null +++ b/docs/src/components/ui/sidebar/sidebar.client.ts @@ -0,0 +1,203 @@ +/** Sidebar runtime: filter, persistence, "/" shortcut. */ + +import { mount } from "nimbus-docs/client"; + +const STORAGE_KEY = "sidebar-state"; + +interface SidebarState { + hash: string; + open: boolean[]; + scroll: number; +} + +function initSidebar(root: HTMLElement): () => void { + const teardowns: Array<() => void> = []; + const persist = root.hasAttribute("data-nb-sidebar-persist"); + + const filterTeardown = initFilter(root); + if (filterTeardown) teardowns.push(filterTeardown); + + if (persist) { + const persistTeardown = initPersistence(root); + if (persistTeardown) teardowns.push(persistTeardown); + } + + return () => teardowns.forEach((t) => t()); +} + +// --------------------------------------------------------------------------- +// Filter +// --------------------------------------------------------------------------- + +function initFilter(root: HTMLElement): (() => void) | null { + const input = root.querySelector("[data-nb-sidebar-filter-input]"); + // SidebarFilter is rendered *next to* Sidebar (sibling), so also look in + // the parent — preserves the existing layout where filter sits above. + const inputElement = input ?? root.parentElement?.querySelector("[data-nb-sidebar-filter-input]") ?? null; + if (!inputElement) return null; + const filterInput = inputElement; + + function handleInput() { + const query = filterInput.value.trim().toLowerCase(); + if (!query) { + resetFilter(root); + return; + } + applyFilter(root, query); + } + + function handleKeydown(e: KeyboardEvent) { + if (e.key === "Escape") { + filterInput.value = ""; + handleInput(); + filterInput.blur(); + } + } + + filterInput.addEventListener("input", handleInput); + filterInput.addEventListener("keydown", handleKeydown); + + return () => { + filterInput.removeEventListener("input", handleInput); + filterInput.removeEventListener("keydown", handleKeydown); + resetFilter(root); + }; +} + +function resetFilter(root: HTMLElement): void { + root.querySelectorAll("[data-nb-sidebar-hidden]").forEach((el) => { + el.removeAttribute("data-nb-sidebar-hidden"); + }); + // Reset groups opened by the filter back to their saved state. + root.querySelectorAll("[data-nb-sidebar-group][data-nb-opened-by-filter]").forEach((group) => { + const trigger = group.querySelector("[data-nb-collapsible-trigger]"); + trigger?.click(); + group.removeAttribute("data-nb-opened-by-filter"); + }); +} + +function applyFilter(root: HTMLElement, query: string): void { + const links = root.querySelectorAll("[data-nb-sidebar-link]"); + const groups = root.querySelectorAll("[data-nb-sidebar-group]"); + + links.forEach((link) => link.setAttribute("data-nb-sidebar-hidden", "")); + groups.forEach((group) => group.setAttribute("data-nb-sidebar-hidden", "")); + + links.forEach((link) => { + const text = link.textContent?.toLowerCase() ?? ""; + if (!text.includes(query)) return; + link.removeAttribute("data-nb-sidebar-hidden"); + revealAncestors(link, root); + }); + + groups.forEach((group) => { + const label = group.querySelector("[data-nb-sidebar-group-label]"); + const text = label?.textContent?.toLowerCase() ?? ""; + if (!text.includes(query)) return; + group.removeAttribute("data-nb-sidebar-hidden"); + openGroup(group); + group + .querySelectorAll("[data-nb-sidebar-link], [data-nb-sidebar-group]") + .forEach((child) => child.removeAttribute("data-nb-sidebar-hidden")); + }); +} + +function revealAncestors(el: HTMLElement, scope: Element): void { + let parent: HTMLElement | null = el.parentElement; + while (parent && parent !== scope) { + if (parent.hasAttribute("data-nb-sidebar-group")) { + parent.removeAttribute("data-nb-sidebar-hidden"); + openGroup(parent); + } + parent = parent.parentElement; + } +} + +function openGroup(group: HTMLElement): void { + const trigger = group.querySelector("[data-nb-collapsible-trigger]"); + if (!trigger) return; + if (trigger.getAttribute("data-nb-state") === "open") return; + group.setAttribute("data-nb-opened-by-filter", ""); + trigger.click(); +} + +// --------------------------------------------------------------------------- +// Persistence (open state + scroll) +// --------------------------------------------------------------------------- + +function initPersistence(root: HTMLElement): (() => void) | null { + // The scrollable container is the closest
` props. Everything above is +// runtime-valid; the only type friction is nominal — nimbus-docs and Astro +// can resolve different `@shikijs/types` copies (e.g. 4.2.x vs 4.1.x), so the +// `ShikiTransformer`/`ThemePresets` shapes differ on paper while matching at +// runtime. `Astro.props` was already `as Props`; re-asserting here keeps the +// component compiling regardless of which shiki types version dedupes in. +--- + + diff --git a/docs/src/components/ui/code/index.ts b/docs/src/components/ui/code/index.ts new file mode 100644 index 0000000..152859d --- /dev/null +++ b/docs/src/components/ui/code/index.ts @@ -0,0 +1 @@ +export { default as Code } from "./Code.astro"; diff --git a/docs/src/components/ui/collapsible/Collapsible.astro b/docs/src/components/ui/collapsible/Collapsible.astro new file mode 100644 index 0000000..24b51c2 --- /dev/null +++ b/docs/src/components/ui/collapsible/Collapsible.astro @@ -0,0 +1,25 @@ +--- +/** Collapsible — disclosure. Compose with CollapsibleTrigger + CollapsibleContent. */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> { + /** Start open. Default false. */ + open?: boolean; +} + +const { open = false, class: className, ...attrs } = Astro.props; +--- + + + + + + diff --git a/docs/src/components/ui/collapsible/CollapsibleContent.astro b/docs/src/components/ui/collapsible/CollapsibleContent.astro new file mode 100644 index 0000000..e8b7686 --- /dev/null +++ b/docs/src/components/ui/collapsible/CollapsibleContent.astro @@ -0,0 +1,27 @@ +--- +/** + * CollapsibleContent — the panel that animates open/closed. + * Uses `grid-template-rows: 0fr → 1fr` for smooth height transition. + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> {} + +const { class: className, ...attrs } = Astro.props; +--- + + + + + + diff --git a/docs/src/components/ui/collapsible/CollapsibleTrigger.astro b/docs/src/components/ui/collapsible/CollapsibleTrigger.astro new file mode 100644 index 0000000..d5f8cea --- /dev/null +++ b/docs/src/components/ui/collapsible/CollapsibleTrigger.astro @@ -0,0 +1,21 @@ +--- +/** + * CollapsibleTrigger — the button that toggles the Collapsible. + * Slot accepts arbitrary content; component author provides full visuals. + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"button"> {} + +const { class: className, type, ...attrs } = Astro.props; +--- + + + + diff --git a/docs/src/components/ui/collapsible/collapsible.client.ts b/docs/src/components/ui/collapsible/collapsible.client.ts new file mode 100644 index 0000000..9e93213 --- /dev/null +++ b/docs/src/components/ui/collapsible/collapsible.client.ts @@ -0,0 +1,22 @@ +/** Wires Collapsible via the disclosure module. */ + +import { mount, makeDisclosure } from "nimbus-docs/client"; + +function initCollapsible(root: HTMLElement): () => void { + const trigger = root.querySelector("[data-nb-collapsible-trigger]"); + const content = root.querySelector("[data-nb-collapsible-content]"); + + if (!trigger || !content) return () => {}; + + const defaultOpen = root.dataset.nbDefaultOpen === "true"; + + const disclosure = makeDisclosure({ + trigger, + content, + defaultOpen, + }); + + return () => disclosure.destroy(); +} + +mount("[data-nb-collapsible]", initCollapsible); diff --git a/docs/src/components/ui/collapsible/index.ts b/docs/src/components/ui/collapsible/index.ts new file mode 100644 index 0000000..a4c4221 --- /dev/null +++ b/docs/src/components/ui/collapsible/index.ts @@ -0,0 +1,3 @@ +export { default as Collapsible } from "./Collapsible.astro"; +export { default as CollapsibleTrigger } from "./CollapsibleTrigger.astro"; +export { default as CollapsibleContent } from "./CollapsibleContent.astro"; diff --git a/docs/src/components/ui/dialog/Dialog.astro b/docs/src/components/ui/dialog/Dialog.astro new file mode 100644 index 0000000..24e3309 --- /dev/null +++ b/docs/src/components/ui/dialog/Dialog.astro @@ -0,0 +1,53 @@ +--- +/** + * Dialog — modal overlay built on the native . + * + * Open with `el.showModal()`, close with `el.close()` or Escape. + * Scroll-lock and backdrop-click-to-close are handled automatically. + * + * + * + * Are you sure? + * Cancel + * + * + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"dialog"> {} + +const { class: className, ...attrs } = Astro.props; +--- + + + + + + diff --git a/docs/src/components/ui/dialog/DialogClose.astro b/docs/src/components/ui/dialog/DialogClose.astro new file mode 100644 index 0000000..bc6e81f --- /dev/null +++ b/docs/src/components/ui/dialog/DialogClose.astro @@ -0,0 +1,35 @@ +--- +/** + * DialogClose — button that closes the nearest ancestor . + * Consumer provides the visual (icon, text, kbd hint) via default slot. + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"button"> {} + +const { class: className, ...attrs } = Astro.props; +--- + + + + + + diff --git a/docs/src/components/ui/dialog/DialogContent.astro b/docs/src/components/ui/dialog/DialogContent.astro new file mode 100644 index 0000000..462448a --- /dev/null +++ b/docs/src/components/ui/dialog/DialogContent.astro @@ -0,0 +1,21 @@ +--- +/** + * DialogContent — inner frame of a Dialog. Centered, constrained, styled. + * Consumer controls max-width/height via class override. + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> {} + +const { class: className, ...attrs } = Astro.props; +--- + + + + + + diff --git a/docs/src/components/ui/dialog/index.ts b/docs/src/components/ui/dialog/index.ts new file mode 100644 index 0000000..fe7fb64 --- /dev/null +++ b/docs/src/components/ui/dialog/index.ts @@ -0,0 +1,3 @@ +export { default as Dialog } from "./Dialog.astro"; +export { default as DialogContent } from "./DialogContent.astro"; +export { default as DialogClose } from "./DialogClose.astro"; diff --git a/docs/src/components/ui/layer-card/LayerCard.astro b/docs/src/components/ui/layer-card/LayerCard.astro new file mode 100644 index 0000000..8473c7b --- /dev/null +++ b/docs/src/components/ui/layer-card/LayerCard.astro @@ -0,0 +1,23 @@ +--- +/** + * LayerCard — two-layer card (recessed header + raised content). + * + * + * Title or tabs + * Main content + * + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> {} + +const { class: className, ...attrs } = Astro.props; +--- + + + + diff --git a/docs/src/components/ui/layer-card/LayerCardContent.astro b/docs/src/components/ui/layer-card/LayerCardContent.astro new file mode 100644 index 0000000..12bdaea --- /dev/null +++ b/docs/src/components/ui/layer-card/LayerCardContent.astro @@ -0,0 +1,16 @@ +--- +/** LayerCardContent — raised content layer of a LayerCard. */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> {} + +const { class: className, ...attrs } = Astro.props; +--- + + + + diff --git a/docs/src/components/ui/layer-card/LayerCardHeader.astro b/docs/src/components/ui/layer-card/LayerCardHeader.astro new file mode 100644 index 0000000..b9be404 --- /dev/null +++ b/docs/src/components/ui/layer-card/LayerCardHeader.astro @@ -0,0 +1,16 @@ +--- +/** LayerCardHeader — recessed header layer of a LayerCard. */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> {} + +const { class: className, ...attrs } = Astro.props; +--- + + + + diff --git a/docs/src/components/ui/layer-card/index.ts b/docs/src/components/ui/layer-card/index.ts new file mode 100644 index 0000000..dd347fe --- /dev/null +++ b/docs/src/components/ui/layer-card/index.ts @@ -0,0 +1,3 @@ +export { default as LayerCard } from "./LayerCard.astro"; +export { default as LayerCardHeader } from "./LayerCardHeader.astro"; +export { default as LayerCardContent } from "./LayerCardContent.astro"; diff --git a/docs/src/components/ui/link-button/LinkButton.astro b/docs/src/components/ui/link-button/LinkButton.astro new file mode 100644 index 0000000..e1eb918 --- /dev/null +++ b/docs/src/components/ui/link-button/LinkButton.astro @@ -0,0 +1,75 @@ +--- +/** + * LinkButton — an anchor styled as a button. + * + * Get started + * Read the docs + * Learn more + * … + * + * The `icon` prop appends a right-caret that nudges on hover. + * + * Styling is delegated to the shared `ui/button/variants`, so LinkButton and + * Button stay visually identical. The original props are preserved (used by + * MDX): the LinkButton-only aliases map onto the shared vocabulary — + * `minimal → ghost`, `md → base`. + */ +import { cn } from "@/lib/cn"; +import { Icon } from "astro-icon/components"; +import type { HTMLAttributes } from "astro/types"; +import { + buttonVariants, + buttonIconSize, + type ButtonVariant, + type ButtonSize, + type ButtonShape, +} from "../button/variants"; + +interface Props extends HTMLAttributes<"a"> { + href: string; + /** Original `primary | secondary | minimal`, plus Button's variants. */ + variant?: ButtonVariant | "minimal"; + /** Original `sm | md | lg`, plus Button's `xs | base`. */ + size?: ButtonSize | "md"; + /** `base` (default) · `square` · `circle` (icon-only — pass `aria-label`). */ + shape?: ButtonShape; + /** Append a caret-right that nudges on hover. */ + icon?: boolean; +} + +const { + href, + variant = "primary", + size = "md", + shape = "base", + icon = false, + class: className, + ...attrs +} = Astro.props; + +// Map the LinkButton-only aliases onto the shared Button vocabulary. +const resolvedVariant: ButtonVariant = variant === "minimal" ? "ghost" : variant; +const resolvedSize: ButtonSize = size === "md" ? "base" : size; +--- + + + + { + icon && ( + + ) + } + diff --git a/docs/src/components/ui/link-button/index.ts b/docs/src/components/ui/link-button/index.ts new file mode 100644 index 0000000..71411b5 --- /dev/null +++ b/docs/src/components/ui/link-button/index.ts @@ -0,0 +1 @@ +export { default as LinkButton } from "./LinkButton.astro"; diff --git a/docs/src/components/ui/package-managers/PackageManagers.astro b/docs/src/components/ui/package-managers/PackageManagers.astro new file mode 100644 index 0000000..4e203af --- /dev/null +++ b/docs/src/components/ui/package-managers/PackageManagers.astro @@ -0,0 +1,123 @@ +--- +/** + * PackageManagers — code block with a tab per package manager (npm, + * pnpm, yarn, bun). Selection syncs across instances via sessionStorage; + * an inline custom element restores the saved tab before paint. + */ +import { createHash } from "node:crypto"; +import { Icon } from "astro-icon/components"; +import { getTabs } from "nimbus-docs/lib/pkgm"; +import type { CommandType, CommandOptions } from "nimbus-docs/lib/pkgm"; +import { LayerCard, LayerCardHeader } from "@/components/ui/layer-card"; + +interface Props extends CommandOptions { + pkg?: string; + type?: CommandType; +} + +const { pkg, type = "add", args, dev, comment } = Astro.props; +const tabs = getTabs(type, pkg, { args, dev, comment }); +// Deterministic ID — hash the props so two builds with identical content +// produce identical IDs. Required for incremental builds: a per-render +// `crypto.randomUUID()` would make warm output differ from cold. +// Two instances with identical props on the same page would collide; in +// practice this is a rare authoring pattern (same package + type + args). +const uid = + "pm-" + + createHash("sha256") + .update(JSON.stringify({ pkg, type, args, dev, comment })) + .digest("hex") + .slice(0, 12); +--- + + + + + + + {tabs.map((tab, i) => ( + + {tab.mgr} + + ))} + + + {tabs.map((tab, i) => { + const cmdLines = tab.cmd.split("\n"); + const commentPrefix = cmdLines.length > 1 ? cmdLines.slice(0, -1).join("\n") + "\n" : ""; + const codeLine = cmdLines[cmdLines.length - 1]; + const spaceIdx = codeLine.indexOf(" "); + const codeFirst = spaceIdx === -1 ? codeLine : codeLine.slice(0, spaceIdx); + const codeRest = spaceIdx === -1 ? "" : codeLine.slice(spaceIdx); + return ( + + + {commentPrefix && {commentPrefix}}{codeFirst}{codeRest} + + + + + + ); + })} + + + + + + + diff --git a/docs/src/components/ui/package-managers/index.ts b/docs/src/components/ui/package-managers/index.ts new file mode 100644 index 0000000..248021d --- /dev/null +++ b/docs/src/components/ui/package-managers/index.ts @@ -0,0 +1 @@ +export { default as PackageManagers } from "./PackageManagers.astro"; diff --git a/docs/src/components/ui/package-managers/package-managers.client.ts b/docs/src/components/ui/package-managers/package-managers.client.ts new file mode 100644 index 0000000..a7c1152 --- /dev/null +++ b/docs/src/components/ui/package-managers/package-managers.client.ts @@ -0,0 +1,55 @@ +/** + * Sync key `ui-pm-tab` (sessionStorage) is shared with the + * `` early-paint element to avoid flash across navigations. + */ + +import { mount, initTabs } from "nimbus-docs/client"; + +function cloneIcon(tpl: HTMLTemplateElement | null): Node { + return tpl ? tpl.content.cloneNode(true) : document.createTextNode(""); +} + +function initPackageManager(container: HTMLElement): () => void { + const copyTpl = container.querySelector("[data-nb-pm-icon-copy]"); + const checkTpl = container.querySelector("[data-nb-pm-icon-check]"); + + const tabs = initTabs({ + container, + tabSelector: "[data-nb-pm-tab]", + panelSelector: "[data-nb-pm-panel]", + rovingTabindex: true, + sync: { key: "ui-pm-tab", storage: "session" }, + }); + + const copyHandlers: Array<{ btn: HTMLButtonElement; handler: () => void; timer?: number }> = []; + + container.querySelectorAll("[data-nb-pm-copy]").forEach((btn) => { + const handlerInfo: { btn: HTMLButtonElement; handler: () => void; timer?: number } = { + btn, + handler: async () => { + try { + await navigator.clipboard.writeText(btn.dataset.nbCommand ?? ""); + } catch { + return; + } + btn.replaceChildren(cloneIcon(checkTpl)); + if (handlerInfo.timer) window.clearTimeout(handlerInfo.timer); + handlerInfo.timer = window.setTimeout(() => { + btn.replaceChildren(cloneIcon(copyTpl)); + }, 1500); + }, + }; + btn.addEventListener("click", handlerInfo.handler); + copyHandlers.push(handlerInfo); + }); + + return () => { + tabs.destroy(); + copyHandlers.forEach(({ btn, handler, timer }) => { + btn.removeEventListener("click", handler); + if (timer) window.clearTimeout(timer); + }); + }; +} + +mount("[data-nb-pm]", initPackageManager); diff --git a/docs/src/components/ui/page-actions/PageActions.astro b/docs/src/components/ui/page-actions/PageActions.astro new file mode 100644 index 0000000..09719d6 --- /dev/null +++ b/docs/src/components/ui/page-actions/PageActions.astro @@ -0,0 +1,82 @@ +--- +import { Icon } from "astro-icon/components"; +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> { + markdownUrl?: string; + lastUpdated?: Date; + /** Page was drafted by an AI agent and not yet reviewed by a human. */ + aiGenerated?: boolean; +} + +const { markdownUrl, lastUpdated, aiGenerated, class: className, ...attrs } = Astro.props; + +const baseBtn = + "inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-transparent px-2 py-1 text-muted-foreground no-underline transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2"; + +const formattedDate = lastUpdated + ? new Intl.DateTimeFormat(undefined, { year: "numeric", month: "short", day: "numeric" }).format(lastUpdated) + : null; +--- + +{(markdownUrl || lastUpdated || aiGenerated) && ( + + {aiGenerated && ( + <> + + + AI-generated · awaiting review + + {(lastUpdated || markdownUrl) && ( + | + )} + > + )} + + {lastUpdated && ( + + + Updated {formattedDate} + + )} + + {markdownUrl && lastUpdated && ( + | + )} + + {markdownUrl && ( + <> + + + + Copy page + + + | + + + + View as Markdown + + > + )} + +)} + + diff --git a/docs/src/components/ui/page-actions/index.ts b/docs/src/components/ui/page-actions/index.ts new file mode 100644 index 0000000..3d7bcd1 --- /dev/null +++ b/docs/src/components/ui/page-actions/index.ts @@ -0,0 +1 @@ +export { default as PageActions } from "./PageActions.astro"; diff --git a/docs/src/components/ui/page-actions/page-actions.client.ts b/docs/src/components/ui/page-actions/page-actions.client.ts new file mode 100644 index 0000000..56c9d59 --- /dev/null +++ b/docs/src/components/ui/page-actions/page-actions.client.ts @@ -0,0 +1,55 @@ +import { mount } from "nimbus-docs/client"; + +function initPageActions(root: HTMLElement): () => void { + const copyBtn = root.querySelector("[data-nb-page-actions-copy]"); + const copyIcon = root.querySelector("[data-nb-page-actions-copy-icon]"); + const checkIcon = root.querySelector("[data-nb-page-actions-check-icon]"); + const label = root.querySelector("[data-nb-page-actions-label]"); + const mdUrl = root.dataset.mdUrl; + + if (!copyBtn || !mdUrl) return () => {}; + const markdownUrl = mdUrl; + + let resetTimer: number | undefined; + + function showState(state: "copied" | "error") { + if (!copyIcon || !checkIcon || !label) return; + if (state === "copied") { + copyIcon.classList.add("hidden"); + checkIcon.classList.remove("hidden"); + label.textContent = "Copied"; + } else { + label.textContent = "Couldn't copy"; + } + if (resetTimer) window.clearTimeout(resetTimer); + resetTimer = window.setTimeout(() => { + copyIcon.classList.remove("hidden"); + checkIcon.classList.add("hidden"); + label.textContent = "Copy page"; + }, 1500); + } + + async function handleCopyPage() { + try { + const res = await fetch(markdownUrl); + if (!res.ok) { + showState("error"); + return; + } + const text = await res.text(); + await navigator.clipboard.writeText(text); + showState("copied"); + } catch { + showState("error"); + } + } + + copyBtn.addEventListener("click", handleCopyPage); + + return () => { + if (resetTimer) window.clearTimeout(resetTimer); + copyBtn.removeEventListener("click", handleCopyPage); + }; +} + +mount("[data-nb-page-actions]", initPageActions); diff --git a/docs/src/components/ui/pagination/Pagination.astro b/docs/src/components/ui/pagination/Pagination.astro new file mode 100644 index 0000000..6768078 --- /dev/null +++ b/docs/src/components/ui/pagination/Pagination.astro @@ -0,0 +1,36 @@ +--- +import { Icon } from "astro-icon/components"; +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; +import type { PrevNext } from "nimbus-docs/types"; + +interface Props extends HTMLAttributes<"nav"> { + prevNext: PrevNext; +} + +const { prevNext, class: className, ...attrs } = Astro.props; +const { prev, next } = prevNext; +--- + +{(prev || next) && ( + + {prev ? ( + + + + Previous + {prev.label} + + + ) : } + {next ? ( + + + Next + {next.label} + + + + ) : } + +)} diff --git a/docs/src/components/ui/pagination/index.ts b/docs/src/components/ui/pagination/index.ts new file mode 100644 index 0000000..8972ed7 --- /dev/null +++ b/docs/src/components/ui/pagination/index.ts @@ -0,0 +1 @@ +export { default as Pagination } from "./Pagination.astro"; diff --git a/docs/src/components/ui/search/SearchDialog.astro b/docs/src/components/ui/search/SearchDialog.astro new file mode 100644 index 0000000..1dd0847 --- /dev/null +++ b/docs/src/components/ui/search/SearchDialog.astro @@ -0,0 +1,112 @@ +--- +import { Icon } from "astro-icon/components"; +import { Dialog, DialogClose, DialogContent } from "@/components/ui/dialog"; +--- + + + + + + + + + Esc + + + + + + Type to search… + + + + + + ↑↓ navigate + ↵ select + Esc close + + + + + diff --git a/docs/src/components/ui/search/SearchTrigger.astro b/docs/src/components/ui/search/SearchTrigger.astro new file mode 100644 index 0000000..4126e75 --- /dev/null +++ b/docs/src/components/ui/search/SearchTrigger.astro @@ -0,0 +1,40 @@ +--- +/** Button that opens the search dialog. Cmd+K on macOS, Ctrl+K elsewhere. */ +import { Icon } from "astro-icon/components"; +--- + + + + Search + + CtrlK + + + + diff --git a/docs/src/components/ui/search/index.ts b/docs/src/components/ui/search/index.ts new file mode 100644 index 0000000..9f7c86a --- /dev/null +++ b/docs/src/components/ui/search/index.ts @@ -0,0 +1,2 @@ +export { default as SearchDialog } from "./SearchDialog.astro"; +export { default as SearchTrigger } from "./SearchTrigger.astro"; diff --git a/docs/src/components/ui/search/providers/pagefind.ts b/docs/src/components/ui/search/providers/pagefind.ts new file mode 100644 index 0000000..6a8d8e9 --- /dev/null +++ b/docs/src/components/ui/search/providers/pagefind.ts @@ -0,0 +1,75 @@ +import type { SearchProvider, SearchResult } from "nimbus-docs/types"; +import { config } from "virtual:nimbus/config"; + +interface PagefindSubResult { + title?: string; + url?: string; +} + +interface PagefindResultData { + url: string; + excerpt?: string; + meta?: { title?: string }; + sub_results?: PagefindSubResult[]; +} + +interface PagefindSearchResponse { + results: Array<{ data(): Promise }>; +} + +interface PagefindFilters { + [key: string]: string | string[] | { none?: string | string[]; any?: string | string[] }; +} + +interface PagefindApi { + init(): Promise; + search(query: string, options?: { filters?: PagefindFilters }): Promise; +} + +let pagefind: PagefindApi | undefined; + +/** + * Default Pagefind filters applied to every search. + * + * Versioning: when the site has a `versions.deprecated` list, the + * layout emits `data-pagefind-filter="status:deprecated"` on every + * deprecated-version page. Search defaults to excluding those results + * (readers searching for "auth" want the current version's auth page, + * not the deprecated one). Future UI work can expose a "include + * deprecated" toggle; for now the default is current + non-deprecated. + * + * Versions are still searchable individually — readers on a v0 page + * who explicitly search from there can opt the UI into a version-scoped + * filter. The default exclusion is just for the top-level search. + * + * Computed at module-import time so we don't pay the config lookup on + * every keystroke. + */ +const defaultFilters: PagefindFilters | undefined = + config.versions && config.versions.deprecated && config.versions.deprecated.length > 0 ? { status: { none: "deprecated" } } : undefined; + +export const provider: SearchProvider = { + async init() { + if (pagefind) return; + const baseUrl = new URL(import.meta.env.BASE_URL ?? "/", window.location.origin); + const pagefindUrl = new URL("pagefind/pagefind.js", baseUrl); + pagefind = (await import(/* @vite-ignore */ pagefindUrl.href)) as PagefindApi; + await pagefind.init(); + }, + + async search(query) { + if (!pagefind) await this.init?.(); + if (!pagefind) return []; + + const search = await pagefind.search(query, defaultFilters ? { filters: defaultFilters } : undefined); + const results = await Promise.all(search.results.slice(0, 10).map((result) => result.data())); + return results.map((result): SearchResult => ({ + title: result.meta?.title ?? "Untitled", + url: result.url, + snippet: result.excerpt, + subResults: result.sub_results + ?.filter((sub): sub is Required => Boolean(sub.title && sub.url)) + .map((sub) => ({ title: sub.title, url: sub.url })), + })); + }, +}; diff --git a/docs/src/components/ui/search/search.client.ts b/docs/src/components/ui/search/search.client.ts new file mode 100644 index 0000000..96f76f5 --- /dev/null +++ b/docs/src/components/ui/search/search.client.ts @@ -0,0 +1,208 @@ +import type { SearchProvider, SearchResult } from "nimbus-docs/types"; + +export interface SearchConfig { + input: HTMLInputElement; + resultsContainer: HTMLElement; + emptyState: HTMLElement; + provider: SearchProvider; + onNavigate?: () => void; +} + +export interface SearchInstance { + reset(): Promise; + destroy(): void; +} + +export function initSearch(config: SearchConfig): SearchInstance { + const { input, resultsContainer, emptyState, provider, onNavigate } = config; + + let initialized = false; + let activeIndex = -1; + let resultIdCounter = 0; + let debounceTimer: ReturnType | undefined; + let activeController: AbortController | undefined; + + function getOptions(): HTMLElement[] { + return Array.from(resultsContainer.querySelectorAll("[role='option']")); + } + + function updateActive(newIndex: number): void { + const options = getOptions(); + if (options.length === 0) { + activeIndex = -1; + input.removeAttribute("aria-activedescendant"); + return; + } + activeIndex = Math.max(-1, Math.min(newIndex, options.length - 1)); + options.forEach((option, index) => { + if (index === activeIndex) { + option.setAttribute("data-highlighted", ""); + option.scrollIntoView({ block: "nearest" }); + input.setAttribute("aria-activedescendant", option.id); + } else { + option.removeAttribute("data-highlighted"); + } + }); + if (activeIndex < 0) input.removeAttribute("aria-activedescendant"); + } + + function clearResults(): void { + for (const result of resultsContainer.querySelectorAll("[role='option']")) result.remove(); + input.setAttribute("aria-expanded", "false"); + input.removeAttribute("aria-activedescendant"); + } + + function resultLink(title: string, href: string, className: string): HTMLAnchorElement { + const link = document.createElement("a"); + link.href = href; + link.className = className; + link.textContent = title; + link.addEventListener("click", () => onNavigate?.()); + return link; + } + + function buildResult(result: SearchResult): HTMLElement { + const option = document.createElement("div"); + option.id = `search-result-${resultIdCounter++}`; + option.setAttribute("role", "option"); + option.className = + "rounded-lg px-2 py-2 transition-colors cursor-pointer hover:bg-accent focus-within:bg-accent data-[highlighted]:bg-accent"; + + const link = resultLink( + result.title, + result.url, + "block truncate text-sm font-medium text-foreground no-underline focus-visible:outline-none", + ); + option.appendChild(link); + + if (result.snippet) { + const snippet = document.createElement("p"); + snippet.className = "mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground"; + snippet.innerHTML = result.snippet; + option.appendChild(snippet); + } + + if (result.subResults?.length) { + const subList = document.createElement("div"); + subList.className = "mt-2 border-l border-border pl-3"; + for (const sub of result.subResults.slice(0, 3)) { + subList.appendChild( + resultLink(sub.title, sub.url, "block truncate py-0.5 text-xs text-muted-foreground no-underline hover:text-foreground"), + ); + } + option.appendChild(subList); + } + + option.addEventListener("click", (event) => { + if ((event.target as Element | null)?.closest("a")) return; + link.click(); + }); + + return option; + } + + async function ensureInitialized(): Promise { + if (initialized) return true; + try { + await provider.init?.(); + initialized = true; + return true; + } catch { + emptyState.textContent = "Search is available after a production build."; + return false; + } + } + + async function runSearch(query: string): Promise { + activeController?.abort(); + activeController = new AbortController(); + const signal = activeController.signal; + + emptyState.style.display = ""; + emptyState.textContent = "Searching…"; + clearResults(); + + if (!(await ensureInitialized()) || signal.aborted) return; + + try { + const results = await provider.search(query, { signal }); + if (signal.aborted) return; + + clearResults(); + activeIndex = -1; + + if (results.length === 0) { + emptyState.style.display = ""; + emptyState.textContent = "No results found."; + return; + } + + emptyState.style.display = "none"; + input.setAttribute("aria-expanded", "true"); + for (const result of results) resultsContainer.appendChild(buildResult(result)); + } catch { + if (signal.aborted) return; + clearResults(); + emptyState.style.display = ""; + emptyState.textContent = "Search is temporarily unavailable."; + } + } + + function handleInput(): void { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + const query = input.value.trim(); + if (!query) { + activeController?.abort(); + clearResults(); + emptyState.style.display = ""; + emptyState.textContent = "Type to search…"; + return; + } + void runSearch(query); + }, 150); + } + + function handleKeydown(event: KeyboardEvent): void { + const options = getOptions(); + if (event.key === "ArrowDown") { + event.preventDefault(); + updateActive(activeIndex + 1); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + updateActive(activeIndex - 1); + } else if (event.key === "Home") { + event.preventDefault(); + updateActive(0); + } else if (event.key === "End") { + event.preventDefault(); + updateActive(options.length - 1); + } else if (event.key === "Enter" && activeIndex >= 0) { + event.preventDefault(); + options[activeIndex]?.querySelector("a")?.click(); + } + } + + input.addEventListener("input", handleInput); + input.closest("dialog")?.addEventListener("keydown", handleKeydown); + + return { + async reset() { + activeController?.abort(); + if (debounceTimer) clearTimeout(debounceTimer); + input.value = ""; + input.focus(); + activeIndex = -1; + clearResults(); + emptyState.style.display = ""; + emptyState.textContent = "Type to search…"; + await ensureInitialized(); + }, + destroy() { + activeController?.abort(); + if (debounceTimer) clearTimeout(debounceTimer); + input.removeEventListener("input", handleInput); + input.closest("dialog")?.removeEventListener("keydown", handleKeydown); + }, + }; +} diff --git a/docs/src/components/ui/sidebar/Sidebar.astro b/docs/src/components/ui/sidebar/Sidebar.astro new file mode 100644 index 0000000..1ca6bcd --- /dev/null +++ b/docs/src/components/ui/sidebar/Sidebar.astro @@ -0,0 +1,64 @@ +--- +/** + * Sidebar — recursive navigation tree from a `SidebarItem[]`. Composes + * SidebarGroup + SidebarLink. Pass `persist` to opt into sessionStorage + * for open/scroll state (desktop only). + */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; +import SidebarGroup from "./SidebarGroup.astro"; +import SidebarLink from "./SidebarLink.astro"; +import type { SidebarItem } from "nimbus-docs/types"; +import { sidebarHash } from "nimbus-docs"; + +interface Props extends HTMLAttributes<"div"> { + items: SidebarItem[]; + /** Persist open/scroll state to sessionStorage. Desktop sidebar only. */ + persist?: boolean; +} + +const { items, persist = false, class: className, ...attrs } = Astro.props; +const hash = sidebarHash(items); +--- + + + + {items.map((item) => + item.type === "group" ? ( + + + + ) : item.type === "external" ? ( + + + + ) : ( + + + + ), + )} + + + + + + diff --git a/docs/src/components/ui/sidebar/SidebarFilter.astro b/docs/src/components/ui/sidebar/SidebarFilter.astro new file mode 100644 index 0000000..be9cb97 --- /dev/null +++ b/docs/src/components/ui/sidebar/SidebarFilter.astro @@ -0,0 +1,20 @@ +--- +/** SidebarFilter — text input that filters the adjacent Sidebar. Press "/" to focus. */ +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; + +interface Props extends HTMLAttributes<"div"> {} + +const { class: className, ...attrs } = Astro.props; +--- + + + + diff --git a/docs/src/components/ui/sidebar/SidebarGroup.astro b/docs/src/components/ui/sidebar/SidebarGroup.astro new file mode 100644 index 0000000..fa22b55 --- /dev/null +++ b/docs/src/components/ui/sidebar/SidebarGroup.astro @@ -0,0 +1,209 @@ +--- +/** + * SidebarGroup — autogenerated section header in the sidebar rail. + * + * Renders in one of two shapes depending on whether the group has a + * landing page (`indexHref`): + * + * - Has landing: the label is an `` linking to indexHref. The + * collapse caret sits next to it as a separate ``. Matches + * the structural-separation pattern used by Fumadocs / Fern / + * Docusaurus: the group label IS the link to the landing page; + * children are listed separately below. + * - No landing: the entire row is a single `` that toggles + * the collapse. Label is non-interactive. Used for directories + * without an `index.mdx`, where the group is a pure visual section + * divider over its children. + */ +import { Icon } from "astro-icon/components"; +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; +import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; +import SidebarLink from "./SidebarLink.astro"; +import { Badge } from "@/components/ui/badge"; +import type { SidebarItem, SidebarBadge } from "nimbus-docs/types"; + +interface Props extends HTMLAttributes<"div"> { + label: string; + items: SidebarItem[]; + collapsed?: boolean; + badge?: SidebarBadge; + /** Landing-page URL when the group has an `index.mdx`. Renders the label as a link. */ + indexHref?: string; + /** True when the landing page is the current route. */ + indexIsCurrent?: boolean; + /** True when `indexHref` is an off-site URL — render with target="_blank" rel="noopener". */ + indexIsExternal?: boolean; +} + +const { + label, + items, + collapsed, + badge, + indexHref, + indexIsCurrent, + indexIsExternal, + class: className, + ...attrs +} = Astro.props; + +function hasActiveDescendant(items: SidebarItem[]): boolean { + return items.some((item) => + item.type === "link" + ? Boolean(item.isCurrent) + : item.type === "group" + ? Boolean(item.indexIsCurrent) || hasActiveDescendant(item.children) + : false, + ); +} + +const hasActive = Boolean(indexIsCurrent) || hasActiveDescendant(items); +const isOpen = Boolean(hasActive || collapsed === false || collapsed === undefined); + +// Shared classes for the row that holds the label + caret in both +// rendering modes (landing-as-link vs. label-as-trigger). +const rowClass = cn( + "group/expander flex min-h-[2rem] items-center rounded-lg px-3 py-1 text-[0.8125rem] no-underline transition-colors duration-150 focus-visible:outline-offset-[-2px]", + "hover:bg-accent hover:text-foreground", + hasActive ? "font-semibold text-foreground" : "font-medium text-muted-foreground", +); +// Chevron icon classes. Rotation is driven by an explicit CSS rule in +// ` diff --git a/docs/src/components/ui/sidebar/SidebarLink.astro b/docs/src/components/ui/sidebar/SidebarLink.astro new file mode 100644 index 0000000..62769dc --- /dev/null +++ b/docs/src/components/ui/sidebar/SidebarLink.astro @@ -0,0 +1,37 @@ +--- +import { cn } from "@/lib/cn"; +import type { HTMLAttributes } from "astro/types"; +import { Badge } from "@/components/ui/badge"; +import type { SidebarBadge } from "nimbus-docs/types"; + +interface Props extends HTMLAttributes<"a"> { + label: string; + href: string; + isCurrent?: boolean; + badge?: SidebarBadge; +} + +const { label, href, isCurrent, badge, class: className, ...attrs } = Astro.props; +--- + + + {label} + {badge && + (typeof badge === "string" ? ( + + ) : ( + + ))} + diff --git a/docs/src/components/ui/sidebar/index.ts b/docs/src/components/ui/sidebar/index.ts new file mode 100644 index 0000000..8b4c73a --- /dev/null +++ b/docs/src/components/ui/sidebar/index.ts @@ -0,0 +1,4 @@ +export { default as Sidebar } from "./Sidebar.astro"; +export { default as SidebarFilter } from "./SidebarFilter.astro"; +export { default as SidebarGroup } from "./SidebarGroup.astro"; +export { default as SidebarLink } from "./SidebarLink.astro"; diff --git a/docs/src/components/ui/sidebar/sidebar.client.ts b/docs/src/components/ui/sidebar/sidebar.client.ts new file mode 100644 index 0000000..84edeb4 --- /dev/null +++ b/docs/src/components/ui/sidebar/sidebar.client.ts @@ -0,0 +1,203 @@ +/** Sidebar runtime: filter, persistence, "/" shortcut. */ + +import { mount } from "nimbus-docs/client"; + +const STORAGE_KEY = "sidebar-state"; + +interface SidebarState { + hash: string; + open: boolean[]; + scroll: number; +} + +function initSidebar(root: HTMLElement): () => void { + const teardowns: Array<() => void> = []; + const persist = root.hasAttribute("data-nb-sidebar-persist"); + + const filterTeardown = initFilter(root); + if (filterTeardown) teardowns.push(filterTeardown); + + if (persist) { + const persistTeardown = initPersistence(root); + if (persistTeardown) teardowns.push(persistTeardown); + } + + return () => teardowns.forEach((t) => t()); +} + +// --------------------------------------------------------------------------- +// Filter +// --------------------------------------------------------------------------- + +function initFilter(root: HTMLElement): (() => void) | null { + const input = root.querySelector("[data-nb-sidebar-filter-input]"); + // SidebarFilter is rendered *next to* Sidebar (sibling), so also look in + // the parent — preserves the existing layout where filter sits above. + const inputElement = input ?? root.parentElement?.querySelector("[data-nb-sidebar-filter-input]") ?? null; + if (!inputElement) return null; + const filterInput = inputElement; + + function handleInput() { + const query = filterInput.value.trim().toLowerCase(); + if (!query) { + resetFilter(root); + return; + } + applyFilter(root, query); + } + + function handleKeydown(e: KeyboardEvent) { + if (e.key === "Escape") { + filterInput.value = ""; + handleInput(); + filterInput.blur(); + } + } + + filterInput.addEventListener("input", handleInput); + filterInput.addEventListener("keydown", handleKeydown); + + return () => { + filterInput.removeEventListener("input", handleInput); + filterInput.removeEventListener("keydown", handleKeydown); + resetFilter(root); + }; +} + +function resetFilter(root: HTMLElement): void { + root.querySelectorAll("[data-nb-sidebar-hidden]").forEach((el) => { + el.removeAttribute("data-nb-sidebar-hidden"); + }); + // Reset groups opened by the filter back to their saved state. + root.querySelectorAll("[data-nb-sidebar-group][data-nb-opened-by-filter]").forEach((group) => { + const trigger = group.querySelector("[data-nb-collapsible-trigger]"); + trigger?.click(); + group.removeAttribute("data-nb-opened-by-filter"); + }); +} + +function applyFilter(root: HTMLElement, query: string): void { + const links = root.querySelectorAll("[data-nb-sidebar-link]"); + const groups = root.querySelectorAll("[data-nb-sidebar-group]"); + + links.forEach((link) => link.setAttribute("data-nb-sidebar-hidden", "")); + groups.forEach((group) => group.setAttribute("data-nb-sidebar-hidden", "")); + + links.forEach((link) => { + const text = link.textContent?.toLowerCase() ?? ""; + if (!text.includes(query)) return; + link.removeAttribute("data-nb-sidebar-hidden"); + revealAncestors(link, root); + }); + + groups.forEach((group) => { + const label = group.querySelector("[data-nb-sidebar-group-label]"); + const text = label?.textContent?.toLowerCase() ?? ""; + if (!text.includes(query)) return; + group.removeAttribute("data-nb-sidebar-hidden"); + openGroup(group); + group + .querySelectorAll("[data-nb-sidebar-link], [data-nb-sidebar-group]") + .forEach((child) => child.removeAttribute("data-nb-sidebar-hidden")); + }); +} + +function revealAncestors(el: HTMLElement, scope: Element): void { + let parent: HTMLElement | null = el.parentElement; + while (parent && parent !== scope) { + if (parent.hasAttribute("data-nb-sidebar-group")) { + parent.removeAttribute("data-nb-sidebar-hidden"); + openGroup(parent); + } + parent = parent.parentElement; + } +} + +function openGroup(group: HTMLElement): void { + const trigger = group.querySelector("[data-nb-collapsible-trigger]"); + if (!trigger) return; + if (trigger.getAttribute("data-nb-state") === "open") return; + group.setAttribute("data-nb-opened-by-filter", ""); + trigger.click(); +} + +// --------------------------------------------------------------------------- +// Persistence (open state + scroll) +// --------------------------------------------------------------------------- + +function initPersistence(root: HTMLElement): (() => void) | null { + // The scrollable container is the closest
{commentPrefix && {commentPrefix}}{codeFirst}{codeRest}
+ Type to search… +