@opendocsdev/cli 0.2.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 (78) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +300 -0
  3. package/dist/bin/opendocs.js +712 -0
  4. package/dist/bin/opendocs.js.map +1 -0
  5. package/dist/templates/api-reference.mdx +308 -0
  6. package/dist/templates/components.mdx +286 -0
  7. package/dist/templates/configuration.mdx +190 -0
  8. package/dist/templates/docs.json +27 -0
  9. package/dist/templates/introduction.mdx +25 -0
  10. package/dist/templates/logo.svg +4 -0
  11. package/dist/templates/quickstart.mdx +59 -0
  12. package/dist/templates/writing-content.mdx +236 -0
  13. package/package.json +92 -0
  14. package/src/engine/astro.config.ts +75 -0
  15. package/src/engine/src/components/Analytics.astro +57 -0
  16. package/src/engine/src/components/ApiPlayground.astro +24 -0
  17. package/src/engine/src/components/Callout.astro +66 -0
  18. package/src/engine/src/components/Card.astro +75 -0
  19. package/src/engine/src/components/CardGroup.astro +29 -0
  20. package/src/engine/src/components/CodeGroup.astro +231 -0
  21. package/src/engine/src/components/CopyButton.astro +179 -0
  22. package/src/engine/src/components/Steps.astro +27 -0
  23. package/src/engine/src/components/Tab.astro +21 -0
  24. package/src/engine/src/components/TableOfContents.astro +119 -0
  25. package/src/engine/src/components/Tabs.astro +135 -0
  26. package/src/engine/src/components/index.ts +107 -0
  27. package/src/engine/src/components/react/ApiPlayground/AuthSection.tsx +91 -0
  28. package/src/engine/src/components/react/ApiPlayground/CodeBlock.tsx +66 -0
  29. package/src/engine/src/components/react/ApiPlayground/CodeSnippets.tsx +66 -0
  30. package/src/engine/src/components/react/ApiPlayground/CollapsibleSection.tsx +26 -0
  31. package/src/engine/src/components/react/ApiPlayground/KeyValueEditor.tsx +58 -0
  32. package/src/engine/src/components/react/ApiPlayground/ResponseDisplay.tsx +109 -0
  33. package/src/engine/src/components/react/ApiPlayground/Spinner.tsx +6 -0
  34. package/src/engine/src/components/react/ApiPlayground/constants.ts +16 -0
  35. package/src/engine/src/components/react/ApiPlayground/generators.test.ts +130 -0
  36. package/src/engine/src/components/react/ApiPlayground/generators.ts +75 -0
  37. package/src/engine/src/components/react/ApiPlayground/index.tsx +490 -0
  38. package/src/engine/src/components/react/ApiPlayground/types.ts +35 -0
  39. package/src/engine/src/components/react/Callout.tsx +54 -0
  40. package/src/engine/src/components/react/Card.tsx +48 -0
  41. package/src/engine/src/components/react/CardGroup.tsx +24 -0
  42. package/src/engine/src/components/react/FeedbackWidget.tsx +166 -0
  43. package/src/engine/src/components/react/GitHubLink.tsx +28 -0
  44. package/src/engine/src/components/react/NavigationCard.tsx +53 -0
  45. package/src/engine/src/components/react/PageActions.tsx +124 -0
  46. package/src/engine/src/components/react/PageFooter.tsx +91 -0
  47. package/src/engine/src/components/react/SearchModal.tsx +358 -0
  48. package/src/engine/src/components/react/SearchProvider.tsx +37 -0
  49. package/src/engine/src/components/react/Sidebar.tsx +369 -0
  50. package/src/engine/src/components/react/SidebarSearchTrigger.tsx +57 -0
  51. package/src/engine/src/components/react/Steps.tsx +25 -0
  52. package/src/engine/src/components/react/ThemeToggle.tsx +72 -0
  53. package/src/engine/src/components/react/index.ts +14 -0
  54. package/src/engine/src/env.d.ts +10 -0
  55. package/src/engine/src/layouts/DocsLayout.astro +357 -0
  56. package/src/engine/src/lib/__tests__/markdown.test.ts +124 -0
  57. package/src/engine/src/lib/__tests__/mdx-loader.test.ts +205 -0
  58. package/src/engine/src/lib/config.ts +79 -0
  59. package/src/engine/src/lib/markdown.ts +54 -0
  60. package/src/engine/src/lib/mdx-loader.ts +143 -0
  61. package/src/engine/src/lib/mdx-utils.ts +72 -0
  62. package/src/engine/src/lib/remark-opendocs.ts +195 -0
  63. package/src/engine/src/lib/utils.ts +221 -0
  64. package/src/engine/src/pages/[...slug].astro +115 -0
  65. package/src/engine/src/pages/index.astro +71 -0
  66. package/src/engine/src/scripts/mobile-sidebar.ts +56 -0
  67. package/src/engine/src/scripts/theme-init.ts +25 -0
  68. package/src/engine/src/styles/global.css +703 -0
  69. package/src/engine/tailwind.config.mjs +60 -0
  70. package/src/engine/tsconfig.json +15 -0
  71. package/src/templates/api-reference.mdx +308 -0
  72. package/src/templates/components.mdx +286 -0
  73. package/src/templates/configuration.mdx +190 -0
  74. package/src/templates/docs.json +27 -0
  75. package/src/templates/introduction.mdx +25 -0
  76. package/src/templates/logo.svg +4 -0
  77. package/src/templates/quickstart.mdx +59 -0
  78. package/src/templates/writing-content.mdx +236 -0
@@ -0,0 +1,231 @@
1
+ ---
2
+ /**
3
+ * CodeGroup - Tabbed code block component
4
+ * Server-renders tab buttons to eliminate FOUC
5
+ * Uses Web Components for click handlers and localStorage sync
6
+ */
7
+ interface Props {
8
+ "data-labels"?: string;
9
+ "data-sync-key"?: string;
10
+ class?: string;
11
+ }
12
+
13
+ const {
14
+ "data-labels": labelsJson,
15
+ "data-sync-key": syncKey,
16
+ class: className,
17
+ } = Astro.props;
18
+
19
+ // Parse labels from remark plugin (or empty array for fallback)
20
+ const labels: string[] = labelsJson ? JSON.parse(labelsJson) : [];
21
+ const showTabs = labels.length > 1;
22
+ ---
23
+
24
+ <code-group
25
+ class:list={[
26
+ "code-group not-prose my-4 rounded-xl border border-[var(--color-border)] overflow-hidden bg-[var(--color-surface-raised)] block",
27
+ className,
28
+ ]}
29
+ data-sync-key={syncKey}
30
+ data-labels={labelsJson}
31
+ >
32
+ {showTabs && (
33
+ <nav
34
+ class="tabs flex gap-1 px-3 sm:px-4 pt-3 pb-0 border-b border-[var(--color-border)] overflow-x-auto"
35
+ role="tablist"
36
+ aria-label="Code examples"
37
+ >
38
+ {labels.map((label, i) => (
39
+ <button
40
+ type="button"
41
+ role="tab"
42
+ class:list={[
43
+ "px-2 sm:px-3 py-2 text-xs sm:text-sm font-medium transition-colors -mb-px whitespace-nowrap flex-shrink-0",
44
+ i === 0
45
+ ? "text-[var(--color-foreground)] border-b-2 border-[var(--color-primary)]"
46
+ : "text-[var(--color-muted)] hover:text-[var(--color-foreground)]",
47
+ ]}
48
+ aria-selected={i === 0 ? "true" : "false"}
49
+ tabindex={i === 0 ? 0 : -1}
50
+ data-index={i}
51
+ data-label={label}
52
+ >
53
+ {label}
54
+ </button>
55
+ ))}
56
+ </nav>
57
+ )}
58
+ <div class="panels">
59
+ <slot />
60
+ </div>
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>
@@ -0,0 +1,179 @@
1
+ ---
2
+ // CopyButton component - enhances code blocks with headers and copy buttons
3
+ // This component should be included once in the layout
4
+ ---
5
+
6
+ <script>
7
+ function initCodeBlocks() {
8
+ // Find all pre elements that haven't been enhanced yet
9
+ document.querySelectorAll("pre:not([data-enhanced])").forEach((pre) => {
10
+ // Skip if inside a code-group (those handle their own styling)
11
+ if (pre.closest(".code-group")) {
12
+ // Just add copy button to code-group pre elements
13
+ addCopyButtonToCodeGroup(pre as HTMLPreElement);
14
+ return;
15
+ }
16
+
17
+ pre.setAttribute("data-enhanced", "true");
18
+
19
+ // Get title from data-title attribute (set by Shiki transformer from meta string)
20
+ const title = pre.getAttribute("data-title");
21
+
22
+ // Get language from code element
23
+ const codeEl = pre.querySelector("code");
24
+ const langClass = codeEl
25
+ ? Array.from(codeEl.classList).find((c) => c.startsWith("language-"))
26
+ : null;
27
+ const language = langClass ? langClass.replace("language-", "") : null;
28
+
29
+ // Create wrapper
30
+ const wrapper = document.createElement("div");
31
+ wrapper.className =
32
+ "code-block-wrapper not-prose my-4 rounded-xl border border-[var(--color-border)] overflow-hidden bg-[var(--color-surface-raised)]";
33
+
34
+ // Determine display label
35
+ const displayLabel = title || language;
36
+
37
+ if (displayLabel) {
38
+ // Create header with filename and copy button
39
+ const header = document.createElement("div");
40
+ header.className =
41
+ "code-block-header flex items-center justify-between px-4 py-2.5 border-b border-[var(--color-border)]";
42
+
43
+ // Left side - icon and title
44
+ const leftSide = document.createElement("div");
45
+ leftSide.className =
46
+ "flex items-center gap-2 text-sm text-[var(--color-muted)]";
47
+ leftSide.innerHTML = `
48
+ <svg class="w-4 h-4 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
49
+ <polyline points="16 18 22 12 16 6"/>
50
+ <polyline points="8 6 2 12 8 18"/>
51
+ </svg>
52
+ <span class="font-medium">${displayLabel}</span>
53
+ `;
54
+
55
+ // Right side - copy button
56
+ const copyBtn = createCopyButton();
57
+ copyBtn.classList.remove("opacity-0", "group-hover:opacity-100");
58
+
59
+ header.appendChild(leftSide);
60
+ header.appendChild(copyBtn);
61
+ wrapper.appendChild(header);
62
+
63
+ // Setup copy functionality
64
+ setupCopyHandler(copyBtn, pre as HTMLPreElement);
65
+ } else {
66
+ // No title - add copy button that appears on hover
67
+ wrapper.classList.add("group", "relative");
68
+ const copyBtn = createCopyButton();
69
+ copyBtn.classList.add("absolute", "top-3", "right-3");
70
+
71
+ wrapper.appendChild(copyBtn);
72
+ setupCopyHandler(copyBtn, pre as HTMLPreElement);
73
+ }
74
+
75
+ // Insert wrapper and move pre inside
76
+ pre.parentNode?.insertBefore(wrapper, pre);
77
+ wrapper.appendChild(pre);
78
+
79
+ // Style the pre element inside wrapper
80
+ (pre as HTMLElement).style.margin = "0";
81
+ (pre as HTMLElement).style.borderRadius = "0";
82
+ (pre as HTMLElement).style.border = "none";
83
+ });
84
+ }
85
+
86
+ function addCopyButtonToCodeGroup(pre: HTMLPreElement) {
87
+ if (pre.hasAttribute("data-has-copy")) return;
88
+ pre.setAttribute("data-has-copy", "true");
89
+
90
+ const wrapper = document.createElement("div");
91
+ wrapper.className = "relative group";
92
+
93
+ const copyBtn = createCopyButton();
94
+ copyBtn.classList.add("absolute", "top-3", "right-3");
95
+
96
+ pre.parentNode?.insertBefore(wrapper, pre);
97
+ wrapper.appendChild(pre);
98
+ wrapper.appendChild(copyBtn);
99
+
100
+ setupCopyHandler(copyBtn, pre);
101
+ }
102
+
103
+ function createCopyButton(): HTMLButtonElement {
104
+ const button = document.createElement("button");
105
+ button.type = "button";
106
+ button.className =
107
+ "copy-button p-1.5 rounded-md text-[var(--color-muted)] hover:text-[var(--color-foreground)] hover:bg-[var(--color-surface-sunken)] transition-colors opacity-0 group-hover:opacity-100";
108
+ button.setAttribute("aria-label", "Copy code");
109
+ button.innerHTML = `
110
+ <svg class="w-4 h-4 copy-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
111
+ <rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>
112
+ <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>
113
+ </svg>
114
+ <svg class="w-4 h-4 check-icon hidden text-[var(--color-success)]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
115
+ <path d="M20 6 9 17l-5-5"/>
116
+ </svg>
117
+ `;
118
+ return button;
119
+ }
120
+
121
+ function setupCopyHandler(button: HTMLButtonElement, pre: HTMLPreElement) {
122
+ button.addEventListener("click", async () => {
123
+ const code = pre.querySelector("code");
124
+ const text = code ? code.textContent : pre.textContent;
125
+
126
+ if (!text) return;
127
+
128
+ try {
129
+ await navigator.clipboard.writeText(text);
130
+
131
+ // Show success state
132
+ const copyIcon = button.querySelector(".copy-icon");
133
+ const checkIcon = button.querySelector(".check-icon");
134
+
135
+ copyIcon?.classList.add("hidden");
136
+ checkIcon?.classList.remove("hidden");
137
+
138
+ // Reset after 2 seconds
139
+ setTimeout(() => {
140
+ copyIcon?.classList.remove("hidden");
141
+ checkIcon?.classList.add("hidden");
142
+ }, 2000);
143
+ } catch (err) {
144
+ console.error("Failed to copy code:", err);
145
+ }
146
+ });
147
+ }
148
+
149
+ // Initialize on DOMContentLoaded and after page transitions
150
+ if (document.readyState === "loading") {
151
+ document.addEventListener("DOMContentLoaded", initCodeBlocks);
152
+ } else {
153
+ initCodeBlocks();
154
+ }
155
+ document.addEventListener("astro:after-swap", initCodeBlocks);
156
+ </script>
157
+
158
+ <style is:global>
159
+ /* Code block wrapper styles */
160
+ .code-block-wrapper pre {
161
+ margin: 0 !important;
162
+ border-radius: 0 !important;
163
+ border: none !important;
164
+ }
165
+
166
+ /* Ensure copy button is always visible in header */
167
+ .code-block-header .copy-button {
168
+ opacity: 1 !important;
169
+ }
170
+
171
+ /* Code group copy button positioning */
172
+ .code-group .copy-button {
173
+ z-index: 10;
174
+ }
175
+
176
+ .code-group-panels > .relative {
177
+ position: relative;
178
+ }
179
+ </style>
@@ -0,0 +1,27 @@
1
+ ---
2
+ /**
3
+ * Steps - Numbered step list with connecting line
4
+ * Pure Astro component - no client-side JavaScript needed
5
+ */
6
+ interface Props {
7
+ title?: string;
8
+ class?: string;
9
+ }
10
+
11
+ const { title, class: className } = Astro.props;
12
+ ---
13
+
14
+ <div class:list={["steps my-8 not-prose", className]}>
15
+ {title && (
16
+ <h4 class="steps-title text-lg font-semibold text-[var(--color-foreground)] mb-4">
17
+ {title}
18
+ </h4>
19
+ )}
20
+ <div class="steps-list relative pl-12">
21
+ <div
22
+ class="steps-line absolute left-4 top-4 bottom-4 w-0.5 bg-[var(--color-border)]"
23
+ aria-hidden="true"
24
+ />
25
+ <slot />
26
+ </div>
27
+ </div>
@@ -0,0 +1,21 @@
1
+ ---
2
+ /**
3
+ * Tab - Individual tab panel for use inside <Tabs>
4
+ * Renders as a div with data attributes for the parent Tabs component to read
5
+ */
6
+ export interface Props {
7
+ label: string;
8
+ icon?: string;
9
+ class?: string;
10
+ }
11
+
12
+ const { label, icon, class: className } = Astro.props;
13
+ ---
14
+
15
+ <div
16
+ class:list={["tab-panel prose prose-sm dark:prose-invert max-w-none", className]}
17
+ data-label={label}
18
+ data-icon={icon}
19
+ >
20
+ <slot />
21
+ </div>
@@ -0,0 +1,119 @@
1
+ ---
2
+ /**
3
+ * TableOfContents - Displays page headings for navigation
4
+ * Highlights active heading based on scroll position
5
+ */
6
+ interface Heading {
7
+ depth: number;
8
+ slug: string;
9
+ text: string;
10
+ }
11
+
12
+ interface Props {
13
+ headings: Heading[];
14
+ }
15
+
16
+ const { headings } = Astro.props;
17
+
18
+ // Filter to only show h2 and h3 headings for the table of contents
19
+ const tocHeadings = headings.filter((h) => h.depth >= 2 && h.depth <= 3);
20
+ ---
21
+
22
+ {tocHeadings.length > 0 && (
23
+ <nav class="toc toc-nav" aria-label="Table of contents">
24
+ <h2 class="text-xs font-semibold uppercase tracking-widest text-[var(--color-muted)] mb-4">
25
+ On This Page
26
+ </h2>
27
+ <ul class="space-y-0.5 text-sm">
28
+ {tocHeadings.map((heading) => (
29
+ <li>
30
+ <a
31
+ href={`#${heading.slug}`}
32
+ class:list={[
33
+ "toc-link block transition-all py-1.5 border-l-[3px] border-transparent rounded-r-sm",
34
+ heading.depth === 2
35
+ ? "pl-3 text-[var(--color-foreground)] font-medium"
36
+ : "pl-6 text-[var(--color-muted)] text-[13px]",
37
+ ]}
38
+ data-slug={heading.slug}
39
+ data-depth={heading.depth}
40
+ >
41
+ {heading.text}
42
+ </a>
43
+ </li>
44
+ ))}
45
+ </ul>
46
+ </nav>
47
+ )}
48
+
49
+ <script>
50
+ // Highlight active heading based on scroll position
51
+ function highlightActiveHeading() {
52
+ const headings = document.querySelectorAll("article h2[id], article h3[id]");
53
+ const tocLinks = document.querySelectorAll(".toc-link");
54
+
55
+ if (headings.length === 0 || tocLinks.length === 0) return;
56
+
57
+ const windowHeight = window.innerHeight;
58
+
59
+ // Find the active heading by checking which one is in view
60
+ let activeSlug: string | null = null;
61
+
62
+ headings.forEach((heading) => {
63
+ const rect = heading.getBoundingClientRect();
64
+ // Consider a heading active if it's in the top third of the viewport
65
+ if (rect.top <= windowHeight / 3) {
66
+ activeSlug = heading.id;
67
+ }
68
+ });
69
+
70
+ // If no heading is in view yet, activate the first TOC link
71
+ if (!activeSlug && tocLinks.length > 0) {
72
+ const firstLink = tocLinks[0] as HTMLElement;
73
+ activeSlug = firstLink.dataset.slug || null;
74
+ }
75
+
76
+ // Update active state using CSS class
77
+ tocLinks.forEach((link) => {
78
+ const linkSlug = (link as HTMLElement).dataset.slug;
79
+
80
+ if (linkSlug === activeSlug) {
81
+ link.classList.add("active");
82
+ } else {
83
+ link.classList.remove("active");
84
+ }
85
+ });
86
+ }
87
+
88
+ function initTocHighlight() {
89
+ // Run on scroll with throttling
90
+ let ticking = false;
91
+
92
+ const scrollHandler = () => {
93
+ if (!ticking) {
94
+ window.requestAnimationFrame(() => {
95
+ highlightActiveHeading();
96
+ ticking = false;
97
+ });
98
+ ticking = true;
99
+ }
100
+ };
101
+
102
+ window.addEventListener("scroll", scrollHandler);
103
+
104
+ // Run immediately
105
+ highlightActiveHeading();
106
+ }
107
+
108
+ // Initialize on page load
109
+ if (document.readyState === "loading") {
110
+ document.addEventListener("DOMContentLoaded", initTocHighlight);
111
+ } else {
112
+ initTocHighlight();
113
+ }
114
+
115
+ // Reinitialize after View Transitions
116
+ document.addEventListener("astro:after-swap", () => {
117
+ highlightActiveHeading();
118
+ });
119
+ </script>