@opendocsdev/cli 0.2.6 → 0.2.8

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendocsdev/cli",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -59,173 +59,3 @@ const showTabs = labels.length > 1;
59
59
  <slot />
60
60
  </div>
61
61
  </code-group>
62
-
63
- <script>
64
- interface CodeGroupSyncEvent extends CustomEvent {
65
- detail: {
66
- syncKey: string;
67
- label: string;
68
- };
69
- }
70
-
71
- class CodeGroupElement extends HTMLElement {
72
- private syncKey: string = "";
73
- private labels: string[] = [];
74
- private panels: HTMLPreElement[] = [];
75
- private boundHandleSync: (e: Event) => void;
76
- private boundHandleStorage: (e: StorageEvent) => void;
77
-
78
- constructor() {
79
- super();
80
- this.boundHandleSync = this.handleSync.bind(this);
81
- this.boundHandleStorage = this.handleStorage.bind(this);
82
- }
83
-
84
- connectedCallback() {
85
- // Parse labels and sync key
86
- const labelsJson = this.dataset.labels;
87
- this.labels = labelsJson ? JSON.parse(labelsJson) : [];
88
- this.syncKey = this.dataset.syncKey || "";
89
-
90
- const panelsContainer = this.querySelector(".panels");
91
- if (!panelsContainer) return;
92
-
93
- // Find all pre elements (code blocks)
94
- this.panels = Array.from(panelsContainer.querySelectorAll(":scope > pre"));
95
- if (this.panels.length === 0) {
96
- this.panels = Array.from(panelsContainer.querySelectorAll("pre"));
97
- }
98
-
99
- if (this.panels.length === 0) return;
100
-
101
- // Initialize panels with data-active attribute
102
- this.initPanels();
103
-
104
- // If only one panel, just mark it active
105
- if (this.panels.length === 1) {
106
- this.panels[0].setAttribute("data-active", "true");
107
- return;
108
- }
109
-
110
- // Add click handlers to server-rendered buttons
111
- this.attachClickHandlers();
112
-
113
- // Restore saved selection from localStorage
114
- this.restoreFromStorage();
115
-
116
- // Listen for sync events from other CodeGroups on the same page
117
- window.addEventListener("codegroup-sync", this.boundHandleSync);
118
-
119
- // Listen for storage events (cross-tab sync)
120
- window.addEventListener("storage", this.boundHandleStorage);
121
- }
122
-
123
- disconnectedCallback() {
124
- window.removeEventListener("codegroup-sync", this.boundHandleSync);
125
- window.removeEventListener("storage", this.boundHandleStorage);
126
- }
127
-
128
- private initPanels() {
129
- this.panels.forEach((panel, index) => {
130
- panel.setAttribute("role", "tabpanel");
131
- panel.setAttribute("data-active", String(index === 0));
132
- });
133
- }
134
-
135
- private attachClickHandlers() {
136
- const buttons = this.querySelectorAll<HTMLButtonElement>(".tabs button");
137
- buttons.forEach((btn) => {
138
- btn.addEventListener("click", () => {
139
- const label = btn.dataset.label;
140
- if (label) {
141
- this.setActiveByLabel(label);
142
- this.broadcastChange(label);
143
- }
144
- });
145
- });
146
- }
147
-
148
- private restoreFromStorage() {
149
- if (!this.syncKey) return;
150
-
151
- try {
152
- const saved = localStorage.getItem(`codegroup:${this.syncKey}`);
153
- if (saved && this.labels.includes(saved)) {
154
- this.setActiveByLabel(saved);
155
- }
156
- } catch {
157
- // localStorage may be unavailable (private browsing, etc.)
158
- }
159
- }
160
-
161
- private handleSync(e: Event) {
162
- const event = e as CodeGroupSyncEvent;
163
- const { syncKey, label } = event.detail;
164
- if (syncKey === this.syncKey && this.labels.includes(label)) {
165
- this.setActiveByLabel(label);
166
- }
167
- }
168
-
169
- private handleStorage(e: StorageEvent) {
170
- if (!e.key?.startsWith("codegroup:")) return;
171
-
172
- const storedSyncKey = e.key.replace("codegroup:", "");
173
- if (storedSyncKey === this.syncKey && e.newValue) {
174
- this.setActiveByLabel(e.newValue);
175
- }
176
- }
177
-
178
- private broadcastChange(label: string) {
179
- if (!this.syncKey) return;
180
-
181
- // Save to localStorage
182
- try {
183
- localStorage.setItem(`codegroup:${this.syncKey}`, label);
184
- } catch {
185
- // localStorage may be unavailable
186
- }
187
-
188
- // Notify other CodeGroups on the same page
189
- window.dispatchEvent(
190
- new CustomEvent("codegroup-sync", {
191
- detail: { syncKey: this.syncKey, label },
192
- })
193
- );
194
- }
195
-
196
- private setActiveByLabel(label: string) {
197
- const index = this.labels.indexOf(label);
198
- if (index >= 0) {
199
- this.setActiveTab(index);
200
- }
201
- }
202
-
203
- private setActiveTab(index: number) {
204
- // Update buttons
205
- const buttons = this.querySelectorAll<HTMLButtonElement>(".tabs button");
206
- buttons.forEach((btn, i) => {
207
- const isActive = i === index;
208
- btn.className = this.getTabClass(isActive);
209
- btn.setAttribute("aria-selected", String(isActive));
210
- btn.tabIndex = isActive ? 0 : -1;
211
- });
212
-
213
- // Update panels
214
- this.panels.forEach((panel, i) => {
215
- panel.setAttribute("data-active", String(i === index));
216
- });
217
- }
218
-
219
- private getTabClass(isActive: boolean): string {
220
- const base =
221
- "px-2 sm:px-3 py-2 text-xs sm:text-sm font-medium transition-colors -mb-px whitespace-nowrap flex-shrink-0";
222
- const active =
223
- "text-[var(--color-foreground)] border-b-2 border-[var(--color-primary)]";
224
- const inactive =
225
- "text-[var(--color-muted)] hover:text-[var(--color-foreground)]";
226
- return `${base} ${isActive ? active : inactive}`;
227
- }
228
- }
229
-
230
- customElements.define("code-group", CodeGroupElement);
231
- </script>
@@ -59,77 +59,3 @@ const showTabs = tabs.length > 1;
59
59
  <slot />
60
60
  </div>
61
61
  </content-tabs>
62
-
63
- <script>
64
- class ContentTabsElement extends HTMLElement {
65
- private panels: HTMLElement[] = [];
66
-
67
- connectedCallback() {
68
- const panelsContainer = this.querySelector(".tabs-panels");
69
- if (!panelsContainer) return;
70
-
71
- this.panels = Array.from(
72
- panelsContainer.querySelectorAll(":scope > .tab-panel")
73
- );
74
-
75
- if (this.panels.length === 0) return;
76
-
77
- // Initialize panels with data-active attribute
78
- this.initPanels();
79
-
80
- // If only one panel, just mark it active
81
- if (this.panels.length === 1) {
82
- this.panels[0].setAttribute("data-active", "true");
83
- return;
84
- }
85
-
86
- // Add click handlers to server-rendered buttons
87
- this.attachClickHandlers();
88
- }
89
-
90
- private initPanels() {
91
- this.panels.forEach((panel, index) => {
92
- panel.setAttribute("role", "tabpanel");
93
- panel.setAttribute("data-active", String(index === 0));
94
- });
95
- }
96
-
97
- private attachClickHandlers() {
98
- const buttons = this.querySelectorAll<HTMLButtonElement>(
99
- ".tabs-nav button"
100
- );
101
- buttons.forEach((btn, index) => {
102
- btn.addEventListener("click", () => this.setActiveTab(index));
103
- });
104
- }
105
-
106
- private setActiveTab(index: number) {
107
- // Update buttons
108
- const buttons = this.querySelectorAll<HTMLButtonElement>(
109
- ".tabs-nav button"
110
- );
111
- buttons.forEach((btn, i) => {
112
- const isActive = i === index;
113
- btn.className = this.getTabClass(isActive);
114
- btn.setAttribute("aria-selected", String(isActive));
115
- btn.tabIndex = isActive ? 0 : -1;
116
- });
117
-
118
- // Update panels
119
- this.panels.forEach((panel, i) => {
120
- panel.setAttribute("data-active", String(i === index));
121
- });
122
- }
123
-
124
- private getTabClass(isActive: boolean): string {
125
- const base =
126
- "px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px";
127
- const active = "border-[var(--color-primary)] text-[var(--color-primary)]";
128
- const inactive =
129
- "border-transparent text-[var(--color-muted)] hover:text-[var(--color-foreground)] hover:border-[var(--color-border)]";
130
- return `${base} ${isActive ? active : inactive}`;
131
- }
132
- }
133
-
134
- customElements.define("content-tabs", ContentTabsElement);
135
- </script>
@@ -0,0 +1,68 @@
1
+ import React from "react";
2
+ import { cn } from "../../lib/utils";
3
+
4
+ interface CodeGroupProps {
5
+ "data-labels"?: string;
6
+ "data-sync-key"?: string;
7
+ className?: string;
8
+ children: React.ReactNode;
9
+ }
10
+
11
+ /**
12
+ * React version of CodeGroup for use in snippets.
13
+ * Renders a <div> with the same class/structure as CodeGroup.astro.
14
+ * Uses <div> instead of <code-group> custom element because React 18 SSR
15
+ * doesn't convert className to class for custom elements.
16
+ * The global interactive-components.ts script handles interactivity via event delegation.
17
+ */
18
+ export function CodeGroup({
19
+ "data-labels": labelsJson,
20
+ "data-sync-key": syncKey,
21
+ className,
22
+ children,
23
+ }: CodeGroupProps) {
24
+ const labels: string[] = labelsJson ? JSON.parse(labelsJson) : [];
25
+ const showTabs = labels.length > 1;
26
+
27
+ return (
28
+ <div
29
+ className={cn(
30
+ "code-group not-prose my-4 rounded-xl border border-[var(--color-border)] overflow-hidden bg-[var(--color-surface-raised)] block",
31
+ className
32
+ )}
33
+ data-sync-key={syncKey}
34
+ data-labels={labelsJson}
35
+ >
36
+ {showTabs && (
37
+ <nav
38
+ className="tabs flex gap-1 px-3 sm:px-4 pt-3 pb-0 border-b border-[var(--color-border)] overflow-x-auto"
39
+ role="tablist"
40
+ aria-label="Code examples"
41
+ >
42
+ {labels.map((label, i) => (
43
+ <button
44
+ key={label}
45
+ type="button"
46
+ role="tab"
47
+ className={cn(
48
+ "px-2 sm:px-3 py-2 text-xs sm:text-sm font-medium transition-colors -mb-px whitespace-nowrap flex-shrink-0",
49
+ i === 0
50
+ ? "text-[var(--color-foreground)] border-b-2 border-[var(--color-primary)]"
51
+ : "text-[var(--color-muted)] hover:text-[var(--color-foreground)]"
52
+ )}
53
+ aria-selected={i === 0 ? true : false}
54
+ tabIndex={i === 0 ? 0 : -1}
55
+ data-index={i}
56
+ data-label={label}
57
+ >
58
+ {label}
59
+ </button>
60
+ ))}
61
+ </nav>
62
+ )}
63
+ <div className="panels">{children}</div>
64
+ </div>
65
+ );
66
+ }
67
+
68
+ export default CodeGroup;
@@ -8,6 +8,11 @@ interface PageLink {
8
8
  description?: string;
9
9
  }
10
10
 
11
+ interface FooterConfig {
12
+ copyright?: string;
13
+ links?: { title: string; url: string }[];
14
+ }
15
+
11
16
  interface PageFooterProps {
12
17
  path: string;
13
18
  backend?: string;
@@ -16,6 +21,7 @@ interface PageFooterProps {
16
21
  previousPage?: PageLink | null;
17
22
  nextPage?: PageLink | null;
18
23
  lastUpdated?: string;
24
+ footer?: FooterConfig;
19
25
  className?: string;
20
26
  }
21
27
 
@@ -27,10 +33,12 @@ export function PageFooter({
27
33
  previousPage,
28
34
  nextPage,
29
35
  lastUpdated,
36
+ footer,
30
37
  className,
31
38
  }: PageFooterProps) {
32
39
  const hasNavigation = previousPage || nextPage;
33
40
  const showFeedback = feedbackEnabled && backend && siteId;
41
+ const hasFooter = footer?.copyright || (footer?.links && footer.links.length > 0);
34
42
 
35
43
  return (
36
44
  <footer
@@ -84,6 +92,44 @@ export function PageFooter({
84
92
  </div>
85
93
  </nav>
86
94
  )}
95
+
96
+ {/* Footer content */}
97
+ {hasFooter && (
98
+ <div className="mt-8 pt-6 border-t border-[var(--color-border)]">
99
+ <div className="flex flex-col sm:flex-row items-center justify-between gap-3">
100
+ {footer?.copyright && (
101
+ <p className="text-sm text-[var(--color-muted)]">{footer.copyright}</p>
102
+ )}
103
+ {footer?.links && footer.links.length > 0 && (
104
+ <div className="flex items-center gap-4">
105
+ {footer.links.map((link) => (
106
+ <a
107
+ key={link.url}
108
+ href={link.url}
109
+ target="_blank"
110
+ rel="noopener noreferrer"
111
+ className="text-sm text-[var(--color-muted)] hover:text-[var(--color-foreground)] transition-colors"
112
+ >
113
+ {link.title}
114
+ </a>
115
+ ))}
116
+ </div>
117
+ )}
118
+ </div>
119
+ </div>
120
+ )}
121
+
122
+ {/* Built with OpenDocs */}
123
+ <div className="mt-6 flex items-center justify-center">
124
+ <a
125
+ href="https://docs.opendocs.dev"
126
+ target="_blank"
127
+ rel="noopener noreferrer"
128
+ className="text-xs text-[var(--color-muted)]/60 hover:text-[var(--color-muted)] transition-colors"
129
+ >
130
+ Built with OpenDocs
131
+ </a>
132
+ </div>
87
133
  </footer>
88
134
  );
89
135
  }
@@ -12,7 +12,7 @@ import {
12
12
  type NavigationGroup,
13
13
  } from "../../lib/utils";
14
14
  import { ThemeToggle } from "./ThemeToggle";
15
- import { GitHubLink } from "./GitHubLink";
15
+ import { SocialLinks } from "./SocialLinks";
16
16
  import { SidebarSearchTrigger } from "./SidebarSearchTrigger";
17
17
 
18
18
  interface LogoConfig {
@@ -26,6 +26,8 @@ interface SidebarProps {
26
26
  logo?: string | LogoConfig;
27
27
  currentPath: string;
28
28
  githubUrl?: string;
29
+ discordUrl?: string;
30
+ twitterUrl?: string;
29
31
  }
30
32
 
31
33
  // Check if any page in a group of children is active
@@ -250,11 +252,18 @@ function Logo({ logo, siteName }: { logo?: string | LogoConfig; siteName: string
250
252
  );
251
253
  }
252
254
 
255
+ const nameEl = (
256
+ <span className="text-base font-semibold text-[var(--color-foreground)]">
257
+ {siteName}
258
+ </span>
259
+ );
260
+
253
261
  // String logo - no theme switching needed
254
262
  if (typeof logo === "string") {
255
263
  return (
256
- <a href="/" className="flex items-center gap-2">
257
- <img src={logo} alt={siteName} className="h-8 w-auto" />
264
+ <a href="/" className="flex items-center gap-2 hover:opacity-80 transition-opacity">
265
+ <img src={logo} alt={siteName} className="h-7 w-auto" />
266
+ {nameEl}
258
267
  </a>
259
268
  );
260
269
  }
@@ -264,17 +273,19 @@ function Logo({ logo, siteName }: { logo?: string | LogoConfig; siteName: string
264
273
 
265
274
  if (dark) {
266
275
  return (
267
- <a href="/" className="flex items-center gap-2">
268
- <img src={light} alt={siteName} className="h-8 w-auto dark:hidden" />
269
- <img src={dark} alt={siteName} className="h-8 w-auto hidden dark:block" />
276
+ <a href="/" className="flex items-center gap-2 hover:opacity-80 transition-opacity">
277
+ <img src={light} alt={siteName} className="h-7 w-auto dark:hidden" />
278
+ <img src={dark} alt={siteName} className="h-7 w-auto hidden dark:block" />
279
+ {nameEl}
270
280
  </a>
271
281
  );
272
282
  }
273
283
 
274
284
  // Only light logo provided
275
285
  return (
276
- <a href="/" className="flex items-center gap-2">
277
- <img src={light} alt={siteName} className="h-8 w-auto" />
286
+ <a href="/" className="flex items-center gap-2 hover:opacity-80 transition-opacity">
287
+ <img src={light} alt={siteName} className="h-7 w-auto" />
288
+ {nameEl}
278
289
  </a>
279
290
  );
280
291
  }
@@ -285,6 +296,8 @@ export function Sidebar({
285
296
  logo,
286
297
  currentPath: initialPath,
287
298
  githubUrl,
299
+ discordUrl,
300
+ twitterUrl,
288
301
  }: SidebarProps) {
289
302
  // Track current path locally so it updates after View Transition navigations
290
303
  const [currentPath, setCurrentPath] = useState(initialPath);
@@ -298,9 +311,9 @@ export function Sidebar({
298
311
  return (
299
312
  <div className="flex flex-col h-full">
300
313
  {/* Logo/Site name + Theme toggle */}
301
- <div className="flex items-center justify-between h-16 px-4">
314
+ <div className="flex items-center justify-between h-14 px-4">
302
315
  <Logo logo={logo} siteName={siteName} />
303
- <ThemeToggle />
316
+ <ThemeToggle className="flex-shrink-0" />
304
317
  </div>
305
318
 
306
319
  {/* Search trigger */}
@@ -363,10 +376,10 @@ export function Sidebar({
363
376
  ))}
364
377
  </nav>
365
378
 
366
- {/* Bottom section with GitHub link */}
367
- {githubUrl && (
379
+ {/* Bottom section with social links */}
380
+ {(githubUrl || discordUrl || twitterUrl) && (
368
381
  <div className="px-4 py-3">
369
- <GitHubLink url={githubUrl} />
382
+ <SocialLinks github={githubUrl} discord={discordUrl} twitter={twitterUrl} />
370
383
  </div>
371
384
  )}
372
385
  </div>
@@ -0,0 +1,59 @@
1
+ import { Github } from "lucide-react";
2
+ import { cn } from "../../lib/utils";
3
+
4
+ // Inline Discord SVG — lucide-react doesn't include Discord
5
+ function DiscordIcon({ className }: { className?: string }) {
6
+ return (
7
+ <svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
8
+ <path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
9
+ </svg>
10
+ );
11
+ }
12
+
13
+ // Inline X/Twitter SVG
14
+ function TwitterIcon({ className }: { className?: string }) {
15
+ return (
16
+ <svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
17
+ <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
18
+ </svg>
19
+ );
20
+ }
21
+
22
+ interface SocialLinksProps {
23
+ github?: string;
24
+ discord?: string;
25
+ twitter?: string;
26
+ className?: string;
27
+ }
28
+
29
+ const linkStyle = cn(
30
+ "flex items-center justify-center w-9 h-9 rounded-lg transition-colors",
31
+ "text-[var(--color-muted)] hover:text-[var(--color-foreground)]",
32
+ "hover:bg-[var(--color-surface-sunken)]"
33
+ );
34
+
35
+ export function SocialLinks({ github, discord, twitter, className }: SocialLinksProps) {
36
+ if (!github && !discord && !twitter) return null;
37
+
38
+ return (
39
+ <div className={cn("flex items-center gap-1", className)}>
40
+ {github && (
41
+ <a href={github} target="_blank" rel="noopener noreferrer" className={linkStyle} aria-label="GitHub">
42
+ <Github className="w-5 h-5" aria-hidden="true" />
43
+ </a>
44
+ )}
45
+ {discord && (
46
+ <a href={discord} target="_blank" rel="noopener noreferrer" className={linkStyle} aria-label="Discord">
47
+ <DiscordIcon className="w-5 h-5" />
48
+ </a>
49
+ )}
50
+ {twitter && (
51
+ <a href={twitter} target="_blank" rel="noopener noreferrer" className={linkStyle} aria-label="Twitter">
52
+ <TwitterIcon className="w-5 h-5" />
53
+ </a>
54
+ )}
55
+ </div>
56
+ );
57
+ }
58
+
59
+ export default SocialLinks;
@@ -0,0 +1,25 @@
1
+ import { cn } from "../../lib/utils";
2
+
3
+ interface TabProps {
4
+ label: string;
5
+ icon?: string;
6
+ className?: string;
7
+ children: React.ReactNode;
8
+ }
9
+
10
+ export function Tab({ label, icon, className, children }: TabProps) {
11
+ return (
12
+ <div
13
+ className={cn(
14
+ "tab-panel prose prose-sm dark:prose-invert max-w-none",
15
+ className
16
+ )}
17
+ data-label={label}
18
+ data-icon={icon}
19
+ >
20
+ {children}
21
+ </div>
22
+ );
23
+ }
24
+
25
+ export default Tab;
@@ -0,0 +1,71 @@
1
+ import React from "react";
2
+ import { cn } from "../../lib/utils";
3
+
4
+ interface TabInfo {
5
+ label: string;
6
+ icon?: string;
7
+ }
8
+
9
+ interface TabsProps {
10
+ "data-tabs"?: string;
11
+ className?: string;
12
+ children: React.ReactNode;
13
+ }
14
+
15
+ /**
16
+ * React version of Tabs for use in snippets.
17
+ * Renders a <div> with the same class/structure as Tabs.astro.
18
+ * Uses <div> instead of <content-tabs> custom element because React 18 SSR
19
+ * doesn't convert className to class for custom elements.
20
+ * The global interactive-components.ts script handles interactivity via event delegation.
21
+ */
22
+ export function Tabs({
23
+ "data-tabs": tabsJson,
24
+ className,
25
+ children,
26
+ }: TabsProps) {
27
+ const tabs: TabInfo[] = tabsJson ? JSON.parse(tabsJson) : [];
28
+ const showTabs = tabs.length > 1;
29
+
30
+ return (
31
+ <div
32
+ className={cn("tabs-container my-6 block", className)}
33
+ data-tabs={tabsJson}
34
+ >
35
+ {showTabs && (
36
+ <nav
37
+ className="tabs-nav flex border-b border-[var(--color-border)]"
38
+ role="tablist"
39
+ aria-label="Content tabs"
40
+ >
41
+ {tabs.map((tab, i) => (
42
+ <button
43
+ key={tab.label}
44
+ type="button"
45
+ role="tab"
46
+ className={cn(
47
+ "px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px",
48
+ i === 0
49
+ ? "border-[var(--color-primary)] text-[var(--color-primary)]"
50
+ : "border-transparent text-[var(--color-muted)] hover:text-[var(--color-foreground)] hover:border-[var(--color-border)]"
51
+ )}
52
+ aria-selected={i === 0 ? true : false}
53
+ tabIndex={i === 0 ? 0 : -1}
54
+ data-index={i}
55
+ >
56
+ {tab.icon && (
57
+ <span className="mr-2" aria-hidden="true">
58
+ {tab.icon}
59
+ </span>
60
+ )}
61
+ {tab.label}
62
+ </button>
63
+ ))}
64
+ </nav>
65
+ )}
66
+ <div className="tabs-panels pt-4">{children}</div>
67
+ </div>
68
+ );
69
+ }
70
+
71
+ export default Tabs;
@@ -36,14 +36,14 @@ export function ThemeToggle({ className }: ThemeToggleProps) {
36
36
  <button
37
37
  type="button"
38
38
  className={cn(
39
- "flex items-center justify-center w-9 h-9 rounded-lg transition-colors",
39
+ "flex items-center justify-center w-7 h-7 rounded-md transition-colors",
40
40
  "text-[var(--color-muted)] hover:text-[var(--color-foreground)]",
41
41
  "hover:bg-[var(--color-surface-sunken)]",
42
42
  className
43
43
  )}
44
44
  aria-label="Toggle theme"
45
45
  >
46
- <span className="w-5 h-5" />
46
+ <span className="w-4 h-4" />
47
47
  </button>
48
48
  );
49
49
  }
@@ -53,7 +53,7 @@ export function ThemeToggle({ className }: ThemeToggleProps) {
53
53
  type="button"
54
54
  onClick={toggleTheme}
55
55
  className={cn(
56
- "flex items-center justify-center w-9 h-9 rounded-lg transition-colors",
56
+ "flex items-center justify-center w-7 h-7 rounded-md transition-colors",
57
57
  "text-[var(--color-muted)] hover:text-[var(--color-foreground)]",
58
58
  "hover:bg-[var(--color-surface-sunken)]",
59
59
  className
@@ -61,9 +61,9 @@ export function ThemeToggle({ className }: ThemeToggleProps) {
61
61
  aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
62
62
  >
63
63
  {isDark ? (
64
- <Sun className="w-5 h-5" />
64
+ <Sun className="w-4 h-4" />
65
65
  ) : (
66
- <Moon className="w-5 h-5" />
66
+ <Moon className="w-4 h-4" />
67
67
  )}
68
68
  </button>
69
69
  );
@@ -11,4 +11,7 @@
11
11
  export { Callout } from "./Callout";
12
12
  export { Card } from "./Card";
13
13
  export { CardGroup } from "./CardGroup";
14
+ export { CodeGroup } from "./CodeGroup";
14
15
  export { Steps } from "./Steps";
16
+ export { Tab } from "./Tab";
17
+ export { Tabs } from "./Tabs";
@@ -35,7 +35,10 @@ const faviconPath = config.favicon || "/favicon.ico";
35
35
  const navigation = config.navigation || [];
36
36
  const logo = config.logo;
37
37
  const githubUrl = config.socialLinks?.github;
38
+ const discordUrl = config.socialLinks?.discord;
39
+ const twitterUrl = config.socialLinks?.twitter;
38
40
  const feedbackEnabled = config.features?.feedback !== false;
41
+ const footerConfig = config.footer as { copyright?: string; links?: { title: string; url: string }[] } | undefined;
39
42
 
40
43
  // Backend config
41
44
  const backendConfig = config.backend as { apiUrl?: string; siteId?: string } | undefined;
@@ -48,6 +51,10 @@ const accentColor = config.theme?.accentColor || primaryColor;
48
51
  const primary = generateColorVariants(primaryColor);
49
52
  const accent = generateColorVariants(accentColor);
50
53
 
54
+ // Favicon MIME type from extension
55
+ const faviconExt = faviconPath.split(".").pop()?.toLowerCase();
56
+ const faviconType = faviconExt === "svg" ? "image/svg+xml" : faviconExt === "png" ? "image/png" : faviconExt === "ico" ? "image/x-icon" : undefined;
57
+
51
58
  // SEO metadata
52
59
  const siteUrl = (config.metadata as { url?: string; ogImage?: string } | undefined)?.url || "";
53
60
  const ogImage = (config.metadata as { url?: string; ogImage?: string } | undefined)?.ogImage;
@@ -55,6 +62,9 @@ const canonicalUrl = siteUrl ? `${siteUrl}${currentPath}` : "";
55
62
  const pageDescription = description || `${title} - ${siteName}`;
56
63
  const fullTitle = `${title} | ${siteName}`;
57
64
 
65
+ // Extract Twitter handle from URL for twitter:site meta tag
66
+ const twitterHandle = twitterUrl ? `@${twitterUrl.replace(/\/$/, "").split("/").pop()}` : undefined;
67
+
58
68
  // Build breadcrumb segments from current path
59
69
  const pathSegments = currentPath.split("/").filter(Boolean);
60
70
  const breadcrumbItems = pathSegments.map((segment, i) => ({
@@ -72,7 +82,11 @@ const { previous: previousPage, next: nextPage } = getPageNavigation(navigation,
72
82
  <meta charset="UTF-8" />
73
83
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
74
84
  <meta name="description" content={pageDescription} />
75
- <link rel="icon" type="image/x-icon" href={faviconPath} />
85
+ {faviconType ? (
86
+ <link rel="icon" type={faviconType} href={faviconPath} />
87
+ ) : (
88
+ <link rel="icon" href={faviconPath} />
89
+ )}
76
90
  <title>{fullTitle}</title>
77
91
 
78
92
  {/* Canonical URL */}
@@ -88,11 +102,13 @@ const { previous: previousPage, next: nextPage } = getPageNavigation(navigation,
88
102
  <meta property="og:site_name" content={siteName} />
89
103
  {canonicalUrl && <meta property="og:url" content={canonicalUrl} />}
90
104
  {ogImage && <meta property="og:image" content={ogImage.startsWith("http") ? ogImage : `${siteUrl}${ogImage}`} />}
105
+ {ogImage && <meta property="og:image:alt" content={pageDescription} />}
91
106
 
92
107
  {/* Twitter Card */}
93
108
  <meta name="twitter:card" content={ogImage ? "summary_large_image" : "summary"} />
94
109
  <meta name="twitter:title" content={fullTitle} />
95
110
  <meta name="twitter:description" content={pageDescription} />
111
+ {twitterHandle && <meta name="twitter:site" content={twitterHandle} />}
96
112
  {ogImage && <meta name="twitter:image" content={ogImage.startsWith("http") ? ogImage : `${siteUrl}${ogImage}`} />}
97
113
 
98
114
  {/* Structured Data */}
@@ -163,6 +179,8 @@ const { previous: previousPage, next: nextPage } = getPageNavigation(navigation,
163
179
  logo={logo}
164
180
  currentPath={currentPath}
165
181
  githubUrl={githubUrl}
182
+ discordUrl={discordUrl}
183
+ twitterUrl={twitterUrl}
166
184
  />
167
185
  </aside>
168
186
 
@@ -187,6 +205,8 @@ const { previous: previousPage, next: nextPage } = getPageNavigation(navigation,
187
205
  logo={logo}
188
206
  currentPath={currentPath}
189
207
  githubUrl={githubUrl}
208
+ discordUrl={discordUrl}
209
+ twitterUrl={twitterUrl}
190
210
  />
191
211
  </div>
192
212
  </aside>
@@ -209,7 +229,7 @@ const { previous: previousPage, next: nextPage } = getPageNavigation(navigation,
209
229
  </button>
210
230
 
211
231
  <!-- Logo/site name -->
212
- <a href="/" class="flex items-center justify-center">
232
+ <a href="/" class="flex items-center gap-2">
213
233
  {logo ? (
214
234
  typeof logo === "string" ? (
215
235
  <img src={logo} alt={siteName} class="h-6 w-auto" />
@@ -221,9 +241,8 @@ const { previous: previousPage, next: nextPage } = getPageNavigation(navigation,
221
241
  ) : (
222
242
  <img src={logo.light} alt={siteName} class="h-6 w-auto" />
223
243
  )
224
- ) : (
225
- <span class="text-lg font-semibold text-[var(--color-foreground)]">{siteName}</span>
226
- )}
244
+ ) : null}
245
+ <span class="text-base font-semibold text-[var(--color-foreground)]">{siteName}</span>
227
246
  </a>
228
247
 
229
248
  <!-- Search button -->
@@ -259,6 +278,7 @@ const { previous: previousPage, next: nextPage } = getPageNavigation(navigation,
259
278
  previousPage={previousPage}
260
279
  nextPage={nextPage}
261
280
  lastUpdated={lastUpdated}
281
+ footer={footerConfig}
262
282
  />
263
283
  <CopyButton />
264
284
  </main>
@@ -281,6 +301,9 @@ const { previous: previousPage, next: nextPage } = getPageNavigation(navigation,
281
301
  <!-- Mobile sidebar toggle script -->
282
302
  <script src="../scripts/mobile-sidebar.ts"></script>
283
303
 
304
+ <!-- Interactive components (CodeGroup, Tabs) - global so both Astro and snippet-rendered instances work -->
305
+ <script src="../scripts/interactive-components.ts"></script>
306
+
284
307
  <!-- Search trigger for mobile header -->
285
308
  <script>
286
309
  function initSearchTriggers() {
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Global Web Component definitions for CodeGroup and Tabs.
3
+ * Also includes event-delegation handlers for React-rendered instances
4
+ * (React 18 SSR doesn't convert className→class for custom elements,
5
+ * so React versions render <div> instead).
6
+ *
7
+ * Loaded in the layout so both Astro and React-rendered instances work.
8
+ */
9
+
10
+ // ============================================
11
+ // Shared helpers
12
+ // ============================================
13
+
14
+ const CODE_TAB_BASE =
15
+ "px-2 sm:px-3 py-2 text-xs sm:text-sm font-medium transition-colors -mb-px whitespace-nowrap flex-shrink-0";
16
+ const CODE_TAB_ACTIVE =
17
+ "text-[var(--color-foreground)] border-b-2 border-[var(--color-primary)]";
18
+ const CODE_TAB_INACTIVE =
19
+ "text-[var(--color-muted)] hover:text-[var(--color-foreground)]";
20
+
21
+ const TABS_TAB_BASE =
22
+ "px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px";
23
+ const TABS_TAB_ACTIVE =
24
+ "border-[var(--color-primary)] text-[var(--color-primary)]";
25
+ const TABS_TAB_INACTIVE =
26
+ "border-transparent text-[var(--color-muted)] hover:text-[var(--color-foreground)] hover:border-[var(--color-border)]";
27
+
28
+ function codeTabClass(active: boolean) {
29
+ return `${CODE_TAB_BASE} ${active ? CODE_TAB_ACTIVE : CODE_TAB_INACTIVE}`;
30
+ }
31
+
32
+ function tabsTabClass(active: boolean) {
33
+ return `${TABS_TAB_BASE} ${active ? TABS_TAB_ACTIVE : TABS_TAB_INACTIVE}`;
34
+ }
35
+
36
+ function findPanels(container: Element): HTMLElement[] {
37
+ const pc = container.querySelector(".panels");
38
+ if (!pc) return [];
39
+ let panels = Array.from(pc.querySelectorAll<HTMLElement>(":scope > pre"));
40
+ if (panels.length === 0) {
41
+ panels = Array.from(pc.querySelectorAll<HTMLElement>("pre"));
42
+ }
43
+ return panels;
44
+ }
45
+
46
+ function setCodeGroupActive(
47
+ cg: Element,
48
+ panels: HTMLElement[],
49
+ index: number
50
+ ) {
51
+ cg.querySelectorAll<HTMLButtonElement>(".tabs button").forEach((btn, i) => {
52
+ const active = i === index;
53
+ btn.className = codeTabClass(active);
54
+ btn.setAttribute("aria-selected", String(active));
55
+ btn.tabIndex = active ? 0 : -1;
56
+ });
57
+ panels.forEach((p, i) =>
58
+ p.setAttribute("data-active", String(i === index))
59
+ );
60
+ }
61
+
62
+ function setTabsActive(ct: Element, panels: HTMLElement[], index: number) {
63
+ ct.querySelectorAll<HTMLButtonElement>(".tabs-nav button").forEach(
64
+ (btn, i) => {
65
+ const active = i === index;
66
+ btn.className = tabsTabClass(active);
67
+ btn.setAttribute("aria-selected", String(active));
68
+ btn.tabIndex = active ? 0 : -1;
69
+ }
70
+ );
71
+ panels.forEach((p, i) =>
72
+ p.setAttribute("data-active", String(i === index))
73
+ );
74
+ }
75
+
76
+ // ============================================
77
+ // CodeGroup Web Component (for Astro-rendered <code-group>)
78
+ // ============================================
79
+
80
+ interface CodeGroupSyncEvent extends CustomEvent {
81
+ detail: { syncKey: string; label: string };
82
+ }
83
+
84
+ if (!customElements.get("code-group")) {
85
+ class CodeGroupElement extends HTMLElement {
86
+ private syncKey = "";
87
+ private labels: string[] = [];
88
+ private panels: HTMLElement[] = [];
89
+ private boundSync: (e: Event) => void;
90
+ private boundStorage: (e: StorageEvent) => void;
91
+
92
+ constructor() {
93
+ super();
94
+ this.boundSync = this.handleSync.bind(this);
95
+ this.boundStorage = this.handleStorage.bind(this);
96
+ }
97
+
98
+ connectedCallback() {
99
+ this.labels = this.dataset.labels
100
+ ? JSON.parse(this.dataset.labels)
101
+ : [];
102
+ this.syncKey = this.dataset.syncKey || "";
103
+ this.panels = findPanels(this);
104
+ if (this.panels.length === 0) return;
105
+
106
+ this.panels.forEach((p, i) => {
107
+ p.setAttribute("role", "tabpanel");
108
+ p.setAttribute("data-active", String(i === 0));
109
+ });
110
+
111
+ if (this.panels.length === 1) return;
112
+
113
+ this.querySelectorAll<HTMLButtonElement>(".tabs button").forEach(
114
+ (btn) => {
115
+ btn.addEventListener("click", () => {
116
+ const label = btn.dataset.label;
117
+ if (label) {
118
+ this.activateLabel(label);
119
+ this.broadcast(label);
120
+ }
121
+ });
122
+ }
123
+ );
124
+
125
+ this.restore();
126
+ window.addEventListener("codegroup-sync", this.boundSync);
127
+ window.addEventListener("storage", this.boundStorage);
128
+ }
129
+
130
+ disconnectedCallback() {
131
+ window.removeEventListener("codegroup-sync", this.boundSync);
132
+ window.removeEventListener("storage", this.boundStorage);
133
+ }
134
+
135
+ private activateLabel(label: string) {
136
+ const i = this.labels.indexOf(label);
137
+ if (i >= 0) setCodeGroupActive(this, this.panels, i);
138
+ }
139
+
140
+ private broadcast(label: string) {
141
+ if (!this.syncKey) return;
142
+ try {
143
+ localStorage.setItem(`codegroup:${this.syncKey}`, label);
144
+ } catch {}
145
+ window.dispatchEvent(
146
+ new CustomEvent("codegroup-sync", {
147
+ detail: { syncKey: this.syncKey, label },
148
+ })
149
+ );
150
+ }
151
+
152
+ private restore() {
153
+ if (!this.syncKey) return;
154
+ try {
155
+ const saved = localStorage.getItem(`codegroup:${this.syncKey}`);
156
+ if (saved && this.labels.includes(saved)) this.activateLabel(saved);
157
+ } catch {}
158
+ }
159
+
160
+ private handleSync(e: Event) {
161
+ const { syncKey, label } = (e as CodeGroupSyncEvent).detail;
162
+ if (syncKey === this.syncKey && this.labels.includes(label))
163
+ this.activateLabel(label);
164
+ }
165
+
166
+ private handleStorage(e: StorageEvent) {
167
+ if (!e.key?.startsWith("codegroup:")) return;
168
+ const sk = e.key.replace("codegroup:", "");
169
+ if (sk === this.syncKey && e.newValue) this.activateLabel(e.newValue);
170
+ }
171
+ }
172
+
173
+ customElements.define("code-group", CodeGroupElement);
174
+ }
175
+
176
+ // ============================================
177
+ // Tabs Web Component (for Astro-rendered <content-tabs>)
178
+ // ============================================
179
+
180
+ if (!customElements.get("content-tabs")) {
181
+ class ContentTabsElement extends HTMLElement {
182
+ private panels: HTMLElement[] = [];
183
+
184
+ connectedCallback() {
185
+ const pc = this.querySelector(".tabs-panels");
186
+ if (!pc) return;
187
+ this.panels = Array.from(
188
+ pc.querySelectorAll<HTMLElement>(":scope > .tab-panel")
189
+ );
190
+ if (this.panels.length === 0) return;
191
+
192
+ this.panels.forEach((p, i) => {
193
+ p.setAttribute("role", "tabpanel");
194
+ p.setAttribute("data-active", String(i === 0));
195
+ });
196
+
197
+ if (this.panels.length === 1) return;
198
+
199
+ this.querySelectorAll<HTMLButtonElement>(".tabs-nav button").forEach(
200
+ (btn, index) => {
201
+ btn.addEventListener("click", () =>
202
+ setTabsActive(this, this.panels, index)
203
+ );
204
+ }
205
+ );
206
+ }
207
+ }
208
+
209
+ customElements.define("content-tabs", ContentTabsElement);
210
+ }
211
+
212
+ // ============================================
213
+ // Event delegation for React-rendered instances
214
+ // (div.code-group and div.tabs-container)
215
+ // ============================================
216
+
217
+ function initDivCodeGroups() {
218
+ document
219
+ .querySelectorAll<HTMLElement>(".code-group:not(code-group)")
220
+ .forEach((cg) => {
221
+ if (cg.dataset.cgInit) return;
222
+ cg.dataset.cgInit = "1";
223
+
224
+ const labels: string[] = cg.dataset.labels
225
+ ? JSON.parse(cg.dataset.labels)
226
+ : [];
227
+ const syncKey = cg.dataset.syncKey || "";
228
+ const panels = findPanels(cg);
229
+ if (panels.length === 0) return;
230
+
231
+ panels.forEach((p, i) => {
232
+ p.setAttribute("role", "tabpanel");
233
+ p.setAttribute("data-active", String(i === 0));
234
+ });
235
+ if (panels.length === 1) return;
236
+
237
+ cg.querySelectorAll<HTMLButtonElement>(".tabs button").forEach((btn) => {
238
+ btn.addEventListener("click", () => {
239
+ const label = btn.dataset.label;
240
+ if (!label) return;
241
+ const idx = labels.indexOf(label);
242
+ if (idx < 0) return;
243
+ setCodeGroupActive(cg, panels, idx);
244
+ if (syncKey) {
245
+ try {
246
+ localStorage.setItem(`codegroup:${syncKey}`, label);
247
+ } catch {}
248
+ window.dispatchEvent(
249
+ new CustomEvent("codegroup-sync", {
250
+ detail: { syncKey, label },
251
+ })
252
+ );
253
+ }
254
+ });
255
+ });
256
+
257
+ // Restore from localStorage
258
+ if (syncKey) {
259
+ try {
260
+ const saved = localStorage.getItem(`codegroup:${syncKey}`);
261
+ if (saved && labels.includes(saved)) {
262
+ const idx = labels.indexOf(saved);
263
+ if (idx >= 0) setCodeGroupActive(cg, panels, idx);
264
+ }
265
+ } catch {}
266
+
267
+ // Cross-instance sync
268
+ window.addEventListener("codegroup-sync", (e: Event) => {
269
+ const { syncKey: sk, label } = (e as CodeGroupSyncEvent).detail;
270
+ if (sk === syncKey && labels.includes(label)) {
271
+ const idx = labels.indexOf(label);
272
+ if (idx >= 0) setCodeGroupActive(cg, panels, idx);
273
+ }
274
+ });
275
+
276
+ window.addEventListener("storage", (e: StorageEvent) => {
277
+ if (e.key === `codegroup:${syncKey}` && e.newValue) {
278
+ const idx = labels.indexOf(e.newValue);
279
+ if (idx >= 0) setCodeGroupActive(cg, panels, idx);
280
+ }
281
+ });
282
+ }
283
+ });
284
+ }
285
+
286
+ function initDivTabs() {
287
+ document
288
+ .querySelectorAll<HTMLElement>(".tabs-container:not(content-tabs)")
289
+ .forEach((ct) => {
290
+ if (ct.dataset.tabsInit) return;
291
+ ct.dataset.tabsInit = "1";
292
+
293
+ const pc = ct.querySelector(".tabs-panels");
294
+ if (!pc) return;
295
+ const panels = Array.from(
296
+ pc.querySelectorAll<HTMLElement>(":scope > .tab-panel")
297
+ );
298
+ if (panels.length === 0) return;
299
+
300
+ panels.forEach((p, i) => {
301
+ p.setAttribute("role", "tabpanel");
302
+ p.setAttribute("data-active", String(i === 0));
303
+ });
304
+ if (panels.length === 1) return;
305
+
306
+ ct.querySelectorAll<HTMLButtonElement>(".tabs-nav button").forEach(
307
+ (btn, index) => {
308
+ btn.addEventListener("click", () =>
309
+ setTabsActive(ct, panels, index)
310
+ );
311
+ }
312
+ );
313
+ });
314
+ }
315
+
316
+ function initAll() {
317
+ initDivCodeGroups();
318
+ initDivTabs();
319
+ }
320
+
321
+ if (document.readyState === "loading") {
322
+ document.addEventListener("DOMContentLoaded", initAll);
323
+ } else {
324
+ initAll();
325
+ }
326
+
327
+ // Re-initialize after Astro view transitions
328
+ document.addEventListener("astro:after-swap", initAll);
@@ -415,17 +415,17 @@
415
415
  /* Handle both direct children and nested pre elements (MDX may wrap them) */
416
416
  .code-group .panels > pre:not(:first-child),
417
417
  .code-group .panels pre:not(:first-of-type) {
418
- @apply hidden; /* Hide non-first panels by default (prevents flash before JS) */
418
+ display: none; /* Hide non-first panels by default (prevents flash before JS) */
419
419
  }
420
420
 
421
421
  .code-group .panels > pre[data-active="true"],
422
422
  .code-group .panels pre[data-active="true"] {
423
- @apply block;
423
+ display: block;
424
424
  }
425
425
 
426
426
  .code-group .panels > pre[data-active="false"],
427
427
  .code-group .panels pre[data-active="false"] {
428
- @apply hidden;
428
+ display: none;
429
429
  }
430
430
 
431
431
  /* Tabs component styles - visibility for slotted tab panels */