ted-mosby 1.0.0 → 1.1.1

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.
@@ -0,0 +1,855 @@
1
+ /**
2
+ * Client-Side JavaScript for Interactive Wiki Features
3
+ *
4
+ * Provides:
5
+ * - Full-text search
6
+ * - Guided tours / onboarding
7
+ * - Code explorer with syntax highlighting
8
+ * - Keyboard navigation
9
+ * - Theme switching
10
+ * - Progress tracking
11
+ * - Mermaid diagram interactions
12
+ */
13
+ export function getClientScripts(features) {
14
+ return `
15
+ (function() {
16
+ 'use strict';
17
+
18
+ // ========================================
19
+ // Configuration & State
20
+ // ========================================
21
+ const config = window.WIKI_CONFIG || {};
22
+ const state = {
23
+ manifest: null,
24
+ searchIndex: [],
25
+ readPages: new Set(),
26
+ currentTour: null,
27
+ tourStep: 0
28
+ };
29
+
30
+ // ========================================
31
+ // Initialization
32
+ // ========================================
33
+ document.addEventListener('DOMContentLoaded', async () => {
34
+ // Load manifest
35
+ await loadManifest();
36
+
37
+ // Initialize features
38
+ initTheme();
39
+ initMobileMenu();
40
+ initScrollToTop();
41
+ initCopyButtons();
42
+ initMermaid();
43
+ initSourceLinks();
44
+ initTocHighlight();
45
+
46
+ ${features.search ? 'initSearch();' : ''}
47
+ ${features.guidedTour ? 'initTours();' : ''}
48
+ ${features.codeExplorer ? 'initCodeExplorer();' : ''}
49
+ ${features.keyboardNav ? 'initKeyboardNav();' : ''}
50
+ ${features.progressTracking ? 'initProgressTracking();' : ''}
51
+
52
+ // Highlight code blocks
53
+ if (window.Prism) {
54
+ Prism.highlightAll();
55
+ }
56
+ });
57
+
58
+ // ========================================
59
+ // Manifest Loading
60
+ // ========================================
61
+ async function loadManifest() {
62
+ try {
63
+ const response = await fetch(config.rootPath + 'manifest.json');
64
+ state.manifest = await response.json();
65
+ state.searchIndex = state.manifest.searchIndex || [];
66
+ } catch (e) {
67
+ console.warn('Failed to load manifest:', e);
68
+ }
69
+ }
70
+
71
+ // ========================================
72
+ // Theme Management
73
+ // ========================================
74
+ function initTheme() {
75
+ const toggle = document.querySelector('.theme-toggle');
76
+ if (!toggle) return;
77
+
78
+ // Load saved theme
79
+ const savedTheme = localStorage.getItem('wiki-theme');
80
+ if (savedTheme) {
81
+ document.documentElement.setAttribute('data-theme', savedTheme);
82
+ }
83
+
84
+ toggle.addEventListener('click', () => {
85
+ const current = document.documentElement.getAttribute('data-theme');
86
+ const next = current === 'dark' ? 'light' : 'dark';
87
+ document.documentElement.setAttribute('data-theme', next);
88
+ localStorage.setItem('wiki-theme', next);
89
+ showToast('Theme switched to ' + next, 'info');
90
+ });
91
+ }
92
+
93
+ // ========================================
94
+ // Mobile Menu
95
+ // ========================================
96
+ function initMobileMenu() {
97
+ const toggle = document.querySelector('.mobile-menu-toggle');
98
+ const sidebar = document.querySelector('.sidebar');
99
+
100
+ if (!toggle || !sidebar) return;
101
+
102
+ toggle.addEventListener('click', () => {
103
+ sidebar.classList.toggle('open');
104
+ });
105
+
106
+ // Close on outside click
107
+ document.addEventListener('click', (e) => {
108
+ if (sidebar.classList.contains('open') &&
109
+ !sidebar.contains(e.target) &&
110
+ !toggle.contains(e.target)) {
111
+ sidebar.classList.remove('open');
112
+ }
113
+ });
114
+ }
115
+
116
+ // ========================================
117
+ // Scroll to Top
118
+ // ========================================
119
+ function initScrollToTop() {
120
+ const btn = document.querySelector('.scroll-to-top');
121
+ if (!btn) return;
122
+
123
+ btn.addEventListener('click', () => {
124
+ window.scrollTo({ top: 0, behavior: 'smooth' });
125
+ });
126
+ }
127
+
128
+ // ========================================
129
+ // Code Copy Buttons
130
+ // ========================================
131
+ function initCopyButtons() {
132
+ document.querySelectorAll('.code-copy').forEach(btn => {
133
+ btn.addEventListener('click', async () => {
134
+ const codeBlock = btn.closest('.code-block');
135
+ const code = codeBlock.querySelector('code').textContent;
136
+
137
+ try {
138
+ await navigator.clipboard.writeText(code);
139
+ btn.classList.add('copied');
140
+ showToast('Code copied!', 'success');
141
+
142
+ setTimeout(() => {
143
+ btn.classList.remove('copied');
144
+ }, 2000);
145
+ } catch (e) {
146
+ showToast('Failed to copy', 'error');
147
+ }
148
+ });
149
+ });
150
+ }
151
+
152
+ // ========================================
153
+ // Mermaid Diagrams
154
+ // ========================================
155
+ function initMermaid() {
156
+ // Initialize mermaid
157
+ if (window.mermaid) {
158
+ const isDark = document.documentElement.getAttribute('data-theme') === 'dark' ||
159
+ (document.documentElement.getAttribute('data-theme') === 'auto' &&
160
+ window.matchMedia('(prefers-color-scheme: dark)').matches);
161
+
162
+ mermaid.initialize({
163
+ startOnLoad: true,
164
+ theme: isDark ? 'dark' : 'default',
165
+ securityLevel: 'loose'
166
+ });
167
+ }
168
+
169
+ // Fullscreen buttons
170
+ document.querySelectorAll('.mermaid-fullscreen').forEach(btn => {
171
+ btn.addEventListener('click', () => {
172
+ const container = btn.closest('.mermaid-container');
173
+ const diagram = container.querySelector('.mermaid');
174
+ openMermaidFullscreen(diagram.innerHTML);
175
+ });
176
+ });
177
+
178
+ // Close fullscreen
179
+ const modal = document.querySelector('.mermaid-fullscreen-modal');
180
+ if (modal) {
181
+ modal.querySelector('.mermaid-fullscreen-backdrop')?.addEventListener('click', closeMermaidFullscreen);
182
+ modal.querySelector('.mermaid-fullscreen-close')?.addEventListener('click', closeMermaidFullscreen);
183
+ }
184
+ }
185
+
186
+ function openMermaidFullscreen(content) {
187
+ const modal = document.querySelector('.mermaid-fullscreen-modal');
188
+ const diagramContainer = modal.querySelector('.mermaid-fullscreen-diagram');
189
+ diagramContainer.innerHTML = content;
190
+ modal.classList.add('open');
191
+ document.body.style.overflow = 'hidden';
192
+ }
193
+
194
+ function closeMermaidFullscreen() {
195
+ const modal = document.querySelector('.mermaid-fullscreen-modal');
196
+ modal.classList.remove('open');
197
+ document.body.style.overflow = '';
198
+ }
199
+
200
+ // ========================================
201
+ // Source Links (Code Explorer)
202
+ // ========================================
203
+ function initSourceLinks() {
204
+ document.querySelectorAll('.source-link, .code-source').forEach(link => {
205
+ link.addEventListener('click', (e) => {
206
+ e.preventDefault();
207
+ const source = link.dataset.source;
208
+ if (source && config.features?.codeExplorer) {
209
+ openCodeExplorer(source);
210
+ }
211
+ });
212
+ });
213
+ }
214
+
215
+ // ========================================
216
+ // Table of Contents Highlighting
217
+ // ========================================
218
+ function initTocHighlight() {
219
+ const toc = document.querySelector('.toc-list');
220
+ if (!toc) return;
221
+
222
+ const headings = document.querySelectorAll('.heading-anchor');
223
+ const tocLinks = toc.querySelectorAll('a');
224
+
225
+ const observer = new IntersectionObserver((entries) => {
226
+ entries.forEach(entry => {
227
+ if (entry.isIntersecting) {
228
+ const id = entry.target.id;
229
+ tocLinks.forEach(link => {
230
+ link.classList.toggle('active', link.getAttribute('href') === '#' + id);
231
+ });
232
+ }
233
+ });
234
+ }, { rootMargin: '-100px 0px -66%' });
235
+
236
+ headings.forEach(h => observer.observe(h));
237
+ }
238
+
239
+ // ========================================
240
+ // Search
241
+ // ========================================
242
+ ${features.search ? `
243
+ function initSearch() {
244
+ const modal = document.querySelector('.search-modal');
245
+ const trigger = document.querySelector('.search-trigger');
246
+ const input = modal?.querySelector('.search-input');
247
+ const results = modal?.querySelector('.search-results');
248
+ const backdrop = modal?.querySelector('.search-modal-backdrop');
249
+
250
+ if (!modal || !trigger || !input) return;
251
+
252
+ let selectedIndex = -1;
253
+
254
+ // Open search
255
+ trigger.addEventListener('click', openSearch);
256
+
257
+ function openSearch() {
258
+ modal.classList.add('open');
259
+ input.focus();
260
+ document.body.style.overflow = 'hidden';
261
+ }
262
+
263
+ function closeSearch() {
264
+ modal.classList.remove('open');
265
+ input.value = '';
266
+ results.innerHTML = '<div class="search-empty"><p>Start typing to search...</p></div>';
267
+ document.body.style.overflow = '';
268
+ selectedIndex = -1;
269
+ }
270
+
271
+ // Close on backdrop/escape
272
+ backdrop?.addEventListener('click', closeSearch);
273
+ document.addEventListener('keydown', (e) => {
274
+ if (e.key === 'Escape' && modal.classList.contains('open')) {
275
+ closeSearch();
276
+ }
277
+ if (e.key === '/' && !e.ctrlKey && !e.metaKey && !isInputFocused()) {
278
+ e.preventDefault();
279
+ openSearch();
280
+ }
281
+ });
282
+
283
+ // Search input handling
284
+ let debounceTimer;
285
+ input.addEventListener('input', () => {
286
+ clearTimeout(debounceTimer);
287
+ debounceTimer = setTimeout(() => {
288
+ performSearch(input.value.trim());
289
+ }, 200);
290
+ });
291
+
292
+ // Keyboard navigation in results
293
+ input.addEventListener('keydown', (e) => {
294
+ const items = results.querySelectorAll('.search-result');
295
+
296
+ if (e.key === 'ArrowDown') {
297
+ e.preventDefault();
298
+ selectedIndex = Math.min(selectedIndex + 1, items.length - 1);
299
+ updateSelection(items);
300
+ } else if (e.key === 'ArrowUp') {
301
+ e.preventDefault();
302
+ selectedIndex = Math.max(selectedIndex - 1, 0);
303
+ updateSelection(items);
304
+ } else if (e.key === 'Enter' && selectedIndex >= 0) {
305
+ e.preventDefault();
306
+ items[selectedIndex]?.click();
307
+ }
308
+ });
309
+
310
+ function updateSelection(items) {
311
+ items.forEach((item, i) => {
312
+ item.classList.toggle('selected', i === selectedIndex);
313
+ });
314
+ if (items[selectedIndex]) {
315
+ items[selectedIndex].scrollIntoView({ block: 'nearest' });
316
+ }
317
+ }
318
+
319
+ function performSearch(query) {
320
+ selectedIndex = -1;
321
+
322
+ if (!query || query.length < 2) {
323
+ results.innerHTML = '<div class="search-empty"><p>Start typing to search...</p><div class="search-hints"><p><kbd>Enter</kbd> to select</p><p><kbd>↑</kbd> <kbd>↓</kbd> to navigate</p><p><kbd>ESC</kbd> to close</p></div></div>';
324
+ return;
325
+ }
326
+
327
+ const queryLower = query.toLowerCase();
328
+ const matches = state.searchIndex.filter(page => {
329
+ return page.title.toLowerCase().includes(queryLower) ||
330
+ page.content.toLowerCase().includes(queryLower) ||
331
+ page.headings.some(h => h.toLowerCase().includes(queryLower));
332
+ }).slice(0, 10);
333
+
334
+ if (matches.length === 0) {
335
+ results.innerHTML = '<div class="search-empty"><p>No results found for "' + escapeHtml(query) + '"</p></div>';
336
+ return;
337
+ }
338
+
339
+ results.innerHTML = matches.map(match => {
340
+ const snippet = getSnippet(match.content, query);
341
+ return \`
342
+ <a href="\${config.rootPath}\${match.path}" class="search-result">
343
+ <div class="search-result-title">\${escapeHtml(match.title)}</div>
344
+ <div class="search-result-snippet">\${snippet}</div>
345
+ </a>
346
+ \`;
347
+ }).join('');
348
+
349
+ // Add click handlers to close search
350
+ results.querySelectorAll('.search-result').forEach(result => {
351
+ result.addEventListener('click', closeSearch);
352
+ });
353
+ }
354
+
355
+ function getSnippet(content, query) {
356
+ const index = content.toLowerCase().indexOf(query.toLowerCase());
357
+ if (index === -1) return '';
358
+
359
+ const start = Math.max(0, index - 50);
360
+ const end = Math.min(content.length, index + query.length + 50);
361
+ let snippet = content.slice(start, end);
362
+
363
+ if (start > 0) snippet = '...' + snippet;
364
+ if (end < content.length) snippet = snippet + '...';
365
+
366
+ // Highlight matches
367
+ const regex = new RegExp('(' + escapeRegex(query) + ')', 'gi');
368
+ return escapeHtml(snippet).replace(regex, '<mark>$1</mark>');
369
+ }
370
+ }
371
+ ` : ''}
372
+
373
+ // ========================================
374
+ // Guided Tours
375
+ // ========================================
376
+ ${features.guidedTour ? `
377
+ function initTours() {
378
+ const triggerBtn = document.querySelector('.tour-trigger');
379
+ const selectorModal = document.querySelector('.tour-selector-modal');
380
+ const overlay = document.querySelector('.tour-overlay');
381
+
382
+ if (!triggerBtn || !state.manifest?.tours) return;
383
+
384
+ // Build tour list
385
+ const tourList = selectorModal?.querySelector('.tour-list');
386
+ if (tourList && state.manifest.tours.length > 0) {
387
+ tourList.innerHTML = state.manifest.tours.map(tour => \`
388
+ <button class="tour-item" data-tour-id="\${tour.id}">
389
+ <div class="tour-item-name">\${escapeHtml(tour.name)}</div>
390
+ <div class="tour-item-desc">\${escapeHtml(tour.description)}</div>
391
+ </button>
392
+ \`).join('');
393
+
394
+ tourList.querySelectorAll('.tour-item').forEach(btn => {
395
+ btn.addEventListener('click', () => {
396
+ closeTourSelector();
397
+ startTour(btn.dataset.tourId);
398
+ });
399
+ });
400
+ }
401
+
402
+ // Tour trigger button
403
+ triggerBtn.addEventListener('click', () => {
404
+ if (state.manifest.tours.length === 1) {
405
+ startTour(state.manifest.tours[0].id);
406
+ } else {
407
+ openTourSelector();
408
+ }
409
+ });
410
+
411
+ // Tour selector modal
412
+ selectorModal?.querySelector('.tour-selector-backdrop')?.addEventListener('click', closeTourSelector);
413
+ selectorModal?.querySelector('.tour-selector-close')?.addEventListener('click', closeTourSelector);
414
+
415
+ // Tour overlay buttons
416
+ overlay?.querySelector('.tour-btn-skip')?.addEventListener('click', endTour);
417
+ overlay?.querySelector('.tour-btn-prev')?.addEventListener('click', prevTourStep);
418
+ overlay?.querySelector('.tour-btn-next')?.addEventListener('click', nextTourStep);
419
+
420
+ // Check if first visit - show tour offer
421
+ if (!localStorage.getItem('wiki-tour-seen')) {
422
+ setTimeout(() => {
423
+ if (state.manifest.tours.length > 0) {
424
+ openTourSelector();
425
+ localStorage.setItem('wiki-tour-seen', 'true');
426
+ }
427
+ }, 1000);
428
+ }
429
+ }
430
+
431
+ function openTourSelector() {
432
+ document.querySelector('.tour-selector-modal')?.classList.add('open');
433
+ }
434
+
435
+ function closeTourSelector() {
436
+ document.querySelector('.tour-selector-modal')?.classList.remove('open');
437
+ }
438
+
439
+ function startTour(tourId) {
440
+ const tour = state.manifest?.tours?.find(t => t.id === tourId);
441
+ if (!tour || tour.steps.length === 0) return;
442
+
443
+ state.currentTour = tour;
444
+ state.tourStep = 0;
445
+
446
+ document.querySelector('.tour-overlay')?.classList.add('active');
447
+ showTourStep();
448
+ }
449
+
450
+ function showTourStep() {
451
+ const overlay = document.querySelector('.tour-overlay');
452
+ const spotlight = overlay?.querySelector('.tour-spotlight');
453
+ const tooltip = overlay?.querySelector('.tour-tooltip');
454
+ const tour = state.currentTour;
455
+
456
+ if (!tour || !overlay || !spotlight || !tooltip) return;
457
+
458
+ const step = tour.steps[state.tourStep];
459
+ if (!step) return;
460
+
461
+ // Check if step is on a different page
462
+ if (step.page && step.page !== config.currentPath) {
463
+ // Navigate to the page
464
+ window.location.href = config.rootPath + step.page + '?tour=' + tour.id + '&step=' + state.tourStep;
465
+ return;
466
+ }
467
+
468
+ // Find target element
469
+ const target = document.querySelector(step.targetSelector);
470
+ if (!target) {
471
+ // Skip to next step if target not found
472
+ if (state.tourStep < tour.steps.length - 1) {
473
+ state.tourStep++;
474
+ showTourStep();
475
+ } else {
476
+ endTour();
477
+ }
478
+ return;
479
+ }
480
+
481
+ // Position spotlight
482
+ const rect = target.getBoundingClientRect();
483
+ const padding = 8;
484
+ spotlight.style.top = (rect.top + window.scrollY - padding) + 'px';
485
+ spotlight.style.left = (rect.left - padding) + 'px';
486
+ spotlight.style.width = (rect.width + padding * 2) + 'px';
487
+ spotlight.style.height = (rect.height + padding * 2) + 'px';
488
+
489
+ // Scroll target into view
490
+ target.scrollIntoView({ behavior: 'smooth', block: 'center' });
491
+
492
+ // Position tooltip
493
+ const pos = step.position || 'bottom';
494
+ tooltip.className = 'tour-tooltip tour-tooltip-' + pos;
495
+
496
+ // Update tooltip content
497
+ tooltip.querySelector('.tour-step-title').textContent = step.title;
498
+ tooltip.querySelector('.tour-step-description').textContent = step.description;
499
+ tooltip.querySelector('.tour-step-counter').textContent =
500
+ (state.tourStep + 1) + ' of ' + tour.steps.length;
501
+
502
+ // Update buttons
503
+ const prevBtn = tooltip.querySelector('.tour-btn-prev');
504
+ const nextBtn = tooltip.querySelector('.tour-btn-next');
505
+
506
+ prevBtn.style.display = state.tourStep === 0 ? 'none' : 'block';
507
+ nextBtn.textContent = state.tourStep === tour.steps.length - 1 ? 'Finish' : 'Next';
508
+
509
+ // Position tooltip based on direction
510
+ setTimeout(() => {
511
+ const tooltipRect = tooltip.getBoundingClientRect();
512
+ let top, left;
513
+
514
+ switch (pos) {
515
+ case 'top':
516
+ top = rect.top + window.scrollY - tooltipRect.height - 16;
517
+ left = rect.left + (rect.width - tooltipRect.width) / 2;
518
+ break;
519
+ case 'bottom':
520
+ top = rect.bottom + window.scrollY + 16;
521
+ left = rect.left + (rect.width - tooltipRect.width) / 2;
522
+ break;
523
+ case 'left':
524
+ top = rect.top + window.scrollY + (rect.height - tooltipRect.height) / 2;
525
+ left = rect.left - tooltipRect.width - 16;
526
+ break;
527
+ case 'right':
528
+ top = rect.top + window.scrollY + (rect.height - tooltipRect.height) / 2;
529
+ left = rect.right + 16;
530
+ break;
531
+ }
532
+
533
+ // Keep tooltip in viewport
534
+ left = Math.max(16, Math.min(left, window.innerWidth - tooltipRect.width - 16));
535
+ top = Math.max(16, top);
536
+
537
+ tooltip.style.top = top + 'px';
538
+ tooltip.style.left = left + 'px';
539
+ }, 50);
540
+ }
541
+
542
+ function nextTourStep() {
543
+ if (state.tourStep < state.currentTour.steps.length - 1) {
544
+ state.tourStep++;
545
+ showTourStep();
546
+ } else {
547
+ endTour();
548
+ showToast('Tour complete! Explore freely.', 'success');
549
+ }
550
+ }
551
+
552
+ function prevTourStep() {
553
+ if (state.tourStep > 0) {
554
+ state.tourStep--;
555
+ showTourStep();
556
+ }
557
+ }
558
+
559
+ function endTour() {
560
+ state.currentTour = null;
561
+ state.tourStep = 0;
562
+ document.querySelector('.tour-overlay')?.classList.remove('active');
563
+ }
564
+
565
+ // Resume tour from URL parameters
566
+ (function checkTourResume() {
567
+ const params = new URLSearchParams(window.location.search);
568
+ const tourId = params.get('tour');
569
+ const step = parseInt(params.get('step'));
570
+
571
+ if (tourId && !isNaN(step)) {
572
+ // Clean URL
573
+ history.replaceState({}, '', window.location.pathname);
574
+
575
+ // Wait for manifest then resume
576
+ const checkManifest = setInterval(() => {
577
+ if (state.manifest?.tours) {
578
+ clearInterval(checkManifest);
579
+ const tour = state.manifest.tours.find(t => t.id === tourId);
580
+ if (tour) {
581
+ state.currentTour = tour;
582
+ state.tourStep = step;
583
+ document.querySelector('.tour-overlay')?.classList.add('active');
584
+ showTourStep();
585
+ }
586
+ }
587
+ }, 100);
588
+ }
589
+ })();
590
+ ` : ''}
591
+
592
+ // ========================================
593
+ // Code Explorer
594
+ // ========================================
595
+ ${features.codeExplorer ? `
596
+ function initCodeExplorer() {
597
+ const modal = document.querySelector('.code-explorer-modal');
598
+ if (!modal) return;
599
+
600
+ modal.querySelector('.code-explorer-backdrop')?.addEventListener('click', closeCodeExplorer);
601
+ modal.querySelector('.code-explorer-close')?.addEventListener('click', closeCodeExplorer);
602
+ }
603
+
604
+ function openCodeExplorer(sourceRef) {
605
+ const modal = document.querySelector('.code-explorer-modal');
606
+ if (!modal) return;
607
+
608
+ // Parse source reference (e.g., "src/auth.ts:23-45")
609
+ const match = sourceRef.match(/^(.+?)(?::(\\d+)(?:-(\\d+))?)?$/);
610
+ if (!match) return;
611
+
612
+ const [, filePath, startLine, endLine] = match;
613
+
614
+ modal.querySelector('.code-explorer-file').textContent = filePath;
615
+ modal.querySelector('.code-explorer-info').textContent =
616
+ startLine ? 'Lines ' + startLine + (endLine ? '-' + endLine : '') : 'Full file';
617
+
618
+ // Show loading state
619
+ const codeEl = modal.querySelector('.code-explorer-code');
620
+ codeEl.textContent = 'Loading...';
621
+ modal.classList.add('open');
622
+ document.body.style.overflow = 'hidden';
623
+
624
+ // In a real implementation, this would fetch from the repo
625
+ // For static sites, we show a placeholder with navigation hint
626
+ setTimeout(() => {
627
+ codeEl.innerHTML = \`<span class="comment">// Source: \${escapeHtml(sourceRef)}</span>
628
+ <span class="comment">// This code viewer shows source references from the documentation.</span>
629
+ <span class="comment">// In the full version, code is fetched from your repository.</span>
630
+
631
+ <span class="comment">// Navigate to:</span>
632
+ <span class="string">"\${escapeHtml(filePath)}"</span>
633
+ \${startLine ? '<span class="comment">// Lines: ' + startLine + (endLine ? '-' + endLine : '') + '</span>' : ''}
634
+
635
+ <span class="comment">// Tip: Use the source links in code blocks to navigate</span>
636
+ <span class="comment">// directly to the relevant code in your editor or IDE.</span>\`;
637
+
638
+ if (window.Prism) {
639
+ Prism.highlightElement(codeEl);
640
+ }
641
+ }, 300);
642
+ }
643
+
644
+ function closeCodeExplorer() {
645
+ document.querySelector('.code-explorer-modal')?.classList.remove('open');
646
+ document.body.style.overflow = '';
647
+ }
648
+ ` : ''}
649
+
650
+ // ========================================
651
+ // Keyboard Navigation
652
+ // ========================================
653
+ ${features.keyboardNav ? `
654
+ function initKeyboardNav() {
655
+ const helpModal = document.querySelector('.keyboard-help-modal');
656
+
657
+ helpModal?.querySelector('.keyboard-help-backdrop')?.addEventListener('click', closeKeyboardHelp);
658
+ helpModal?.querySelector('.keyboard-help-close')?.addEventListener('click', closeKeyboardHelp);
659
+
660
+ // Track g key for gg command
661
+ let lastKey = '';
662
+ let lastKeyTime = 0;
663
+
664
+ document.addEventListener('keydown', (e) => {
665
+ // Ignore when typing in inputs
666
+ if (isInputFocused()) return;
667
+
668
+ // Ignore with modifiers (except shift for capital letters)
669
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
670
+
671
+ const key = e.key;
672
+ const now = Date.now();
673
+
674
+ switch (key) {
675
+ case 'j':
676
+ e.preventDefault();
677
+ navigateHeading(1);
678
+ break;
679
+
680
+ case 'k':
681
+ e.preventDefault();
682
+ navigateHeading(-1);
683
+ break;
684
+
685
+ case 'h':
686
+ e.preventDefault();
687
+ navigatePage(-1);
688
+ break;
689
+
690
+ case 'l':
691
+ e.preventDefault();
692
+ navigatePage(1);
693
+ break;
694
+
695
+ case 'g':
696
+ if (lastKey === 'g' && now - lastKeyTime < 500) {
697
+ e.preventDefault();
698
+ window.scrollTo({ top: 0, behavior: 'smooth' });
699
+ }
700
+ break;
701
+
702
+ case 'G':
703
+ e.preventDefault();
704
+ window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
705
+ break;
706
+
707
+ case 't':
708
+ document.querySelector('.theme-toggle')?.click();
709
+ break;
710
+
711
+ case '?':
712
+ e.preventDefault();
713
+ openKeyboardHelp();
714
+ break;
715
+ }
716
+
717
+ lastKey = key;
718
+ lastKeyTime = now;
719
+ });
720
+ }
721
+
722
+ function navigateHeading(direction) {
723
+ const headings = Array.from(document.querySelectorAll('.heading-anchor'));
724
+ if (headings.length === 0) return;
725
+
726
+ const scrollTop = window.scrollY + 100;
727
+ let targetIndex = -1;
728
+
729
+ if (direction > 0) {
730
+ // Find next heading below current scroll
731
+ targetIndex = headings.findIndex(h => h.offsetTop > scrollTop);
732
+ } else {
733
+ // Find previous heading above current scroll
734
+ for (let i = headings.length - 1; i >= 0; i--) {
735
+ if (headings[i].offsetTop < scrollTop - 10) {
736
+ targetIndex = i;
737
+ break;
738
+ }
739
+ }
740
+ }
741
+
742
+ if (targetIndex >= 0 && targetIndex < headings.length) {
743
+ headings[targetIndex].scrollIntoView({ behavior: 'smooth', block: 'start' });
744
+ }
745
+ }
746
+
747
+ function navigatePage(direction) {
748
+ const navLinks = Array.from(document.querySelectorAll('.nav-link'));
749
+ const currentIndex = navLinks.findIndex(link =>
750
+ link.getAttribute('href').endsWith(config.currentPath)
751
+ );
752
+
753
+ const targetIndex = currentIndex + direction;
754
+ if (targetIndex >= 0 && targetIndex < navLinks.length) {
755
+ window.location.href = navLinks[targetIndex].href;
756
+ }
757
+ }
758
+
759
+ function openKeyboardHelp() {
760
+ document.querySelector('.keyboard-help-modal')?.classList.add('open');
761
+ }
762
+
763
+ function closeKeyboardHelp() {
764
+ document.querySelector('.keyboard-help-modal')?.classList.remove('open');
765
+ }
766
+ ` : ''}
767
+
768
+ // ========================================
769
+ // Progress Tracking
770
+ // ========================================
771
+ ${features.progressTracking ? `
772
+ function initProgressTracking() {
773
+ // Load read pages from storage
774
+ const saved = localStorage.getItem('wiki-read-pages');
775
+ if (saved) {
776
+ try {
777
+ state.readPages = new Set(JSON.parse(saved));
778
+ } catch (e) {}
779
+ }
780
+
781
+ // Mark current page as read
782
+ state.readPages.add(config.currentPath);
783
+ saveReadPages();
784
+
785
+ // Update UI
786
+ updateProgressUI();
787
+
788
+ // Mark as read in sidebar
789
+ document.querySelectorAll('.nav-link').forEach(link => {
790
+ const href = link.getAttribute('href');
791
+ const path = href.replace(config.rootPath, '').replace(/^\\.?\\//, '');
792
+ if (state.readPages.has(path)) {
793
+ link.closest('.nav-item')?.classList.add('read');
794
+ }
795
+ });
796
+ }
797
+
798
+ function saveReadPages() {
799
+ localStorage.setItem('wiki-read-pages', JSON.stringify([...state.readPages]));
800
+ }
801
+
802
+ function updateProgressUI() {
803
+ const totalPages = state.manifest?.pages?.length || 1;
804
+ const readCount = state.readPages.size;
805
+ const percentage = Math.round((readCount / totalPages) * 100);
806
+
807
+ const fill = document.querySelector('.progress-fill');
808
+ const text = document.querySelector('.progress-text');
809
+
810
+ if (fill) fill.style.width = percentage + '%';
811
+ if (text) text.textContent = percentage + '% complete (' + readCount + '/' + totalPages + ')';
812
+ }
813
+ ` : ''}
814
+
815
+ // ========================================
816
+ // Utility Functions
817
+ // ========================================
818
+ function isInputFocused() {
819
+ const active = document.activeElement;
820
+ return active && (
821
+ active.tagName === 'INPUT' ||
822
+ active.tagName === 'TEXTAREA' ||
823
+ active.contentEditable === 'true'
824
+ );
825
+ }
826
+
827
+ function escapeHtml(text) {
828
+ const div = document.createElement('div');
829
+ div.textContent = text;
830
+ return div.innerHTML;
831
+ }
832
+
833
+ function escapeRegex(string) {
834
+ return string.replace(/[.*+?^\${}()|[\\]\\\\]/g, '\\\\$&');
835
+ }
836
+
837
+ function showToast(message, type = 'info') {
838
+ const container = document.querySelector('.toast-container');
839
+ if (!container) return;
840
+
841
+ const toast = document.createElement('div');
842
+ toast.className = 'toast toast-' + type;
843
+ toast.textContent = message;
844
+
845
+ container.appendChild(toast);
846
+
847
+ setTimeout(() => {
848
+ toast.classList.add('toast-out');
849
+ setTimeout(() => toast.remove(), 300);
850
+ }, 3000);
851
+ }
852
+ })();
853
+ `;
854
+ }
855
+ //# sourceMappingURL=scripts.js.map