@sntlr/registry-shell 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +200 -0
  3. package/dist/adapter/custom.d.ts +47 -0
  4. package/dist/adapter/custom.js +53 -0
  5. package/dist/adapter/custom.js.map +1 -0
  6. package/dist/adapter/default.d.ts +40 -0
  7. package/dist/adapter/default.js +202 -0
  8. package/dist/adapter/default.js.map +1 -0
  9. package/dist/cli/build.d.ts +1 -0
  10. package/dist/cli/build.js +31 -0
  11. package/dist/cli/build.js.map +1 -0
  12. package/dist/cli/dev.d.ts +1 -0
  13. package/dist/cli/dev.js +26 -0
  14. package/dist/cli/dev.js.map +1 -0
  15. package/dist/cli/index.d.ts +12 -0
  16. package/dist/cli/index.js +49 -0
  17. package/dist/cli/index.js.map +1 -0
  18. package/dist/cli/init.d.ts +1 -0
  19. package/dist/cli/init.js +70 -0
  20. package/dist/cli/init.js.map +1 -0
  21. package/dist/cli/shared.d.ts +33 -0
  22. package/dist/cli/shared.js +278 -0
  23. package/dist/cli/shared.js.map +1 -0
  24. package/dist/cli/start.d.ts +1 -0
  25. package/dist/cli/start.js +24 -0
  26. package/dist/cli/start.js.map +1 -0
  27. package/dist/config-loader.d.ts +49 -0
  28. package/dist/config-loader.js +140 -0
  29. package/dist/define-config.d.ts +188 -0
  30. package/dist/define-config.js +21 -0
  31. package/dist/index.d.ts +11 -0
  32. package/dist/index.js +9 -0
  33. package/package.json +124 -0
  34. package/src/adapter/custom.ts +90 -0
  35. package/src/adapter/default.ts +241 -0
  36. package/src/cli/build.ts +38 -0
  37. package/src/cli/dev.ts +38 -0
  38. package/src/cli/index.ts +52 -0
  39. package/src/cli/init.ts +76 -0
  40. package/src/cli/shared.ts +306 -0
  41. package/src/cli/start.ts +28 -0
  42. package/src/config-loader.ts +190 -0
  43. package/src/define-config.ts +206 -0
  44. package/src/index.ts +17 -0
  45. package/src/next-app/app/[...asset]/route.ts +81 -0
  46. package/src/next-app/app/_user-global.css +6 -0
  47. package/src/next-app/app/_user-sources.css +9 -0
  48. package/src/next-app/app/a11y/[name]/route.ts +19 -0
  49. package/src/next-app/app/api/search-index/route.ts +19 -0
  50. package/src/next-app/app/components/[name]/page.tsx +61 -0
  51. package/src/next-app/app/components/layout.tsx +18 -0
  52. package/src/next-app/app/docs/[slug]/page.tsx +53 -0
  53. package/src/next-app/app/docs/layout.tsx +18 -0
  54. package/src/next-app/app/globals.css +329 -0
  55. package/src/next-app/app/layout.tsx +102 -0
  56. package/src/next-app/app/page.tsx +9 -0
  57. package/src/next-app/app/preview-snapshot/[name]/page.tsx +20 -0
  58. package/src/next-app/app/preview-snapshot/layout.tsx +17 -0
  59. package/src/next-app/app/props/[name]/route.ts +19 -0
  60. package/src/next-app/app/r/[name]/route.ts +14 -0
  61. package/src/next-app/app/tests/[name]/route.ts +19 -0
  62. package/src/next-app/components/a11y-info.tsx +287 -0
  63. package/src/next-app/components/a11y-provider.tsx +39 -0
  64. package/src/next-app/components/component-breadcrumb.tsx +55 -0
  65. package/src/next-app/components/component-icon.tsx +140 -0
  66. package/src/next-app/components/component-preview.tsx +13 -0
  67. package/src/next-app/components/component-tabs.tsx +209 -0
  68. package/src/next-app/components/docs-toc.tsx +86 -0
  69. package/src/next-app/components/global-mobile-sidebar.tsx +35 -0
  70. package/src/next-app/components/header.tsx +188 -0
  71. package/src/next-app/components/heading-anchor.tsx +52 -0
  72. package/src/next-app/components/homepage-demo.tsx +180 -0
  73. package/src/next-app/components/locale-toggle.tsx +35 -0
  74. package/src/next-app/components/localized-mdx-client.tsx +14 -0
  75. package/src/next-app/components/localized-mdx.tsx +27 -0
  76. package/src/next-app/components/mobile-sidebar.tsx +22 -0
  77. package/src/next-app/components/nav-data-provider.tsx +37 -0
  78. package/src/next-app/components/navigation-progress.tsx +62 -0
  79. package/src/next-app/components/preview-canvas.tsx +368 -0
  80. package/src/next-app/components/preview-controls.tsx +94 -0
  81. package/src/next-app/components/preview-layout.tsx +218 -0
  82. package/src/next-app/components/props-table.tsx +134 -0
  83. package/src/next-app/components/resizable-preview.tsx +101 -0
  84. package/src/next-app/components/search.tsx +177 -0
  85. package/src/next-app/components/settings-modal.tsx +98 -0
  86. package/src/next-app/components/shell-ui/accordion.tsx +70 -0
  87. package/src/next-app/components/shell-ui/backdrop.tsx +29 -0
  88. package/src/next-app/components/shell-ui/badge.tsx +55 -0
  89. package/src/next-app/components/shell-ui/breadcrumb.tsx +120 -0
  90. package/src/next-app/components/shell-ui/button.tsx +64 -0
  91. package/src/next-app/components/shell-ui/card.tsx +127 -0
  92. package/src/next-app/components/shell-ui/checkbox.tsx +33 -0
  93. package/src/next-app/components/shell-ui/dialog.tsx +171 -0
  94. package/src/next-app/components/shell-ui/empty-state.tsx +66 -0
  95. package/src/next-app/components/shell-ui/input.tsx +27 -0
  96. package/src/next-app/components/shell-ui/kbd.tsx +30 -0
  97. package/src/next-app/components/shell-ui/label.tsx +25 -0
  98. package/src/next-app/components/shell-ui/select.tsx +204 -0
  99. package/src/next-app/components/shell-ui/separator.tsx +32 -0
  100. package/src/next-app/components/shell-ui/skeleton.tsx +18 -0
  101. package/src/next-app/components/shell-ui/table.tsx +124 -0
  102. package/src/next-app/components/shell-ui/tabs.tsx +102 -0
  103. package/src/next-app/components/shell-ui/toggle.tsx +56 -0
  104. package/src/next-app/components/sidebar-layout.tsx +37 -0
  105. package/src/next-app/components/sidebar-provider.tsx +75 -0
  106. package/src/next-app/components/sidebar.tsx +222 -0
  107. package/src/next-app/components/snapshot-preview.tsx +28 -0
  108. package/src/next-app/components/test-info.tsx +155 -0
  109. package/src/next-app/components/theme-provider.tsx +16 -0
  110. package/src/next-app/components/theme-toggle.tsx +21 -0
  111. package/src/next-app/components/translated-text.tsx +8 -0
  112. package/src/next-app/fallback/homepage.tsx +112 -0
  113. package/src/next-app/fallback/previews.ts +17 -0
  114. package/src/next-app/hooks/use-active-section.ts +23 -0
  115. package/src/next-app/hooks/use-controls.ts +72 -0
  116. package/src/next-app/hooks/use-mobile.ts +19 -0
  117. package/src/next-app/lib/branding.ts +52 -0
  118. package/src/next-app/lib/components-nav.ts +8 -0
  119. package/src/next-app/lib/docs.ts +16 -0
  120. package/src/next-app/lib/github.ts +38 -0
  121. package/src/next-app/lib/i18n.tsx +630 -0
  122. package/src/next-app/lib/locales.ts +17 -0
  123. package/src/next-app/lib/preview-loader.ts +7 -0
  124. package/src/next-app/lib/registry-adapter.ts +199 -0
  125. package/src/next-app/lib/utils.ts +6 -0
  126. package/src/next-app/next-env.d.ts +6 -0
  127. package/src/next-app/next.config.ts +101 -0
  128. package/src/next-app/postcss.config.mjs +7 -0
  129. package/src/next-app/public/favicon.ico +0 -0
  130. package/src/next-app/public/favicon_dark.svg +3 -0
  131. package/src/next-app/public/favicon_light.svg +3 -0
  132. package/src/next-app/registry.config.ts +50 -0
  133. package/src/next-app/tsconfig.json +29 -0
  134. package/src/next-app/user-aliases.d.ts +17 -0
@@ -0,0 +1,209 @@
1
+ "use client"
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react"
4
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@shell/components/shell-ui/tabs"
5
+ import { PropsTable } from "@shell/components/props-table"
6
+ import { A11yInfo } from "@shell/components/a11y-info"
7
+ import { EmptyState, EmptyStateIcon, EmptyStateTitle, EmptyStateDescription } from "@shell/components/shell-ui/empty-state"
8
+ import { TestInfo } from "@shell/components/test-info"
9
+ import { useTranslations } from "@shell/lib/i18n"
10
+ import { branding } from "@shell/lib/branding"
11
+ import { FileText, Link as LinkIcon } from "lucide-react"
12
+
13
+ const DEFAULT_INSTALL_TEMPLATE = "npx shadcn@latest add {siteUrl}/r/{name}.json"
14
+
15
+ function renderInstallCommand(name: string): string {
16
+ const template = process.env.NEXT_PUBLIC_SHELL_INSTALL_CMD ?? DEFAULT_INSTALL_TEMPLATE
17
+ return template
18
+ .replace(/\{name\}/g, name)
19
+ .replace(/\{siteUrl\}/g, branding.siteUrl.replace(/\/$/, ""))
20
+ }
21
+
22
+ interface ComponentTabsProps {
23
+ name: string
24
+ source: string | null
25
+ }
26
+
27
+ interface TocEntry {
28
+ id: string
29
+ text: string
30
+ level: number
31
+ }
32
+
33
+ function useTocFromContent(containerRef: React.RefObject<HTMLDivElement | null>, activeTab: string) {
34
+ const [entries, setEntries] = useState<TocEntry[]>([])
35
+
36
+ const scan = useCallback(() => {
37
+ const el = containerRef.current
38
+ if (!el) return
39
+ const headings = el.querySelectorAll<HTMLHeadingElement>("h3[id], h4[id]")
40
+ const items: TocEntry[] = []
41
+ headings.forEach((h) => {
42
+ items.push({
43
+ id: h.id,
44
+ text: h.textContent ?? "",
45
+ level: h.tagName === "H3" ? 3 : 4,
46
+ })
47
+ })
48
+ setEntries(items)
49
+ }, [containerRef])
50
+
51
+ useEffect(() => {
52
+ // Small delay to let tab content render
53
+ const t = setTimeout(scan, 50)
54
+ return () => clearTimeout(t)
55
+ }, [activeTab, scan])
56
+
57
+ return entries
58
+ }
59
+
60
+ function SectionHeading({ id, children }: { id: string; children: React.ReactNode }) {
61
+ return (
62
+ <h3 id={id} className="text-lg font-semibold group relative scroll-mt-20">
63
+ {children}
64
+ <a
65
+ href={`#${id}`}
66
+ onClick={(e) => {
67
+ e.preventDefault()
68
+ history.replaceState(null, "", `#${id}`)
69
+ document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" })
70
+ }}
71
+ className="inline-flex items-center ml-2 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground"
72
+ aria-label={`Link to ${typeof children === "string" ? children : "section"}`}
73
+ >
74
+ <LinkIcon className="size-3.5" />
75
+ </a>
76
+ </h3>
77
+ )
78
+ }
79
+
80
+ const tabs = [
81
+ { value: "install", labelKey: "tabs.install" as const },
82
+ { value: "docs", labelKey: "tabs.documentation" as const },
83
+ { value: "guidelines", labelKey: "tabs.guidelines" as const },
84
+ { value: "a11y", labelKey: "tabs.accessibility" as const },
85
+ { value: "tests", labelKey: "tabs.tests" as const },
86
+ ]
87
+
88
+ export function ComponentTabs({ name, source }: ComponentTabsProps) {
89
+ const [activeTab, setActiveTab] = useState("install")
90
+ const contentRef = useRef<HTMLDivElement>(null)
91
+ const tocEntries = useTocFromContent(contentRef, activeTab)
92
+ const t = useTranslations()
93
+
94
+ return (
95
+ <div className="flex justify-center gap-8">
96
+ <div ref={contentRef} className="flex-1 min-w-0 xl:max-w-225">
97
+ <Tabs value={activeTab} onValueChange={setActiveTab}>
98
+ <div className="sticky top-14 md:top-14 z-10 bg-background border-b border-border pt-4 md:pt-8 -mx-1 px-1">
99
+ <TabsList variant="line" className="w-full justify-start *:shrink-0">
100
+ {tabs.map((tab) => (
101
+ <TabsTrigger key={tab.value} value={tab.value}>
102
+ {t(tab.labelKey)}
103
+ </TabsTrigger>
104
+ ))}
105
+ </TabsList>
106
+ </div>
107
+
108
+ <TabsContent value="install" className="px-2 mt-6">
109
+ <div className="space-y-6">
110
+ <div>
111
+ <SectionHeading id="installation">
112
+ {t("component.installation")}
113
+ </SectionHeading>
114
+ <pre className="bg-muted rounded-lg p-4 overflow-x-auto text-sm">
115
+ <code>{renderInstallCommand(name)}</code>
116
+ </pre>
117
+ </div>
118
+
119
+ {source && (
120
+ <div>
121
+ <SectionHeading id="source">
122
+ {t("component.source")}
123
+ </SectionHeading>
124
+ <pre className="bg-muted rounded-lg p-4 overflow-x-auto text-sm">
125
+ <code>{source}</code>
126
+ </pre>
127
+ </div>
128
+ )}
129
+ </div>
130
+ </TabsContent>
131
+
132
+ <TabsContent value="docs" className="px-2 mt-6">
133
+ <div className="space-y-6">
134
+ <SectionHeading id="props-behavior">
135
+ {t("component.propsBehavior")}
136
+ </SectionHeading>
137
+ <PropsTable name={name} />
138
+ </div>
139
+ </TabsContent>
140
+
141
+ <TabsContent value="guidelines" className="px-2 mt-6">
142
+ <div className="space-y-4">
143
+ <SectionHeading id="guidelines">
144
+ {t("tabs.guidelines")}
145
+ </SectionHeading>
146
+ <EmptyState>
147
+ <EmptyStateIcon><FileText /></EmptyStateIcon>
148
+ <EmptyStateTitle>{t("tabs.guidelines")}</EmptyStateTitle>
149
+ <EmptyStateDescription>{t("component.guidelinesPlaceholder")}</EmptyStateDescription>
150
+ </EmptyState>
151
+ </div>
152
+ </TabsContent>
153
+
154
+ <TabsContent value="a11y" className="px-2 mt-6">
155
+ <div className="space-y-4">
156
+ <SectionHeading id="accessibility">
157
+ {t("tabs.accessibility")}
158
+ </SectionHeading>
159
+ <A11yInfo name={name} />
160
+ </div>
161
+ </TabsContent>
162
+
163
+ <TabsContent value="tests" className="px-2 mt-6">
164
+ <div className="space-y-4">
165
+ <SectionHeading id="tests">
166
+ {t("tabs.tests")}
167
+ </SectionHeading>
168
+ <TestInfo name={name} />
169
+ </div>
170
+ </TabsContent>
171
+ </Tabs>
172
+ </div>
173
+
174
+ {/* TOC column — space always reserved at xl+ to avoid layout shift when
175
+ switching tabs; inner nav only renders when there are headings. */}
176
+ <div className="hidden xl:block w-44 shrink-0">
177
+ {tocEntries.length > 0 && (
178
+ <nav aria-label={t("toc.title")} className="sticky top-16 pt-20">
179
+ <p className="text-xs font-semibold text-foreground mb-2" aria-hidden="true">
180
+ {t("toc.title")}
181
+ </p>
182
+ <ul role="list" className="space-y-0.5">
183
+ {tocEntries.map((entry) => (
184
+ <li key={entry.id}>
185
+ <a
186
+ href={`#${entry.id}`}
187
+ onClick={(e) => {
188
+ e.preventDefault()
189
+ const el = document.getElementById(entry.id)
190
+ if (el) {
191
+ el.scrollIntoView({ behavior: "smooth", block: "start" })
192
+ history.replaceState(null, "", `#${entry.id}`)
193
+ }
194
+ }}
195
+ className={`block text-xs px-2 py-1 rounded-md transition-colors text-muted-foreground hover:text-foreground hover:bg-accent/50 ${
196
+ entry.level === 4 ? "pl-4" : ""
197
+ }`}
198
+ >
199
+ {entry.text}
200
+ </a>
201
+ </li>
202
+ ))}
203
+ </ul>
204
+ </nav>
205
+ )}
206
+ </div>
207
+ </div>
208
+ )
209
+ }
@@ -0,0 +1,86 @@
1
+ "use client"
2
+
3
+ import { useEffect, useState } from "react"
4
+ import { useTranslations } from "@shell/lib/i18n"
5
+
6
+ interface TocEntry {
7
+ id: string
8
+ text: string
9
+ level: number
10
+ }
11
+
12
+ export function DocsToc() {
13
+ const [entries, setEntries] = useState<TocEntry[]>([])
14
+ const [activeId, setActiveId] = useState<string>("")
15
+ const t = useTranslations()
16
+
17
+ useEffect(() => {
18
+ const article = document.querySelector("[data-docs-content]")
19
+ if (!article) return
20
+
21
+ const headings = article.querySelectorAll<HTMLHeadingElement>("h1, h2, h3")
22
+ const items: TocEntry[] = []
23
+ headings.forEach((h) => {
24
+ if (!h.id) {
25
+ h.id = h.textContent
26
+ ?.toLowerCase()
27
+ .replace(/[^\w\s-]/g, "")
28
+ .replace(/\s+/g, "-") ?? ""
29
+ }
30
+ if (!h.id) return
31
+ const level = parseInt(h.tagName[1])
32
+ items.push({ id: h.id, text: h.textContent ?? "", level })
33
+ })
34
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- syncing with external DOM headings
35
+ setEntries(items)
36
+
37
+ const observer = new IntersectionObserver(
38
+ (entries) => {
39
+ for (const entry of entries) {
40
+ if (entry.isIntersecting) {
41
+ setActiveId(entry.target.id)
42
+ }
43
+ }
44
+ },
45
+ { rootMargin: "0px 0px -80% 0px", threshold: 0.1 }
46
+ )
47
+ headings.forEach((h) => { if (h.id) observer.observe(h) })
48
+ return () => observer.disconnect()
49
+ }, [])
50
+
51
+ if (entries.length === 0) return null
52
+
53
+ return (
54
+ <nav aria-label={t("toc.title")} className="w-44 hidden xl:block">
55
+ <p className="text-xs font-semibold text-foreground mb-2" aria-hidden="true">
56
+ {t("toc.title")}
57
+ </p>
58
+ <ul role="list" className="space-y-0.5">
59
+ {entries.map((entry) => (
60
+ <li key={entry.id}>
61
+ <a
62
+ href={`#${entry.id}`}
63
+ aria-current={activeId === entry.id ? "true" : undefined}
64
+ onClick={(e) => {
65
+ e.preventDefault()
66
+ const el = document.getElementById(entry.id)
67
+ if (el) {
68
+ el.scrollIntoView({ behavior: "smooth", block: "start" })
69
+ // Update URL hash without scroll jump
70
+ history.replaceState(null, "", `#${entry.id}`)
71
+ }
72
+ }}
73
+ className={`block text-xs px-2 py-1 rounded-md transition-colors ${
74
+ activeId === entry.id
75
+ ? "text-foreground font-medium bg-accent"
76
+ : "text-muted-foreground hover:text-foreground hover:bg-accent/50"
77
+ } ${entry.level === 3 ? "pl-4" : ""}`}
78
+ >
79
+ {entry.text}
80
+ </a>
81
+ </li>
82
+ ))}
83
+ </ul>
84
+ </nav>
85
+ )
86
+ }
@@ -0,0 +1,35 @@
1
+ "use client"
2
+
3
+ import { Sidebar } from "@shell/components/sidebar"
4
+ import { useMobileSidebar } from "@shell/components/sidebar-provider"
5
+ import type { DocMeta } from "@shell/lib/docs"
6
+ import type { ComponentMeta } from "@shell/lib/components-nav"
7
+
8
+ /**
9
+ * Client-side wrapper that mounts the mobile-only variant of the Sidebar from
10
+ * the root layout. This ensures the hamburger menu in the header works on
11
+ * **every** page (including the homepage), even though the desktop inline
12
+ * sidebar is still scoped to per-section layouts via `SidebarLayout`.
13
+ *
14
+ * The full Sidebar component reads its `open` / `onClose` from the
15
+ * `useMobileSidebar` hook, which is a client-side context — so this wrapper
16
+ * exists purely to bridge the server `RootLayout` to those hooks.
17
+ */
18
+ export function GlobalMobileSidebar({
19
+ docs,
20
+ components,
21
+ }: {
22
+ docs: DocMeta[]
23
+ components: ComponentMeta[]
24
+ }) {
25
+ const { open, close } = useMobileSidebar()
26
+ return (
27
+ <Sidebar
28
+ docs={docs}
29
+ components={components}
30
+ open={open}
31
+ onClose={close}
32
+ display="mobile"
33
+ />
34
+ )
35
+ }
@@ -0,0 +1,188 @@
1
+ "use client"
2
+
3
+ import Image from "next/image"
4
+ import Link from "next/link"
5
+ import {
6
+ Breadcrumb,
7
+ BreadcrumbItem,
8
+ BreadcrumbLink,
9
+ BreadcrumbList,
10
+ BreadcrumbSeparator,
11
+ } from "@shell/components/shell-ui/breadcrumb"
12
+ import { Badge } from "@shell/components/shell-ui/badge"
13
+ import { useTheme } from "next-themes"
14
+ import { useEffect, useState } from "react"
15
+ import { Github, Menu, Star } from "lucide-react"
16
+ import { Button } from "@shell/components/shell-ui/button"
17
+ import { LocaleToggle } from "@shell/components/locale-toggle"
18
+ import { ThemeToggle } from "@shell/components/theme-toggle"
19
+ import { SearchTrigger } from "@shell/components/search"
20
+ import { useMobileSidebar } from "@shell/components/sidebar-provider"
21
+ import { useNavData } from "@shell/components/nav-data-provider"
22
+ import { useActiveSection, type ActiveSection } from "@shell/hooks/use-active-section"
23
+ import { TranslatedText } from "@shell/components/translated-text"
24
+ import { GITHUB_URL, formatStarCount } from "@shell/lib/github"
25
+ import { branding } from "@shell/lib/branding"
26
+
27
+ function HeaderTab({
28
+ href,
29
+ active,
30
+ children,
31
+ }: {
32
+ href: string
33
+ active: boolean
34
+ children: React.ReactNode
35
+ }) {
36
+ return (
37
+ <Link
38
+ href={href}
39
+ className={`relative px-3 py-1.5 text-sm font-medium transition-colors ${
40
+ active
41
+ ? "text-foreground"
42
+ : "text-muted-foreground hover:text-foreground"
43
+ }`}
44
+ >
45
+ {children}
46
+ {active && (
47
+ <span className="absolute -bottom-3.25 left-0 right-0 h-0.5 bg-primary" />
48
+ )}
49
+ </Link>
50
+ )
51
+ }
52
+
53
+ export function Header({ githubStars }: { githubStars?: number | null } = {}) {
54
+ const { resolvedTheme } = useTheme()
55
+ const [mounted, setMounted] = useState(false)
56
+ const { open: sidebarOpen, toggle, collapsed } = useMobileSidebar()
57
+
58
+ const navData = useNavData()
59
+ const activeSection: ActiveSection = useActiveSection(navData?.components ?? [])
60
+ const firstDocSlug = navData?.docs[0]?.slug
61
+ const firstComponentName = navData?.components.find((c) => c.kind === "component")?.name
62
+ const firstBlockName = navData?.components.find((c) => c.kind === "block")?.name
63
+ const hasBlocks = Boolean(firstBlockName)
64
+
65
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- standard hydration mount detection
66
+ useEffect(() => setMounted(true), [])
67
+
68
+ const faviconSrc = mounted
69
+ ? resolvedTheme === "dark"
70
+ ? branding.faviconLight
71
+ : branding.faviconDark
72
+ : branding.faviconDark
73
+
74
+ return (
75
+ <header className="h-14 border-b border-border sticky top-0 z-30 bg-background">
76
+ <div className="relative flex items-center justify-between h-full px-4 md:px-6">
77
+ {/* Left: hamburger + breadcrumb (fixed-width container so absolute-centered tabs don't shift) */}
78
+ <div className="flex items-center gap-2 md:w-80 md:shrink-0 min-w-0">
79
+ {/* Hamburger — always takes space on mobile for consistent brand position */}
80
+ <div
81
+ className={`md:overflow-hidden md:transition-all md:duration-300 md:ease-in-out motion-reduce:transition-none ${
82
+ collapsed ? "md:w-9 md:opacity-100" : "md:w-0 md:opacity-0"
83
+ }`}
84
+ >
85
+ <Button
86
+ variant={sidebarOpen ? "default" : "ghost"}
87
+ size="icon"
88
+ onClick={toggle}
89
+ aria-label="Toggle menu"
90
+ aria-expanded={sidebarOpen}
91
+ >
92
+ <Menu className="size-4" />
93
+ </Button>
94
+ </div>
95
+
96
+ <Breadcrumb className="min-w-0">
97
+ <BreadcrumbList className="flex-nowrap gap-1.5 sm:gap-2 text-sm">
98
+ <BreadcrumbItem>
99
+ <BreadcrumbLink href="/" className="flex items-center gap-2 hover:no-underline">
100
+ <Image
101
+ src={faviconSrc}
102
+ alt={`${branding.logoAlt} logo`}
103
+ width={22}
104
+ height={22}
105
+ />
106
+ </BreadcrumbLink>
107
+ </BreadcrumbItem>
108
+ <BreadcrumbSeparator />
109
+ <BreadcrumbItem>
110
+ <BreadcrumbLink href="/" className="font-medium hover:no-underline">
111
+ {branding.shortName}
112
+ </BreadcrumbLink>
113
+ </BreadcrumbItem>
114
+ <span id="header-breadcrumb" className="contents" />
115
+ </BreadcrumbList>
116
+ </Breadcrumb>
117
+ </div>
118
+
119
+ {/* Center: section tabs — absolutely centered, independent of breadcrumb width */}
120
+ {navData && (
121
+ <nav
122
+ aria-label="Sections"
123
+ className="hidden md:flex items-center gap-1 absolute left-1/2 -translate-x-1/2 h-full"
124
+ >
125
+ {firstDocSlug && (
126
+ <HeaderTab href={`/docs/${firstDocSlug}`} active={activeSection === "docs"}>
127
+ <TranslatedText k="sidebar.documentation" />
128
+ </HeaderTab>
129
+ )}
130
+ {firstComponentName && (
131
+ <HeaderTab href={`/components/${firstComponentName}`} active={activeSection === "components"}>
132
+ <TranslatedText k="sidebar.components" />
133
+ </HeaderTab>
134
+ )}
135
+ {hasBlocks && firstBlockName && (
136
+ <HeaderTab href={`/components/${firstBlockName}`} active={activeSection === "blocks"}>
137
+ <TranslatedText k="sidebar.blocks" />
138
+ </HeaderTab>
139
+ )}
140
+ </nav>
141
+ )}
142
+
143
+ <div className="flex items-center gap-1">
144
+ {/* GitHub button — rendered only when the registry config provides
145
+ a `github` object. Label defaults to "Github" but is overridable
146
+ (e.g. "Sponsor"). Star count fetches server-side with hourly
147
+ revalidation, unless the registry opted out via showStars=false. */}
148
+ {branding.github && (
149
+ <Button
150
+ asChild
151
+ variant="outline"
152
+ size="sm"
153
+ className="hidden md:inline-flex h-8 px-2.5 gap-1.5 text-xs font-medium"
154
+ >
155
+ <a
156
+ href={GITHUB_URL}
157
+ target="_blank"
158
+ rel="noopener noreferrer"
159
+ aria-label={
160
+ typeof githubStars === "number"
161
+ ? `${branding.github.label ?? "GitHub"}, ${githubStars} stars`
162
+ : branding.github.label ?? "GitHub repository"
163
+ }
164
+ >
165
+ <Github className="size-3.5" />
166
+ <span>{branding.github.label ?? "Github"}</span>
167
+ {typeof githubStars === "number" && (
168
+ <Badge
169
+ variant="secondary"
170
+ className="gap-0.5 px-1.5 py-0 h-4 text-[10px] font-mono tabular-nums"
171
+ >
172
+ {formatStarCount(githubStars)}
173
+ <Star className="size-2.5 fill-current" />
174
+ </Badge>
175
+ )}
176
+ </a>
177
+ </Button>
178
+ )}
179
+ <SearchTrigger />
180
+ {/* Locale + theme switches always visible — small icons fit even on
181
+ mobile and avoid the indirection of a Settings modal. */}
182
+ <LocaleToggle />
183
+ <ThemeToggle />
184
+ </div>
185
+ </div>
186
+ </header>
187
+ )
188
+ }
@@ -0,0 +1,52 @@
1
+ "use client"
2
+
3
+ import { Link as LinkIcon } from "lucide-react"
4
+
5
+ function slugify(text: string): string {
6
+ return text
7
+ .toLowerCase()
8
+ .replace(/[^\w\s-]/g, "")
9
+ .replace(/\s+/g, "-")
10
+ }
11
+
12
+ interface HeadingProps extends React.HTMLAttributes<HTMLHeadingElement> {
13
+ children?: React.ReactNode
14
+ }
15
+
16
+ function createHeading(level: 1 | 2 | 3 | 4 | 5 | 6) {
17
+ const Tag = `h${level}` as const
18
+
19
+ return function Heading({ children, id, ...props }: HeadingProps) {
20
+ const headingId = id || slugify(typeof children === "string" ? children : "")
21
+ if (!headingId) return <Tag {...props}>{children}</Tag>
22
+
23
+ return (
24
+ <Tag id={headingId} className="group relative scroll-mt-20" {...props}>
25
+ {children}
26
+ <a
27
+ href={`#${headingId}`}
28
+ onClick={(e) => {
29
+ e.preventDefault()
30
+ history.replaceState(null, "", `#${headingId}`)
31
+ document.getElementById(headingId)?.scrollIntoView({ behavior: "smooth", block: "start" })
32
+ // Copy link to clipboard
33
+ navigator.clipboard?.writeText(window.location.href)
34
+ }}
35
+ className="inline-flex items-center ml-2 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground"
36
+ aria-label={`Link to ${typeof children === "string" ? children : "section"}`}
37
+ >
38
+ <LinkIcon className="size-4" />
39
+ </a>
40
+ </Tag>
41
+ )
42
+ }
43
+ }
44
+
45
+ export const mdxHeadings = {
46
+ h1: createHeading(1),
47
+ h2: createHeading(2),
48
+ h3: createHeading(3),
49
+ h4: createHeading(4),
50
+ h5: createHeading(5),
51
+ h6: createHeading(6),
52
+ }