@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 +1 -1
- package/src/engine/src/components/CodeGroup.astro +0 -170
- package/src/engine/src/components/Tabs.astro +0 -74
- package/src/engine/src/components/react/CodeGroup.tsx +68 -0
- package/src/engine/src/components/react/PageFooter.tsx +46 -0
- package/src/engine/src/components/react/Sidebar.tsx +26 -13
- package/src/engine/src/components/react/SocialLinks.tsx +59 -0
- package/src/engine/src/components/react/Tab.tsx +25 -0
- package/src/engine/src/components/react/Tabs.tsx +71 -0
- package/src/engine/src/components/react/ThemeToggle.tsx +5 -5
- package/src/engine/src/components/react/index.ts +3 -0
- package/src/engine/src/layouts/DocsLayout.astro +28 -5
- package/src/engine/src/scripts/interactive-components.ts +328 -0
- package/src/engine/src/styles/global.css +3 -3
package/package.json
CHANGED
|
@@ -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 {
|
|
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-
|
|
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-
|
|
269
|
-
<img src={dark} alt={siteName} className="h-
|
|
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-
|
|
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-
|
|
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
|
|
367
|
-
{githubUrl && (
|
|
379
|
+
{/* Bottom section with social links */}
|
|
380
|
+
{(githubUrl || discordUrl || twitterUrl) && (
|
|
368
381
|
<div className="px-4 py-3">
|
|
369
|
-
<
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
64
|
+
<Sun className="w-4 h-4" />
|
|
65
65
|
) : (
|
|
66
|
-
<Moon className="w-
|
|
66
|
+
<Moon className="w-4 h-4" />
|
|
67
67
|
)}
|
|
68
68
|
</button>
|
|
69
69
|
);
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
428
|
+
display: none;
|
|
429
429
|
}
|
|
430
430
|
|
|
431
431
|
/* Tabs component styles - visibility for slotted tab panels */
|