docyard 0.3.0 → 0.5.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -2
- data/README.md +80 -33
- data/lib/docyard/build/asset_bundler.rb +139 -0
- data/lib/docyard/build/file_copier.rb +105 -0
- data/lib/docyard/build/sitemap_generator.rb +57 -0
- data/lib/docyard/build/static_generator.rb +141 -0
- data/lib/docyard/builder.rb +104 -0
- data/lib/docyard/cli.rb +19 -0
- data/lib/docyard/components/heading_anchor_processor.rb +34 -0
- data/lib/docyard/components/table_of_contents_processor.rb +64 -0
- data/lib/docyard/components/table_wrapper_processor.rb +18 -0
- data/lib/docyard/config.rb +15 -2
- data/lib/docyard/icons/phosphor.rb +3 -1
- data/lib/docyard/initializer.rb +80 -14
- data/lib/docyard/markdown.rb +19 -0
- data/lib/docyard/prev_next_builder.rb +159 -0
- data/lib/docyard/preview_server.rb +72 -0
- data/lib/docyard/rack_application.rb +25 -3
- data/lib/docyard/renderer.rb +33 -8
- data/lib/docyard/sidebar/config_parser.rb +180 -0
- data/lib/docyard/sidebar/item.rb +58 -0
- data/lib/docyard/sidebar/renderer.rb +33 -6
- data/lib/docyard/sidebar_builder.rb +45 -1
- data/lib/docyard/templates/assets/css/components/callout.css +1 -1
- data/lib/docyard/templates/assets/css/components/code-block.css +2 -2
- data/lib/docyard/templates/assets/css/components/heading-anchor.css +77 -0
- data/lib/docyard/templates/assets/css/components/navigation.css +65 -7
- data/lib/docyard/templates/assets/css/components/prev-next.css +114 -0
- data/lib/docyard/templates/assets/css/components/table-of-contents.css +269 -0
- data/lib/docyard/templates/assets/css/components/tabs.css +3 -2
- data/lib/docyard/templates/assets/css/components/theme-toggle.css +8 -0
- data/lib/docyard/templates/assets/css/layout.css +58 -1
- data/lib/docyard/templates/assets/css/markdown.css +20 -11
- data/lib/docyard/templates/assets/css/variables.css +1 -0
- data/lib/docyard/templates/assets/js/components/heading-anchor.js +90 -0
- data/lib/docyard/templates/assets/js/components/navigation.js +225 -0
- data/lib/docyard/templates/assets/js/components/table-of-contents.js +301 -0
- data/lib/docyard/templates/assets/js/theme.js +2 -185
- data/lib/docyard/templates/config/docyard.yml.erb +32 -10
- data/lib/docyard/templates/layouts/default.html.erb +10 -2
- data/lib/docyard/templates/markdown/getting-started/installation.md.erb +46 -12
- data/lib/docyard/templates/markdown/guides/configuration.md.erb +202 -0
- data/lib/docyard/templates/markdown/guides/markdown-features.md.erb +247 -0
- data/lib/docyard/templates/markdown/index.md.erb +55 -59
- data/lib/docyard/templates/partials/_heading_anchor.html.erb +1 -0
- data/lib/docyard/templates/partials/_nav_group.html.erb +10 -4
- data/lib/docyard/templates/partials/_nav_leaf.html.erb +9 -1
- data/lib/docyard/templates/partials/_prev_next.html.erb +23 -0
- data/lib/docyard/templates/partials/_table_of_contents.html.erb +45 -0
- data/lib/docyard/templates/partials/_table_of_contents_toggle.html.erb +8 -0
- data/lib/docyard/version.rb +1 -1
- data/lib/docyard.rb +8 -0
- metadata +67 -10
- data/lib/docyard/templates/markdown/components/callouts.md.erb +0 -204
- data/lib/docyard/templates/markdown/components/icons.md.erb +0 -125
- data/lib/docyard/templates/markdown/components/tabs.md.erb +0 -686
- data/lib/docyard/templates/markdown/configuration.md.erb +0 -202
- data/lib/docyard/templates/markdown/core-concepts/file-structure.md.erb +0 -61
- data/lib/docyard/templates/markdown/core-concepts/markdown.md.erb +0 -90
- data/lib/docyard/templates/markdown/getting-started/introduction.md.erb +0 -30
- data/lib/docyard/templates/markdown/getting-started/quick-start.md.erb +0 -56
- data/lib/docyard/templates/partials/_icons.html.erb +0 -11
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HeadingAnchorManager handles anchor link interactions
|
|
3
|
+
* Provides copy-to-clipboard functionality with visual feedback
|
|
4
|
+
*/
|
|
5
|
+
class HeadingAnchorManager {
|
|
6
|
+
constructor() {
|
|
7
|
+
this.anchors = document.querySelectorAll('.heading-anchor');
|
|
8
|
+
this.init();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
init() {
|
|
12
|
+
this.anchors.forEach(anchor => {
|
|
13
|
+
anchor.addEventListener('click', (e) => this.handleClick(e, anchor));
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Handle anchor link click
|
|
19
|
+
* @param {Event} e - Click event
|
|
20
|
+
* @param {HTMLElement} anchor - Anchor element
|
|
21
|
+
*/
|
|
22
|
+
handleClick(e, anchor) {
|
|
23
|
+
e.preventDefault();
|
|
24
|
+
|
|
25
|
+
const headingId = anchor.dataset.headingId;
|
|
26
|
+
const url = `${window.location.origin}${window.location.pathname}#${headingId}`;
|
|
27
|
+
|
|
28
|
+
this.copyToClipboard(url, anchor);
|
|
29
|
+
|
|
30
|
+
history.pushState(null, null, `#${headingId}`);
|
|
31
|
+
|
|
32
|
+
const heading = document.getElementById(headingId);
|
|
33
|
+
if (heading) {
|
|
34
|
+
heading.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Copy text to clipboard with visual feedback
|
|
40
|
+
* @param {string} text - Text to copy
|
|
41
|
+
* @param {HTMLElement} anchor - Anchor element for feedback
|
|
42
|
+
*/
|
|
43
|
+
async copyToClipboard(text, anchor) {
|
|
44
|
+
try {
|
|
45
|
+
await navigator.clipboard.writeText(text);
|
|
46
|
+
this.showFeedback(anchor, true);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
this.fallbackCopyToClipboard(text);
|
|
49
|
+
this.showFeedback(anchor, true);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Fallback copy method for older browsers
|
|
55
|
+
* @param {string} text - Text to copy
|
|
56
|
+
*/
|
|
57
|
+
fallbackCopyToClipboard(text) {
|
|
58
|
+
const textarea = document.createElement('textarea');
|
|
59
|
+
textarea.value = text;
|
|
60
|
+
textarea.style.position = 'fixed';
|
|
61
|
+
textarea.style.opacity = '0';
|
|
62
|
+
document.body.appendChild(textarea);
|
|
63
|
+
textarea.select();
|
|
64
|
+
document.execCommand('copy');
|
|
65
|
+
document.body.removeChild(textarea);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Show visual feedback on copy
|
|
70
|
+
* @param {HTMLElement} anchor - Anchor element
|
|
71
|
+
* @param {boolean} success - Whether copy succeeded
|
|
72
|
+
*/
|
|
73
|
+
showFeedback(anchor, success) {
|
|
74
|
+
const originalTitle = anchor.getAttribute('aria-label');
|
|
75
|
+
anchor.setAttribute('aria-label', success ? 'Link copied!' : 'Failed to copy');
|
|
76
|
+
|
|
77
|
+
anchor.style.color = success ? 'var(--color-success, #10b981)' : 'var(--color-danger, #ef4444)';
|
|
78
|
+
|
|
79
|
+
setTimeout(() => {
|
|
80
|
+
anchor.setAttribute('aria-label', originalTitle);
|
|
81
|
+
anchor.style.color = '';
|
|
82
|
+
}, 2000);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (typeof window !== 'undefined') {
|
|
87
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
88
|
+
new HeadingAnchorManager();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Docyard Navigation JavaScript
|
|
2
|
+
// Handles sidebar navigation, mobile menu, accordion groups, and scroll behavior
|
|
3
|
+
|
|
4
|
+
(function() {
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
function initMobileMenu() {
|
|
8
|
+
const toggle = document.querySelector('.secondary-header-menu');
|
|
9
|
+
const sidebar = document.querySelector('.sidebar');
|
|
10
|
+
const overlay = document.querySelector('.mobile-overlay');
|
|
11
|
+
|
|
12
|
+
if (!toggle || !sidebar || !overlay) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function openMenu() {
|
|
17
|
+
sidebar.classList.add('is-open');
|
|
18
|
+
overlay.classList.add('is-visible');
|
|
19
|
+
toggle.setAttribute('aria-expanded', 'true');
|
|
20
|
+
document.body.style.overflow = 'hidden';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function closeMenu() {
|
|
24
|
+
sidebar.classList.remove('is-open');
|
|
25
|
+
overlay.classList.remove('is-visible');
|
|
26
|
+
toggle.setAttribute('aria-expanded', 'false');
|
|
27
|
+
document.body.style.overflow = '';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function toggleMenu() {
|
|
31
|
+
if (sidebar.classList.contains('is-open')) {
|
|
32
|
+
closeMenu();
|
|
33
|
+
} else {
|
|
34
|
+
openMenu();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
toggle.addEventListener('click', function(e) {
|
|
39
|
+
toggleMenu();
|
|
40
|
+
});
|
|
41
|
+
overlay.addEventListener('click', closeMenu);
|
|
42
|
+
|
|
43
|
+
document.addEventListener('keydown', function(e) {
|
|
44
|
+
if (e.key === 'Escape' && sidebar.classList.contains('is-open')) {
|
|
45
|
+
closeMenu();
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
sidebar.querySelectorAll('a').forEach(function(link) {
|
|
50
|
+
link.addEventListener('click', closeMenu);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function initAccordion() {
|
|
55
|
+
const toggles = document.querySelectorAll('.nav-group-toggle');
|
|
56
|
+
|
|
57
|
+
toggles.forEach(function(toggle) {
|
|
58
|
+
toggle.addEventListener('click', function() {
|
|
59
|
+
const expanded = toggle.getAttribute('aria-expanded') === 'true';
|
|
60
|
+
const children = toggle.nextElementSibling;
|
|
61
|
+
|
|
62
|
+
if (!children || !children.classList.contains('nav-group-children')) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
toggle.setAttribute('aria-expanded', !expanded);
|
|
67
|
+
children.classList.toggle('collapsed');
|
|
68
|
+
|
|
69
|
+
if (expanded) {
|
|
70
|
+
children.style.maxHeight = '0';
|
|
71
|
+
} else {
|
|
72
|
+
children.style.maxHeight = children.scrollHeight + 'px';
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
document.querySelectorAll('.nav-group-children.collapsed').forEach(function(el) {
|
|
78
|
+
el.style.maxHeight = '0';
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function expandActiveGroups() {
|
|
83
|
+
const activeLinks = document.querySelectorAll('a.active');
|
|
84
|
+
|
|
85
|
+
activeLinks.forEach(function(activeLink) {
|
|
86
|
+
let parent = activeLink.closest('.nav-group-children');
|
|
87
|
+
|
|
88
|
+
while (parent) {
|
|
89
|
+
if (parent.classList.contains('nav-group-children')) {
|
|
90
|
+
parent.classList.remove('collapsed');
|
|
91
|
+
|
|
92
|
+
const toggle = parent.previousElementSibling;
|
|
93
|
+
if (toggle && toggle.classList.contains('nav-group-toggle')) {
|
|
94
|
+
toggle.setAttribute('aria-expanded', 'true');
|
|
95
|
+
|
|
96
|
+
parent.style.maxHeight = 'none';
|
|
97
|
+
const height = parent.scrollHeight;
|
|
98
|
+
parent.style.maxHeight = height + 'px';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
parent = parent.parentElement?.closest('.nav-group-children');
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function initSidebarScroll() {
|
|
108
|
+
const scrollContainer = document.querySelector('.sidebar nav');
|
|
109
|
+
if (!scrollContainer) return;
|
|
110
|
+
|
|
111
|
+
const STORAGE_KEY = 'docyard_sidebar_scroll';
|
|
112
|
+
const savedPosition = sessionStorage.getItem(STORAGE_KEY);
|
|
113
|
+
|
|
114
|
+
if (savedPosition) {
|
|
115
|
+
const position = parseInt(savedPosition, 10);
|
|
116
|
+
scrollContainer.scrollTop = position;
|
|
117
|
+
|
|
118
|
+
setTimeout(function() {
|
|
119
|
+
scrollContainer.scrollTop = position;
|
|
120
|
+
}, 100);
|
|
121
|
+
} else {
|
|
122
|
+
const activeLink = scrollContainer.querySelector('a.active');
|
|
123
|
+
if (activeLink) {
|
|
124
|
+
setTimeout(function() {
|
|
125
|
+
activeLink.scrollIntoView({
|
|
126
|
+
behavior: 'instant',
|
|
127
|
+
block: 'center'
|
|
128
|
+
});
|
|
129
|
+
}, 50);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
scrollContainer.querySelectorAll('a').forEach(function(link) {
|
|
134
|
+
link.addEventListener('click', function() {
|
|
135
|
+
sessionStorage.setItem(STORAGE_KEY, scrollContainer.scrollTop);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
let scrollTimeout;
|
|
140
|
+
scrollContainer.addEventListener('scroll', function() {
|
|
141
|
+
clearTimeout(scrollTimeout);
|
|
142
|
+
scrollTimeout = setTimeout(function() {
|
|
143
|
+
sessionStorage.setItem(STORAGE_KEY, scrollContainer.scrollTop);
|
|
144
|
+
}, 150);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const logo = document.querySelector('.header-logo');
|
|
148
|
+
if (logo) {
|
|
149
|
+
logo.addEventListener('click', function() {
|
|
150
|
+
sessionStorage.removeItem(STORAGE_KEY);
|
|
151
|
+
scrollContainer.scrollTop = 0;
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function initScrollBehavior() {
|
|
157
|
+
const header = document.querySelector('.header');
|
|
158
|
+
const secondaryHeader = document.querySelector('.secondary-header');
|
|
159
|
+
|
|
160
|
+
if (!header || !secondaryHeader) return;
|
|
161
|
+
|
|
162
|
+
let lastScrollTop = 0;
|
|
163
|
+
let ticking = false;
|
|
164
|
+
|
|
165
|
+
function isMobile() {
|
|
166
|
+
return window.innerWidth <= 1024;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function updateHeaders() {
|
|
170
|
+
if (!isMobile()) {
|
|
171
|
+
header.classList.remove('hide-on-scroll');
|
|
172
|
+
secondaryHeader.classList.remove('shift-up');
|
|
173
|
+
ticking = false;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
|
178
|
+
|
|
179
|
+
if (scrollTop > lastScrollTop && scrollTop > 100) {
|
|
180
|
+
header.classList.add('hide-on-scroll');
|
|
181
|
+
secondaryHeader.classList.add('shift-up');
|
|
182
|
+
} else if (scrollTop < lastScrollTop) {
|
|
183
|
+
header.classList.remove('hide-on-scroll');
|
|
184
|
+
secondaryHeader.classList.remove('shift-up');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
lastScrollTop = scrollTop <= 0 ? 0 : scrollTop;
|
|
188
|
+
ticking = false;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
window.addEventListener('scroll', function() {
|
|
192
|
+
if (!ticking) {
|
|
193
|
+
window.requestAnimationFrame(updateHeaders);
|
|
194
|
+
ticking = true;
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
window.addEventListener('resize', function() {
|
|
199
|
+
if (!isMobile()) {
|
|
200
|
+
header.classList.remove('hide-on-scroll');
|
|
201
|
+
secondaryHeader.classList.remove('shift-up');
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if ('scrollRestoration' in history) {
|
|
207
|
+
history.scrollRestoration = 'manual';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (document.readyState === 'loading') {
|
|
211
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
212
|
+
initMobileMenu();
|
|
213
|
+
initAccordion();
|
|
214
|
+
expandActiveGroups();
|
|
215
|
+
initSidebarScroll();
|
|
216
|
+
initScrollBehavior();
|
|
217
|
+
});
|
|
218
|
+
} else {
|
|
219
|
+
initMobileMenu();
|
|
220
|
+
initAccordion();
|
|
221
|
+
expandActiveGroups();
|
|
222
|
+
initSidebarScroll();
|
|
223
|
+
initScrollBehavior();
|
|
224
|
+
}
|
|
225
|
+
})();
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TableOfContentsManager handles TOC interactions and scroll tracking
|
|
3
|
+
* Features: scroll spy, active highlighting, smooth scrolling, mobile toggle
|
|
4
|
+
*/
|
|
5
|
+
class TableOfContentsManager {
|
|
6
|
+
constructor() {
|
|
7
|
+
this.toc = document.querySelector('.docyard-toc');
|
|
8
|
+
if (!this.toc) return;
|
|
9
|
+
|
|
10
|
+
this.links = Array.from(this.toc.querySelectorAll('.docyard-toc__link'));
|
|
11
|
+
this.headings = this.getHeadings();
|
|
12
|
+
this.nav = this.toc.querySelector('.docyard-toc__nav');
|
|
13
|
+
this.activeLink = null;
|
|
14
|
+
this.observer = null;
|
|
15
|
+
this.ticking = false;
|
|
16
|
+
|
|
17
|
+
this.init();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
init() {
|
|
21
|
+
if (this.headings.length === 0) return;
|
|
22
|
+
|
|
23
|
+
this.setupScrollSpy();
|
|
24
|
+
this.setupSmoothScrolling();
|
|
25
|
+
this.setupMobileToggle();
|
|
26
|
+
this.setupKeyboardNavigation();
|
|
27
|
+
this.handleInitialHash();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Check if viewport is mobile/tablet (TOC in secondary header)
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
isMobile() {
|
|
35
|
+
return window.innerWidth <= 1280;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check if viewport is tablet (both headers visible)
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
isTablet() {
|
|
43
|
+
return window.innerWidth > 1024 && window.innerWidth <= 1280;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get scroll offset based on viewport and header state
|
|
48
|
+
* @returns {number}
|
|
49
|
+
*/
|
|
50
|
+
getScrollOffset() {
|
|
51
|
+
if (this.isTablet()) {
|
|
52
|
+
return 128;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (window.innerWidth <= 1024) {
|
|
56
|
+
const header = document.querySelector('.header');
|
|
57
|
+
const isHeaderHidden = header && header.classList.contains('hide-on-scroll');
|
|
58
|
+
return isHeaderHidden ? 128 : 64;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return 100;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get all headings referenced in TOC
|
|
66
|
+
* @returns {Array<HTMLElement>}
|
|
67
|
+
*/
|
|
68
|
+
getHeadings() {
|
|
69
|
+
return this.links
|
|
70
|
+
.map(link => {
|
|
71
|
+
const id = link.dataset.headingId;
|
|
72
|
+
return document.getElementById(id);
|
|
73
|
+
})
|
|
74
|
+
.filter(Boolean);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Setup Intersection Observer for scroll spy
|
|
79
|
+
*/
|
|
80
|
+
setupScrollSpy() {
|
|
81
|
+
const options = {
|
|
82
|
+
root: null,
|
|
83
|
+
rootMargin: '-80px 0px -80% 0px',
|
|
84
|
+
threshold: 0
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
this.observer = new IntersectionObserver((entries) => {
|
|
88
|
+
entries.forEach(entry => {
|
|
89
|
+
if (entry.isIntersecting) {
|
|
90
|
+
this.setActiveLink(entry.target.id);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}, options);
|
|
94
|
+
|
|
95
|
+
this.headings.forEach(heading => {
|
|
96
|
+
this.observer.observe(heading);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Set active link in TOC
|
|
102
|
+
* @param {string} id - Heading ID
|
|
103
|
+
*/
|
|
104
|
+
setActiveLink(id) {
|
|
105
|
+
const link = this.links.find(l => l.dataset.headingId === id);
|
|
106
|
+
if (!link || link === this.activeLink) return;
|
|
107
|
+
|
|
108
|
+
if (this.activeLink) {
|
|
109
|
+
this.activeLink.classList.remove('is-active');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
link.classList.add('is-active');
|
|
113
|
+
this.activeLink = link;
|
|
114
|
+
|
|
115
|
+
this.scrollLinkIntoView(link);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Scroll TOC to keep active link visible
|
|
120
|
+
* @param {HTMLElement} link - Active link element
|
|
121
|
+
*/
|
|
122
|
+
scrollLinkIntoView(link) {
|
|
123
|
+
if (this.isMobile()) return;
|
|
124
|
+
|
|
125
|
+
if (!this.ticking) {
|
|
126
|
+
requestAnimationFrame(() => {
|
|
127
|
+
const tocRect = this.toc.getBoundingClientRect();
|
|
128
|
+
const linkRect = link.getBoundingClientRect();
|
|
129
|
+
|
|
130
|
+
if (linkRect.top < tocRect.top || linkRect.bottom > tocRect.bottom) {
|
|
131
|
+
link.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
this.ticking = false;
|
|
135
|
+
});
|
|
136
|
+
this.ticking = true;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Setup smooth scrolling for TOC links
|
|
142
|
+
*/
|
|
143
|
+
setupSmoothScrolling() {
|
|
144
|
+
this.links.forEach(link => {
|
|
145
|
+
link.addEventListener('click', (e) => {
|
|
146
|
+
e.preventDefault();
|
|
147
|
+
|
|
148
|
+
const id = link.dataset.headingId;
|
|
149
|
+
const heading = document.getElementById(id);
|
|
150
|
+
|
|
151
|
+
if (heading) {
|
|
152
|
+
const offsetTop = heading.getBoundingClientRect().top + window.pageYOffset - this.getScrollOffset();
|
|
153
|
+
|
|
154
|
+
window.scrollTo({
|
|
155
|
+
top: offsetTop,
|
|
156
|
+
behavior: 'smooth'
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
history.pushState(null, null, `#${id}`);
|
|
160
|
+
|
|
161
|
+
heading.focus({ preventScroll: true });
|
|
162
|
+
|
|
163
|
+
// Close mobile menu after clicking a link
|
|
164
|
+
if (this.isMobile()) {
|
|
165
|
+
this.collapseMobile();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Setup mobile toggle functionality
|
|
174
|
+
*/
|
|
175
|
+
setupMobileToggle() {
|
|
176
|
+
const secondaryToggle = document.querySelector('.secondary-header-toc-toggle');
|
|
177
|
+
|
|
178
|
+
if (!secondaryToggle || !this.nav) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
secondaryToggle.addEventListener('click', () => {
|
|
183
|
+
const isExpanded = secondaryToggle.getAttribute('aria-expanded') === 'true';
|
|
184
|
+
secondaryToggle.setAttribute('aria-expanded', !isExpanded);
|
|
185
|
+
this.nav.classList.toggle('is-expanded', !isExpanded);
|
|
186
|
+
|
|
187
|
+
if (!isExpanded) {
|
|
188
|
+
document.body.style.overflow = 'hidden';
|
|
189
|
+
} else {
|
|
190
|
+
document.body.style.overflow = '';
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Close menu when clicking outside (mobile only)
|
|
195
|
+
document.addEventListener('click', (e) => {
|
|
196
|
+
if (!this.isMobile()) return;
|
|
197
|
+
|
|
198
|
+
const isExpanded = secondaryToggle.getAttribute('aria-expanded') === 'true';
|
|
199
|
+
if (!isExpanded) return;
|
|
200
|
+
|
|
201
|
+
const isClickInsideToc = this.nav.contains(e.target);
|
|
202
|
+
const isClickOnToggle = secondaryToggle.contains(e.target);
|
|
203
|
+
|
|
204
|
+
if (!isClickInsideToc && !isClickOnToggle) {
|
|
205
|
+
this.collapseMobile();
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Collapse mobile TOC
|
|
212
|
+
*/
|
|
213
|
+
collapseMobile() {
|
|
214
|
+
const secondaryToggle = document.querySelector('.secondary-header-toc-toggle');
|
|
215
|
+
if (secondaryToggle) {
|
|
216
|
+
secondaryToggle.setAttribute('aria-expanded', 'false');
|
|
217
|
+
if (this.nav) {
|
|
218
|
+
this.nav.classList.remove('is-expanded');
|
|
219
|
+
}
|
|
220
|
+
document.body.style.overflow = '';
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Setup keyboard navigation
|
|
226
|
+
*/
|
|
227
|
+
setupKeyboardNavigation() {
|
|
228
|
+
this.links.forEach((link, index) => {
|
|
229
|
+
link.addEventListener('keydown', (e) => {
|
|
230
|
+
let targetIndex = -1;
|
|
231
|
+
|
|
232
|
+
switch (e.key) {
|
|
233
|
+
case 'ArrowDown':
|
|
234
|
+
e.preventDefault();
|
|
235
|
+
targetIndex = index + 1;
|
|
236
|
+
break;
|
|
237
|
+
case 'ArrowUp':
|
|
238
|
+
e.preventDefault();
|
|
239
|
+
targetIndex = index - 1;
|
|
240
|
+
break;
|
|
241
|
+
case 'Home':
|
|
242
|
+
e.preventDefault();
|
|
243
|
+
targetIndex = 0;
|
|
244
|
+
break;
|
|
245
|
+
case 'End':
|
|
246
|
+
e.preventDefault();
|
|
247
|
+
targetIndex = this.links.length - 1;
|
|
248
|
+
break;
|
|
249
|
+
default:
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (targetIndex >= 0 && targetIndex < this.links.length) {
|
|
254
|
+
this.links[targetIndex].focus();
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Handle initial hash in URL on page load
|
|
262
|
+
*/
|
|
263
|
+
handleInitialHash() {
|
|
264
|
+
const hash = window.location.hash.slice(1);
|
|
265
|
+
if (!hash) return;
|
|
266
|
+
|
|
267
|
+
const heading = document.getElementById(hash);
|
|
268
|
+
if (!heading) return;
|
|
269
|
+
|
|
270
|
+
setTimeout(() => {
|
|
271
|
+
this.setActiveLink(hash);
|
|
272
|
+
|
|
273
|
+
const offsetTop = heading.getBoundingClientRect().top + window.pageYOffset - this.getScrollOffset();
|
|
274
|
+
window.scrollTo({
|
|
275
|
+
top: offsetTop,
|
|
276
|
+
behavior: 'auto'
|
|
277
|
+
});
|
|
278
|
+
}, 100);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Cleanup and destroy
|
|
283
|
+
*/
|
|
284
|
+
destroy() {
|
|
285
|
+
if (this.observer) {
|
|
286
|
+
this.observer.disconnect();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (typeof window !== 'undefined') {
|
|
292
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
293
|
+
window.tocManager = new TableOfContentsManager();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
window.addEventListener('beforeunload', () => {
|
|
297
|
+
if (window.tocManager) {
|
|
298
|
+
window.tocManager.destroy();
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
}
|