{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "help-center",
  "title": "Help Center",
  "description": "The AI-native knowledge layer as code you own. Drops a complete help center into any Next.js + shadcn/ui project: sidebar navigation, Cmd+K search, dark mode, MDX pipeline, and `llms.txt` generation at build time. Pair with the helpbase-mcp registry item to expose your docs to AI agents from your own infra.",
  "dependencies": [
    "next-mdx-remote",
    "next-themes",
    "gray-matter",
    "rehype-slug",
    "remark-gfm",
    "rehype-pretty-code",
    "shiki",
    "zod",
    "lucide-react",
    "@radix-ui/react-slot"
  ],
  "registryDependencies": [
    "badge",
    "accordion",
    "button",
    "tabs"
  ],
  "files": [
    {
      "path": "registry/helpbase/app/(docs)/layout.tsx",
      "content": "import { getCategories } from \"@/lib/content\"\nimport { getSearchIndex } from \"@/lib/search\"\nimport { DocsSidebar } from \"@/components/docs-sidebar\"\nimport { MobileSidebar } from \"@/components/mobile-sidebar\"\nimport { SearchDialog } from \"@/components/search-dialog\"\nimport \"./helpbase-styles.css\"\n\nexport default async function DocsLayout({\n  children,\n}: {\n  children: React.ReactNode\n}) {\n  const [categories, searchItems] = await Promise.all([\n    getCategories(),\n    getSearchIndex(),\n  ])\n\n  return (\n    <>\n      <div className=\"mx-auto max-w-7xl\">\n        <div className=\"flex\">\n          {/* Desktop sidebar */}\n          <aside className=\"hidden w-60 shrink-0 lg:block\">\n            <div className=\"sticky top-14 h-[calc(100svh-3.5rem)] overflow-y-auto border-r border-border/50 px-4 py-8\">\n              <DocsSidebar categories={categories} />\n            </div>\n          </aside>\n\n          {/* Mobile sidebar trigger */}\n          <div className=\"lg:hidden\">\n            <MobileSidebar categories={categories} />\n          </div>\n\n          {/* Main content */}\n          <div className=\"min-w-0 flex-1\">{children}</div>\n        </div>\n      </div>\n\n      {/* Cmd+K search — mounts invisibly, opens on keyboard shortcut */}\n      <SearchDialog items={searchItems} />\n    </>\n  )\n}\n",
      "type": "registry:page",
      "target": "app/(docs)/layout.tsx"
    },
    {
      "path": "registry/helpbase/app/(docs)/[category]/page.tsx",
      "content": "import Link from \"next/link\"\nimport { notFound } from \"next/navigation\"\nimport { getCategories } from \"@/lib/content\"\n\nexport async function generateStaticParams() {\n  const categories = await getCategories()\n  return categories.map((c) => ({ category: c.slug }))\n}\n\nexport default async function CategoryPage({\n  params,\n}: {\n  params: Promise<{ category: string }>\n}) {\n  const { category: categorySlug } = await params\n  const categories = await getCategories()\n  const category = categories.find((c) => c.slug === categorySlug)\n\n  if (!category) notFound()\n\n  return (\n    <div className=\"max-w-5xl px-8 py-10 lg:px-12\">\n      {/* Breadcrumb */}\n      <nav className=\"mb-6 flex items-center gap-1.5 text-sm text-muted-foreground\">\n        <Link href=\"/docs\" className=\"transition-colors hover:text-foreground\">\n          Docs\n        </Link>\n        <ChevronIcon />\n        <span className=\"text-foreground\">{category.title}</span>\n      </nav>\n\n      {/* Header */}\n      <div className=\"mb-10\">\n        <h1 className=\"text-3xl font-bold tracking-tight\">\n          {category.title}\n        </h1>\n        {category.description && (\n          <p className=\"mt-2 text-lg text-muted-foreground\">\n            {category.description}\n          </p>\n        )}\n      </div>\n\n      {/* Articles */}\n      {category.articles.length > 0 ? (\n        <div className=\"grid gap-2\">\n          {category.articles.map((article) => (\n            <Link\n              key={article.slug}\n              href={`/${categorySlug}/${article.slug}`}\n              className=\"group flex items-center gap-4 rounded-xl border border-transparent px-4 py-4 transition-[border-color,background-color] duration-150 ease-out hover:border-border hover:bg-muted/30\"\n            >\n              <div className=\"flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted transition-colors group-hover:bg-foreground group-hover:text-background\">\n                <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"size-4\">\n                  <path d=\"M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z\" />\n                  <path d=\"M14 2v4a2 2 0 0 0 2 2h4\" />\n                </svg>\n              </div>\n              <div className=\"min-w-0 flex-1\">\n                <h2 className=\"font-medium leading-snug\">{article.title}</h2>\n                <p className=\"mt-0.5 line-clamp-1 text-sm text-muted-foreground\">\n                  {article.description}\n                </p>\n              </div>\n              <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"size-4 shrink-0 text-muted-foreground/50 transition-transform group-hover:translate-x-0.5 group-hover:text-foreground\">\n                <path d=\"m9 18 6-6-6-6\" />\n              </svg>\n            </Link>\n          ))}\n        </div>\n      ) : (\n        <div className=\"rounded-xl border border-dashed border-border px-6 py-16 text-center text-muted-foreground\">\n          <p>No articles in this category yet.</p>\n        </div>\n      )}\n    </div>\n  )\n}\n\nfunction ChevronIcon() {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"size-3.5\">\n      <path d=\"m9 18 6-6-6-6\" />\n    </svg>\n  )\n}\n",
      "type": "registry:page",
      "target": "app/(docs)/[category]/page.tsx"
    },
    {
      "path": "registry/helpbase/app/(docs)/[category]/[slug]/page.tsx",
      "content": "import Link from \"next/link\"\nimport { notFound } from \"next/navigation\"\nimport {\n  getAllArticles,\n  getArticle,\n  getAdjacentArticles,\n} from \"@/lib/content\"\nimport { resolveAssetPath } from \"@/lib/assets\"\nimport { titleCase } from \"@/lib/slugify\"\nimport { Badge } from \"@/components/ui/badge\"\nimport { TableOfContents } from \"@/components/toc\"\n\nexport async function generateStaticParams() {\n  const articles = await getAllArticles()\n  return articles.map((a) => ({\n    category: a.category,\n    slug: a.slug,\n  }))\n}\n\nexport async function generateMetadata({\n  params,\n}: {\n  params: Promise<{ category: string; slug: string }>\n}) {\n  const { category, slug } = await params\n  const article = await getArticle(category, slug)\n  if (!article) return {}\n\n  return {\n    title: article.title,\n    description: article.description,\n  }\n}\n\nexport default async function ArticlePage({\n  params,\n}: {\n  params: Promise<{ category: string; slug: string }>\n}) {\n  const { category, slug } = await params\n  const article = await getArticle(category, slug)\n\n  if (!article) notFound()\n\n  const { prev, next } = await getAdjacentArticles(category, slug)\n\n  return (\n    <div className=\"flex\">\n      {/* Main content area */}\n      <div className=\"min-w-0 flex-1 px-8 py-10 lg:px-12 xl:max-w-4xl\">\n        {/* Breadcrumb */}\n        <nav className=\"mb-6 flex items-center gap-1.5 text-sm text-muted-foreground\">\n          <Link href=\"/docs\" className=\"transition-colors hover:text-foreground\">\n            Docs\n          </Link>\n          <ChevronIcon />\n          <Link\n            href={`/${category}`}\n            className=\"transition-colors hover:text-foreground\"\n          >\n            {titleCase(category)}\n          </Link>\n          <ChevronIcon />\n          <span className=\"truncate text-foreground\">{article.title}</span>\n        </nav>\n\n        <article>\n          <header className=\"mb-10\">\n            <h1 className=\"text-3xl font-bold tracking-tight\">\n              {article.title}\n            </h1>\n            <p className=\"mt-3 text-lg leading-relaxed text-muted-foreground\">\n              {article.description}\n            </p>\n            {article.tags.length > 0 && (\n              <div className=\"mt-4 flex flex-wrap gap-2\">\n                {article.tags.map((tag) => (\n                  <Badge key={tag} variant=\"secondary\">\n                    {tag}\n                  </Badge>\n                ))}\n              </div>\n            )}\n            <a\n              href={`https://github.com/Codehagen/helpbase/edit/main/apps/web/${article.filePath}`}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"mt-4 inline-flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground\"\n            >\n              <PencilIcon />\n              Edit on GitHub\n            </a>\n          </header>\n\n          {/* Hero image — between description and article body */}\n          {article.heroImage && (\n            <div className=\"mb-8 max-h-[360px] overflow-hidden rounded-xl border border-border\">\n              <img\n                src={resolveAssetPath(article.category, article.slug, article.heroImage)}\n                alt={article.title}\n                loading=\"eager\"\n                className=\"w-full object-cover\"\n              />\n            </div>\n          )}\n          {/* Video embed fallback when no hero image */}\n          {article.videoEmbed && !article.heroImage && (\n            <div className=\"relative mb-8 aspect-video overflow-hidden rounded-xl border border-border\">\n              <iframe\n                src={article.videoEmbed}\n                title={`Video: ${article.title}`}\n                sandbox=\"allow-scripts allow-same-origin\"\n                referrerPolicy=\"no-referrer\"\n                allow=\"fullscreen\"\n                className=\"size-full\"\n              />\n            </div>\n          )}\n\n          {/* MDX content */}\n          <div className=\"article-content max-w-none\">\n            {article.content}\n          </div>\n\n          {/* Prev/Next navigation */}\n          <div className=\"mt-16 grid grid-cols-2 gap-4 border-t border-border pt-8\">\n            {prev ? (\n              <Link\n                href={`/${prev.category}/${prev.slug}`}\n                className=\"group flex flex-col gap-1 rounded-xl border border-border p-4 transition-[border-color,background-color] duration-150 ease-out hover:border-foreground/15 hover:bg-muted/30\"\n              >\n                <span className=\"text-xs text-muted-foreground\">Previous</span>\n                <span className=\"text-sm font-medium group-hover:text-foreground\">\n                  {prev.title}\n                </span>\n              </Link>\n            ) : (\n              <div />\n            )}\n            {next ? (\n              <Link\n                href={`/${next.category}/${next.slug}`}\n                className=\"group flex flex-col items-end gap-1 rounded-xl border border-border p-4 text-right transition-[border-color,background-color] duration-150 ease-out hover:border-foreground/15 hover:bg-muted/30\"\n              >\n                <span className=\"text-xs text-muted-foreground\">Next</span>\n                <span className=\"text-sm font-medium group-hover:text-foreground\">\n                  {next.title}\n                </span>\n              </Link>\n            ) : (\n              <div />\n            )}\n          </div>\n        </article>\n      </div>\n\n      {/* TOC sidebar */}\n      {article.toc.length > 0 && (\n        <aside className=\"hidden w-52 shrink-0 xl:block\">\n          <div className=\"sticky top-14 h-[calc(100svh-3.5rem)] overflow-y-auto px-4 py-10\">\n            <h4 className=\"mb-4 pl-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground\">\n              On this page\n            </h4>\n            <TableOfContents items={article.toc} />\n          </div>\n        </aside>\n      )}\n    </div>\n  )\n}\n\nfunction PencilIcon() {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"size-3\">\n      <path d=\"M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z\" />\n    </svg>\n  )\n}\n\nfunction ChevronIcon() {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"size-3.5\">\n      <path d=\"m9 18 6-6-6-6\" />\n    </svg>\n  )\n}\n",
      "type": "registry:page",
      "target": "app/(docs)/[category]/[slug]/page.tsx"
    },
    {
      "path": "registry/helpbase/app/(docs)/helpbase-styles.css",
      "content": "/* helpbase styles — loaded by app/(docs)/layout.tsx.\n * CSS variables themselves come in via the cssVars field in the registry\n * item. This file carries the non-variable rules: keyframes, animations,\n * the .article-content typography used by MDX rendering, and the\n * prefers-reduced-motion escape hatch.\n */\n\n/* Custom easing tokens (Emil Kowalski's animations.dev methodology) */\n:root {\n  --ease-out-quad: cubic-bezier(0.25, 0.46, 0.45, 0.94);\n  --ease-out-cubic: cubic-bezier(0.215, 0.61, 0.355, 1);\n  --ease-out-quart: cubic-bezier(0.165, 0.84, 0.44, 1);\n  --ease-in-out-quad: cubic-bezier(0.455, 0.03, 0.515, 0.955);\n}\n\n/* Animations */\n@keyframes fade-in {\n  from {\n    opacity: 0;\n    transform: translateY(12px);\n  }\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n@keyframes scale-fade-in {\n  from {\n    opacity: 0;\n    transform: scale(0.98);\n  }\n  to {\n    opacity: 1;\n    transform: scale(1);\n  }\n}\n\n.animate-fade-in {\n  will-change: transform, opacity;\n  animation: fade-in 0.4s var(--ease-out-quad) both;\n}\n\n.animate-fade-in-delay-1 {\n  will-change: transform, opacity;\n  animation: fade-in 0.4s var(--ease-out-quad) 0.1s both;\n}\n\n.animate-fade-in-delay-2 {\n  will-change: transform, opacity;\n  animation: fade-in 0.4s var(--ease-out-quad) 0.2s both;\n}\n\n.animate-scale-fade-in {\n  will-change: transform, opacity;\n  animation: scale-fade-in 0.2s var(--ease-out-quad) both;\n}\n\n/* TOC sliding indicator —\n * ease-in-out: element already on screen moving to new position.\n * transform + opacity only: GPU-accelerated, no layout thrash.\n */\n.toc-indicator {\n  will-change: transform, opacity;\n  transition:\n    transform 0.2s var(--ease-in-out-quad),\n    height 0.2s var(--ease-in-out-quad),\n    opacity 0.15s ease;\n}\n\n/* Accessibility: disable all animations for users who prefer reduced motion */\n@media (prefers-reduced-motion: reduce) {\n  .animate-fade-in,\n  .animate-fade-in-delay-1,\n  .animate-fade-in-delay-2,\n  .animate-scale-fade-in {\n    animation: none;\n  }\n\n  *,\n  *::before,\n  *::after {\n    animation-duration: 0.01ms !important;\n    animation-iteration-count: 1 !important;\n    transition-duration: 0.01ms !important;\n  }\n}\n\n/* Article content typography — consumed by the MDX page renderer */\n.article-content {\n  line-height: 1.75;\n  color: var(--foreground);\n}\n\n.article-content h1 {\n  font-size: 2.25rem;\n  font-weight: 700;\n  letter-spacing: -0.025em;\n  margin-top: 2.5rem;\n  margin-bottom: 1rem;\n  scroll-margin-top: 5rem;\n}\n\n.article-content h2 {\n  font-size: 1.5rem;\n  font-weight: 600;\n  letter-spacing: -0.025em;\n  margin-top: 2.5rem;\n  margin-bottom: 0.75rem;\n  padding-bottom: 0.5rem;\n  border-bottom: 1px solid var(--border);\n  scroll-margin-top: 5rem;\n}\n\n.article-content h3 {\n  font-size: 1.25rem;\n  font-weight: 600;\n  letter-spacing: -0.015em;\n  margin-top: 2rem;\n  margin-bottom: 0.5rem;\n  scroll-margin-top: 5rem;\n}\n\n.article-content p {\n  margin-top: 0;\n  margin-bottom: 1.25rem;\n  line-height: 1.75;\n}\n\n.article-content a {\n  color: var(--foreground);\n  font-weight: 500;\n  text-decoration: underline;\n  text-underline-offset: 4px;\n  text-decoration-color: var(--border);\n  transition: text-decoration-color 0.15s;\n}\n\n.article-content a:hover {\n  text-decoration-color: var(--foreground);\n}\n\n.article-content strong {\n  font-weight: 600;\n}\n\n.article-content ul,\n.article-content ol {\n  margin-top: 0;\n  margin-bottom: 1.25rem;\n  padding-left: 1.5rem;\n}\n\n.article-content li {\n  margin-bottom: 0.375rem;\n}\n\n.article-content ul > li {\n  list-style-type: disc;\n}\n\n.article-content ol > li {\n  list-style-type: decimal;\n}\n\n.article-content code {\n  font-family: var(--font-mono), ui-monospace, monospace;\n  font-size: 0.875em;\n  background: var(--muted);\n  border-radius: 0.375rem;\n  padding: 0.125rem 0.375rem;\n}\n\n.article-content pre {\n  margin-top: 0;\n  margin-bottom: 1.5rem;\n  padding: 1rem 1.25rem;\n  border-radius: 0.75rem;\n  border: 1px solid var(--border);\n  background: var(--muted);\n  overflow-x: auto;\n  font-size: 0.875rem;\n  line-height: 1.7;\n}\n\n.article-content pre code {\n  background: transparent;\n  border-radius: 0;\n  padding: 0;\n  font-size: inherit;\n}\n\n.article-content blockquote {\n  margin-top: 0;\n  margin-bottom: 1.25rem;\n  padding-left: 1rem;\n  border-left: 3px solid var(--border);\n  color: var(--muted-foreground);\n  font-style: italic;\n}\n\n.article-content hr {\n  margin: 2rem 0;\n  border: none;\n  border-top: 1px solid var(--border);\n}\n\n.article-content img {\n  border-radius: 0.75rem;\n  border: 1px solid var(--border);\n  margin-top: 0.5rem;\n  margin-bottom: 1.5rem;\n}\n\n.article-content table {\n  width: 100%;\n  margin-bottom: 1.5rem;\n  border-collapse: collapse;\n  font-size: 0.875rem;\n}\n\n.article-content th {\n  text-align: left;\n  font-weight: 600;\n  padding: 0.5rem 0.75rem;\n  border-bottom: 2px solid var(--border);\n}\n\n.article-content td {\n  padding: 0.5rem 0.75rem;\n  border-bottom: 1px solid var(--border);\n}\n\n.article-content tr:last-child td {\n  border-bottom: none;\n}\n",
      "type": "registry:file",
      "target": "app/(docs)/helpbase-styles.css"
    },
    {
      "path": "registry/helpbase/components/header.tsx",
      "content": "\"use client\"\n\nimport Link from \"next/link\"\nimport { useTheme } from \"next-themes\"\nimport { useEffect, useState } from \"react\"\n\nfunction SearchTrigger() {\n  return (\n    <button\n      type=\"button\"\n      className=\"group flex h-9 w-full max-w-sm items-center gap-2 rounded-lg border border-border bg-muted/40 px-3 text-sm text-muted-foreground transition-[border-color,background-color] duration-150 ease hover:border-foreground/20 hover:bg-muted/60 sm:w-64\"\n      onClick={() => {\n        window.dispatchEvent(\n          new KeyboardEvent(\"keydown\", {\n            key: \"k\",\n            metaKey: true,\n            bubbles: true,\n          })\n        )\n      }}\n    >\n      <SearchIcon className=\"size-4 shrink-0 opacity-50\" />\n      <span className=\"flex-1 text-left\">Search articles...</span>\n      <kbd className=\"pointer-events-none hidden h-5 items-center gap-0.5 rounded border border-border bg-background px-1.5 font-mono text-[10px] font-medium text-muted-foreground sm:flex\">\n        <span className=\"text-xs\">&#8984;</span>K\n      </kbd>\n    </button>\n  )\n}\n\nfunction ThemeToggle() {\n  const { resolvedTheme, setTheme } = useTheme()\n  const [mounted, setMounted] = useState(false)\n\n  useEffect(() => setMounted(true), [])\n\n  return (\n    <button\n      type=\"button\"\n      onClick={() => setTheme(resolvedTheme === \"dark\" ? \"light\" : \"dark\")}\n      className=\"inline-flex size-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\"\n      aria-label=\"Toggle theme\"\n    >\n      {mounted ? (\n        resolvedTheme === \"dark\" ? (\n          <SunIcon className=\"size-4\" />\n        ) : (\n          <MoonIcon className=\"size-4\" />\n        )\n      ) : (\n        <div className=\"size-4\" />\n      )}\n    </button>\n  )\n}\n\nfunction SearchIcon({ className }: { className?: string }) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n    >\n      <circle cx=\"11\" cy=\"11\" r=\"8\" />\n      <path d=\"m21 21-4.3-4.3\" />\n    </svg>\n  )\n}\n\nfunction SunIcon({ className }: { className?: string }) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n    >\n      <circle cx=\"12\" cy=\"12\" r=\"4\" />\n      <path d=\"M12 2v2\" />\n      <path d=\"M12 20v2\" />\n      <path d=\"m4.93 4.93 1.41 1.41\" />\n      <path d=\"m17.66 17.66 1.41 1.41\" />\n      <path d=\"M2 12h2\" />\n      <path d=\"M20 12h2\" />\n      <path d=\"m6.34 17.66-1.41 1.41\" />\n      <path d=\"m19.07 4.93-1.41 1.41\" />\n    </svg>\n  )\n}\n\nfunction MoonIcon({ className }: { className?: string }) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={className}\n    >\n      <path d=\"M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z\" />\n    </svg>\n  )\n}\n\nfunction GitHubIcon({ className }: { className?: string }) {\n  return (\n    <svg\n      xmlns=\"http://www.w3.org/2000/svg\"\n      viewBox=\"0 0 24 24\"\n      fill=\"currentColor\"\n      className={className}\n    >\n      <path d=\"M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z\" />\n    </svg>\n  )\n}\n\nexport function Header() {\n  return (\n    <header className=\"sticky top-0 z-50 w-full border-b border-border/50 bg-background/80 backdrop-blur-lg\">\n      <div className=\"mx-auto flex h-14 max-w-6xl items-center gap-4 px-6\">\n        {/* Logo */}\n        <Link href=\"/docs\" className=\"font-semibold tracking-tight\">\n          helpbase\n        </Link>\n\n        {/* Search */}\n        <div className=\"flex flex-1 justify-center px-4\">\n          <SearchTrigger />\n        </div>\n\n        {/* Right side */}\n        <div className=\"flex items-center gap-1\">\n          <ThemeToggle />\n          <a\n            href=\"https://github.com/Codehagen/helpbase\"\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"inline-flex size-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\"\n            aria-label=\"GitHub\"\n          >\n            <GitHubIcon className=\"size-4\" />\n          </a>\n        </div>\n      </div>\n    </header>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/header.tsx"
    },
    {
      "path": "registry/helpbase/components/footer.tsx",
      "content": "import { Button } from '@/components/ui/button'\nimport Link from 'next/link'\n\nimport {\n    MADE_WITH_SHADCN_LABEL,\n    MADE_WITH_SHADCN_URL,\n    SHADCN_TAGLINE,\n} from '@/lib/tagline'\n\nconst links = [\n    {\n        group: 'Product',\n        items: [\n            { title: 'Pricing', href: '/#pricing' },\n            { title: 'Demo', href: 'https://demo.helpbase.dev' },\n            { title: 'FAQ', href: '/#faq' },\n        ],\n    },\n    {\n        group: 'Resources',\n        items: [\n            { title: 'Docs', href: '/docs' },\n            { title: 'GitHub', href: 'https://github.com/Codehagen/helpbase' },\n            { title: 'MCP', href: '/docs/mcp' },\n        ],\n    },\n    {\n        group: 'Company',\n        items: [\n            { title: 'Changelog', href: 'https://github.com/Codehagen/helpbase/releases' },\n            { title: 'License', href: 'https://github.com/Codehagen/helpbase/blob/main/LICENSE' },\n            { title: 'Privacy', href: '/docs/privacy' },\n        ],\n    },\n]\n\nexport default function FooterSection() {\n    return (\n        <footer\n            role=\"contentinfo\"\n            className=\"bg-background pt-8 sm:pt-20\">\n            <div className=\"mx-auto max-w-5xl space-y-16 px-6\">\n                <div className=\"flex flex-wrap justify-between gap-6\">\n                    <div className=\"max-w-xs space-y-6 md:col-span-2\">\n                        <Link\n                            href=\"/\"\n                            aria-label=\"helpbase home\"\n                            className=\"text-foreground block size-fit text-lg font-semibold tracking-tight\">\n                            helpbase\n                        </Link>\n\n                        <p className=\"text-muted-foreground text-balance text-sm\">\n                            {SHADCN_TAGLINE} Open-source help centers with MCP + llms.txt built in. Self-host, or deploy with us.\n                        </p>\n                    </div>\n\n                    <div className=\"flex flex-wrap items-center gap-3 text-sm\">\n                        <Link\n                            href={MADE_WITH_SHADCN_URL}\n                            target=\"_blank\"\n                            rel=\"noopener noreferrer\"\n                            aria-label={MADE_WITH_SHADCN_LABEL}\n                            className=\"ring-foreground/10 bg-card text-muted-foreground hover:text-primary inline-flex items-center gap-1.5 rounded-full border border-transparent px-3 py-1 text-xs shadow-sm ring-1 transition-colors\">\n                            <span aria-hidden className=\"size-1.5 rounded-full bg-foreground\" />\n                            {MADE_WITH_SHADCN_LABEL}\n                        </Link>\n                        <Link\n                            href=\"https://github.com/Codehagen/helpbase\"\n                            target=\"_blank\"\n                            rel=\"noopener noreferrer\"\n                            aria-label=\"GitHub\"\n                            className=\"text-muted-foreground hover:text-primary block\">\n                            <svg\n                                className=\"size-5\"\n                                xmlns=\"http://www.w3.org/2000/svg\"\n                                viewBox=\"0 0 24 24\"\n                                fill=\"currentColor\">\n                                <path d=\"M12 .5C5.73.5.5 5.73.5 12a11.5 11.5 0 0 0 7.86 10.94c.57.1.78-.25.78-.55v-2.1c-3.2.7-3.87-1.37-3.87-1.37-.52-1.34-1.27-1.7-1.27-1.7-1.04-.71.08-.69.08-.69 1.15.08 1.76 1.18 1.76 1.18 1.02 1.75 2.68 1.25 3.34.96.1-.74.4-1.25.73-1.54-2.55-.29-5.24-1.28-5.24-5.7 0-1.26.45-2.29 1.18-3.1-.12-.3-.52-1.47.11-3.06 0 0 .97-.31 3.17 1.18a11 11 0 0 1 5.78 0c2.2-1.5 3.17-1.18 3.17-1.18.64 1.59.23 2.76.12 3.05.74.82 1.18 1.86 1.18 3.1 0 4.43-2.7 5.41-5.26 5.69.41.36.78 1.06.78 2.13v3.17c0 .31.21.67.79.56A11.5 11.5 0 0 0 23.5 12C23.5 5.73 18.27.5 12 .5z\" />\n                            </svg>\n                        </Link>\n                        <Link\n                            href=\"https://x.com/codehagen\"\n                            target=\"_blank\"\n                            rel=\"noopener noreferrer\"\n                            aria-label=\"X/Twitter\"\n                            className=\"text-muted-foreground hover:text-primary block\">\n                            <svg\n                                className=\"size-5\"\n                                xmlns=\"http://www.w3.org/2000/svg\"\n                                viewBox=\"0 0 24 24\">\n                                <path\n                                    fill=\"currentColor\"\n                                    d=\"M10.488 14.651L15.25 21h7l-7.858-10.478L20.93 3h-2.65l-5.117 5.886L8.75 3h-7l7.51 10.015L2.32 21h2.65zM16.25 19L5.75 5h2l10.5 14z\"\n                                />\n                            </svg>\n                        </Link>\n                    </div>\n                </div>\n                <div\n                    aria-hidden\n                    className=\"h-px bg-[length:6px_1px] bg-repeat-x opacity-25 [background-image:linear-gradient(90deg,var(--color-foreground)_1px,transparent_1px)]\"\n                />\n                <div className=\"grid gap-12 md:grid-cols-5\">\n                    <div className=\"grid gap-6 sm:grid-cols-3 md:col-span-3\">\n                        {links.map((group) => (\n                            <div\n                                key={group.group}\n                                className=\"space-y-4 text-sm\">\n                                <span className=\"block font-medium\">{group.group}</span>\n\n                                <div className=\"flex flex-wrap gap-4 sm:flex-col\">\n                                    {group.items.map((item) => (\n                                        <Link\n                                            key={item.title}\n                                            href={item.href}\n                                            {...(item.href.startsWith('http') && {\n                                                target: '_blank',\n                                                rel: 'noreferrer',\n                                            })}\n                                            className=\"text-muted-foreground hover:text-primary block duration-150\">\n                                            <span>{item.title}</span>\n                                        </Link>\n                                    ))}\n                                </div>\n                            </div>\n                        ))}\n                    </div>\n\n                    <div className=\"md:col-span-2\">\n                        <div className=\"ml-auto w-full space-y-4 md:max-w-xs\">\n                            <div className=\"block text-sm font-medium\">Updates, every release</div>\n                            <div className=\"flex gap-2\">\n                                <Button\n                                    asChild\n                                    variant=\"outline\"\n                                    size=\"sm\">\n                                    <Link\n                                        href=\"https://github.com/Codehagen/helpbase/releases\"\n                                        target=\"_blank\"\n                                        rel=\"noreferrer\">\n                                        Follow on GitHub Releases\n                                        <span\n                                            aria-hidden\n                                            className=\"text-muted-foreground ml-1\">\n                                            ↗\n                                        </span>\n                                    </Link>\n                                </Button>\n                            </div>\n                            <p className=\"text-muted-foreground text-xs\">\n                                Release notes and shipped features, straight from the repo. No newsletter inbox to opt out of.\n                            </p>\n                        </div>\n                    </div>\n                </div>\n\n                <div className=\"flex flex-wrap justify-between gap-4 border-t py-8\">\n                    <span className=\"text-muted-foreground text-sm\">\n                        © {new Date().getFullYear()} helpbase. Built with shadcn/ui, Next.js, Supabase, Vercel.\n                    </span>\n                    <div className=\"ring-foreground/5 bg-card flex items-center gap-2 rounded-full border border-transparent py-1 pl-2 pr-4 shadow ring-1\">\n                        <div className=\"relative flex size-3\">\n                            <span className=\"duration-1500 absolute inset-0 block size-full animate-pulse rounded-full bg-emerald-100\"></span>\n                            <span className=\"relative m-auto block size-1 rounded-full bg-emerald-500\"></span>\n                        </div>\n                        <span className=\"text-sm\">Open source, live</span>\n                    </div>\n                </div>\n            </div>\n        </footer>\n    )\n}\n\nexport { FooterSection as Footer }\n",
      "type": "registry:component",
      "target": "components/footer.tsx"
    },
    {
      "path": "registry/helpbase/components/docs-sidebar.tsx",
      "content": "\"use client\"\n\nimport { useState } from \"react\"\nimport Link from \"next/link\"\nimport { usePathname } from \"next/navigation\"\nimport { cn } from \"@/lib/utils\"\nimport type { Category } from \"@/lib/types\"\n\ninterface DocsSidebarProps {\n  categories: Category[]\n}\n\nexport function DocsSidebar({ categories }: DocsSidebarProps) {\n  const pathname = usePathname()\n\n  return (\n    <nav className=\"space-y-1\">\n      {categories.map((category, index) => (\n        <SidebarSection\n          key={category.slug}\n          category={category}\n          pathname={pathname}\n          isLast={index === categories.length - 1}\n        />\n      ))}\n    </nav>\n  )\n}\n\nfunction SidebarSection({\n  category,\n  pathname,\n  isLast,\n}: {\n  category: Category\n  pathname: string\n  isLast: boolean\n}) {\n  const isCategoryActive = pathname.startsWith(`/${category.slug}`)\n  const [isOpen, setIsOpen] = useState<boolean>(true)\n\n  return (\n    <div className={cn(!isLast && \"pb-4\")}>\n      {/* Category header with toggle */}\n      <button\n        type=\"button\"\n        onClick={() => setIsOpen((prev) => !prev)}\n        className={cn(\n          \"group flex w-full items-center justify-between rounded-md px-2 py-1.5 text-sm font-medium transition-colors duration-150 ease-out hover:bg-muted/60\",\n          isCategoryActive ? \"text-foreground\" : \"text-muted-foreground\"\n        )}\n      >\n        <span className=\"flex items-center gap-2\">\n          <CategoryIcon slug={category.slug} />\n          {category.title}\n        </span>\n        <svg\n          xmlns=\"http://www.w3.org/2000/svg\"\n          viewBox=\"0 0 24 24\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"2\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          className={cn(\n            \"size-3.5 text-muted-foreground/50 transition-transform duration-200\",\n            isOpen && \"rotate-90\"\n          )}\n        >\n          <path d=\"m9 18 6-6-6-6\" />\n        </svg>\n      </button>\n\n      {/* Article list */}\n      {isOpen && category.articles.length > 0 && (\n        <ul className=\"mt-1 space-y-0.5 pl-2\">\n          {category.articles.map((article) => {\n            const articlePath = `/${category.slug}/${article.slug}`\n            const isActive = pathname === articlePath\n\n            return (\n              <li key={article.slug}>\n                <Link\n                  href={articlePath}\n                  className={cn(\n                    \"flex items-center gap-2 rounded-md px-2 py-1.5 text-[13px] transition-colors duration-150 ease-out\",\n                    isActive\n                      ? \"bg-muted font-medium text-foreground\"\n                      : \"text-muted-foreground hover:bg-muted/40 hover:text-foreground\"\n                  )}\n                >\n                  {isActive && (\n                    <span className=\"h-1.5 w-1.5 shrink-0 rounded-full bg-foreground\" />\n                  )}\n                  <span className={cn(!isActive && \"pl-[14px]\")}>\n                    {article.title}\n                  </span>\n                </Link>\n              </li>\n            )\n          })}\n        </ul>\n      )}\n\n      {/* Section separator */}\n      {!isLast && (\n        <div className=\"mt-4 border-b border-border/50\" />\n      )}\n    </div>\n  )\n}\n\nfunction CategoryIcon({ slug }: { slug: string }) {\n  const iconClass = \"size-4 shrink-0\"\n\n  switch (slug) {\n    case \"getting-started\":\n      return (\n        <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={iconClass}>\n          <path d=\"m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z\" />\n        </svg>\n      )\n    case \"customization\":\n      return (\n        <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={iconClass}>\n          <path d=\"M12 20a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z\" />\n          <path d=\"M12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z\" />\n          <path d=\"M12 2v2\" /><path d=\"M12 22v-2\" />\n          <path d=\"m17 20.66-1-1.73\" /><path d=\"M11 10.27 7 3.34\" />\n          <path d=\"m20.66 17-1.73-1\" /><path d=\"m3.34 7 1.73 1\" />\n          <path d=\"M14 12h8\" /><path d=\"M2 12h2\" />\n          <path d=\"m20.66 7-1.73 1\" /><path d=\"m3.34 17 1.73-1\" />\n          <path d=\"m17 3.34-1 1.73\" /><path d=\"m11 13.73-4 6.93\" />\n        </svg>\n      )\n    case \"reference\":\n      return (\n        <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={iconClass}>\n          <path d=\"M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20\" />\n        </svg>\n      )\n    case \"cli\":\n      return (\n        <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={iconClass}>\n          <polyline points=\"4 17 10 11 4 5\" />\n          <line x1=\"12\" x2=\"20\" y1=\"19\" y2=\"19\" />\n        </svg>\n      )\n    case \"guides\":\n      return (\n        <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={iconClass}>\n          <path d=\"M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z\" />\n          <path d=\"M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z\" />\n        </svg>\n      )\n    default:\n      return (\n        <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={iconClass}>\n          <path d=\"M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20\" />\n        </svg>\n      )\n  }\n}\n",
      "type": "registry:component",
      "target": "components/docs-sidebar.tsx"
    },
    {
      "path": "registry/helpbase/components/mobile-sidebar.tsx",
      "content": "\"use client\"\n\nimport { useState } from \"react\"\nimport { usePathname } from \"next/navigation\"\nimport { useEffect } from \"react\"\nimport { DocsSidebar } from \"@/components/docs-sidebar\"\nimport type { Category } from \"@/lib/types\"\n\ninterface MobileSidebarProps {\n  categories: Category[]\n}\n\nexport function MobileSidebar({ categories }: MobileSidebarProps) {\n  const [open, setOpen] = useState(false)\n  const pathname = usePathname()\n\n  // Close on navigation\n  useEffect(() => {\n    setOpen(false)\n  }, [pathname])\n\n  return (\n    <>\n      {/* Floating trigger button */}\n      <button\n        type=\"button\"\n        onClick={() => setOpen(true)}\n        className=\"fixed bottom-6 left-6 z-40 flex size-12 items-center justify-center rounded-full bg-foreground text-background shadow-lg transition-transform duration-150 ease-out active:scale-[0.97] lg:hidden\"\n        aria-label=\"Open navigation\"\n      >\n        <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"size-5\">\n          <line x1=\"4\" x2=\"20\" y1=\"12\" y2=\"12\" />\n          <line x1=\"4\" x2=\"20\" y1=\"6\" y2=\"6\" />\n          <line x1=\"4\" x2=\"20\" y1=\"18\" y2=\"18\" />\n        </svg>\n      </button>\n\n      {/* Overlay + Drawer */}\n      {open && (\n        <>\n          <div\n            className=\"fixed inset-0 z-50 bg-black/40 backdrop-blur-sm lg:hidden\"\n            onClick={() => setOpen(false)}\n          />\n          <div className=\"fixed inset-y-0 left-0 z-50 w-72 bg-background shadow-xl lg:hidden\">\n            <div className=\"flex h-14 items-center justify-between border-b border-border/50 px-4\">\n              <span className=\"text-sm font-semibold\">Navigation</span>\n              <button\n                type=\"button\"\n                onClick={() => setOpen(false)}\n                className=\"inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground\"\n                aria-label=\"Close navigation\"\n              >\n                <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"size-4\">\n                  <path d=\"M18 6 6 18\" />\n                  <path d=\"m6 6 12 12\" />\n                </svg>\n              </button>\n            </div>\n            <div className=\"overflow-y-auto px-4 py-6\" style={{ height: \"calc(100% - 3.5rem)\" }}>\n              <DocsSidebar categories={categories} />\n            </div>\n          </div>\n        </>\n      )}\n    </>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/mobile-sidebar.tsx"
    },
    {
      "path": "registry/helpbase/components/search-dialog.tsx",
      "content": "\"use client\"\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\"\nimport { useRouter } from \"next/navigation\"\nimport type { SearchItem } from \"@/lib/search\"\n\ninterface SearchDialogProps {\n  items: SearchItem[]\n}\n\nexport function SearchDialog({ items }: SearchDialogProps) {\n  const [open, setOpen] = useState(false)\n  const [query, setQuery] = useState(\"\")\n  const [selectedIndex, setSelectedIndex] = useState(0)\n  const inputRef = useRef<HTMLInputElement>(null)\n  const listRef = useRef<HTMLDivElement>(null)\n  const router = useRouter()\n\n  // Cmd+K to open\n  useEffect(() => {\n    function onKeyDown(e: KeyboardEvent) {\n      if ((e.metaKey || e.ctrlKey) && e.key === \"k\") {\n        e.preventDefault()\n        setOpen((prev) => !prev)\n      }\n      if (e.key === \"Escape\") {\n        setOpen(false)\n      }\n    }\n    window.addEventListener(\"keydown\", onKeyDown)\n    return () => window.removeEventListener(\"keydown\", onKeyDown)\n  }, [])\n\n  // Focus input when opening\n  useEffect(() => {\n    if (open) {\n      setQuery(\"\")\n      setSelectedIndex(0)\n      // Small delay to let the dialog render\n      requestAnimationFrame(() => inputRef.current?.focus())\n    }\n  }, [open])\n\n  // Filter results\n  const results = useMemo(() => {\n    if (!query.trim()) return items\n    const terms = query.toLowerCase().split(/\\s+/)\n    return items.filter((item) => {\n      const text = `${item.title} ${item.description} ${item.categoryTitle}`.toLowerCase()\n      return terms.every((term) => text.includes(term))\n    })\n  }, [items, query])\n\n  // Scroll selected item into view\n  useEffect(() => {\n    const container = listRef.current\n    if (!container) return\n    const selected = container.querySelector(\"[data-selected=true]\")\n    if (selected) {\n      selected.scrollIntoView({ block: \"nearest\" })\n    }\n  }, [selectedIndex])\n\n  const navigate = useCallback(\n    (href: string) => {\n      setOpen(false)\n      router.push(href)\n    },\n    [router]\n  )\n\n  function onKeyDown(e: React.KeyboardEvent) {\n    if (e.key === \"ArrowDown\") {\n      e.preventDefault()\n      setSelectedIndex((i) => Math.min(i + 1, results.length - 1))\n    } else if (e.key === \"ArrowUp\") {\n      e.preventDefault()\n      setSelectedIndex((i) => Math.max(i - 1, 0))\n    } else if (e.key === \"Enter\" && results[selectedIndex]) {\n      e.preventDefault()\n      navigate(results[selectedIndex].href)\n    }\n  }\n\n  if (!open) return null\n\n  return (\n    <>\n      {/* Backdrop */}\n      <div\n        className=\"fixed inset-0 z-50 bg-black/40 backdrop-blur-sm\"\n        onClick={() => setOpen(false)}\n      />\n\n      {/* Dialog */}\n      <div className=\"fixed inset-x-0 top-[20%] z-50 mx-auto w-full max-w-lg px-4\">\n        <div className=\"animate-scale-fade-in overflow-hidden rounded-xl border border-border bg-background shadow-2xl\">\n          {/* Search input */}\n          <div className=\"flex items-center gap-3 border-b border-border px-4\">\n            <SearchIcon className=\"size-4 shrink-0 text-muted-foreground\" />\n            <input\n              ref={inputRef}\n              type=\"text\"\n              aria-label=\"Search articles\"\n              placeholder=\"Search articles...\"\n              value={query}\n              onChange={(e) => {\n                setQuery(e.target.value)\n                setSelectedIndex(0)\n              }}\n              onKeyDown={onKeyDown}\n              className=\"h-12 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground\"\n            />\n            <kbd className=\"hidden h-5 items-center rounded border border-border bg-muted px-1.5 font-mono text-[10px] text-muted-foreground sm:flex\">\n              ESC\n            </kbd>\n          </div>\n\n          {/* Results */}\n          <div ref={listRef} className=\"max-h-72 overflow-y-auto p-2\">\n            {results.length === 0 ? (\n              <div className=\"px-3 py-8 text-center text-sm text-muted-foreground\">\n                No results found for &ldquo;{query}&rdquo;\n              </div>\n            ) : (\n              results.map((item, index) => (\n                <button\n                  key={item.href}\n                  type=\"button\"\n                  data-selected={index === selectedIndex}\n                  onClick={() => navigate(item.href)}\n                  onMouseEnter={() => setSelectedIndex(index)}\n                  className=\"flex w-full items-start gap-3 rounded-lg px-3 py-2.5 text-left transition-colors data-[selected=true]:bg-muted\"\n                >\n                  <div className=\"mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-md bg-muted data-[selected=true]:bg-background\">\n                    <FileIcon className=\"size-3.5 text-muted-foreground\" />\n                  </div>\n                  <div className=\"min-w-0 flex-1\">\n                    <div className=\"text-sm font-medium\">{item.title}</div>\n                    <div className=\"mt-0.5 flex items-center gap-2 text-xs text-muted-foreground\">\n                      <span>{item.categoryTitle}</span>\n                      <span className=\"text-border\">·</span>\n                      <span className=\"line-clamp-1\">{item.description}</span>\n                    </div>\n                  </div>\n                  <ReturnIcon className=\"mt-1 size-3.5 shrink-0 text-muted-foreground opacity-0 data-[selected=true]:opacity-100\" />\n                </button>\n              ))\n            )}\n          </div>\n\n          {/* Footer */}\n          <div className=\"flex items-center justify-between border-t border-border px-4 py-2 text-xs text-muted-foreground\">\n            <div className=\"flex items-center gap-3\">\n              <span className=\"flex items-center gap-1\">\n                <kbd className=\"inline-flex size-4 items-center justify-center rounded border border-border bg-muted font-mono text-[10px]\">↑</kbd>\n                <kbd className=\"inline-flex size-4 items-center justify-center rounded border border-border bg-muted font-mono text-[10px]\">↓</kbd>\n                navigate\n              </span>\n              <span className=\"flex items-center gap-1\">\n                <kbd className=\"inline-flex h-4 items-center justify-center rounded border border-border bg-muted px-1 font-mono text-[10px]\">↵</kbd>\n                open\n              </span>\n            </div>\n            <span>{results.length} result{results.length !== 1 ? \"s\" : \"\"}</span>\n          </div>\n        </div>\n      </div>\n    </>\n  )\n}\n\nfunction SearchIcon({ className }: { className?: string }) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n      <circle cx=\"11\" cy=\"11\" r=\"8\" />\n      <path d=\"m21 21-4.3-4.3\" />\n    </svg>\n  )\n}\n\nfunction FileIcon({ className }: { className?: string }) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n      <path d=\"M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z\" />\n      <path d=\"M14 2v4a2 2 0 0 0 2 2h4\" />\n    </svg>\n  )\n}\n\nfunction ReturnIcon({ className }: { className?: string }) {\n  return (\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n      <polyline points=\"9 10 4 15 9 20\" />\n      <path d=\"M20 4v7a4 4 0 0 1-4 4H4\" />\n    </svg>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/search-dialog.tsx"
    },
    {
      "path": "registry/helpbase/components/search-trigger.tsx",
      "content": "\"use client\"\n\nexport function SearchTriggerHero() {\n  return (\n    <button\n      type=\"button\"\n      onClick={() => {\n        window.dispatchEvent(\n          new KeyboardEvent(\"keydown\", {\n            key: \"k\",\n            metaKey: true,\n            bubbles: true,\n          })\n        )\n      }}\n      className=\"group flex h-12 w-full items-center gap-3 rounded-xl border border-border bg-background px-4 shadow-sm transition-[border-color,box-shadow] duration-150 ease-out hover:border-foreground/20 hover:shadow-md\"\n    >\n      <svg\n        xmlns=\"http://www.w3.org/2000/svg\"\n        viewBox=\"0 0 24 24\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        className=\"size-4 shrink-0 text-muted-foreground\"\n      >\n        <circle cx=\"11\" cy=\"11\" r=\"8\" />\n        <path d=\"m21 21-4.3-4.3\" />\n      </svg>\n      <span className=\"flex-1 text-left text-sm text-muted-foreground\">\n        Search for articles...\n      </span>\n      <kbd className=\"pointer-events-none hidden h-5 items-center gap-0.5 rounded border border-border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground sm:flex\">\n        <span className=\"text-xs\">&#8984;</span>K\n      </kbd>\n    </button>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/search-trigger.tsx"
    },
    {
      "path": "registry/helpbase/components/theme-provider.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ThemeProvider as NextThemesProvider, useTheme } from \"next-themes\"\n\nfunction ThemeProvider({\n  children,\n  ...props\n}: React.ComponentProps<typeof NextThemesProvider>) {\n  return (\n    <NextThemesProvider\n      attribute=\"class\"\n      defaultTheme=\"light\"\n      enableSystem={false}\n      disableTransitionOnChange\n      {...props}\n    >\n      <ThemeHotkey />\n      {children}\n    </NextThemesProvider>\n  )\n}\n\nfunction isTypingTarget(target: EventTarget | null) {\n  if (!(target instanceof HTMLElement)) {\n    return false\n  }\n\n  return (\n    target.isContentEditable ||\n    target.tagName === \"INPUT\" ||\n    target.tagName === \"TEXTAREA\" ||\n    target.tagName === \"SELECT\"\n  )\n}\n\nfunction ThemeHotkey() {\n  const { resolvedTheme, setTheme } = useTheme()\n\n  React.useEffect(() => {\n    function onKeyDown(event: KeyboardEvent) {\n      if (event.defaultPrevented || event.repeat) {\n        return\n      }\n\n      if (event.metaKey || event.ctrlKey || event.altKey) {\n        return\n      }\n\n      if (event.key.toLowerCase() !== \"d\") {\n        return\n      }\n\n      if (isTypingTarget(event.target)) {\n        return\n      }\n\n      setTheme(resolvedTheme === \"dark\" ? \"light\" : \"dark\")\n    }\n\n    window.addEventListener(\"keydown\", onKeyDown)\n\n    return () => {\n      window.removeEventListener(\"keydown\", onKeyDown)\n    }\n  }, [resolvedTheme, setTheme])\n\n  return null\n}\n\nexport { ThemeProvider }\n",
      "type": "registry:component",
      "target": "components/theme-provider.tsx"
    },
    {
      "path": "registry/helpbase/components/toc.tsx",
      "content": "\"use client\"\n\nimport { useCallback, useEffect, useRef, useState } from \"react\"\nimport { cn } from \"@/lib/utils\"\nimport type { TocItem } from \"@/lib/types\"\n\ninterface TableOfContentsProps {\n  items: TocItem[]\n}\n\nexport function TableOfContents({ items }: TableOfContentsProps) {\n  const [activeId, setActiveId] = useState<string>(\"\")\n  const indicatorRef = useRef<HTMLDivElement>(null)\n  const navRef = useRef<HTMLElement>(null)\n\n  // Determine which heading is active based on scroll position.\n  // Uses a \"last heading above the fold\" approach instead of\n  // IntersectionObserver, which fixes both problems:\n  //   1. Works on initial load (no scroll event needed)\n  //   2. Works at page bottom (last heading wins when no heading is below)\n  const updateActiveHeading = useCallback(() => {\n    const headings = items\n      .map((item) => ({\n        id: item.id,\n        el: document.getElementById(item.id),\n      }))\n      .filter((h) => h.el != null) as { id: string; el: HTMLElement }[]\n\n    if (headings.length === 0) return\n\n    // If scrolled to the bottom, activate the last heading\n    const atBottom =\n      window.innerHeight + window.scrollY >=\n      document.documentElement.scrollHeight - 50\n\n    if (atBottom) {\n      setActiveId(headings[headings.length - 1]!.id)\n      return\n    }\n\n    // Otherwise find the last heading that has scrolled past the top threshold\n    const scrollY = window.scrollY + 100\n    let active = headings[0]!.id\n\n    for (const heading of headings) {\n      if (heading.el.offsetTop <= scrollY) {\n        active = heading.id\n      } else {\n        break\n      }\n    }\n\n    setActiveId(active)\n  }, [items])\n\n  useEffect(() => {\n    // Set initial active heading on mount\n    updateActiveHeading()\n\n    window.addEventListener(\"scroll\", updateActiveHeading, { passive: true })\n    return () => window.removeEventListener(\"scroll\", updateActiveHeading)\n  }, [updateActiveHeading])\n\n  // Move the indicator bar using transform (GPU-accelerated)\n  useEffect(() => {\n    if (!activeId || !navRef.current || !indicatorRef.current) return\n\n    const activeLink = navRef.current.querySelector(\n      `[data-toc-id=\"${activeId}\"]`\n    ) as HTMLElement | null\n\n    if (activeLink) {\n      const navRect = navRef.current.getBoundingClientRect()\n      const linkRect = activeLink.getBoundingClientRect()\n      const y = linkRect.top - navRect.top\n      indicatorRef.current.style.transform = `translateY(${y}px)`\n      indicatorRef.current.style.height = `${linkRect.height}px`\n      indicatorRef.current.style.opacity = \"1\"\n    }\n  }, [activeId])\n\n  return (\n    <nav ref={navRef} className=\"relative\">\n      {/* Track line */}\n      <div className=\"absolute left-0 top-0 h-full w-px bg-border\" />\n\n      {/* Active indicator — slides along the track */}\n      <div\n        ref={indicatorRef}\n        className=\"toc-indicator absolute left-0 top-0 w-px bg-foreground opacity-0\"\n      />\n\n      {/* Links */}\n      <div className=\"space-y-0.5\">\n        {items.map((item) => {\n          const isActive = item.id === activeId\n          return (\n            <a\n              key={item.id}\n              href={`#${item.id}`}\n              data-toc-id={item.id}\n              className={cn(\n                \"block border-l border-transparent py-1 pl-3 text-[13px] leading-snug transition-colors duration-150\",\n                isActive\n                  ? \"font-medium text-foreground\"\n                  : \"text-muted-foreground hover:text-foreground\",\n                item.depth === 3 && \"pl-6\"\n              )}\n            >\n              {item.text}\n            </a>\n          )\n        })}\n      </div>\n    </nav>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/toc.tsx"
    },
    {
      "path": "registry/helpbase/lib/mdx-components.tsx",
      "content": "type MDXComponents = Record<string, React.ComponentType<any>>\nimport { Callout } from \"@/components/mdx/callout\"\nimport { Figure } from \"@/components/mdx/figure\"\nimport { Video } from \"@/components/mdx/video\"\nimport { Steps, Step } from \"@/components/mdx/steps\"\nimport { Accordion, AccordionItem } from \"@/components/mdx/accordion\"\nimport { Tabs, Tab } from \"@/components/mdx/tabs\"\nimport { CardGroup, Card } from \"@/components/mdx/card-group\"\nimport { CtaCard } from \"@/components/mdx/cta-card\"\n\n/**\n * Creates an MDX component map bound to a specific article's context.\n *\n * The category and slug are needed by Figure, Video, and CtaCard to\n * resolve relative asset paths (e.g. \"hero.png\" → \"/_helpbase-assets/cat/slug/hero.png\").\n * Without this binding, relative paths in MDX body would 404.\n *\n * The img override routes markdown images ![](path) through Figure\n * so they get the same asset resolution as <Figure src=\"path\">.\n */\nexport function createArticleComponents(category: string, slug: string): MDXComponents {\n  return {\n    Callout,\n    Figure: (props) => <Figure {...props} category={category} slug={slug} />,\n    Video: (props) => <Video {...props} category={category} slug={slug} />,\n    CtaCard: (props) => <CtaCard {...props} category={category} slug={slug} />,\n    Steps,\n    Step,\n    Accordion,\n    AccordionItem,\n    Tabs,\n    Tab,\n    CardGroup,\n    Card,\n\n    // Prose overrides — route markdown images through Figure resolver\n    img: (props) => (\n      <Figure\n        src={props.src || \"\"}\n        alt={props.alt || \"\"}\n        category={category}\n        slug={slug}\n      />\n    ),\n\n    // Enhanced table wrapper for horizontal scroll\n    table: (props) => (\n      <div className=\"my-6 overflow-x-auto rounded-lg border border-border\">\n        <table {...props} />\n      </div>\n    ),\n\n    // The article page template already renders the article title as an <h1>\n    // before the MDX body. Most authors still write `# Title` as the first\n    // heading, which produces a second <h1> on the page. That duplicates\n    // semantic page titles for assistive tech and surfaces in Lighthouse.\n    // Downgrading body h1 to h2 preserves author intent without breaking\n    // the document outline.\n    h1: (props) => <h2 {...props} />,\n  }\n}\n",
      "type": "registry:lib",
      "target": "lib/mdx-components.tsx"
    },
    {
      "path": "registry/helpbase/lib/assets.ts",
      "content": "import path from \"node:path\"\n\nexport class PathTraversalError extends Error {\n  constructor(assetPath: string) {\n    super(\n      `Path traversal rejected: \"${assetPath}\". ` +\n        `Asset paths must be relative filenames within the article's content directory. ` +\n        `Do not use \"..\", leading \"/\", backslashes, or URL schemes.`,\n    )\n    this.name = \"PathTraversalError\"\n  }\n}\n\n/**\n * Resolve a relative asset path to a public URL.\n *\n * Given an article at content/<category>/<slug>.mdx and a relative asset\n * path like \"hero.png\", returns \"/_helpbase-assets/<category>/<slug>/hero.png\".\n *\n * Security: rejects path traversal attempts (.., leading /, backslash,\n * null bytes, URL schemes). This is a pure string-join after sanitization,\n * NOT a filesystem operation.\n */\nexport function resolveAssetPath(\n  category: string,\n  slug: string,\n  assetPath: string,\n): string {\n  // Reject null bytes\n  if (assetPath.includes(\"\\0\")) {\n    throw new PathTraversalError(assetPath)\n  }\n\n  // Reject backslashes (Windows path separators)\n  if (assetPath.includes(\"\\\\\")) {\n    throw new PathTraversalError(assetPath)\n  }\n\n  // Reject absolute paths\n  if (assetPath.startsWith(\"/\")) {\n    throw new PathTraversalError(assetPath)\n  }\n\n  // Reject URL schemes (http://, https://, javascript:, data:, etc.)\n  if (/^[a-z][a-z0-9+.-]*:/i.test(assetPath)) {\n    throw new PathTraversalError(assetPath)\n  }\n\n  // Normalize and reject any traversal\n  const normalized = path.posix.normalize(assetPath)\n  if (normalized.startsWith(\"..\") || normalized.startsWith(\"/\")) {\n    throw new PathTraversalError(assetPath)\n  }\n\n  // Reject if normalized path still contains ..\n  if (normalized.includes(\"..\")) {\n    throw new PathTraversalError(assetPath)\n  }\n\n  return `/_helpbase-assets/${category}/${slug}/${normalized}`\n}\n",
      "type": "registry:lib",
      "target": "lib/assets.ts"
    },
    {
      "path": "registry/helpbase/lib/content.ts",
      "content": "import fs from \"node:fs\"\nimport path from \"node:path\"\nimport matter from \"gray-matter\"\nimport { compileMDX } from \"next-mdx-remote/rsc\"\nimport { cache } from \"react\"\nimport { remarkPlugins, rehypePlugins } from \"./mdx-config\"\n\nimport { frontmatterSchema, categoryMetaSchema } from \"@/lib/schemas\"\nimport type { ArticleMeta, Article, Category, TocItem } from \"@/lib/types\"\nimport { titleCase } from \"@/lib/slugify\"\nimport { extractToc } from \"./toc\"\nimport { createArticleComponents } from \"./mdx-components\"\nimport { resolveContentDir } from \"./content-dir\"\n\nconst CONTENT_DIR = resolveContentDir()\n\n/**\n * Get all articles (frontmatter only, no compiled content).\n * Fails loudly on invalid frontmatter — never silently drops articles.\n */\nexport const getAllArticles = cache(async (): Promise<ArticleMeta[]> => {\n  const articles: ArticleMeta[] = []\n  const errors: string[] = []\n\n  if (!fs.existsSync(CONTENT_DIR)) {\n    return []\n  }\n\n  const categoryDirs = fs\n    .readdirSync(CONTENT_DIR, { withFileTypes: true })\n    .filter((d) => d.isDirectory())\n\n  for (const dir of categoryDirs) {\n    const categorySlug = dir.name\n    const categoryPath = path.join(CONTENT_DIR, categorySlug)\n    const files = fs\n      .readdirSync(categoryPath)\n      .filter((f) => f.endsWith(\".mdx\") || f.endsWith(\".md\"))\n\n    for (const file of files) {\n      const filePath = path.join(categoryPath, file)\n      const raw = fs.readFileSync(filePath, \"utf-8\")\n      const { data, content } = matter(raw)\n\n      const parsed = frontmatterSchema.safeParse(data)\n      if (!parsed.success) {\n        errors.push(\n          `${categorySlug}/${file}: ${parsed.error.issues.map((i) => i.message).join(\", \")}`\n        )\n        continue\n      }\n\n      const slug = file.replace(/\\.mdx?$/, \"\")\n      articles.push({\n        ...parsed.data,\n        slug,\n        category: categorySlug,\n        filePath: `content/${categorySlug}/${file}`,\n        rawContent: content,\n      })\n    }\n  }\n\n  // Fail the build on invalid articles (never silently drop content)\n  if (errors.length > 0 && process.env.NODE_ENV === \"production\") {\n    throw new Error(\n      `Invalid frontmatter in ${errors.length} article(s):\\n${errors.map((e) => `  - ${e}`).join(\"\\n\")}\\n\\nFix these issues or run 'helpcenter audit' for details.`\n    )\n  } else if (errors.length > 0) {\n    console.warn(\n      `⚠ Skipping ${errors.length} article(s) with invalid frontmatter:\\n${errors.map((e) => `  - ${e}`).join(\"\\n\")}`\n    )\n  }\n\n  return articles.sort((a, b) => a.order - b.order)\n})\n\n/**\n * Get all categories with their articles.\n */\nexport const getCategories = cache(async (): Promise<Category[]> => {\n  const articles = await getAllArticles()\n\n  if (!fs.existsSync(CONTENT_DIR)) {\n    return []\n  }\n\n  const categoryDirs = fs\n    .readdirSync(CONTENT_DIR, { withFileTypes: true })\n    .filter((d) => d.isDirectory())\n\n  const categories: Category[] = categoryDirs.map((dir) => {\n    const slug = dir.name\n    const metaPath = path.join(CONTENT_DIR, slug, \"_category.json\")\n\n    let meta = { title: titleCase(slug), description: \"\", icon: \"file-text\", order: 999 }\n    if (fs.existsSync(metaPath)) {\n      // Two failure modes surface the same way to the docs author: a typo'd\n      // _category.json silently reverts to defaults, which is confusing when\n      // the title/icon they set doesn't show up. Warn loudly on both\n      // (invalid JSON syntax + valid JSON that fails the schema).\n      try {\n        const raw = JSON.parse(fs.readFileSync(metaPath, \"utf-8\"))\n        const parsed = categoryMetaSchema.safeParse(raw)\n        if (parsed.success) {\n          meta = parsed.data\n        } else {\n          console.warn(\n            `⚠ Invalid _category.json for \"${slug}\": ${parsed.error.issues.map((i) => i.message).join(\", \")} — using defaults`,\n          )\n        }\n      } catch (err) {\n        console.warn(\n          `⚠ Could not parse _category.json for \"${slug}\": ${err instanceof Error ? err.message : String(err)} — using defaults`,\n        )\n      }\n    }\n\n    return {\n      slug,\n      ...meta,\n      articles: articles.filter((a) => a.category === slug),\n    }\n  })\n\n  return categories.sort((a, b) => a.order - b.order)\n})\n\n/**\n * Get a single article with compiled MDX content and TOC.\n */\nexport const getArticle = cache(\n  async (category: string, slug: string): Promise<Article | null> => {\n    const extensions = [\".mdx\", \".md\"]\n    let filePath: string | null = null\n    let rawFile: string | null = null\n\n    for (const ext of extensions) {\n      const candidate = path.join(CONTENT_DIR, category, `${slug}${ext}`)\n      if (fs.existsSync(candidate)) {\n        filePath = candidate\n        rawFile = fs.readFileSync(candidate, \"utf-8\")\n        break\n      }\n    }\n\n    if (!filePath || !rawFile) return null\n\n    const { data, content: rawContent } = matter(rawFile)\n    const parsed = frontmatterSchema.safeParse(data)\n    if (!parsed.success) return null\n\n    const toc = extractToc(rawContent)\n\n    const { content } = await compileMDX({\n      source: rawContent,\n      components: createArticleComponents(category, slug),\n      options: {\n        mdxOptions: {\n          remarkPlugins,\n          rehypePlugins,\n        },\n      },\n    })\n\n    return {\n      ...parsed.data,\n      slug,\n      category,\n      filePath: `content/${category}/${slug}.mdx`,\n      content,\n      toc,\n    }\n  }\n)\n\n/**\n * Get featured articles for the homepage.\n */\nexport const getFeaturedArticles = cache(async (): Promise<ArticleMeta[]> => {\n  const articles = await getAllArticles()\n  return articles.filter((a) => a.featured).slice(0, 6)\n})\n\n/**\n * Get previous and next articles for navigation.\n */\nexport const getAdjacentArticles = cache(\n  async (\n    category: string,\n    slug: string\n  ): Promise<{ prev: ArticleMeta | null; next: ArticleMeta | null }> => {\n    const articles = await getAllArticles()\n    const categoryArticles = articles.filter((a) => a.category === category)\n    const index = categoryArticles.findIndex((a) => a.slug === slug)\n\n    return {\n      prev: index > 0 ? categoryArticles[index - 1]! : null,\n      next: index < categoryArticles.length - 1 ? categoryArticles[index + 1]! : null,\n    }\n  }\n)\n",
      "type": "registry:lib",
      "target": "lib/content.ts"
    },
    {
      "path": "registry/helpbase/lib/content-dir.ts",
      "content": "import path from \"node:path\"\n\n/**\n * Resolve the MDX content directory the renderer reads from.\n *\n * Priority order:\n *   1. `HELPBASE_CONTENT_DIR` env var (absolute, or relative to cwd).\n *      Matches the MCP server's convention so one env var points both\n *      the human-facing renderer and the agent-facing MCP server at the\n *      same docs. Used by `helpbase preview` to render .helpbase/docs/\n *      from a different project without scaffolding files in it.\n *   2. `<cwd>/content` — the default when running a standard scaffold\n *      where content ships in-repo.\n *\n * Kept in its own file (not inlined in `content.ts`) so unit tests can\n * import it without pulling in the whole MDX + React stack.\n */\n/**\n * `env` is typed as a plain record (not NodeJS.ProcessEnv) so tests can\n * pass `{}` or `{ HELPBASE_CONTENT_DIR: \"...\" }` without TypeScript\n * complaining about the rest of the process.env surface (NODE_ENV,\n * PATH, PWD, etc.). process.env is still compatible because it extends\n * this shape.\n */\nexport function resolveContentDir(\n  env: Record<string, string | undefined> = process.env,\n  cwd: string = process.cwd(),\n): string {\n  const envOverride = env.HELPBASE_CONTENT_DIR\n  if (envOverride && envOverride.length > 0) {\n    return path.isAbsolute(envOverride) ? envOverride : path.resolve(cwd, envOverride)\n  }\n  return path.join(cwd, \"content\")\n}\n",
      "type": "registry:lib",
      "target": "lib/content-dir.ts"
    },
    {
      "path": "registry/helpbase/lib/mdx-config.ts",
      "content": "import type { compileMDX } from \"next-mdx-remote/rsc\"\nimport remarkGfm from \"remark-gfm\"\nimport rehypeSlug from \"rehype-slug\"\nimport rehypePrettyCode, {\n  type Options as PrettyCodeOptions,\n} from \"rehype-pretty-code\"\nimport { createHighlighter, type Highlighter } from \"shiki\"\n\n// Mirror the exact type compileMDX expects for its plugin lists. Pulled\n// from next-mdx-remote's own signature so we don't need a direct dep on\n// `unified` (where `PluggableList` canonically lives). If next-mdx-remote\n// ever widens/narrows this, we follow it for free.\ntype MdxOptions = NonNullable<\n  NonNullable<Parameters<typeof compileMDX>[0][\"options\"]>[\"mdxOptions\"]\n>\ntype PluginList = NonNullable<MdxOptions[\"rehypePlugins\"]>\n\n/**\n * Shared MDX pipeline config used by both the apex docs site\n * (lib/content.ts) and the hosted tenant route\n * (app/(tenant)/t/[tenant]/[...slug]/page.tsx).\n *\n * Having one source for the plugin list keeps syntax highlighting,\n * heading slugs, and GFM behavior identical across surfaces — the\n * divergence between the two pipelines is what left the tenant side\n * without prev/next + breadcrumbs for a while. Keep it consolidated.\n */\n\n// Languages preloaded into the shared shiki highlighter. rehype-pretty-code\n// otherwise lazy-loads per-fence and falls back to `defaultLang` when a\n// lang isn't known, which is why ```mdx blocks were rendering as monochrome\n// plaintext before QA flagged it on 2026-04-17 (ISSUE-002). Add to this\n// list if new content uses a fence language that appears as plaintext.\nconst SHIKI_LANGS = [\n  \"bash\",\n  \"shell\",\n  \"sh\",\n  \"zsh\",\n  \"javascript\",\n  \"typescript\",\n  \"jsx\",\n  \"tsx\",\n  \"mdx\",\n  \"md\",\n  \"json\",\n  \"jsonc\",\n  \"yaml\",\n  \"toml\",\n  \"html\",\n  \"css\",\n  \"diff\",\n  \"python\",\n  \"go\",\n  \"ruby\",\n  \"rust\",\n  \"sql\",\n  \"dockerfile\",\n] as const\n\nconst SHIKI_THEMES = [\"github-light\", \"github-dark-dimmed\"] as const\n\n// One shared highlighter across the process. Shiki's `createHighlighter`\n// is expensive (loads WASM + every language grammar), so we cache the\n// promise and reuse it for every MDX compile. Next.js RSC keeps this in\n// the server runtime; each request reuses the same instance.\nlet highlighterPromise: Promise<Highlighter> | null = null\nfunction getSharedHighlighter(): Promise<Highlighter> {\n  if (!highlighterPromise) {\n    highlighterPromise = createHighlighter({\n      themes: [...SHIKI_THEMES],\n      langs: [...SHIKI_LANGS],\n    })\n  }\n  return highlighterPromise\n}\n\nexport const prettyCodeOptions: PrettyCodeOptions = {\n  // Dual-theme output: shiki injects inline styles keyed to a\n  // data-theme attribute. `next-themes` toggles a class on <html>,\n  // so we wire this to that class via CSS in globals.css (the\n  // [data-theme] selector rehype-pretty-code emits is handled there).\n  theme: {\n    light: \"github-light\",\n    dark: \"github-dark-dimmed\",\n  },\n  // Let shiki own the background on <pre> so colors line up with\n  // tokens. Our own <pre> styling can still add padding/radius/border.\n  keepBackground: true,\n  // Don't crash on unknown language fences — fall back to plain text.\n  defaultLang: \"plaintext\",\n  // Skip the <span data-rehype-pretty-code-figure> wrapper on inline\n  // `code` — we don't need token coloring for single-backtick code and\n  // the wrapper plus figure-only CSS combined to push inline code onto\n  // its own grid row with padding, which showed up as \"inline `.mdx`\n  // mysteriously appearing on its own indented line\" in QA (ISSUE-003).\n  bypassInlineCode: true,\n  // Use the shared preloaded highlighter so all configured langs are\n  // available synchronously at highlight time.\n  getHighlighter: getSharedHighlighter,\n}\n\nexport const remarkPlugins: PluginList = [remarkGfm]\nexport const rehypePlugins: PluginList = [\n  rehypeSlug,\n  [rehypePrettyCode, prettyCodeOptions],\n]\n",
      "type": "registry:lib",
      "target": "lib/mdx-config.ts"
    },
    {
      "path": "registry/helpbase/components/mdx/callout.tsx",
      "content": "import { Info, Lightbulb, TriangleAlert, OctagonX } from \"lucide-react\"\n\nconst CALLOUT_CONFIG = {\n  note: {\n    icon: Info,\n    label: \"Note\",\n    borderColor: \"border-l-blue-500\",\n    bg: \"bg-blue-50/50 dark:bg-blue-950/30\",\n  },\n  tip: {\n    icon: Lightbulb,\n    label: \"Tip\",\n    borderColor: \"border-l-emerald-500\",\n    bg: \"bg-emerald-50/50 dark:bg-emerald-950/30\",\n  },\n  warning: {\n    icon: TriangleAlert,\n    label: \"Warning\",\n    borderColor: \"border-l-amber-500\",\n    bg: \"bg-amber-50/50 dark:bg-amber-950/30\",\n  },\n  danger: {\n    icon: OctagonX,\n    label: \"Danger\",\n    borderColor: \"border-l-red-500\",\n    bg: \"bg-red-50/50 dark:bg-red-950/30\",\n  },\n} as const\n\nexport function Callout({\n  type = \"note\",\n  children,\n}: {\n  type?: keyof typeof CALLOUT_CONFIG\n  children: React.ReactNode\n}) {\n  const config = CALLOUT_CONFIG[type]\n  const Icon = config.icon\n\n  return (\n    <div\n      role={type === \"danger\" ? \"alert\" : \"note\"}\n      className={`my-6 flex gap-3 rounded-xl border-l-4 ${config.borderColor} ${config.bg} p-4`}\n    >\n      <div className=\"flex w-12 shrink-0 flex-col items-center pt-0.5\">\n        <Icon className=\"size-4 text-current\" />\n        <span className=\"mt-1 text-[0.6875rem] font-semibold uppercase tracking-wider\">\n          {config.label}\n        </span>\n      </div>\n      <div className=\"min-w-0 flex-1 text-sm [&>p:last-child]:mb-0\">\n        {children}\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/mdx/callout.tsx"
    },
    {
      "path": "registry/helpbase/components/mdx/figure.tsx",
      "content": "\"use client\"\n\nimport { useState } from \"react\"\nimport { resolveAssetPath } from \"@/lib/assets\"\n\nexport function Figure({\n  src,\n  alt = \"\",\n  caption,\n  category,\n  slug,\n}: {\n  src: string\n  alt?: string\n  caption?: string\n  category?: string\n  slug?: string\n}) {\n  const [error, setError] = useState(false)\n  const resolvedSrc =\n    category && slug && src && !src.startsWith(\"http\") && !src.startsWith(\"/\")\n      ? resolveAssetPath(category, slug, src)\n      : src\n\n  return (\n    <figure className=\"my-6\">\n      {error ? (\n        <div className=\"flex aspect-video items-center justify-center rounded-xl border border-border bg-muted text-sm text-muted-foreground\">\n          Image not found\n        </div>\n      ) : (\n        <img\n          src={resolvedSrc}\n          alt={alt}\n          loading=\"lazy\"\n          onError={() => setError(true)}\n          className=\"w-full rounded-xl border border-border\"\n        />\n      )}\n      {caption && (\n        <figcaption className=\"mt-2 text-center text-sm text-muted-foreground\">\n          {caption}\n        </figcaption>\n      )}\n    </figure>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/mdx/figure.tsx"
    },
    {
      "path": "registry/helpbase/components/mdx/video.tsx",
      "content": "\"use client\"\n\nimport { useState, useEffect } from \"react\"\nimport { resolveAssetPath } from \"@/lib/assets\"\n\nexport function Video({\n  src,\n  embed,\n  loop = true,\n  autoplay = true,\n  muted = true,\n  caption,\n  poster,\n  category,\n  slug,\n}: {\n  src?: string\n  embed?: string\n  loop?: boolean\n  autoplay?: boolean\n  muted?: boolean\n  caption?: string\n  poster?: string\n  category?: string\n  slug?: string\n}) {\n  if (src && embed) {\n    throw new Error(\n      \"Video: provide either 'src' or 'embed', not both. \" +\n        \"Use 'src' for local video files, 'embed' for YouTube/Loom/Vimeo URLs.\",\n    )\n  }\n  if (!src && !embed) {\n    throw new Error(\n      \"Video: provide either 'src' (local video file) or 'embed' (YouTube/Loom/Vimeo URL).\",\n    )\n  }\n\n  const [prefersReducedMotion, setPrefersReducedMotion] = useState(false)\n\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n    setPrefersReducedMotion(mq.matches)\n    const handler = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches)\n    mq.addEventListener(\"change\", handler)\n    return () => mq.removeEventListener(\"change\", handler)\n  }, [])\n\n  const resolvedSrc =\n    src && category && slug && !src.startsWith(\"http\") && !src.startsWith(\"/\")\n      ? resolveAssetPath(category, slug, src)\n      : src\n\n  const resolvedPoster =\n    poster && category && slug && !poster.startsWith(\"http\") && !poster.startsWith(\"/\")\n      ? resolveAssetPath(category, slug, poster)\n      : poster\n\n  return (\n    <figure className=\"my-6\">\n      {src ? (\n        <video\n          src={resolvedSrc}\n          poster={resolvedPoster}\n          loop={loop && !prefersReducedMotion}\n          autoPlay={autoplay && !prefersReducedMotion}\n          muted={muted}\n          playsInline\n          controls={prefersReducedMotion}\n          className=\"w-full rounded-xl border border-border\"\n        />\n      ) : (\n        <div className=\"relative aspect-video overflow-hidden rounded-xl border border-border\">\n          <iframe\n            src={embed}\n            title=\"Embedded video\"\n            sandbox=\"allow-scripts allow-same-origin\"\n            referrerPolicy=\"no-referrer\"\n            allow=\"fullscreen\"\n            className=\"size-full\"\n          />\n        </div>\n      )}\n      {caption && (\n        <figcaption className=\"mt-2 text-center text-sm text-muted-foreground\">\n          {caption}\n        </figcaption>\n      )}\n    </figure>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/mdx/video.tsx"
    },
    {
      "path": "registry/helpbase/components/mdx/steps.tsx",
      "content": "import { Children, isValidElement } from \"react\"\n\nexport function Steps({ children }: { children: React.ReactNode }) {\n  const steps = Children.toArray(children).filter(\n    (child): child is React.ReactElement<{ title?: string; children?: React.ReactNode }> =>\n      isValidElement(child) && (child as React.ReactElement).type === Step,\n  )\n\n  return (\n    <div className=\"my-8\" role=\"list\">\n      {steps.map((child, i) => {\n        if (!isValidElement(child)) return null\n        return (\n          <div key={i} className=\"relative flex gap-4 pb-8 last:pb-0\" role=\"listitem\">\n            {/* Number + connecting line */}\n            <div className=\"relative flex flex-col items-center\">\n              <div className=\"flex size-8 shrink-0 items-center justify-center rounded-full border-2 border-border bg-background text-sm font-semibold\">\n                {i + 1}\n              </div>\n              {i < steps.length - 1 && (\n                <div className=\"absolute top-8 bottom-0 w-0.5 bg-border\" />\n              )}\n            </div>\n            {/* Content */}\n            <div className=\"min-w-0 flex-1 pt-1\">\n              {child.props.title && (\n                <h4 className=\"mb-2 font-semibold\">{child.props.title}</h4>\n              )}\n              <div className=\"text-sm text-muted-foreground [&>p:last-child]:mb-0\">\n                {child.props.children}\n              </div>\n            </div>\n          </div>\n        )\n      })}\n    </div>\n  )\n}\n\nexport function Step({\n  title,\n  children,\n}: {\n  title?: string\n  children: React.ReactNode\n}) {\n  // Rendered by Steps parent — this is a data component\n  return null\n}\n\n// Mark Step so Steps can identify it\nStep.displayName = \"Step\"\n",
      "type": "registry:component",
      "target": "components/mdx/steps.tsx"
    },
    {
      "path": "registry/helpbase/components/mdx/accordion.tsx",
      "content": "\"use client\"\n\nimport { useState } from \"react\"\nimport { ChevronDown } from \"lucide-react\"\n\nexport function Accordion({\n  children,\n  defaultOpen,\n}: {\n  children: React.ReactNode\n  defaultOpen?: boolean\n}) {\n  return <div className=\"my-6 divide-y divide-border rounded-xl border border-border\">{children}</div>\n}\n\nexport function AccordionItem({\n  title,\n  children,\n  defaultOpen = false,\n}: {\n  title: string\n  children: React.ReactNode\n  defaultOpen?: boolean\n}) {\n  const [open, setOpen] = useState(defaultOpen)\n\n  return (\n    <div>\n      <button\n        type=\"button\"\n        onClick={() => setOpen(!open)}\n        onKeyDown={(e) => {\n          if (e.key === \"Enter\" || e.key === \" \") {\n            e.preventDefault()\n            setOpen(!open)\n          }\n        }}\n        aria-expanded={open}\n        className=\"flex w-full items-center justify-between px-4 py-3 text-left text-sm font-medium transition-colors hover:bg-muted/30 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none\"\n      >\n        {title}\n        <ChevronDown\n          className={`size-4 shrink-0 text-muted-foreground transition-transform duration-200 ${\n            open ? \"rotate-180\" : \"\"\n          }`}\n        />\n      </button>\n      {open && (\n        <div className=\"px-4 pb-4 text-sm text-muted-foreground [&>p:last-child]:mb-0\">\n          {children}\n        </div>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/mdx/accordion.tsx"
    },
    {
      "path": "registry/helpbase/components/mdx/tabs.tsx",
      "content": "\"use client\"\n\nimport { useState, useRef, useCallback, Children, isValidElement } from \"react\"\n\nexport function Tabs({ children }: { children: React.ReactNode }) {\n  const tabs = Children.toArray(children).filter(\n    (child): child is React.ReactElement<{ label: string; children?: React.ReactNode }> =>\n      isValidElement(child) && (child as React.ReactElement).type === Tab,\n  )\n  const [active, setActive] = useState(0)\n  const tabRefs = useRef<(HTMLButtonElement | null)[]>([])\n\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent) => {\n      let next = active\n      if (e.key === \"ArrowRight\") next = (active + 1) % tabs.length\n      else if (e.key === \"ArrowLeft\") next = (active - 1 + tabs.length) % tabs.length\n      else if (e.key === \"Home\") next = 0\n      else if (e.key === \"End\") next = tabs.length - 1\n      else return\n      e.preventDefault()\n      setActive(next)\n      tabRefs.current[next]?.focus()\n    },\n    [active, tabs.length],\n  )\n\n  return (\n    <div className=\"my-6\">\n      {/* Tab list with horizontal scroll on narrow viewports */}\n      <div\n        role=\"tablist\"\n        className=\"flex overflow-x-auto border-b border-border [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\"\n        onKeyDown={handleKeyDown}\n      >\n        {tabs.map((child, i) => {\n          if (!isValidElement(child)) return null\n          return (\n            <button\n              key={i}\n              ref={(el) => { tabRefs.current[i] = el }}\n              role=\"tab\"\n              aria-selected={i === active}\n              tabIndex={i === active ? 0 : -1}\n              onClick={() => setActive(i)}\n              className={`shrink-0 border-b-2 px-4 py-2 text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none ${\n                i === active\n                  ? \"border-foreground text-foreground\"\n                  : \"border-transparent text-muted-foreground hover:text-foreground/80\"\n              }`}\n            >\n              {child.props.label}\n            </button>\n          )\n        })}\n      </div>\n      {/* Tab panels */}\n      {tabs.map((child, i) => {\n        if (!isValidElement(child)) return null\n        return (\n          <div\n            key={i}\n            role=\"tabpanel\"\n            hidden={i !== active}\n            className=\"pt-4 text-sm [&>p:last-child]:mb-0\"\n          >\n            {child.props.children}\n          </div>\n        )\n      })}\n    </div>\n  )\n}\n\nexport function Tab({\n  label,\n  children,\n}: {\n  label: string\n  children: React.ReactNode\n}) {\n  return null\n}\n\nTab.displayName = \"Tab\"\n",
      "type": "registry:component",
      "target": "components/mdx/tabs.tsx"
    },
    {
      "path": "registry/helpbase/components/mdx/card-group.tsx",
      "content": "import Link from \"next/link\"\nimport { ChevronRight, FileText } from \"lucide-react\"\nimport * as LucideIcons from \"lucide-react\"\n\nfunction getIcon(name: string) {\n  const pascalName = name\n    .split(\"-\")\n    .map((s) => s.charAt(0).toUpperCase() + s.slice(1))\n    .join(\"\")\n  // eslint-disable-next-line @typescript-eslint/no-explicit-any\n  const icons = LucideIcons as Record<string, any>\n  const Icon = icons[pascalName]\n  if (Icon && typeof Icon === \"function\") return Icon as React.ComponentType<{ className?: string }>\n  return FileText\n}\n\nexport function CardGroup({\n  cols = 2,\n  children,\n}: {\n  cols?: 2 | 3\n  children: React.ReactNode\n}) {\n  return (\n    <div\n      className={`my-6 grid gap-4 ${\n        cols === 3\n          ? \"grid-cols-1 sm:grid-cols-2 lg:grid-cols-3\"\n          : \"grid-cols-1 sm:grid-cols-2\"\n      }`}\n    >\n      {children}\n    </div>\n  )\n}\n\nexport function Card({\n  icon,\n  title,\n  href,\n  children,\n}: {\n  icon?: string\n  title: string\n  href: string\n  children?: React.ReactNode\n}) {\n  const Icon = icon ? getIcon(icon) : FileText\n\n  return (\n    <Link\n      href={href}\n      className=\"group relative flex flex-col gap-2 rounded-xl border border-border p-4 transition-[border-color,background-color] duration-200 hover:border-foreground/15 hover:bg-muted/30 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none\"\n    >\n      <Icon className=\"size-5 text-muted-foreground\" />\n      <span className=\"text-sm font-semibold\">{title}</span>\n      {children && (\n        <span className=\"line-clamp-2 text-sm text-muted-foreground\">\n          {children}\n        </span>\n      )}\n      <ChevronRight className=\"absolute right-4 top-4 size-4 text-muted-foreground opacity-0 transition-all duration-200 group-hover:translate-x-1 group-hover:opacity-100\" />\n    </Link>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/mdx/card-group.tsx"
    },
    {
      "path": "registry/helpbase/components/mdx/cta-card.tsx",
      "content": "\"use client\"\n\nimport { useState } from \"react\"\nimport Link from \"next/link\"\nimport { ArrowUpRight } from \"lucide-react\"\nimport { resolveAssetPath } from \"@/lib/assets\"\n\nexport function CtaCard({\n  src,\n  title,\n  href,\n  description,\n  category,\n  slug,\n}: {\n  src: string\n  title: string\n  href: string\n  description?: string\n  category?: string\n  slug?: string\n}) {\n  const [imgError, setImgError] = useState(false)\n  const resolvedSrc =\n    category && slug && src && !src.startsWith(\"http\") && !src.startsWith(\"/\")\n      ? resolveAssetPath(category, slug, src)\n      : src\n\n  return (\n    <Link\n      href={href}\n      className=\"group relative my-6 block overflow-hidden rounded-xl border border-border focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none\"\n    >\n      {imgError ? (\n        <div className=\"flex aspect-video items-center justify-center bg-muted text-sm text-muted-foreground\">\n          Preview unavailable\n        </div>\n      ) : (\n        <img\n          src={resolvedSrc}\n          alt={title}\n          loading=\"lazy\"\n          onError={() => setImgError(true)}\n          className=\"aspect-video w-full object-cover\"\n        />\n      )}\n      <div className=\"absolute inset-0 flex flex-col items-center justify-center bg-black/60 opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-visible:opacity-100\">\n        <span className=\"text-sm font-semibold text-white\">{title}</span>\n        {description && (\n          <span className=\"mt-1 text-xs text-white/80\">{description}</span>\n        )}\n        <ArrowUpRight className=\"mt-2 size-4 text-white\" />\n      </div>\n    </Link>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/mdx/cta-card.tsx"
    },
    {
      "path": "registry/helpbase/lib/search.ts",
      "content": "import { getAllArticles, getCategories } from \"./content\"\n\nexport interface SearchItem {\n  title: string\n  description: string\n  category: string\n  categoryTitle: string\n  slug: string\n  href: string\n}\n\nexport async function getSearchIndex(): Promise<SearchItem[]> {\n  const [articles, categories] = await Promise.all([\n    getAllArticles(),\n    getCategories(),\n  ])\n\n  const categoryMap = new Map(categories.map((c) => [c.slug, c.title]))\n\n  return articles.map((article) => ({\n    title: article.title,\n    description: article.description,\n    category: article.category,\n    categoryTitle: categoryMap.get(article.category) ?? article.category,\n    slug: article.slug,\n    href: `/${article.category}/${article.slug}`,\n  }))\n}\n",
      "type": "registry:lib",
      "target": "lib/search.ts"
    },
    {
      "path": "registry/helpbase/lib/toc.ts",
      "content": "import type { TocItem } from \"@/lib/types\"\nimport { slugify } from \"@/lib/slugify\"\n\n/**\n * Extract table of contents from raw MDX content.\n * Parses h2 and h3 headings. Uses the same slugify function\n * that rehype-slug uses for consistency.\n */\nexport function extractToc(rawMdx: string): TocItem[] {\n  const toc: TocItem[] = []\n  const lines = rawMdx.split(\"\\n\")\n  let inCodeBlock = false\n\n  for (const line of lines) {\n    // Skip headings inside code blocks\n    if (line.trim().startsWith(\"```\")) {\n      inCodeBlock = !inCodeBlock\n      continue\n    }\n    if (inCodeBlock) continue\n\n    const match = line.match(/^(#{2,3})\\s+(.+)$/)\n    if (match) {\n      const text = match[2]!.trim()\n      toc.push({\n        depth: match[1]!.length,\n        text,\n        id: slugify(text),\n      })\n    }\n  }\n\n  return toc\n}\n",
      "type": "registry:lib",
      "target": "lib/toc.ts"
    },
    {
      "path": "registry/helpbase/lib/schemas.ts",
      "content": "import { z } from \"zod\"\n\n/**\n * Article frontmatter schema.\n * This is the single source of truth for content validation —\n * used by the CLI, audit, and the web app.\n */\n/**\n * Allowed hosts for videoEmbed iframe URLs.\n * Prevents arbitrary iframe injection while supporting common video platforms.\n */\nexport const EMBED_HOST_ALLOWLIST = [\n  \"youtube.com\",\n  \"www.youtube.com\",\n  \"youtube-nocookie.com\",\n  \"www.youtube-nocookie.com\",\n  \"loom.com\",\n  \"www.loom.com\",\n  \"vimeo.com\",\n  \"player.vimeo.com\",\n  \"supercut.ai\",\n] as const\n\nfunction isAllowedEmbedHost(url: string): boolean {\n  try {\n    const parsed = new URL(url)\n    return EMBED_HOST_ALLOWLIST.some(\n      (host) => parsed.hostname === host || parsed.hostname.endsWith(`.${host}`),\n    )\n  } catch {\n    return false\n  }\n}\n\n/**\n * A citation produced by `helpbase ingest` — points at the specific file\n * + line range that justifies a generated how-to guide.\n *\n * v2 contract: the LLM returns `{ file, startLine, endLine, reason? }` and\n * the CLI reads literal bytes from disk at that line range. Decoupling the\n * snippet from the model eliminates the paraphrase-drift failure mode that\n * made v1 unusable on cheaper models (see context dogfood, 2026-04-16).\n *\n * `snippet` is optional: absent on fresh model output, filled in by the CLI\n * from disk bytes before MDX write. Kept readable in the schema so v1\n * committed docs (with literal snippets) keep parsing without a migration.\n *\n *   file       — repo-relative path (e.g. \"src/routes/auth.ts\")\n *   startLine  — 1-indexed inclusive\n *   endLine    — 1-indexed inclusive, >= startLine\n *   reason     — one short sentence on why this range supports the claim\n *   snippet    — disk bytes from [startLine, endLine] (CLI-populated at write)\n */\nexport const contextCitationSchema = z\n  .object({\n    file: z.string().min(1, \"file is required\"),\n    startLine: z.number().int().positive(\"startLine must be >= 1\"),\n    endLine: z.number().int().positive(\"endLine must be >= 1\"),\n    reason: z.string().optional(),\n    snippet: z.string().optional(),\n  })\n  .refine((c) => c.endLine >= c.startLine, {\n    message: \"endLine must be >= startLine\",\n    path: [\"endLine\"],\n  })\n\nexport type ContextCitation = z.infer<typeof contextCitationSchema>\n\nexport const frontmatterSchema = z.object({\n  schemaVersion: z.number({\n    error: \"schemaVersion is required. Add 'schemaVersion: 1' to your frontmatter.\",\n  }),\n  title: z.string().min(1, \"title is required\"),\n  description: z.string().min(1, \"description is required\"),\n  category: z.string().optional(),\n  tags: z.array(z.string()).default([]),\n  order: z.number().default(999),\n  featured: z.boolean().default(false),\n  heroImage: z.string().optional(),\n  coverImage: z.string().optional(),\n  videoEmbed: z\n    .string()\n    .url(\"videoEmbed must be a valid URL\")\n    .refine(isAllowedEmbedHost, {\n      message: `videoEmbed must be from an allowed host: ${EMBED_HOST_ALLOWLIST.filter((h) => !h.startsWith(\"www.\") && !h.startsWith(\"player.\")).join(\", \")}`,\n    })\n    .optional(),\n  ogImage: z.string().optional(),\n  // Fields set by `helpbase ingest` on generated docs. Optional so hand-\n  // written articles and scaffolded content keep validating unchanged.\n  citations: z.array(contextCitationSchema).optional(),\n  source: z.enum([\"generated\", \"custom\"]).optional(),\n  helpbaseContextVersion: z.string().optional(),\n})\n\nexport type Frontmatter = z.infer<typeof frontmatterSchema>\n\n/**\n * Category metadata schema (for _category.json files).\n */\nexport const categoryMetaSchema = z.object({\n  title: z.string(),\n  description: z.string().default(\"\"),\n  icon: z.string().default(\"file-text\"),\n  order: z.number().default(999),\n})\n\nexport type CategoryMeta = z.infer<typeof categoryMetaSchema>\n\n/**\n * Generated article schema (from AI generation).\n * Used with Vercel AI SDK's generateObject via AI Gateway.\n *\n * Field contracts for the model:\n * - title: action-oriented, e.g. \"How to reset your password\"\n * - description: one plain sentence (no marketing copy)\n * - category: human-readable category name, e.g. \"Getting Started\"\n *   (the CLI slugifies this for the directory name)\n * - tags: 2-4 relevant tags, lowercase\n * - content: MDX body without frontmatter (the CLI wraps it)\n */\nexport const generatedArticleSchema = z.object({\n  title: z.string(),\n  description: z.string(),\n  category: z.string(),\n  tags: z.array(z.string()),\n  content: z.string(),\n})\n\nexport const generatedArticlesSchema = z.object({\n  articles: z.array(generatedArticleSchema),\n})\n\nexport type GeneratedArticle = z.infer<typeof generatedArticleSchema>\n\n/**\n * Generated context doc — what the LLM returns for `helpbase ingest`. Extends\n * the article shape with citations (1–5 per doc, enforced; literal-text\n * validated before write) and a list of source file paths the model was\n * inspired by. Dropped docs (zero valid citations) never reach disk.\n */\nexport const generatedContextDocSchema = generatedArticleSchema.extend({\n  citations: z\n    .array(contextCitationSchema)\n    .min(1, \"at least 1 citation is required\")\n    .max(5, \"at most 5 citations per doc\"),\n  sourcePaths: z.array(z.string()).default([]),\n})\n\nexport const generatedContextDocsSchema = z.object({\n  docs: z.array(generatedContextDocSchema),\n})\n\nexport type GeneratedContextDoc = z.infer<typeof generatedContextDocSchema>\n\n/**\n * An image associated with a generated article.\n * Used by the visual generation pipeline (--screenshots flag).\n *\n * Each image maps 1:1 to a <Step> block. When no <Steps> are present,\n * `step` is treated as an ordinal position (Figure N inserted after\n * the Nth prose paragraph).\n */\n/**\n * A citation pointing at the specific lines of source code that justify a\n * proposed documentation edit. Every SyncProposal MUST carry at least one.\n *\n * This is the anti-hallucination gate: a proposal with zero citations cannot\n * be trusted to reflect the actual code change, so the schema rejects it.\n *\n *   sourceFile  — repo-relative path, e.g. \"src/server.ts\"\n *   lineStart   — 1-indexed inclusive\n *   lineEnd     — 1-indexed inclusive, >= lineStart\n */\nexport const syncCitationSchema = z\n  .object({\n    sourceFile: z.string().min(1, \"sourceFile is required\"),\n    lineStart: z.number().int().positive(\"lineStart must be >= 1\"),\n    lineEnd: z.number().int().positive(\"lineEnd must be >= 1\"),\n  })\n  .refine((c) => c.lineEnd >= c.lineStart, {\n    message: \"lineEnd must be >= lineStart\",\n    path: [\"lineEnd\"],\n  })\n\n/**\n * A proposed edit to a single MDX file, grounded in source code citations.\n *\n * The LLM returns an array of these; the CLI converts them into a unified\n * diff locally. `before` and `after` are the exact string contents — the\n * CLI does a literal find-and-replace when applying the proposal, so\n * `before` must match the current file content byte-for-byte.\n *\n * A citations array with zero items is rejected by the schema. This is\n * the property test invariant in `schemas.test.ts`.\n */\nexport const syncProposalSchema = z.object({\n  file: z.string().min(1, \"file path is required\"),\n  before: z.string(),\n  after: z.string(),\n  citations: z.array(syncCitationSchema).min(1, \"at least one citation is required\"),\n  rationale: z.string().optional(),\n})\n\nexport const syncProposalsSchema = z.object({\n  proposals: z.array(syncProposalSchema),\n})\n\nexport type SyncCitation = z.infer<typeof syncCitationSchema>\nexport type SyncProposal = z.infer<typeof syncProposalSchema>\nexport type SyncProposals = z.infer<typeof syncProposalsSchema>\n\nexport interface ArticleImage {\n  /** Source filename, e.g. \"01-dashboard.png\" */\n  filename: string\n  /** AI-generated description of what the screenshot shows */\n  alt: string\n  /** Which Step this image belongs to (1-indexed) */\n  step: number\n}\n\n/**\n * Hosted tier (`helpbase deploy`) schemas — payload shapes for the\n * `deploy_tenant` RPC and the hosted MCP route. Single source of truth.\n */\n\n/**\n * One search chunk uploaded by the CLI for a given article.\n * Keys `article_slug` and `article_category` are what the RPC joins on\n * to resolve `article_id` post-insert.\n */\nexport const tenantChunkSchema = z.object({\n  article_slug: z.string().min(1),\n  article_category: z.string().min(1),\n  chunk_index: z.number().int().nonnegative(),\n  content: z.string().min(1),\n  file_path: z.string(),\n  line_start: z.number().int().positive(),\n  line_end: z.number().int().positive(),\n  token_count: z.number().int().nonnegative().default(0),\n}).refine((c) => c.line_end >= c.line_start, {\n  message: \"line_end must be >= line_start\",\n  path: [\"line_end\"],\n})\n\nexport type TenantChunk = z.infer<typeof tenantChunkSchema>\n\n/**\n * Per-deploy validation report, written to `tenant_deploys.validation_report`\n * JSONB. Mirrors the client-side report the CLI already generates in v2.\n */\nexport const deployReportSchema = z.object({\n  kept_count: z.number().int().nonnegative(),\n  dropped_count: z.number().int().nonnegative(),\n  dropped: z.array(z.object({\n    slug: z.string(),\n    reason: z.string(),\n  })).default([]),\n  ran_at: z.string(),\n})\n\nexport type DeployReport = z.infer<typeof deployReportSchema>\n\n/**\n * The full payload the CLI hands to `deploy_tenant` RPC.\n * Categories + articles + chunks + validation report.\n */\nexport const deployPayloadSchema = z.object({\n  categories: z.array(z.object({\n    slug: z.string().min(1),\n    title: z.string().min(1),\n    description: z.string().default(\"\"),\n    icon: z.string().optional().nullable(),\n    order: z.number().int().default(0),\n  })),\n  articles: z.array(z.object({\n    slug: z.string().min(1),\n    category: z.string().min(1),\n    title: z.string().min(1),\n    description: z.string().default(\"\"),\n    content: z.string(),\n    // content_hash is required, not defaulted: an empty string persists\n    // straight through to tenant_articles.content_hash and the diff\n    // engine treats \"\" as UPDATED-forever until the next deploy. That's\n    // a silent DX regression. The server recomputes on the deploy route\n    // as belt-and-braces, but requiring at the schema level catches\n    // malformed callers with a clear 400 before the RPC fires. Caught\n    // by CodeRabbit on PR #11.\n    content_hash: z.string().min(1, \"content_hash is required (hash via hashArticle)\"),\n    frontmatter: z.record(z.string(), z.unknown()).default({}),\n    order: z.number().int().default(0),\n    tags: z.array(z.string()).optional().nullable(),\n    hero_image: z.string().optional().nullable(),\n    video_embed: z.string().optional().nullable(),\n    featured: z.boolean().default(false),\n    file_path: z.string(),\n  })),\n  chunks: z.array(tenantChunkSchema),\n  validation_report: deployReportSchema.optional(),\n  // Optimistic concurrency: client passes the deploy_version it observed\n  // when fetching /state. Server raises stale_deploy_version (SQLSTATE P0001)\n  // if the value has advanced since. Optional — CI and legacy callers\n  // that don't run `deploy --preview` first pass undefined and skip the\n  // check. See `deploy_tenant_rpc_v2_content_hash_and_version` migration.\n  expected_deploy_version: z.number().int().nonnegative().nullable().optional(),\n})\n\nexport type DeployPayload = z.infer<typeof deployPayloadSchema>\n\n/**\n * MCP tool-call log entry for `tenant_mcp_queries`. Week-1 instrumentation\n * to decide whether FTS retrieval is good enough or we need pgvector.\n */\nexport const tenantMcpQuerySchema = z.object({\n  tenant_id: z.string().uuid(),\n  tool_name: z.enum([\"search_docs\", \"get_doc\", \"list_docs\"]),\n  query: z.string().default(\"\"),\n  result_count: z.number().int().nonnegative(),\n  matched: z.boolean(),\n})\n\nexport type TenantMcpQuery = z.infer<typeof tenantMcpQuerySchema>\n",
      "type": "registry:lib",
      "target": "lib/schemas.ts"
    },
    {
      "path": "registry/helpbase/lib/slugify.ts",
      "content": "/**\n * Convert a string to a URL-safe slug.\n * Used for article slugs, category slugs, and heading IDs.\n */\nexport function slugify(text: string): string {\n  return text\n    .toLowerCase()\n    .replace(/[^\\w\\s-]/g, \"\")\n    .replace(/[\\s_]+/g, \"-\")\n    .replace(/^-+|-+$/g, \"\")\n}\n\n/**\n * Convert a kebab-case slug back to Title Case.\n * Used for deriving category titles from directory names.\n */\nexport function titleCase(slug: string): string {\n  return slug\n    .split(\"-\")\n    .map((word) => word.charAt(0).toUpperCase() + word.slice(1))\n    .join(\" \")\n}\n",
      "type": "registry:lib",
      "target": "lib/slugify.ts"
    },
    {
      "path": "registry/helpbase/lib/types.ts",
      "content": "import type { Frontmatter, CategoryMeta } from \"./schemas\"\n\nexport type { Frontmatter, CategoryMeta }\n\n/**\n * An article's metadata (frontmatter + file info), without compiled content.\n */\nexport interface ArticleMeta extends Frontmatter {\n  slug: string\n  category: string\n  filePath: string\n  rawContent?: string\n}\n\n/**\n * A fully loaded article with compiled MDX content.\n */\nexport interface Article extends ArticleMeta {\n  content: React.ReactElement\n  toc: TocItem[]\n}\n\n/**\n * A category with its articles.\n */\nexport interface Category {\n  slug: string\n  title: string\n  description: string\n  icon: string\n  order: number\n  articles: ArticleMeta[]\n}\n\n/**\n * A table of contents entry.\n */\nexport interface TocItem {\n  depth: number // 2 | 3\n  text: string\n  id: string\n}\n",
      "type": "registry:lib",
      "target": "lib/types.ts"
    },
    {
      "path": "registry/helpbase/lib/tagline.ts",
      "content": "export const SHADCN_TAGLINE = \"Code in your repo, generated but editable.\"\n\nexport const MADE_WITH_SHADCN_URL = \"https://ui.shadcn.com\"\n\nexport const MADE_WITH_SHADCN_LABEL = \"Made with shadcn\"\n",
      "type": "registry:lib",
      "target": "lib/tagline.ts"
    },
    {
      "path": "registry/helpbase/content/getting-started/_category.json",
      "content": "{\n  \"title\": \"Getting Started\",\n  \"description\": \"Everything you need to know to get up and running\",\n  \"icon\": \"sparkles\",\n  \"order\": 1\n}\n",
      "type": "registry:file",
      "target": "content/getting-started/_category.json"
    },
    {
      "path": "registry/helpbase/content/getting-started/introduction.mdx",
      "content": "---\nschemaVersion: 1\ntitle: \"Introduction\"\ndescription: \"What helpbase is, why it exists, and how to get started in under 5 minutes.\"\ntags: [\"getting-started\", \"overview\"]\norder: 1\nfeatured: true\n---\n\nhelpbase is an open-source help center built with Next.js, MDX, and shadcn/ui. You write articles in markdown, and they render as a polished, searchable help center.\n\n<Callout type=\"tip\">\nhelpbase.dev itself is built with helpbase. Every article you're reading was written in MDX and deployed with the same tools you'll use.\n</Callout>\n\n## Why helpbase?\n\nMost help center tools are either closed-source hosted SaaS or generic wikis that don't feel like a product. helpbase gives you:\n\n- **AI content generation** from any URL or product page\n- **8 MDX components** designed for help center content\n- **shadcn/ui integration** so it matches your existing design system\n- **Self-hosted or hosted** at company.helpbase.dev\n- **Open source** with no lock-in\n\n## Quick start\n\n<Steps>\n  <Step title=\"Scaffold a new project\">\n    Run the scaffolder to create a new help center project with sample content:\n    ```bash\n    npx create-helpbase my-docs\n    cd my-docs\n    ```\n  </Step>\n  <Step title=\"Start the dev server\">\n    The project uses Next.js with Turbopack for instant hot reload:\n    ```bash\n    pnpm dev\n    ```\n    Open `http://localhost:3000` to see your help center.\n  </Step>\n  <Step title=\"Generate articles with AI\">\n    Point the CLI at your product URL to generate articles automatically:\n    ```bash\n    npx helpbase generate --url https://your-product.com\n    ```\n  </Step>\n</Steps>\n\n## Project structure\n\nAfter scaffolding, your project looks like this:\n\n```\nmy-docs/\n  content/\n    getting-started/\n      _category.json       # Category metadata\n      introduction.mdx     # This article\n    customization/\n      _category.json\n      theming.mdx\n  app/\n    (main)/\n      (docs)/              # Article routes\n      layout.tsx           # Root layout\n      page.tsx             # Homepage\n  components/\n    header.tsx\n    docs-sidebar.tsx\n    search-dialog.tsx\n  lib/\n    content.ts             # Content pipeline\n    search.ts              # Search index\n```\n\n## Next steps\n\n<CardGroup cols={2}>\n  <Card icon=\"download\" title=\"Installation\" href=\"/getting-started/installation\">\n    Detailed setup guide with all configuration options.\n  </Card>\n  <Card icon=\"sparkles\" title=\"AI Generation\" href=\"/getting-started/ai-generation\">\n    Generate help center articles from any URL with the CLI.\n  </Card>\n</CardGroup>\n",
      "type": "registry:file",
      "target": "content/getting-started/introduction.mdx"
    },
    {
      "path": "registry/helpbase/content/customization/_category.json",
      "content": "{\n  \"title\": \"Customization\",\n  \"description\": \"Make it yours with themes, branding, and custom components\",\n  \"icon\": \"paintbrush\",\n  \"order\": 2\n}\n",
      "type": "registry:file",
      "target": "content/customization/_category.json"
    },
    {
      "path": "registry/helpbase/content/customization/theming.mdx",
      "content": "---\nschemaVersion: 1\ntitle: \"Theming\"\ndescription: \"Customize colors, fonts, dark mode, and the overall look of your help center.\"\ntags: [\"customization\", \"design\"]\norder: 1\nfeatured: false\n---\n\nhelpbase uses shadcn/ui's theming system. Colors, fonts, spacing, and dark mode are all controlled through CSS variables in `app/globals.css`.\n\n## CSS variables\n\nThe core theme is defined in `:root` and `.dark` selectors:\n\n```css\n:root {\n  --background: oklch(1 0 0);\n  --foreground: oklch(0.145 0 0);\n  --muted: oklch(0.97 0 0);\n  --muted-foreground: oklch(0.556 0 0);\n  --border: oklch(0.922 0 0);\n  /* ... */\n}\n\n.dark {\n  --background: oklch(0.145 0 0);\n  --foreground: oklch(0.985 0 0);\n  /* ... */\n}\n```\n\n<Callout type=\"tip\">\nhelpbase uses oklch color space for perceptually uniform colors. You can use any color format CSS supports, but oklch gives the most predictable results across light and dark modes.\n</Callout>\n\n## Dark mode\n\nDark mode is handled by `next-themes`. The toggle is in the header. Users' preferences persist across sessions via localStorage.\n\nTo change the default theme, update the `ThemeProvider` in `app/layout.tsx`:\n\n```tsx\n<ThemeProvider defaultTheme=\"dark\" attribute=\"class\">\n```\n\n## Fonts\n\nThe default font stack uses system fonts for fast loading. To use a custom font:\n\n<Steps>\n  <Step title=\"Import the font\">\n    ```tsx\n    // app/layout.tsx\n    import { Inter } from \"next/font/google\"\n    const inter = Inter({ subsets: [\"latin\"] })\n    ```\n  </Step>\n  <Step title=\"Apply to the body\">\n    ```tsx\n    <body className={inter.className}>\n    ```\n  </Step>\n</Steps>\n\n## Article typography\n\nArticle content uses the `.article-content` class defined in `globals.css`. This controls:\n\n- Heading sizes and spacing\n- Paragraph line height\n- Code block styling\n- Table formatting\n- Blockquote appearance\n- Image border radius\n\nAll values are customizable through the CSS class without touching component code.\n\n## Adding shadcn components\n\nSince helpbase is built on shadcn/ui, you can add any shadcn component:\n\n```bash\nnpx shadcn add button dialog dropdown-menu\n```\n\nComponents install into `components/ui/` and use your theme variables automatically.\n",
      "type": "registry:file",
      "target": "content/customization/theming.mdx"
    }
  ],
  "cssVars": {
    "theme": {
      "font-heading": "var(--font-sans)"
    },
    "light": {
      "sidebar": "oklch(0.988 0.003 106.5)",
      "sidebar-foreground": "oklch(0.153 0.006 107.1)",
      "sidebar-primary": "oklch(0.228 0.013 107.4)",
      "sidebar-primary-foreground": "oklch(0.988 0.003 106.5)",
      "sidebar-accent": "oklch(0.966 0.005 106.5)",
      "sidebar-accent-foreground": "oklch(0.228 0.013 107.4)",
      "sidebar-border": "oklch(0.93 0.007 106.5)",
      "sidebar-ring": "oklch(0.737 0.021 106.9)"
    },
    "dark": {
      "sidebar": "oklch(0.228 0.013 107.4)",
      "sidebar-foreground": "oklch(0.988 0.003 106.5)",
      "sidebar-primary": "oklch(0.488 0.243 264.376)",
      "sidebar-primary-foreground": "oklch(0.988 0.003 106.5)",
      "sidebar-accent": "oklch(0.286 0.016 107.4)",
      "sidebar-accent-foreground": "oklch(0.988 0.003 106.5)",
      "sidebar-border": "oklch(1 0 0 / 10%)",
      "sidebar-ring": "oklch(0.58 0.031 107.3)"
    }
  },
  "type": "registry:block"
}