bloggy 0.1.40__py3-none-any.whl

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.
bloggy/main.py ADDED
@@ -0,0 +1,96 @@
1
+ from pathlib import Path
2
+ import sys
3
+ import os
4
+ from .config import get_config, reload_config
5
+
6
+ # Import app at module level, but config will be initialized before it's used
7
+ from .core import app
8
+
9
+ def build_command():
10
+ """CLI entry point for bloggy build command"""
11
+ import argparse
12
+ from .build import build_static_site
13
+
14
+ parser = argparse.ArgumentParser(description='Build static site from markdown files')
15
+ parser.add_argument('directory', nargs='?', help='Path to markdown files directory')
16
+ parser.add_argument('-o', '--output', help='Output directory (default: ./dist)', default='dist')
17
+
18
+ args = parser.parse_args(sys.argv[2:]) # Skip 'bloggy' and 'build'
19
+
20
+ try:
21
+ output_dir = build_static_site(input_dir=args.directory, output_dir=args.output)
22
+ return 0
23
+ except Exception as e:
24
+ print(f"Error building static site: {e}", file=sys.stderr)
25
+ import traceback
26
+ traceback.print_exc()
27
+ return 1
28
+
29
+ def cli():
30
+ """CLI entry point for bloggy command
31
+
32
+ Usage:
33
+ bloggy [directory] # Run locally on 127.0.0.1:5001
34
+ bloggy [directory] --host 0.0.0.0 # Run on all interfaces
35
+ bloggy build [directory] # Build static site
36
+ bloggy build [directory] -o output # Build to custom output directory
37
+
38
+ Environment variables:
39
+ BLOGGY_ROOT: Path to markdown files
40
+ BLOGGY_HOST: Server host (default: 127.0.0.1)
41
+ BLOGGY_PORT: Server port (default: 5001)
42
+
43
+ Configuration file:
44
+ Create a .bloggy file (TOML format) in your blog directory
45
+ """
46
+ import uvicorn
47
+ import argparse
48
+
49
+ # Check if first argument is 'build'
50
+ if len(sys.argv) > 1 and sys.argv[1] == 'build':
51
+ sys.exit(build_command())
52
+
53
+ parser = argparse.ArgumentParser(description='Run Bloggy server')
54
+ parser.add_argument('directory', nargs='?', help='Path to markdown files directory')
55
+ parser.add_argument('--host', help='Server host (default: 127.0.0.1, use 0.0.0.0 for all interfaces)')
56
+ parser.add_argument('--port', type=int, help='Server port (default: 5001)')
57
+ parser.add_argument('--no-reload', action='store_true', help='Disable auto-reload')
58
+ parser.add_argument('--user', help='Login username (overrides config/env)')
59
+ parser.add_argument('--password', help='Login password (overrides config/env)')
60
+
61
+ args = parser.parse_args()
62
+
63
+ # Set root folder from arguments or environment
64
+ if args.directory:
65
+ root = Path(args.directory).resolve()
66
+ if not root.exists():
67
+ print(f"Error: Directory {root} does not exist")
68
+ sys.exit(1)
69
+ os.environ['BLOGGY_ROOT'] = str(root)
70
+
71
+ # Initialize or reload config to pick up .bloggy file
72
+ # This ensures .bloggy file is loaded and config is refreshed
73
+ config = reload_config() if args.directory else get_config()
74
+
75
+ # Get host and port from arguments, config, or use defaults
76
+ host = args.host or config.get_host()
77
+ port = args.port or config.get_port()
78
+ reload = not args.no_reload
79
+
80
+ # Set login credentials from CLI if provided
81
+ if args.user:
82
+ os.environ['BLOGGY_USER'] = args.user
83
+ if args.password:
84
+ os.environ['BLOGGY_PASSWORD'] = args.password
85
+
86
+ print(f"Starting Bloggy server...")
87
+ print(f"Blog root: {config.get_root_folder()}")
88
+ print(f"Blog title: {config.get_blog_title()}")
89
+ print(f"Serving at: http://{host}:{port}")
90
+ if host == '0.0.0.0':
91
+ print(f"Server accessible from network at: http://<your-ip>:{port}")
92
+
93
+ uvicorn.run("bloggy.main:app", host=host, port=port, reload=reload)
94
+
95
+ if __name__ == "__main__":
96
+ cli()
@@ -0,0 +1,584 @@
1
+ import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
2
+
3
+ const mermaidStates = {};
4
+ const GANTT_WIDTH = 1200;
5
+
6
+ function initMermaidInteraction() {
7
+ document.querySelectorAll('.mermaid-wrapper').forEach(wrapper => {
8
+ const svg = wrapper.querySelector('svg');
9
+ if (!svg || mermaidStates[wrapper.id]) return;
10
+
11
+ // DEBUG: Log initial state
12
+ console.group(`🔍 initMermaidInteraction: ${wrapper.id}`);
13
+ console.log('Theme:', getCurrentTheme());
14
+ console.log('Wrapper computed style height:', window.getComputedStyle(wrapper).height);
15
+ console.log('Wrapper inline style:', wrapper.getAttribute('style'));
16
+
17
+ // Scale SVG to fit container (maintain aspect ratio, fit to width or height whichever is smaller)
18
+ const wrapperRect = wrapper.getBoundingClientRect();
19
+ const svgRect = svg.getBoundingClientRect();
20
+ console.log('Wrapper rect:', { width: wrapperRect.width, height: wrapperRect.height });
21
+ console.log('SVG rect:', { width: svgRect.width, height: svgRect.height });
22
+
23
+ const scaleX = (wrapperRect.width - 32) / svgRect.width; // 32 for p-4 padding (16px each side)
24
+ const scaleY = (wrapperRect.height - 32) / svgRect.height;
25
+ console.log('Scale factors:', { scaleX, scaleY });
26
+
27
+ // For very wide diagrams (like Gantt charts), prefer width scaling even if it exceeds height
28
+ const aspectRatio = svgRect.width / svgRect.height;
29
+ let initialScale;
30
+ if (aspectRatio > 3) {
31
+ // Wide diagram: scale to fit width, allowing vertical scroll if needed
32
+ initialScale = scaleX;
33
+ console.log('Wide diagram detected (aspect ratio > 3):', aspectRatio, 'Using scaleX:', initialScale);
34
+ } else {
35
+ // Normal diagram: fit to smaller dimension, but allow upscaling up to 3x
36
+ initialScale = Math.min(scaleX, scaleY, 3);
37
+ console.log('Normal diagram (aspect ratio <=3):', aspectRatio, 'Using min scale:', initialScale);
38
+ }
39
+
40
+ const state = {
41
+ scale: initialScale,
42
+ translateX: 0,
43
+ translateY: 0,
44
+ isPanning: false,
45
+ startX: 0,
46
+ startY: 0
47
+ };
48
+ mermaidStates[wrapper.id] = state;
49
+ console.log('Final state:', state);
50
+ console.groupEnd();
51
+
52
+ function updateTransform() {
53
+ svg.style.transform = `translate(${state.translateX}px, ${state.translateY}px) scale(${state.scale})`;
54
+ svg.style.transformOrigin = 'center center';
55
+ }
56
+
57
+ // Apply initial scale
58
+ updateTransform();
59
+
60
+ // Mouse wheel zoom (zooms towards cursor position)
61
+ wrapper.addEventListener('wheel', (e) => {
62
+ e.preventDefault();
63
+
64
+ const rect = svg.getBoundingClientRect();
65
+
66
+ // Mouse position relative to SVG's current position
67
+ const mouseX = e.clientX - rect.left - rect.width / 2;
68
+ const mouseY = e.clientY - rect.top - rect.height / 2;
69
+
70
+ const zoomIntensity = 0.01;
71
+ const delta = e.deltaY > 0 ? 1 - zoomIntensity : 1 + zoomIntensity; // Zoom out or in speed
72
+ const newScale = Math.min(Math.max(0.1, state.scale * delta), 55);
73
+
74
+ // Calculate how much to adjust translation to keep point under cursor fixed
75
+ // With center origin, we need to account for the scale change around center
76
+ const scaleFactor = newScale / state.scale - 1;
77
+ state.translateX -= mouseX * scaleFactor;
78
+ state.translateY -= mouseY * scaleFactor;
79
+ state.scale = newScale;
80
+
81
+ updateTransform();
82
+ }, { passive: false });
83
+
84
+ // Pan with mouse drag
85
+ wrapper.addEventListener('mousedown', (e) => {
86
+ if (e.button !== 0) return;
87
+ state.isPanning = true;
88
+ state.startX = e.clientX - state.translateX;
89
+ state.startY = e.clientY - state.translateY;
90
+ wrapper.style.cursor = 'grabbing';
91
+ e.preventDefault();
92
+ });
93
+
94
+ document.addEventListener('mousemove', (e) => {
95
+ if (!state.isPanning) return;
96
+ state.translateX = e.clientX - state.startX;
97
+ state.translateY = e.clientY - state.startY;
98
+ updateTransform();
99
+ });
100
+
101
+ document.addEventListener('mouseup', () => {
102
+ if (state.isPanning) {
103
+ state.isPanning = false;
104
+ wrapper.style.cursor = 'grab';
105
+ }
106
+ });
107
+
108
+ wrapper.style.cursor = 'grab';
109
+ });
110
+ }
111
+
112
+ window.resetMermaidZoom = function(id) {
113
+ const state = mermaidStates[id];
114
+ if (state) {
115
+ state.scale = 1;
116
+ state.translateX = 0;
117
+ state.translateY = 0;
118
+ const svg = document.getElementById(id).querySelector('svg');
119
+ svg.style.transform = 'translate(0px, 0px) scale(1)';
120
+ }
121
+ };
122
+
123
+ window.zoomMermaidIn = function(id) {
124
+ const state = mermaidStates[id];
125
+ if (state) {
126
+ state.scale = Math.min(state.scale * 1.1, 10);
127
+ const svg = document.getElementById(id).querySelector('svg');
128
+ svg.style.transform = `translate(${state.translateX}px, ${state.translateY}px) scale(${state.scale})`;
129
+ }
130
+ };
131
+
132
+ window.zoomMermaidOut = function(id) {
133
+ const state = mermaidStates[id];
134
+ if (state) {
135
+ state.scale = Math.max(state.scale * 0.9, 0.1);
136
+ const svg = document.getElementById(id).querySelector('svg');
137
+ svg.style.transform = `translate(${state.translateX}px, ${state.translateY}px) scale(${state.scale})`;
138
+ }
139
+ };
140
+
141
+ window.openMermaidFullscreen = function(id) {
142
+ const wrapper = document.getElementById(id);
143
+ if (!wrapper) return;
144
+
145
+ const originalCode = wrapper.getAttribute('data-mermaid-code');
146
+ if (!originalCode) return;
147
+
148
+ // Decode HTML entities
149
+ const textarea = document.createElement('textarea');
150
+ textarea.innerHTML = originalCode;
151
+ const code = textarea.value;
152
+
153
+ // Create modal
154
+ const modal = document.createElement('div');
155
+ modal.id = 'mermaid-fullscreen-modal';
156
+ modal.className = 'fixed inset-0 z-[10000] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4';
157
+ modal.style.animation = 'fadeIn 0.2s ease-in';
158
+
159
+ // Create modal content container
160
+ const modalContent = document.createElement('div');
161
+ modalContent.className = 'relative bg-white dark:bg-slate-900 rounded-lg shadow-2xl w-full h-full max-w-[95vw] max-h-[95vh] flex flex-col';
162
+
163
+ // Create header with close button
164
+ const header = document.createElement('div');
165
+ header.className = 'flex items-center justify-between p-4 border-b border-slate-200 dark:border-slate-700';
166
+
167
+ const title = document.createElement('h3');
168
+ title.className = 'text-lg font-semibold text-slate-800 dark:text-slate-200';
169
+ title.textContent = 'Diagram';
170
+
171
+ const closeBtn = document.createElement('button');
172
+ closeBtn.innerHTML = '✕';
173
+ closeBtn.className = 'px-3 py-1 text-xl text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 rounded transition-colors';
174
+ closeBtn.title = 'Close (Esc)';
175
+ closeBtn.onclick = () => document.body.removeChild(modal);
176
+
177
+ header.appendChild(title);
178
+ header.appendChild(closeBtn);
179
+
180
+ // Create diagram container
181
+ const diagramContainer = document.createElement('div');
182
+ diagramContainer.className = 'flex-1 overflow-auto p-4 flex items-center justify-center';
183
+
184
+ const fullscreenId = `${id}-fullscreen`;
185
+ const fullscreenWrapper = document.createElement('div');
186
+ fullscreenWrapper.id = fullscreenId;
187
+ fullscreenWrapper.className = 'mermaid-wrapper w-full h-full flex items-center justify-center';
188
+ fullscreenWrapper.setAttribute('data-mermaid-code', originalCode);
189
+
190
+ const pre = document.createElement('pre');
191
+ pre.className = 'mermaid';
192
+ pre.textContent = code;
193
+ fullscreenWrapper.appendChild(pre);
194
+
195
+ diagramContainer.appendChild(fullscreenWrapper);
196
+
197
+ // Assemble modal
198
+ modalContent.appendChild(header);
199
+ modalContent.appendChild(diagramContainer);
200
+ modal.appendChild(modalContent);
201
+ document.body.appendChild(modal);
202
+
203
+ // Close on Esc key
204
+ const escHandler = (e) => {
205
+ if (e.key === 'Escape') {
206
+ document.body.removeChild(modal);
207
+ document.removeEventListener('keydown', escHandler);
208
+ }
209
+ };
210
+ document.addEventListener('keydown', escHandler);
211
+
212
+ // Close on background click
213
+ modal.addEventListener('click', (e) => {
214
+ if (e.target === modal) {
215
+ document.body.removeChild(modal);
216
+ document.removeEventListener('keydown', escHandler);
217
+ }
218
+ });
219
+
220
+ // Render mermaid in the fullscreen view
221
+ mermaid.run({ nodes: [pre] }).then(() => {
222
+ setTimeout(() => initMermaidInteraction(), 100);
223
+ });
224
+ };
225
+
226
+ function getCurrentTheme() {
227
+ return document.documentElement.classList.contains('dark') ? 'dark' : 'default';
228
+ }
229
+
230
+ function getDynamicGanttWidth() {
231
+ // Check if any mermaid wrapper has custom gantt width
232
+ const wrappers = document.querySelectorAll('.mermaid-wrapper[data-gantt-width]');
233
+ if (wrappers.length > 0) {
234
+ // Use the first custom width found, or max width if multiple
235
+ const widths = Array.from(wrappers).map(w => parseInt(w.getAttribute('data-gantt-width')) || GANTT_WIDTH);
236
+ return Math.max(...widths);
237
+ }
238
+ return GANTT_WIDTH;
239
+ }
240
+
241
+ function reinitializeMermaid() {
242
+ console.group('🔄 reinitializeMermaid called');
243
+ console.log('Switching to theme:', getCurrentTheme());
244
+ console.log('Is initial load?', isInitialLoad);
245
+
246
+ // Skip if this is the initial load (let it render naturally first)
247
+ if (isInitialLoad) {
248
+ console.log('Skipping reinitialize on initial load');
249
+ console.groupEnd();
250
+ return;
251
+ }
252
+
253
+ const dynamicWidth = getDynamicGanttWidth();
254
+ console.log('Using dynamic Gantt width:', dynamicWidth);
255
+
256
+ mermaid.initialize({
257
+ startOnLoad: false,
258
+ theme: getCurrentTheme(),
259
+ gantt: {
260
+ useWidth: dynamicWidth,
261
+ useMaxWidth: false
262
+ }
263
+ });
264
+
265
+ // Find all mermaid wrappers and re-render them
266
+ document.querySelectorAll('.mermaid-wrapper').forEach(wrapper => {
267
+ const originalCode = wrapper.getAttribute('data-mermaid-code');
268
+ if (originalCode) {
269
+ console.log(`Processing wrapper: ${wrapper.id}`);
270
+ console.log('BEFORE clear - wrapper height:', window.getComputedStyle(wrapper).height);
271
+ console.log('BEFORE clear - wrapper rect:', wrapper.getBoundingClientRect());
272
+
273
+ // Preserve the current computed height before clearing (height should already be set explicitly)
274
+ const currentHeight = wrapper.getBoundingClientRect().height;
275
+ console.log('Preserving height:', currentHeight);
276
+ wrapper.style.height = currentHeight + 'px';
277
+
278
+ // Delete the old state so it can be recreated
279
+ delete mermaidStates[wrapper.id];
280
+
281
+ // Decode HTML entities
282
+ const textarea = document.createElement('textarea');
283
+ textarea.innerHTML = originalCode;
284
+ const code = textarea.value;
285
+
286
+ // Clear the wrapper
287
+ wrapper.innerHTML = '';
288
+ console.log('AFTER clear - wrapper height:', window.getComputedStyle(wrapper).height);
289
+ console.log('AFTER clear - wrapper rect:', wrapper.getBoundingClientRect());
290
+
291
+ // Re-add the pre element with mermaid code
292
+ const newPre = document.createElement('pre');
293
+ newPre.className = 'mermaid';
294
+ newPre.textContent = code;
295
+ wrapper.appendChild(newPre);
296
+ }
297
+ });
298
+
299
+ // Re-run mermaid
300
+ mermaid.run().then(() => {
301
+ console.log('Mermaid re-render complete, calling initMermaidInteraction in 100ms');
302
+ setTimeout(() => {
303
+ initMermaidInteraction();
304
+ console.groupEnd();
305
+ }, 100);
306
+ });
307
+ }
308
+
309
+ console.log('🚀 Initial Mermaid setup - Theme:', getCurrentTheme());
310
+
311
+ const initialGanttWidth = getDynamicGanttWidth();
312
+ console.log('Using initial Gantt width:', initialGanttWidth);
313
+
314
+ mermaid.initialize({
315
+ startOnLoad: true,
316
+ theme: getCurrentTheme(),
317
+ gantt: {
318
+ useWidth: initialGanttWidth,
319
+ useMaxWidth: false
320
+ }
321
+ });
322
+
323
+ // Track if this is the initial load
324
+ let isInitialLoad = true;
325
+
326
+ // Initialize interaction after mermaid renders
327
+ mermaid.run().then(() => {
328
+ console.log('Initial mermaid render complete');
329
+ setTimeout(() => {
330
+ console.log('Calling initial initMermaidInteraction');
331
+ initMermaidInteraction();
332
+
333
+ // After initial render, set explicit heights on all wrappers so theme switching works
334
+ document.querySelectorAll('.mermaid-wrapper').forEach(wrapper => {
335
+ const currentHeight = wrapper.getBoundingClientRect().height;
336
+ console.log(`Setting initial height for ${wrapper.id}:`, currentHeight);
337
+ wrapper.style.height = currentHeight + 'px';
338
+ });
339
+ isInitialLoad = false;
340
+ }, 100);
341
+ });
342
+
343
+ // Reveal current file in sidebar
344
+ function revealInSidebar(rootElement = document) {
345
+ if (!window.location.pathname.startsWith('/posts/')) {
346
+ return;
347
+ }
348
+
349
+ const currentPath = window.location.pathname.replace(/^\/posts\//, '');
350
+ const activeLink = rootElement.querySelector(`.post-link[data-path="${currentPath}"]`);
351
+
352
+ if (activeLink) {
353
+ // Expand all parent details elements within this sidebar
354
+ let parent = activeLink.closest('details');
355
+ while (parent && rootElement.contains(parent)) {
356
+ parent.open = true;
357
+ if (parent === rootElement) {
358
+ break;
359
+ }
360
+ parent = parent.parentElement.closest('details');
361
+ }
362
+
363
+ // Scroll to the active link
364
+ const scrollContainer = rootElement.querySelector('#sidebar-scroll-container');
365
+ if (scrollContainer) {
366
+ const linkRect = activeLink.getBoundingClientRect();
367
+ const containerRect = scrollContainer.getBoundingClientRect();
368
+ const scrollTop = scrollContainer.scrollTop;
369
+ const offset = linkRect.top - containerRect.top + scrollTop - (containerRect.height / 2) + (linkRect.height / 2);
370
+
371
+ scrollContainer.scrollTo({
372
+ top: offset,
373
+ behavior: 'smooth'
374
+ });
375
+ }
376
+
377
+ // Highlight the active link temporarily
378
+ activeLink.classList.add('ring-2', 'ring-blue-500', 'ring-offset-2');
379
+ setTimeout(() => {
380
+ activeLink.classList.remove('ring-2', 'ring-blue-500', 'ring-offset-2');
381
+ }, 1500);
382
+ }
383
+ }
384
+
385
+ function initPostsSidebarAutoReveal() {
386
+ const postSidebars = document.querySelectorAll('details[data-sidebar="posts"]');
387
+ postSidebars.forEach((sidebar) => {
388
+ if (sidebar.dataset.revealBound === 'true') {
389
+ return;
390
+ }
391
+ sidebar.dataset.revealBound = 'true';
392
+ sidebar.addEventListener('toggle', () => {
393
+ if (!sidebar.open) {
394
+ return;
395
+ }
396
+ revealInSidebar(sidebar);
397
+ });
398
+ });
399
+ }
400
+
401
+ function initFolderChevronState(rootElement = document) {
402
+ rootElement.querySelectorAll('details[data-folder="true"]').forEach((details) => {
403
+ details.classList.toggle('is-open', details.open);
404
+ });
405
+ }
406
+
407
+ document.addEventListener('toggle', (event) => {
408
+ const details = event.target;
409
+ if (!(details instanceof HTMLDetailsElement)) {
410
+ return;
411
+ }
412
+ if (!details.matches('details[data-folder="true"]')) {
413
+ return;
414
+ }
415
+ details.classList.toggle('is-open', details.open);
416
+ }, true);
417
+
418
+ // Update active post link in sidebar
419
+ function updateActivePostLink() {
420
+ const currentPath = window.location.pathname.replace(/^\/posts\//, '');
421
+ document.querySelectorAll('.post-link').forEach(link => {
422
+ const linkPath = link.getAttribute('data-path');
423
+ if (linkPath === currentPath) {
424
+ link.classList.add('bg-blue-50', 'dark:bg-blue-900/20', 'text-blue-600', 'dark:text-blue-400', 'font-medium');
425
+ link.classList.remove('text-slate-700', 'dark:text-slate-300', 'hover:text-blue-600');
426
+ } else {
427
+ link.classList.remove('bg-blue-50', 'dark:bg-blue-900/20', 'text-blue-600', 'dark:text-blue-400', 'font-medium');
428
+ link.classList.add('text-slate-700', 'dark:text-slate-300', 'hover:text-blue-600');
429
+ }
430
+ });
431
+ }
432
+
433
+ // Update active TOC link based on scroll position
434
+ function updateActiveTocLink() {
435
+ const headings = document.querySelectorAll('h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]');
436
+ const tocLinks = document.querySelectorAll('.toc-link');
437
+
438
+ let activeHeading = null;
439
+ headings.forEach(heading => {
440
+ const rect = heading.getBoundingClientRect();
441
+ if (rect.top <= 100) {
442
+ activeHeading = heading;
443
+ }
444
+ });
445
+
446
+ tocLinks.forEach(link => {
447
+ const anchor = link.getAttribute('data-anchor');
448
+ if (activeHeading && anchor === activeHeading.id) {
449
+ link.classList.add('bg-blue-50', 'dark:bg-blue-900/20', 'text-blue-600', 'dark:text-blue-400', 'font-semibold');
450
+ } else {
451
+ link.classList.remove('bg-blue-50', 'dark:bg-blue-900/20', 'text-blue-600', 'dark:text-blue-400', 'font-semibold');
452
+ }
453
+ });
454
+ }
455
+
456
+ // Listen for scroll events to update active TOC link
457
+ let ticking = false;
458
+ window.addEventListener('scroll', () => {
459
+ if (!ticking) {
460
+ window.requestAnimationFrame(() => {
461
+ updateActiveTocLink();
462
+ ticking = false;
463
+ });
464
+ ticking = true;
465
+ }
466
+ });
467
+
468
+ // Re-run mermaid on HTMX content swaps
469
+ document.body.addEventListener('htmx:afterSwap', function() {
470
+ mermaid.run().then(() => {
471
+ setTimeout(initMermaidInteraction, 100);
472
+ });
473
+ updateActivePostLink();
474
+ updateActiveTocLink();
475
+ initMobileMenus(); // Reinitialize mobile menu handlers
476
+ initPostsSidebarAutoReveal();
477
+ initFolderChevronState();
478
+ });
479
+
480
+ // Watch for theme changes and re-render mermaid diagrams
481
+ const observer = new MutationObserver((mutations) => {
482
+ mutations.forEach((mutation) => {
483
+ if (mutation.attributeName === 'class') {
484
+ reinitializeMermaid();
485
+ }
486
+ });
487
+ });
488
+
489
+ observer.observe(document.documentElement, {
490
+ attributes: true,
491
+ attributeFilter: ['class']
492
+ });
493
+
494
+ // Mobile menu toggle functionality
495
+ function initMobileMenus() {
496
+ const postsToggle = document.getElementById('mobile-posts-toggle');
497
+ const tocToggle = document.getElementById('mobile-toc-toggle');
498
+ const postsPanel = document.getElementById('mobile-posts-panel');
499
+ const tocPanel = document.getElementById('mobile-toc-panel');
500
+ const closePostsBtn = document.getElementById('close-mobile-posts');
501
+ const closeTocBtn = document.getElementById('close-mobile-toc');
502
+
503
+ // Open posts panel
504
+ if (postsToggle) {
505
+ postsToggle.addEventListener('click', () => {
506
+ if (postsPanel) {
507
+ postsPanel.classList.remove('-translate-x-full');
508
+ postsPanel.classList.add('translate-x-0');
509
+ // Close TOC panel if open
510
+ if (tocPanel) {
511
+ tocPanel.classList.remove('translate-x-0');
512
+ tocPanel.classList.add('translate-x-full');
513
+ }
514
+ }
515
+ });
516
+ }
517
+
518
+ // Open TOC panel
519
+ if (tocToggle) {
520
+ tocToggle.addEventListener('click', () => {
521
+ if (tocPanel) {
522
+ tocPanel.classList.remove('translate-x-full');
523
+ tocPanel.classList.add('translate-x-0');
524
+ // Close posts panel if open
525
+ if (postsPanel) {
526
+ postsPanel.classList.remove('translate-x-0');
527
+ postsPanel.classList.add('-translate-x-full');
528
+ }
529
+ }
530
+ });
531
+ }
532
+
533
+ // Close posts panel
534
+ if (closePostsBtn) {
535
+ closePostsBtn.addEventListener('click', () => {
536
+ if (postsPanel) {
537
+ postsPanel.classList.remove('translate-x-0');
538
+ postsPanel.classList.add('-translate-x-full');
539
+ }
540
+ });
541
+ }
542
+
543
+ // Close TOC panel
544
+ if (closeTocBtn) {
545
+ closeTocBtn.addEventListener('click', () => {
546
+ if (tocPanel) {
547
+ tocPanel.classList.remove('translate-x-0');
548
+ tocPanel.classList.add('translate-x-full');
549
+ }
550
+ });
551
+ }
552
+
553
+ // Close panels on link click (for better mobile UX)
554
+ if (postsPanel) {
555
+ postsPanel.addEventListener('click', (e) => {
556
+ if (e.target.tagName === 'A' || e.target.closest('a')) {
557
+ setTimeout(() => {
558
+ postsPanel.classList.remove('translate-x-0');
559
+ postsPanel.classList.add('-translate-x-full');
560
+ }, 100);
561
+ }
562
+ });
563
+ }
564
+
565
+ if (tocPanel) {
566
+ tocPanel.addEventListener('click', (e) => {
567
+ if (e.target.tagName === 'A' || e.target.closest('a')) {
568
+ setTimeout(() => {
569
+ tocPanel.classList.remove('translate-x-0');
570
+ tocPanel.classList.add('translate-x-full');
571
+ }, 100);
572
+ }
573
+ });
574
+ }
575
+ }
576
+
577
+ // Initialize on page load
578
+ document.addEventListener('DOMContentLoaded', () => {
579
+ updateActivePostLink();
580
+ updateActiveTocLink();
581
+ initMobileMenus();
582
+ initPostsSidebarAutoReveal();
583
+ initFolderChevronState();
584
+ });