Skip to content

Commit 2b49249

Browse files
committed
feat: wrap custom.js logic in a docusaurus component
This avoids premature DOM manipulation before the page is fully hydrated. Following Docusaurus: https://docusaurus.io/docs/api/client-modules
1 parent 92deae6 commit 2b49249

File tree

4 files changed

+348
-231
lines changed

4 files changed

+348
-231
lines changed

docusaurus.config.js

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -264,12 +264,11 @@ const config = {
264264
"https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/2.0.0/clipboard.min.js",
265265
async: true,
266266
},
267-
{
268-
src: "/docs/js/custom.js",
269-
async: true,
270-
},
271267
],
272-
clientModules: resolveGlob.sync(["./src/js/**/*.js"]),
268+
clientModules: [
269+
'./src/client/ConfigNavigationClient.js',
270+
'./src/client/DetailsClicksClient.js',
271+
],
273272

274273
themeConfig: (
275274
/** @type {import('@docusaurus/preset-classic').ThemeConfig} */
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
/**
2+
* Config Navigation Client Module
3+
*
4+
* This module provides hydration-safe DOM manipulation for the config reference pages.
5+
* It uses Docusaurus lifecycle methods to ensure DOM updates happen AFTER React hydration.
6+
*
7+
* Key features:
8+
* - Sidebar link highlighting based on current hash
9+
* - Details element expansion/collapse for nested config fields
10+
* - Smooth scrolling to target elements
11+
* - State persistence across navigation
12+
*/
13+
14+
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
15+
16+
// ============================================================================
17+
// State Management
18+
// ============================================================================
19+
20+
let isInitialized = false;
21+
let previousHash = null;
22+
23+
// ============================================================================
24+
// Helper Functions
25+
// ============================================================================
26+
27+
/**
28+
* Get all parent details elements for a given element
29+
*/
30+
const getParentDetailsElements = function(element) {
31+
const parents = [];
32+
let current = element;
33+
while (current && current !== document.documentElement) {
34+
if (current.tagName === 'DETAILS') {
35+
parents.push(current);
36+
}
37+
current = current.parentElement;
38+
}
39+
return parents;
40+
};
41+
42+
/**
43+
* Open a details element and its collapsible content
44+
*/
45+
const openDetailsElement = function(detailsEl) {
46+
if (detailsEl && detailsEl.tagName === 'DETAILS' && !detailsEl.open) {
47+
detailsEl.open = true;
48+
detailsEl.setAttribute('data-collapsed', 'false');
49+
// Expand the collapsible content by removing inline styles
50+
const collapsibleContent = detailsEl.querySelector(':scope > div[style]');
51+
if (collapsibleContent) {
52+
collapsibleContent.style.display = 'block';
53+
collapsibleContent.style.overflow = 'visible';
54+
collapsibleContent.style.height = 'auto';
55+
}
56+
}
57+
};
58+
59+
/**
60+
* Close a details element
61+
*/
62+
const closeDetailsElement = function(detailsEl) {
63+
if (detailsEl && detailsEl.tagName === 'DETAILS' && detailsEl.open) {
64+
detailsEl.open = false;
65+
detailsEl.setAttribute('data-collapsed', 'true');
66+
}
67+
};
68+
69+
/**
70+
* Close all details except those in the keep-open set
71+
*/
72+
const closeOtherDetails = function(keepOpenSet) {
73+
document.querySelectorAll('details.config-field[open]').forEach(function(el) {
74+
if (!keepOpenSet.has(el)) {
75+
closeDetailsElement(el);
76+
}
77+
});
78+
};
79+
80+
// ============================================================================
81+
// Core Navigation Functions
82+
// ============================================================================
83+
84+
/**
85+
* Highlight sidebar links based on current hash
86+
*/
87+
const highlightActiveOnPageLink = function() {
88+
if (!location.hash) {
89+
return;
90+
}
91+
92+
const activeHash = location.hash.substring(1);
93+
const allLinks = document.querySelectorAll('a');
94+
95+
// Remove active class from all links
96+
for (let i = 0; i < allLinks.length; i++) {
97+
const link = allLinks[i];
98+
link.classList.remove('active');
99+
100+
if (link.parentElement && link.parentElement.parentElement && link.parentElement.parentElement.tagName === 'UL') {
101+
link.parentElement.parentElement.classList.remove('active');
102+
}
103+
}
104+
105+
// Add active class to links matching current hash
106+
const activeLinks = document.querySelectorAll("a[href='#" + activeHash + "']");
107+
for (let i = 0; i < activeLinks.length; i++) {
108+
activeLinks[i].classList.add('active');
109+
}
110+
};
111+
112+
/**
113+
* Highlight and expand details elements containing the active hash
114+
*/
115+
const highlightDetailsOnActiveHash = function(activeHash, doNotOpen) {
116+
const activeAnchors = document.querySelectorAll(".anchor[id='" + activeHash + "']");
117+
const detailsElements = document.querySelectorAll('details');
118+
const activeSectionElements = document.querySelectorAll('.active-section');
119+
120+
// Remove active-section class from all elements
121+
for (let i = 0; i < activeSectionElements.length; i++) {
122+
activeSectionElements[i].classList.remove('active-section');
123+
}
124+
125+
// Add active-section class to elements following the active anchor
126+
for (let i = 0; i < activeAnchors.length; i++) {
127+
const headline = activeAnchors[i].parentElement;
128+
const headlineRank = activeAnchors[i].parentElement.nodeName.substr(1);
129+
let el = headline;
130+
131+
while (el) {
132+
if (el.tagName !== 'BR' && el.tagName !== 'HR') {
133+
el.classList.add('active-section');
134+
}
135+
el = el.nextElementSibling;
136+
137+
if (el) {
138+
const elRank = el.nodeName.substr(1);
139+
if (elRank > 0 && elRank <= headlineRank) {
140+
break;
141+
}
142+
}
143+
}
144+
}
145+
146+
// Remove active class from all details
147+
for (let i = 0; i < detailsElements.length; i++) {
148+
detailsElements[i].classList.remove('active');
149+
}
150+
151+
// Add active class and open parent details
152+
if (activeAnchors.length > 0) {
153+
for (let i = 0; i < activeAnchors.length; i++) {
154+
let element = activeAnchors[i];
155+
156+
for (; element && element !== document; element = element.parentElement) {
157+
if (element.tagName === 'DETAILS') {
158+
element.classList.add('active');
159+
160+
if (!doNotOpen) {
161+
element.open = true;
162+
element.setAttribute('data-collapsed', 'false');
163+
const collapsibleContent = element.querySelector(':scope > div[style]');
164+
if (collapsibleContent) {
165+
collapsibleContent.style.display = 'block';
166+
collapsibleContent.style.overflow = 'visible';
167+
collapsibleContent.style.height = 'auto';
168+
}
169+
}
170+
}
171+
}
172+
}
173+
}
174+
175+
// Handle elements with matching IDs (for nested config fields)
176+
const targetElement = activeHash ? document.getElementById(activeHash) : null;
177+
if (targetElement) {
178+
const parentDetails = getParentDetailsElements(targetElement);
179+
const keepOpenSet = new Set(parentDetails);
180+
181+
// Close all other details if not in doNotOpen mode
182+
if (!doNotOpen) {
183+
closeOtherDetails(keepOpenSet);
184+
}
185+
186+
// Process parent details
187+
for (let i = 0; i < parentDetails.length; i++) {
188+
const element = parentDetails[i];
189+
element.classList.add('active');
190+
if (!doNotOpen) {
191+
element.open = true;
192+
element.setAttribute('data-collapsed', 'false');
193+
const collapsibleContent = element.querySelector(':scope > div[style]');
194+
if (collapsibleContent) {
195+
collapsibleContent.style.display = 'block';
196+
collapsibleContent.style.overflow = 'visible';
197+
collapsibleContent.style.height = 'auto';
198+
}
199+
}
200+
}
201+
}
202+
};
203+
204+
/**
205+
* Handle hash navigation - expand details and highlight links
206+
*/
207+
const handleHashNavigation = function(hash) {
208+
if (!hash) return;
209+
210+
const targetId = hash.substring(1);
211+
212+
// Expand parent details elements
213+
highlightDetailsOnActiveHash(targetId);
214+
215+
// Highlight active sidebar link
216+
highlightActiveOnPageLink();
217+
218+
// Scroll to target (with delay for animation)
219+
setTimeout(() => {
220+
const element = document.getElementById(targetId);
221+
if (element) {
222+
const yOffset = -280;
223+
const y = element.getBoundingClientRect().top + window.scrollY + yOffset;
224+
window.scrollTo({ top: y, behavior: 'smooth' });
225+
}
226+
}, 100);
227+
};
228+
229+
/**
230+
* Initialize event handlers for hash links and history navigation
231+
*/
232+
const initializeEventHandlers = function() {
233+
// Set up hash link click handlers
234+
const hashLinkIcons = document.querySelectorAll('.hash-link-icon');
235+
for (let i = 0; i < hashLinkIcons.length; i++) {
236+
const hashLinkIcon = hashLinkIcons[i];
237+
238+
// Only add event listener if not already added
239+
if (!hashLinkIcon.hasAttribute('data-nav-handler')) {
240+
hashLinkIcon.setAttribute('data-nav-handler', 'true');
241+
242+
hashLinkIcon.addEventListener('mousedown', function() {
243+
const href = hashLinkIcon.parentElement.attributes.href.value;
244+
history.pushState(null, null, href);
245+
highlightActiveOnPageLink();
246+
highlightDetailsOnActiveHash(location.hash.substr(1), true);
247+
});
248+
}
249+
}
250+
};
251+
252+
// ============================================================================
253+
// Docusaurus Lifecycle Hooks
254+
// ============================================================================
255+
256+
/**
257+
* Called on client-side navigation (before DOM update)
258+
*/
259+
export function onRouteUpdate({ location, previousLocation }) {
260+
// Track previous hash for comparison
261+
if (previousLocation) {
262+
previousHash = previousLocation.hash;
263+
}
264+
}
265+
266+
/**
267+
* Called after route has updated and DOM is ready
268+
* This is where we safely manipulate DOM after React hydration
269+
*/
270+
export function onRouteDidUpdate({ location, previousLocation }) {
271+
if (!ExecutionEnvironment.canUseDOM) {
272+
return;
273+
}
274+
275+
// Initialize on first load
276+
if (!isInitialized) {
277+
isInitialized = true;
278+
279+
// Wait for React hydration to complete
280+
requestAnimationFrame(() => {
281+
requestAnimationFrame(() => {
282+
// Initialize event handlers
283+
initializeEventHandlers();
284+
285+
// Handle initial hash if present
286+
if (location.hash) {
287+
handleHashNavigation(location.hash);
288+
}
289+
});
290+
});
291+
292+
// Set up browser back/forward navigation handler
293+
window.addEventListener('popstate', function() {
294+
handleHashNavigation(location.hash);
295+
});
296+
297+
// Set up hashchange handler (triggered by internal navigation)
298+
window.addEventListener('hashchange', function() {
299+
highlightActiveOnPageLink();
300+
handleHashNavigation(location.hash);
301+
});
302+
303+
return;
304+
}
305+
306+
// Handle hash changes on subsequent navigations
307+
if (location.hash !== previousHash) {
308+
handleHashNavigation(location.hash);
309+
previousHash = location.hash;
310+
}
311+
312+
// Re-initialize event handlers for newly rendered elements
313+
initializeEventHandlers();
314+
}

src/js/details-clicks.js renamed to src/client/DetailsClicksClient.js

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -784,17 +784,35 @@ const preserveExpansionStates = ExecutionEnvironment.canUseDOM ? function(skipEv
784784
}
785785
} : () => {};
786786

787-
if (ExecutionEnvironment.canUseDOM) {
788-
// Ensure copy buttons are added when DOM is fully loaded
789-
if (document.readyState === 'loading') {
790-
document.addEventListener('DOMContentLoaded', function() {
791-
setTimeout(addCopyButtons, 100);
792-
setTimeout(() => preserveExpansionStates(), 200);
793-
});
794-
} else {
795-
setTimeout(addCopyButtons, 100);
796-
preserveExpansionStates();
787+
// ============================================================================
788+
// Docusaurus Lifecycle Hooks
789+
// ============================================================================
790+
791+
let isInitialized = false;
792+
793+
/**
794+
* Called after route has updated and DOM is ready
795+
* This ensures DOM manipulation happens AFTER React hydration
796+
*/
797+
export function onRouteDidUpdate({ location }) {
798+
if (!ExecutionEnvironment.canUseDOM) {
799+
return;
797800
}
798-
}
799801

800-
export default ExecutionEnvironment.canUseDOM ? preserveExpansionStates : () => {};
802+
// Wait for React hydration to complete before manipulating DOM
803+
requestAnimationFrame(() => {
804+
requestAnimationFrame(() => {
805+
// Call preserveExpansionStates on initial load
806+
if (!isInitialized) {
807+
isInitialized = true;
808+
preserveExpansionStates();
809+
} else {
810+
// On subsequent navigations, skip event listener setup
811+
preserveExpansionStates(true);
812+
}
813+
814+
// Always add copy buttons
815+
addCopyButtons();
816+
});
817+
});
818+
}

0 commit comments

Comments
 (0)