{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "helpbase",
  "title": "Helpbase",
  "description": "The full helpbase install in one command. Drops the help-center routes + starter MDX content, the self-hosted MCP server that exposes your docs to AI agents, and the GitHub Actions workflow that opens citation-grounded PRs whenever code and docs drift. Zero-config auth via GitHub OIDC. All code, all yours, running in your Actions minutes. Customize from there.",
  "dependencies": [
    "@modelcontextprotocol/sdk",
    "@radix-ui/react-slot",
    "gray-matter",
    "lucide-react",
    "next-mdx-remote",
    "next-themes",
    "rehype-pretty-code",
    "rehype-slug",
    "remark-gfm",
    "shiki",
    "zod"
  ],
  "devDependencies": [
    "tsx"
  ],
  "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"
    },
    {
      "path": "registry/helpbase-mcp/index.ts",
      "content": "#!/usr/bin/env node\n\n/**\n * `@helpbase/mcp` — stdio entry point.\n *\n * Spawned by an MCP client (Claude Desktop, Cursor, Zed, Windsurf, etc.)\n * Talks JSON-RPC over stdin/stdout. All logs go to stderr — writing anything\n * else to stdout corrupts the protocol stream.\n */\n\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\"\nimport { buildServer } from \"./server.js\"\n\nasync function main() {\n  const { server, deps } = buildServer()\n\n  // Always log the bootstrap summary to stderr so users can see it when they\n  // run the server manually for debugging, without polluting stdout.\n  process.stderr.write(\n    `[helpbase-mcp] Loaded ${deps.docs.length} docs across ${deps.categories.length} categories from ${deps.contentDir}\\n`,\n  )\n\n  const transport = new StdioServerTransport()\n  await server.connect(transport)\n}\n\nmain().catch((err) => {\n  const message = err instanceof Error ? err.message : String(err)\n  process.stderr.write(`[helpbase-mcp] fatal: ${message}\\n`)\n  process.exit(1)\n})\n",
      "type": "registry:file",
      "target": "mcp/index.ts"
    },
    {
      "path": "registry/helpbase-mcp/server.ts",
      "content": "import { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\"\nimport {\n  findContentDir,\n  loadCategories,\n  loadDocs,\n  type CategoryMeta,\n  type Doc,\n} from \"./content/loader.js\"\nimport {\n  findSkillsDir,\n  loadSkills,\n  type Skill,\n} from \"./content/skills.js\"\nimport {\n  handleSearchDocs,\n  searchDocsInput,\n} from \"./tools/search-docs.js\"\nimport { getDocInput, handleGetDoc } from \"./tools/get-doc.js\"\nimport { handleListDocs, listDocsInput } from \"./tools/list-docs.js\"\nimport { getSkillInput, handleGetSkill } from \"./tools/get-skill.js\"\nimport { handleListSkills, listSkillsInput } from \"./tools/list-skills.js\"\n\nexport interface ServerDeps {\n  contentDir: string\n  docs: Doc[]\n  categories: CategoryMeta[]\n  /** Resolved .helpbase/skills/ directory, or null when none was found. */\n  skillsDir: string | null\n  /** Loaded skills. Empty when no skills dir is present — not an error. */\n  skills: Skill[]\n}\n\nexport interface BuildServerOptions {\n  name?: string\n  version?: string\n  /** Override the content dir. If omitted, resolves via findContentDir(). */\n  contentDir?: string\n  /**\n   * Override the skills dir. If omitted, resolves via findSkillsDir().\n   * Pass `null` to force skills-off regardless of filesystem state.\n   */\n  skillsDir?: string | null\n}\n\n/**\n * Build a fresh McpServer with three tools wired against a loaded content index.\n *\n * Transport-agnostic: this function does NOT connect a transport. Callers pick\n * stdio (entry point) or HTTP (v2) and connect themselves. Keeping transport\n * out of here is what makes adding HTTP in v2 a 1-file change.\n *\n * CRITICAL: never write to stdout from this file or anything it calls. Under\n * stdio transport, stdout carries the JSON-RPC stream and any stray write\n * corrupts it. Logs go to stderr via console.error (or a dedicated logger).\n */\nexport function buildServer(options: BuildServerOptions = {}): {\n  server: McpServer\n  deps: ServerDeps\n} {\n  const contentDir = options.contentDir ?? findContentDir()\n  const docs = loadDocs(contentDir)\n  const categories = loadCategories(contentDir)\n\n  // Skills are OPTIONAL — no .helpbase/skills/ means an empty list,\n  // not an error. Pass `skillsDir: null` to force skills-off.\n  const skillsDir =\n    options.skillsDir === null\n      ? null\n      : (options.skillsDir ?? findSkillsDir())\n  const skills = loadSkills(skillsDir)\n\n  const server = new McpServer({\n    name: options.name ?? \"helpbase-mcp\",\n    version: options.version ?? \"0.0.1\",\n  })\n\n  server.registerTool(\n    \"search_docs\",\n    {\n      title: \"Search docs\",\n      description:\n        \"Search Helpbase docs by keyword. Matches titles, descriptions, and body. Returns ranked list of matching doc slugs.\",\n      inputSchema: searchDocsInput.shape,\n    },\n    async (input) => handleSearchDocs(docs, input),\n  )\n\n  server.registerTool(\n    \"get_doc\",\n    {\n      title: \"Get doc\",\n      description:\n        \"Fetch the full MDX content of a single doc by slug. Slug format: 'category/slug' or just 'slug' (first match wins).\",\n      inputSchema: getDocInput.shape,\n    },\n    async (input) => handleGetDoc(docs, input),\n  )\n\n  server.registerTool(\n    \"list_docs\",\n    {\n      title: \"List docs\",\n      description:\n        \"List all available docs grouped by category. Optionally filter by a single category slug.\",\n      inputSchema: listDocsInput.shape,\n    },\n    async (input) => handleListDocs(docs, categories, input),\n  )\n\n  // Skills server: agents pull writing-style / tone / formatting rules\n  // from .helpbase/skills/. Empty list when no skills are defined — no\n  // error, just silence. See content/skills.ts.\n  server.registerTool(\n    \"list_skills\",\n    {\n      title: \"List skills\",\n      description:\n        \"List writing-style, tone, and formatting rules the docs team has \" +\n        \"published for this product. Returns an empty list if no skills \" +\n        \"are defined in .helpbase/skills/.\",\n      inputSchema: listSkillsInput.shape,\n    },\n    async () => handleListSkills(skills),\n  )\n\n  server.registerTool(\n    \"get_skill\",\n    {\n      title: \"Get skill\",\n      description:\n        \"Fetch the full content of a single skill (writing-style / tone / \" +\n        \"formatting rule) by name. Use list_skills to discover available \" +\n        \"names.\",\n      inputSchema: getSkillInput.shape,\n    },\n    async (input) => handleGetSkill(skills, input),\n  )\n\n  return {\n    server,\n    deps: { contentDir, docs, categories, skillsDir, skills },\n  }\n}\n",
      "type": "registry:file",
      "target": "mcp/server.ts"
    },
    {
      "path": "registry/helpbase-mcp/content/loader.ts",
      "content": "import fs from \"node:fs\"\nimport path from \"node:path\"\nimport matter from \"gray-matter\"\n\nexport interface Doc {\n  slug: string\n  category: string\n  title: string\n  description: string\n  filePath: string\n  content: string\n}\n\nexport interface CategoryMeta {\n  slug: string\n  title: string\n  order: number\n}\n\n// More-specific candidates come first so `content/docs/` wins over a\n// sibling `content/` that might hold non-doc assets (blog posts, changelog\n// entries, marketing copy). `content/docs/` is a common MDX-in-subfolder\n// convention for docs-only content.\nconst CONTENT_DIR_CANDIDATES = [\n  \"apps/web/content\",\n  \"content/docs\",\n  \"content\",\n]\n\n/**\n * Find the content directory.\n *\n * Resolution order:\n *   1. HELPBASE_CONTENT_DIR env var (absolute or relative to cwd)\n *   2. Walk up from cwd trying each candidate in order:\n *      - `apps/web/content/` (monorepo shape)\n *      - `content/docs/`     (MDX-in-subfolder shape)\n *      - `content/`          (flat shape)\n *\n * Returns an absolute path. Throws if nothing is found — callers should let this\n * bubble up with a clear message rather than silently serving an empty index.\n */\nexport function findContentDir(startDir: string = process.cwd()): string {\n  const envOverride = process.env.HELPBASE_CONTENT_DIR\n  if (envOverride && envOverride.length > 0) {\n    const resolved = path.isAbsolute(envOverride)\n      ? envOverride\n      : path.resolve(startDir, envOverride)\n    if (!fs.existsSync(resolved)) {\n      throw new Error(\n        `HELPBASE_CONTENT_DIR points at ${resolved} but that directory does not exist.`,\n      )\n    }\n    return resolved\n  }\n\n  let dir = path.resolve(startDir)\n  const root = path.parse(dir).root\n  while (true) {\n    for (const candidate of CONTENT_DIR_CANDIDATES) {\n      const full = path.join(dir, candidate)\n      if (fs.existsSync(full) && fs.statSync(full).isDirectory()) {\n        return full\n      }\n    }\n    if (dir === root) break\n    dir = path.dirname(dir)\n  }\n\n  throw new Error(\n    `Could not find a content directory. Looked for ${CONTENT_DIR_CANDIDATES.join(\" or \")} walking up from ${startDir}. Set HELPBASE_CONTENT_DIR to point at your docs folder.`,\n  )\n}\n\nfunction deriveTitle(rawTitle: unknown, fallbackSlug: string): string {\n  if (typeof rawTitle === \"string\" && rawTitle.trim().length > 0) {\n    return rawTitle.trim()\n  }\n  return fallbackSlug\n    .split(\"-\")\n    .map((w) => (w.length > 0 ? w[0]!.toUpperCase() + w.slice(1) : w))\n    .join(\" \")\n}\n\nfunction deriveDescription(rawDesc: unknown): string {\n  if (typeof rawDesc === \"string\") return rawDesc.trim()\n  return \"\"\n}\n\n/**\n * Load all docs from the content directory.\n *\n * Shape expected:\n *   <content-dir>/<category-slug>/<doc-slug>.mdx\n *\n * Files prefixed with `_` are skipped (convention for _category.json, etc.).\n * Only .mdx and .md files are loaded.\n */\nexport function loadDocs(contentDir: string): Doc[] {\n  if (!fs.existsSync(contentDir)) return []\n\n  const docs: Doc[] = []\n  const categoryDirs = fs\n    .readdirSync(contentDir, { withFileTypes: true })\n    .filter((d) => d.isDirectory())\n    .filter((d) => !d.name.startsWith(\"_\"))\n\n  for (const dir of categoryDirs) {\n    const categorySlug = dir.name\n    const categoryPath = path.join(contentDir, categorySlug)\n    const files = fs\n      .readdirSync(categoryPath)\n      .filter((f) => !f.startsWith(\"_\"))\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 slug = file.replace(/\\.mdx?$/, \"\")\n\n      // Skip unreadable or malformed files with a stderr warning instead of\n      // crashing the server. A single bad frontmatter should not take down the\n      // MCP stream. Mirrors apps/web/lib/content.ts's lenient-in-dev posture.\n      let raw: string\n      try {\n        raw = fs.readFileSync(filePath, \"utf-8\")\n      } catch (err) {\n        const msg = err instanceof Error ? err.message : String(err)\n        process.stderr.write(\n          `[helpbase-mcp] Skipping ${categorySlug}/${file}: read failed (${msg})\\n`,\n        )\n        continue\n      }\n\n      let parsed: ReturnType<typeof matter>\n      try {\n        parsed = matter(raw)\n      } catch (err) {\n        const msg = err instanceof Error ? err.message : String(err)\n        process.stderr.write(\n          `[helpbase-mcp] Skipping ${categorySlug}/${file}: malformed frontmatter (${msg})\\n`,\n        )\n        continue\n      }\n\n      const { data, content } = parsed\n      docs.push({\n        slug,\n        category: categorySlug,\n        title: deriveTitle(data[\"title\"], slug),\n        description: deriveDescription(data[\"description\"]),\n        filePath: path.relative(contentDir, filePath),\n        content: content.trim(),\n      })\n    }\n  }\n\n  docs.sort((a, b) => {\n    if (a.category !== b.category) return a.category.localeCompare(b.category)\n    return a.slug.localeCompare(b.slug)\n  })\n\n  return docs\n}\n\n/**\n * Load category metadata (title, order) from `_category.json` files.\n * Missing files are fine — we derive a sensible default.\n */\nexport function loadCategories(contentDir: string): CategoryMeta[] {\n  if (!fs.existsSync(contentDir)) return []\n\n  const categories: CategoryMeta[] = []\n  const dirs = fs\n    .readdirSync(contentDir, { withFileTypes: true })\n    .filter((d) => d.isDirectory())\n    .filter((d) => !d.name.startsWith(\"_\"))\n\n  for (const dir of dirs) {\n    const metaPath = path.join(contentDir, dir.name, \"_category.json\")\n    let title = deriveTitle(undefined, dir.name)\n    let order = 999\n    if (fs.existsSync(metaPath)) {\n      try {\n        const parsed = JSON.parse(fs.readFileSync(metaPath, \"utf-8\"))\n        if (typeof parsed.title === \"string\") title = parsed.title\n        if (typeof parsed.order === \"number\") order = parsed.order\n      } catch {\n        // Malformed _category.json — use defaults, don't fail.\n      }\n    }\n    categories.push({ slug: dir.name, title, order })\n  }\n\n  categories.sort((a, b) => {\n    if (a.order !== b.order) return a.order - b.order\n    return a.slug.localeCompare(b.slug)\n  })\n  return categories\n}\n",
      "type": "registry:file",
      "target": "mcp/content/loader.ts"
    },
    {
      "path": "registry/helpbase-mcp/content/index.ts",
      "content": "import type { Doc } from \"./loader.js\"\n\nexport interface SearchHit {\n  doc: Doc\n  score: number\n}\n\n/**\n * Very small keyword-match search. Lowercases both sides, splits the query on\n * whitespace, scores each doc by:\n *   +5 per query term in title\n *   +3 per query term in description\n *   +1 per query term in body (capped at 5 occurrences to avoid term-spam boost)\n *\n * v1 ships with this intentionally simple ranker. Semantic search is TODO-011.\n */\nexport function searchDocs(docs: Doc[], query: string): SearchHit[] {\n  const terms = query\n    .toLowerCase()\n    .split(/\\s+/)\n    .map((t) => t.trim())\n    .filter((t) => t.length > 0)\n\n  if (terms.length === 0) return []\n\n  const hits: SearchHit[] = []\n  for (const doc of docs) {\n    const title = doc.title.toLowerCase()\n    const description = doc.description.toLowerCase()\n    const body = doc.content.toLowerCase()\n    let score = 0\n\n    for (const term of terms) {\n      if (title.includes(term)) score += 5\n      if (description.includes(term)) score += 3\n      const bodyMatches = countOccurrences(body, term)\n      score += Math.min(bodyMatches, 5)\n    }\n\n    if (score > 0) hits.push({ doc, score })\n  }\n\n  hits.sort((a, b) => b.score - a.score)\n  return hits\n}\n\nfunction countOccurrences(haystack: string, needle: string): number {\n  if (needle.length === 0) return 0\n  let count = 0\n  let idx = 0\n  while ((idx = haystack.indexOf(needle, idx)) !== -1) {\n    count += 1\n    idx += needle.length\n  }\n  return count\n}\n",
      "type": "registry:file",
      "target": "mcp/content/index.ts"
    },
    {
      "path": "registry/helpbase-mcp/tools/search-docs.ts",
      "content": "import { z } from \"zod\"\nimport { searchDocs } from \"../content/index.js\"\nimport type { Doc } from \"../content/loader.js\"\n\nexport const searchDocsInput = z.object({\n  query: z.string().min(1, \"query must not be empty\"),\n  limit: z.number().int().positive().max(50).optional(),\n})\n\nexport type SearchDocsInput = z.infer<typeof searchDocsInput>\n\nexport function handleSearchDocs(docs: Doc[], input: SearchDocsInput) {\n  const limit = input.limit ?? 10\n  const hits = searchDocs(docs, input.query).slice(0, limit)\n\n  if (hits.length === 0) {\n    return {\n      content: [\n        {\n          type: \"text\" as const,\n          text: `No docs matched \"${input.query}\".`,\n        },\n      ],\n    }\n  }\n\n  const lines = hits.map((hit) => {\n    const d = hit.doc\n    const desc = d.description ? ` — ${d.description}` : \"\"\n    return `- [${d.category}/${d.slug}] ${d.title}${desc}`\n  })\n\n  return {\n    content: [\n      {\n        type: \"text\" as const,\n        text: `Found ${hits.length} doc(s):\\n${lines.join(\"\\n\")}`,\n      },\n    ],\n  }\n}\n",
      "type": "registry:file",
      "target": "mcp/tools/search-docs.ts"
    },
    {
      "path": "registry/helpbase-mcp/tools/get-doc.ts",
      "content": "import { z } from \"zod\"\nimport type { Doc } from \"../content/loader.js\"\n\nexport const getDocInput = z.object({\n  slug: z\n    .string()\n    .min(1, \"slug must not be empty\")\n    .describe(\"Either 'category/slug' or just 'slug' (first match wins)\"),\n})\n\nexport type GetDocInput = z.infer<typeof getDocInput>\n\nexport function handleGetDoc(docs: Doc[], input: GetDocInput) {\n  const raw = input.slug.trim()\n  const [categoryPart, slugPart] = raw.includes(\"/\")\n    ? raw.split(\"/\", 2)\n    : [undefined, raw]\n\n  const match = docs.find((d) => {\n    if (categoryPart !== undefined && slugPart !== undefined) {\n      return d.category === categoryPart && d.slug === slugPart\n    }\n    return d.slug === slugPart\n  })\n\n  if (!match) {\n    return {\n      isError: true,\n      content: [\n        {\n          type: \"text\" as const,\n          text: `No doc found for \"${raw}\". Use list_docs to see available slugs.`,\n        },\n      ],\n    }\n  }\n\n  const header = [\n    `# ${match.title}`,\n    match.description ? `> ${match.description}` : \"\",\n    `Path: ${match.category}/${match.slug}`,\n    \"\",\n  ]\n    .filter((l) => l.length > 0)\n    .join(\"\\n\")\n\n  return {\n    content: [\n      {\n        type: \"text\" as const,\n        text: `${header}\\n\\n${match.content}`,\n      },\n    ],\n  }\n}\n",
      "type": "registry:file",
      "target": "mcp/tools/get-doc.ts"
    },
    {
      "path": "registry/helpbase-mcp/tools/list-docs.ts",
      "content": "import { z } from \"zod\"\nimport type { Doc, CategoryMeta } from \"../content/loader.js\"\n\nexport const listDocsInput = z.object({\n  category: z\n    .string()\n    .optional()\n    .describe(\"Filter to a single category slug. Omit to list everything.\"),\n})\n\nexport type ListDocsInput = z.infer<typeof listDocsInput>\n\nexport function handleListDocs(\n  docs: Doc[],\n  categories: CategoryMeta[],\n  input: ListDocsInput,\n) {\n  const filter = input.category?.trim()\n  const visible = filter ? docs.filter((d) => d.category === filter) : docs\n\n  if (visible.length === 0) {\n    return {\n      content: [\n        {\n          type: \"text\" as const,\n          text: filter\n            ? `No docs found in category \"${filter}\".`\n            : \"No docs available.\",\n        },\n      ],\n    }\n  }\n\n  const categoryOrder = new Map(categories.map((c, i) => [c.slug, i]))\n  const categoryTitles = new Map(categories.map((c) => [c.slug, c.title]))\n\n  const groups = new Map<string, Doc[]>()\n  for (const d of visible) {\n    const existing = groups.get(d.category) ?? []\n    existing.push(d)\n    groups.set(d.category, existing)\n  }\n\n  const sortedCats = Array.from(groups.keys()).sort((a, b) => {\n    const oa = categoryOrder.get(a) ?? 999\n    const ob = categoryOrder.get(b) ?? 999\n    if (oa !== ob) return oa - ob\n    return a.localeCompare(b)\n  })\n\n  const lines: string[] = []\n  for (const cat of sortedCats) {\n    const title = categoryTitles.get(cat) ?? cat\n    lines.push(`## ${title}`)\n    for (const doc of groups.get(cat)!) {\n      const desc = doc.description ? ` — ${doc.description}` : \"\"\n      lines.push(`- ${cat}/${doc.slug}: ${doc.title}${desc}`)\n    }\n    lines.push(\"\")\n  }\n\n  return {\n    content: [\n      {\n        type: \"text\" as const,\n        text: lines.join(\"\\n\").trim(),\n      },\n    ],\n  }\n}\n",
      "type": "registry:file",
      "target": "mcp/tools/list-docs.ts"
    },
    {
      "path": "registry/helpbase-mcp/content/skills.ts",
      "content": "import fs from \"node:fs\"\nimport path from \"node:path\"\nimport matter from \"gray-matter\"\n\n/**\n * A skill is a markdown file living under `.helpbase/skills/` in the user's\n * repo. It encodes a rule, convention, or style guide that an AI agent\n * should pull when authoring content for this product — tone/voice,\n * formatting standards, terminology, whatever the docs team wants\n * enforced.\n *\n * Served via the MCP tools `list_skills` and `get_skill`. The docs team\n * edits the files in git; downstream agents and tools read them over the\n * wire. No schema migration, no dashboard — markdown wins.\n *\n * This is v1 of the \"skills server\" Shadcn asked about on 2026-04-22:\n * https://x.com/shadcn/... (\"interesting to see if this can also be a\n * skills server... enforcing tone, writing styles... editable by the\n * docs team... pulled by other teams\").\n */\nexport interface Skill {\n  /** Filename without the .md extension. Used as the identifier. */\n  name: string\n  /** Frontmatter `description`, or empty string if absent. */\n  description: string\n  /** Full post-frontmatter body. */\n  content: string\n  /** Absolute path, for diagnostics. */\n  filePath: string\n}\n\n/**\n * Find the `.helpbase/skills/` directory.\n *\n * Resolution order:\n *   1. HELPBASE_SKILLS_DIR env var (absolute or relative to cwd)\n *   2. Walk up from cwd looking for `.helpbase/skills/`\n *\n * Returns `null` when no skills directory is found. Unlike findContentDir\n * (which throws), skills are OPTIONAL — a repo without skills should\n * surface an empty list, not crash the server.\n */\nexport function findSkillsDir(startDir: string = process.cwd()): string | null {\n  const envOverride = process.env.HELPBASE_SKILLS_DIR\n  if (envOverride && envOverride.length > 0) {\n    const resolved = path.isAbsolute(envOverride)\n      ? envOverride\n      : path.resolve(startDir, envOverride)\n    if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {\n      // Explicit override that doesn't exist IS an error — loud signal\n      // that the user's env is misconfigured rather than silently empty.\n      throw new Error(\n        `HELPBASE_SKILLS_DIR points at ${resolved} but that directory does not exist.`,\n      )\n    }\n    return resolved\n  }\n\n  let dir = path.resolve(startDir)\n  const root = path.parse(dir).root\n  while (true) {\n    const candidate = path.join(dir, \".helpbase\", \"skills\")\n    if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {\n      return candidate\n    }\n    if (dir === root) return null\n    dir = path.dirname(dir)\n  }\n}\n\n/**\n * Load all skills from `.helpbase/skills/*.md`.\n *\n * Files prefixed with `_` are treated as drafts and skipped (matches the\n * content/docs convention). Only top-level `.md` files are loaded — no\n * subdirectories, no grouping. Keep the layout flat.\n *\n * Malformed frontmatter logs to stderr and skips the file rather than\n * crashing the server, mirroring loader.ts's lenient posture.\n */\nexport function loadSkills(skillsDir: string | null): Skill[] {\n  if (!skillsDir) return []\n  if (!fs.existsSync(skillsDir)) return []\n\n  const entries = fs\n    .readdirSync(skillsDir, { withFileTypes: true })\n    .filter((e) => e.isFile())\n    .filter((e) => !e.name.startsWith(\"_\"))\n    .filter((e) => e.name.endsWith(\".md\"))\n\n  const skills: Skill[] = []\n  for (const entry of entries) {\n    const filePath = path.join(skillsDir, entry.name)\n    const name = entry.name.replace(/\\.md$/, \"\")\n\n    let raw: string\n    try {\n      raw = fs.readFileSync(filePath, \"utf-8\")\n    } catch (err) {\n      const msg = err instanceof Error ? err.message : String(err)\n      process.stderr.write(\n        `[helpbase-mcp] Skipping skill ${entry.name}: read failed (${msg})\\n`,\n      )\n      continue\n    }\n\n    let parsed: ReturnType<typeof matter>\n    try {\n      parsed = matter(raw)\n    } catch (err) {\n      const msg = err instanceof Error ? err.message : String(err)\n      process.stderr.write(\n        `[helpbase-mcp] Skipping skill ${entry.name}: malformed frontmatter (${msg})\\n`,\n      )\n      continue\n    }\n\n    const description =\n      typeof parsed.data[\"description\"] === \"string\"\n        ? String(parsed.data[\"description\"]).trim()\n        : \"\"\n\n    skills.push({\n      name,\n      description,\n      content: parsed.content.trim(),\n      filePath,\n    })\n  }\n\n  skills.sort((a, b) => a.name.localeCompare(b.name))\n  return skills\n}\n",
      "type": "registry:file",
      "target": "mcp/content/skills.ts"
    },
    {
      "path": "registry/helpbase-mcp/tools/list-skills.ts",
      "content": "import { z } from \"zod\"\nimport type { Skill } from \"../content/skills.js\"\n\nexport const listSkillsInput = z.object({}).describe(\"No inputs.\")\n\nexport type ListSkillsInput = z.infer<typeof listSkillsInput>\n\nexport function handleListSkills(skills: Skill[]) {\n  if (skills.length === 0) {\n    return {\n      content: [\n        {\n          type: \"text\" as const,\n          text:\n            \"No skills defined. Add markdown files to .helpbase/skills/ to \" +\n            \"define writing-style, tone, or formatting rules that agents \" +\n            \"can pull via get_skill.\",\n        },\n      ],\n    }\n  }\n\n  const lines: string[] = [\"## Skills\", \"\"]\n  for (const s of skills) {\n    const desc = s.description ? ` — ${s.description}` : \"\"\n    lines.push(`- ${s.name}${desc}`)\n  }\n  lines.push(\"\", \"Fetch a skill via get_skill({ name }).\")\n\n  return {\n    content: [\n      {\n        type: \"text\" as const,\n        text: lines.join(\"\\n\"),\n      },\n    ],\n  }\n}\n",
      "type": "registry:file",
      "target": "mcp/tools/list-skills.ts"
    },
    {
      "path": "registry/helpbase-mcp/tools/get-skill.ts",
      "content": "import { z } from \"zod\"\nimport type { Skill } from \"../content/skills.js\"\n\nexport const getSkillInput = z.object({\n  name: z\n    .string()\n    .min(1, \"name must not be empty\")\n    .describe(\"Skill name (filename in .helpbase/skills/ without .md extension).\"),\n})\n\nexport type GetSkillInput = z.infer<typeof getSkillInput>\n\nexport function handleGetSkill(skills: Skill[], input: GetSkillInput) {\n  const name = input.name.trim()\n  const match = skills.find((s) => s.name === name)\n\n  if (!match) {\n    const available =\n      skills.length > 0\n        ? ` Available: ${skills.map((s) => s.name).join(\", \")}.`\n        : \" No skills are currently defined.\"\n    return {\n      isError: true,\n      content: [\n        {\n          type: \"text\" as const,\n          text: `No skill found named \"${name}\".${available}`,\n        },\n      ],\n    }\n  }\n\n  const header = [\n    `# ${match.name}`,\n    match.description ? `> ${match.description}` : \"\",\n  ]\n    .filter((l) => l.length > 0)\n    .join(\"\\n\")\n\n  return {\n    content: [\n      {\n        type: \"text\" as const,\n        text: `${header}\\n\\n${match.content}`,\n      },\n    ],\n  }\n}\n",
      "type": "registry:file",
      "target": "mcp/tools/get-skill.ts"
    },
    {
      "path": "registry/helpbase-mcp/README.md",
      "content": "# Helpbase MCP Server\n\nSelf-hosted Model Context Protocol server for your docs. Runs as source code\nin your repo (no vendored npm binary), reads your MDX, exposes five tools\nover stdio to any MCP client:\n\n- `search_docs` / `get_doc` / `list_docs` — docs surface\n- `list_skills` / `get_skill` — writing-style / tone / formatting rules\n  the docs team publishes in `.helpbase/skills/*.md`\n\n## Run it\n\nAfter `npx shadcn add`, you have the server source at `mcp/` and `tsx` in\nyour devDependencies. Set the content directory and start the server.\n\n### Claude Desktop\n\nEdit `~/Library/Application Support/Claude/claude_desktop_config.json`:\n\n```json\n{\n  \"mcpServers\": {\n    \"helpbase\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"tsx\", \"/absolute/path/to/your/repo/mcp/index.ts\"],\n      \"env\": {\n        \"HELPBASE_CONTENT_DIR\": \"/absolute/path/to/your/repo/apps/web/content\"\n      }\n    }\n  }\n}\n```\n\nRestart Claude Desktop. The three tools appear in the tool picker.\n\n### Cursor / Zed / Windsurf / any MCP client\n\nSame pattern: point the client at `npx tsx <absolute-path>/mcp/index.ts` with\n`HELPBASE_CONTENT_DIR` in the env block.\n\n### Local test\n\n```bash\nHELPBASE_CONTENT_DIR=./apps/web/content npx tsx mcp/index.ts\n# sends bootstrap line to stderr, speaks JSON-RPC over stdin/stdout\n```\n\n## Content discovery\n\nIf `HELPBASE_CONTENT_DIR` is not set, the server walks up from its cwd looking\nfor (in order):\n\n- `apps/web/content/` — monorepo shape\n- `content/docs/` — MDX-in-subfolder shape (docs alongside blog, changelog, etc.)\n- `content/` — flat shape\n\nThe first match wins. If none exists, the server fails on startup with a clear\nerror — no silent empty index.\n\n## Skills discovery\n\nSkills are optional. If you want to publish writing-style, tone, or formatting\nrules that agents can pull on demand, drop markdown files under\n`.helpbase/skills/<name>.md` at your repo root:\n\n```\n.helpbase/skills/\n  voice.md          ← tone + writing style\n  api-reference.md  ← formatting rules for API docs\n  terminology.md    ← preferred terms, avoided words\n```\n\nFrontmatter is optional. A `description` field shows up in `list_skills`\noutput; the body is the full skill content returned by `get_skill`.\n\nOverride the location via `HELPBASE_SKILLS_DIR`. Walks up from cwd to find\n`.helpbase/skills/` otherwise. No skills found → empty list, not an error.\n\n## Why ship as source instead of an npm dep?\n\nBecause it's yours. Your MCP server lives in your repo. No vendor sits between\nyour docs and the agents that read them. Edit it, fork it, extend the tools,\nadd your own — it's code you own.\n\nIf you want the zero-config path instead, `npm i @helpbase/mcp` gets you the\nsame server as a published binary. Both paths are supported; this one just\nkeeps the code in your hands.\n",
      "type": "registry:file",
      "target": "mcp/README.md"
    },
    {
      "path": "registry/helpbase-workflow/helpbase-sync.yml",
      "content": "name: helpbase sync\n\n# Keep docs in sync with code. Runs weekly and on every push to the base\n# branch. When a code change would make an existing MDX doc wrong,\n# `helpbase sync` proposes citation-grounded edits and this workflow opens\n# a PR you can review.\n#\n# Zero config. Auth happens via GitHub Actions OIDC — the workflow\n# requests a short-lived JWT scoped to helpbase.dev, and the helpbase\n# backend verifies it against GitHub's JWKS. No secrets to paste, no\n# `helpbase login` step, no tokens to rotate. Per-repo quota (500k\n# tokens/day free tier) is tracked by GitHub repository_id so quota\n# follows the repo across renames + org transfers.\n#\n# BYOK override: if you'd rather run on your own provider key (e.g.\n# Anthropic / OpenAI / Vercel AI Gateway), set the corresponding secret\n# under `env:` below — it short-circuits OIDC and calls your provider\n# directly. Everything still runs in *your* Actions minutes.\n\non:\n  schedule:\n    # Monday 09:00 UTC — adjust to your timezone's start-of-week.\n    - cron: \"0 9 * * 1\"\n  push:\n    branches: [main]\n  workflow_dispatch: {}\n\njobs:\n  sync:\n    name: Propose doc updates\n    runs-on: ubuntu-latest\n    permissions:\n      contents: write\n      pull-requests: write\n      # Required to mint the GitHub OIDC token. Tokens are minted per-job\n      # and scoped to the audience below — `https://helpbase.dev` is a\n      # stable identifier hardcoded in the helpbase backend verifier.\n      id-token: write\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          # Fetch enough history for the diff baseline below. github.event.before\n          # points at the prior tip on push events (usually 1 commit back), but\n          # on schedule/manual runs we fall back to HEAD~1 which requires\n          # history beyond the default shallow clone.\n          fetch-depth: 50\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: \"20\"\n\n      - name: Request GitHub OIDC token\n        id: oidc\n        uses: actions/github-script@v7\n        with:\n          # Matches HELPBASE_OIDC_AUDIENCE in apps/web/lib/oidc-verify.ts.\n          # Do NOT change unless you're also updating the helpbase backend.\n          script: |\n            const token = await core.getIDToken(\"https://helpbase.dev\")\n            core.setSecret(token)\n            core.setOutput(\"token\", token)\n\n      - name: Run helpbase sync\n        env:\n          # GitHub OIDC JWT, zero-config path. The helpbase backend verifies\n          # it against GitHub's JWKS and allocates quota per-repo.\n          HELPBASE_CI_TOKEN: ${{ steps.oidc.outputs.token }}\n          # Optional BYOK overrides. If any of these are set, the CLI\n          # bypasses the helpbase proxy and calls the provider directly.\n          # Leave unset for the zero-config OIDC path.\n          AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }}\n          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}\n          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}\n        run: |\n          npx -y helpbase sync \\\n            --since ${{ github.event.before || 'HEAD~1' }} \\\n            --apply \\\n            --yes\n\n      - name: Open PR if docs changed\n        env:\n          GH_TOKEN: ${{ github.token }}\n        run: |\n          if [ -z \"$(git status --porcelain)\" ]; then\n            echo \"No doc updates proposed — nothing to PR.\"\n            exit 0\n          fi\n\n          BRANCH=\"helpbase-sync/$(date +%Y%m%d-%H%M%S)\"\n          git config user.name \"helpbase-sync\"\n          git config user.email \"helpbase-sync@users.noreply.github.com\"\n          git checkout -b \"$BRANCH\"\n          git add -A\n          git commit -m \"docs: sync with codebase ($(date +%Y-%m-%d))\"\n          git push origin \"$BRANCH\"\n\n          # `gh pr create` needs \"Allow GitHub Actions to create and\n          # approve pull requests\" enabled under\n          # Settings → Actions → General → Workflow permissions.\n          # It's OFF by default on new repos / orgs. If the PR-create\n          # call 403s on that setting, don't fail the workflow — the\n          # branch is already pushed with the proposed doc update;\n          # the user just needs to open the PR manually (or flip the\n          # setting once).\n          PR_LOG=$(mktemp)\n          if ! gh pr create \\\n                --base \"${GITHUB_REF_NAME}\" \\\n                --head \"$BRANCH\" \\\n                --title \"docs: sync with codebase ($(date +%Y-%m-%d))\" \\\n                --body \"Automated proposal from \\`helpbase sync\\`. Every change cites specific lines of source code — check the diff before merging.\" 2>\"$PR_LOG\"; then\n            if grep -qiE \"not permitted|Resource not accessible\" \"$PR_LOG\"; then\n              echo \"::warning::GitHub Actions is not permitted to open PRs on this repo.\"\n              echo \"::warning::Branch was pushed — open the PR manually:\"\n              echo \"::warning::  https://github.com/${GITHUB_REPOSITORY}/pull/new/${BRANCH}\"\n              echo \"::warning::To auto-open future PRs, enable the 'create and approve pull requests' permission at:\"\n              echo \"::warning::  https://github.com/${GITHUB_REPOSITORY}/settings/actions\"\n              exit 0\n            fi\n            # Unknown failure — re-surface the error, fail the workflow.\n            cat \"$PR_LOG\" >&2\n            exit 1\n          fi\n",
      "type": "registry:file",
      "target": ".github/workflows/helpbase-sync.yml"
    },
    {
      "path": "registry/helpbase-workflow/README.md",
      "content": "# helpbase-workflow\n\nDrop-in GitHub Actions workflow that runs `helpbase sync` on a schedule and\non every push to your main branch. When a code change would make an MDX\ndoc wrong, the workflow opens a PR with the proposed update, grounded in\ncitations into your source.\n\nZero config. No secrets to set. Auth happens via GitHub Actions OIDC.\n\n## Install\n\n```bash\nnpx shadcn add https://helpbase.dev/r/helpbase-workflow.json\n```\n\nThat drops a single file into your repo:\n\n```\n.github/workflows/helpbase-sync.yml\n```\n\nPush the workflow. First scheduled run (or push to `main`) triggers it.\nThat's it.\n\n## One-time setup: let Actions open PRs\n\nGitHub's default setting blocks Actions from opening pull requests. If\nyou don't flip this once, helpbase will still push a branch with the\nproposed docs update on every run, but won't open the PR for you — you'll\nsee a warning in the Action log pointing at the branch URL to open it\nmanually.\n\nTo enable auto-PR, visit:\n\n```\nSettings → Actions → General → Workflow permissions\n  [x] Read and write permissions\n  [x] Allow GitHub Actions to create and approve pull requests\n```\n\nOr via the CLI:\n\n```bash\ngh api --method PUT repos/{owner}/{repo}/actions/permissions/workflow \\\n  -F default_workflow_permissions=write \\\n  -F can_approve_pull_request_reviews=true\n```\n\nFor org-owned repos, this might already be set at the organization level.\n\n## How the auth works\n\nWhen the action runs, GitHub mints a short-lived JSON Web Token that\nidentifies which repository is calling. The workflow passes that token\nto the helpbase backend, which verifies it against GitHub's public keys\nand allocates quota to your repository. Per-repo free tier is 500,000\ntokens/day, reset at UTC midnight. Quota is keyed on the GitHub numeric\n`repository_id`, so it follows the repo across renames and org transfers.\n\nNo token is stored on your side. Each CI run mints a fresh one scoped to\nthis workflow, valid for ~6 minutes, bound to this specific repo.\n\n## BYOK override\n\nIf you'd rather use your own LLM provider account (unlimited, your bill,\nyour choice of model routing), set any of these as a repo secret:\n\n- `AI_GATEWAY_API_KEY` — Vercel AI Gateway\n- `ANTHROPIC_API_KEY`\n- `OPENAI_API_KEY`\n\nWhen any of these is set, the CLI skips the helpbase proxy entirely and\ncalls your provider directly. First env var wins.\n\n## Customize\n\nThe workflow ships with sensible defaults you'll likely want to tweak:\n\n- **Schedule** — default is Monday 09:00 UTC (`cron: \"0 9 * * 1\"`). Edit\n  the `cron` line to match when your team starts the week.\n- **Base branch** — default assumes `main`. If you use `master`, `trunk`,\n  or something else, update `branches: [main]` under `push:`. The diff\n  baseline (`--since`) uses `github.event.before` on push events, so no\n  other branch-name reference needs to change.\n- **Content directory** — zero-config for the three common MDX layouts:\n  `content/` (flat), `content/docs/` (MDX-in-subfolder), and\n  `apps/web/content/` (monorepo). `helpbase sync` walks up from the\n  repo root and picks the first match. If your docs live elsewhere,\n  append `--content <path>` to the `helpbase sync` command, or set\n  `HELPBASE_CONTENT_DIR` in the job's env.\n\n## What the PR looks like\n\nEvery edit in the generated PR carries a `citations` trailer pointing at\nspecific lines of source code that justified the change. Review those\ncitations first — if a citation is missing or wrong, reject the PR; the\nschema makes this rare but not impossible.\n\n## Run locally\n\nTo preview what the workflow would propose, run the same command on your\nmachine:\n\n```bash\n# With your own key:\nAI_GATEWAY_API_KEY=your_key_here \\\n  npx helpbase sync --since origin/main --dry-run\n\n# Or with your logged-in session:\nhelpbase login\nnpx helpbase sync --since origin/main --dry-run\n```\n\nDrop `--dry-run` when you're ready to see the real proposals.\n\n## Upgrading from v0.7 or earlier\n\nEarlier versions of this workflow required you to set `HELPBASE_TOKEN` or\n`AI_GATEWAY_API_KEY` as a repo secret. v0.8+ uses GitHub OIDC by default\nand no secret is needed.\n\nTo upgrade: re-run `npx shadcn@latest add https://helpbase.dev/r/helpbase-workflow.json`.\nIt overwrites the YAML. Then go to `Settings → Secrets and variables →\nActions` and delete `HELPBASE_TOKEN` if you set it before — it's no\nlonger read by the workflow.\n",
      "type": "registry:file",
      "target": ".github/workflows/helpbase-sync.README.md"
    }
  ],
  "envVars": {
    "HELPBASE_CONTENT_DIR": ""
  },
  "type": "registry:block"
}