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/core.py ADDED
@@ -0,0 +1,1618 @@
1
+ import re, frontmatter, mistletoe as mst, pathlib, os, tomllib
2
+ from functools import partial
3
+ from functools import lru_cache
4
+ from pathlib import Path
5
+ from fasthtml.common import *
6
+ from fasthtml.common import Beforeware
7
+ from fasthtml.jupyter import *
8
+ from monsterui.all import *
9
+ from starlette.staticfiles import StaticFiles
10
+ from .config import get_config
11
+ from loguru import logger
12
+
13
+ # disable debug level logs to stdout
14
+ logger.remove()
15
+ logger.add(sys.stdout, level="INFO")
16
+ logfile = Path("/tmp/bloggy_core.log")
17
+ logger.add(logfile, rotation="10 MB", retention="10 days", level="DEBUG")
18
+
19
+ slug_to_title = lambda s: ' '.join(word.capitalize() for word in s.replace('-', ' ').replace('_', ' ').split())
20
+ slug_to_title = lambda s: ' '.join(
21
+ word if word.isupper() else word[0].upper() + word[1:]
22
+ for word in s.replace('-', ' ').replace('_', ' ').split()
23
+ )
24
+
25
+ def text_to_anchor(text):
26
+ """Convert text to anchor slug"""
27
+ return re.sub(r'[^\w\s-]', '', text.lower()).replace(' ', '-')
28
+
29
+ # Cache for parsed frontmatter to avoid re-reading files
30
+ _frontmatter_cache = {}
31
+
32
+ def parse_frontmatter(file_path):
33
+ """Parse frontmatter from a markdown file with caching"""
34
+ import time
35
+ start_time = time.time()
36
+
37
+ file_path = Path(file_path)
38
+ cache_key = str(file_path)
39
+ mtime = file_path.stat().st_mtime
40
+
41
+ if cache_key in _frontmatter_cache:
42
+ cached_mtime, cached_data = _frontmatter_cache[cache_key]
43
+ if cached_mtime == mtime:
44
+ elapsed = (time.time() - start_time) * 1000
45
+ logger.debug(f"[DEBUG] parse_frontmatter CACHE HIT for {file_path.name} ({elapsed:.2f}ms)")
46
+ return cached_data
47
+
48
+ try:
49
+ with open(file_path, 'r', encoding='utf-8') as f:
50
+ post = frontmatter.load(f)
51
+ result = (post.metadata, post.content)
52
+ _frontmatter_cache[cache_key] = (mtime, result)
53
+ elapsed = (time.time() - start_time) * 1000
54
+ logger.debug(f"[DEBUG] parse_frontmatter READ FILE {file_path.name} ({elapsed:.2f}ms)")
55
+ return result
56
+ except Exception as e:
57
+ print(f"Error parsing frontmatter from {file_path}: {e}")
58
+ return {}, open(file_path).read()
59
+
60
+ def get_post_title(file_path):
61
+ """Get post title from frontmatter or filename"""
62
+ metadata, _ = parse_frontmatter(file_path)
63
+ return metadata.get('title', slug_to_title(file_path.stem))
64
+
65
+ @lru_cache(maxsize=128)
66
+ def _cached_bloggy_config(path_str, mtime):
67
+ path = Path(path_str)
68
+ try:
69
+ with path.open("rb") as f:
70
+ return tomllib.load(f)
71
+ except Exception:
72
+ return {}
73
+
74
+ def _normalize_bloggy_config(parsed):
75
+ config = {
76
+ "order": [],
77
+ "sort": "name_asc",
78
+ "folders_first": True,
79
+ "folders_always_first": False,
80
+ }
81
+ if not isinstance(parsed, dict):
82
+ return config
83
+
84
+ order = parsed.get("order")
85
+ if order is not None:
86
+ if isinstance(order, (list, tuple)):
87
+ config["order"] = [str(item).strip() for item in order if str(item).strip()]
88
+ else:
89
+ config["order"] = []
90
+
91
+ sort = parsed.get("sort")
92
+ if isinstance(sort, str) and sort in ("name_asc", "name_desc", "mtime_asc", "mtime_desc"):
93
+ config["sort"] = sort
94
+
95
+ folders_first = parsed.get("folders_first")
96
+ if isinstance(folders_first, bool):
97
+ config["folders_first"] = folders_first
98
+ elif isinstance(folders_first, str):
99
+ lowered = folders_first.lower()
100
+ if lowered in ("true", "false"):
101
+ config["folders_first"] = lowered == "true"
102
+
103
+ folders_always_first = parsed.get("folders_always_first")
104
+ if isinstance(folders_always_first, bool):
105
+ config["folders_always_first"] = folders_always_first
106
+ elif isinstance(folders_always_first, str):
107
+ lowered = folders_always_first.lower()
108
+ if lowered in ("true", "false"):
109
+ config["folders_always_first"] = lowered == "true"
110
+
111
+ return config
112
+
113
+ def get_bloggy_config(folder):
114
+ bloggy_path = folder / ".bloggy"
115
+ if not bloggy_path.exists():
116
+ return _normalize_bloggy_config({})
117
+ try:
118
+ mtime = bloggy_path.stat().st_mtime
119
+ except OSError:
120
+ return _normalize_bloggy_config({})
121
+ parsed = _cached_bloggy_config(str(bloggy_path), mtime)
122
+ config = _normalize_bloggy_config(parsed)
123
+ logger.debug(
124
+ "[DEBUG] .bloggy config for %s: order=%s sort=%s folders_first=%s",
125
+ folder,
126
+ config.get("order"),
127
+ config.get("sort"),
128
+ config.get("folders_first"),
129
+ )
130
+ return config
131
+
132
+ def order_bloggy_entries(entries, config):
133
+ if not entries:
134
+ return []
135
+
136
+ order_list = [name.strip().rstrip("/") for name in config.get("order", []) if str(name).strip()]
137
+ if not order_list:
138
+ sorted_entries = _sort_bloggy_entries(entries, config.get("sort"), config.get("folders_first", True))
139
+ if config.get("folders_always_first"):
140
+ sorted_entries = _group_folders_first(sorted_entries)
141
+ logger.debug(
142
+ "[DEBUG] .bloggy order empty; sorted entries: %s",
143
+ [item.name for item in sorted_entries],
144
+ )
145
+ return sorted_entries
146
+
147
+ exact_map = {}
148
+ stem_map = {}
149
+ for item in entries:
150
+ exact_map.setdefault(item.name, item)
151
+ if item.suffix == ".md":
152
+ stem_map.setdefault(item.stem, item)
153
+
154
+ ordered = []
155
+ used = set()
156
+ for name in order_list:
157
+ if name in exact_map:
158
+ item = exact_map[name]
159
+ elif name in stem_map:
160
+ item = stem_map[name]
161
+ else:
162
+ item = None
163
+ if item and item not in used:
164
+ ordered.append(item)
165
+ used.add(item)
166
+
167
+ remaining = [item for item in entries if item not in used]
168
+ remaining_sorted = _sort_bloggy_entries(
169
+ remaining,
170
+ config.get("sort"),
171
+ config.get("folders_first", True)
172
+ )
173
+ combined = ordered + remaining_sorted
174
+ if config.get("folders_always_first"):
175
+ combined = _group_folders_first(combined)
176
+ logger.debug(
177
+ "[DEBUG] .bloggy ordered=%s remaining=%s",
178
+ [item.name for item in ordered],
179
+ [item.name for item in remaining_sorted],
180
+ )
181
+ return combined
182
+
183
+ def _group_folders_first(entries):
184
+ folders = [item for item in entries if item.is_dir()]
185
+ files = [item for item in entries if not item.is_dir()]
186
+ return folders + files
187
+
188
+ def _sort_bloggy_entries(entries, sort_method, folders_first):
189
+ method = sort_method or "name_asc"
190
+ reverse = method.endswith("desc")
191
+ by_mtime = method.startswith("mtime")
192
+
193
+ def sort_key(item):
194
+ if by_mtime:
195
+ try:
196
+ return item.stat().st_mtime
197
+ except OSError:
198
+ return 0
199
+ return item.name.lower()
200
+
201
+ if folders_first:
202
+ folders = [item for item in entries if item.is_dir()]
203
+ files = [item for item in entries if not item.is_dir()]
204
+ folders_sorted = sorted(folders, key=sort_key, reverse=reverse)
205
+ files_sorted = sorted(files, key=sort_key, reverse=reverse)
206
+ return folders_sorted + files_sorted
207
+
208
+ return sorted(entries, key=sort_key, reverse=reverse)
209
+
210
+ # Markdown rendering setup
211
+ try: FrankenRenderer
212
+ except NameError:
213
+ class FrankenRenderer(mst.HTMLRenderer):
214
+ def __init__(self, *args, img_dir=None, **kwargs):
215
+ super().__init__(*args, **kwargs)
216
+ self.img_dir = img_dir
217
+
218
+ def render_image(self, token):
219
+ tpl = '<img src="{}" alt="{}"{} class="max-w-full h-auto rounded-lg mb-6">'
220
+ title = f' title="{token.title}"' if hasattr(token, 'title') else ''
221
+ src = token.src
222
+ # Only prepend img_dir if src is relative and img_dir is provided
223
+ if self.img_dir and not src.startswith(('http://', 'https://', '/', 'attachment:', 'blob:', 'data:')):
224
+ src = f'{self.img_dir}/{src}'
225
+ return tpl.format(src, token.children[0].content if token.children else '', title)
226
+
227
+ def span_token(name, pat, attr, prec=5):
228
+ class T(mst.span_token.SpanToken):
229
+ precedence, parse_inner, parse_group, pattern = prec, False, 1, re.compile(pat)
230
+ def __init__(self, match):
231
+ setattr(self, attr, match.group(1))
232
+ # Optional second parameter
233
+ if hasattr(match, 'lastindex') and match.lastindex and match.lastindex >= 2:
234
+ if name == 'YoutubeEmbed':
235
+ self.caption = match.group(2) if match.group(2) else None
236
+ elif name == 'MermaidEmbed':
237
+ self.option = match.group(2) if match.group(2) else None
238
+ T.__name__ = name
239
+ return T
240
+
241
+ FootnoteRef = span_token('FootnoteRef', r'\[\^([^\]]+)\](?!:)', 'target')
242
+ YoutubeEmbed = span_token(
243
+ 'YoutubeEmbed',
244
+ r'\[yt:([a-zA-Z0-9_-]+)(?:\|(.+))?\]',
245
+ 'video_id',
246
+ 6
247
+ )
248
+
249
+ # Superscript and Subscript tokens with higher precedence
250
+ class Superscript(mst.span_token.SpanToken):
251
+ pattern = re.compile(r'\^([^\^]+?)\^')
252
+ parse_inner = False
253
+ parse_group = 1
254
+ precedence = 7
255
+ def __init__(self, match):
256
+ self.content = match.group(1)
257
+ self.children = []
258
+
259
+ class Subscript(mst.span_token.SpanToken):
260
+ pattern = re.compile(r'~([^~]+?)~')
261
+ parse_inner = False
262
+ parse_group = 1
263
+ precedence = 7
264
+ def __init__(self, match):
265
+ self.content = match.group(1)
266
+ self.children = []
267
+
268
+ # Inline code with Pandoc-style attributes: `code`{.class #id}
269
+ class InlineCodeAttr(mst.span_token.SpanToken):
270
+ pattern = re.compile(r'`([^`]+)`\{([^\}]+)\}')
271
+ parse_inner = False
272
+ parse_group = 1
273
+ precedence = 8 # Higher than other inline elements
274
+ def __init__(self, match):
275
+ self.code = match.group(1)
276
+ self.attrs = match.group(2)
277
+ self.children = []
278
+
279
+ # Strikethrough: ~~text~~
280
+ class Strikethrough(mst.span_token.SpanToken):
281
+ pattern = re.compile(r'~~(.+?)~~')
282
+ parse_inner = True
283
+ parse_group = 1
284
+ precedence = 7
285
+ def __init__(self, match):
286
+ self.children = []
287
+
288
+ def preprocess_super_sub(content):
289
+ """Convert superscript and subscript syntax to HTML before markdown rendering"""
290
+ # Handle superscript ^text^
291
+ content = re.sub(r'\^([^\^\n]+?)\^', r'<sup>\1</sup>', content)
292
+ # Handle subscript ~text~ (but not strikethrough ~~text~~)
293
+ content = re.sub(r'(?<!~)~([^~\n]+?)~(?!~)', r'<sub>\1</sub>', content)
294
+ return content
295
+
296
+ def extract_footnotes(content):
297
+ pat = re.compile(r'^\[\^([^\]]+)\]:\s*(.+?)(?=(?:^|\n)\[\^|\n\n|\Z)', re.MULTILINE | re.DOTALL)
298
+ defs = {m.group(1): m.group(2).strip() for m in pat.finditer(content)}
299
+ for m in pat.finditer(content): content = content.replace(m.group(0), '', 1)
300
+ return content.strip(), defs
301
+
302
+ def preprocess_tabs(content):
303
+ """Convert :::tabs syntax to placeholder tokens, store tab data for later processing"""
304
+ import hashlib
305
+ import base64
306
+
307
+ # Storage for tab data (will be processed after main markdown rendering)
308
+ tab_data_store = {}
309
+
310
+ # Pattern to match :::tabs...:::
311
+ tabs_pattern = re.compile(r'^:::tabs\s*\n(.*?)^:::', re.MULTILINE | re.DOTALL)
312
+
313
+ def replace_tabs_block(match):
314
+ tabs_content = match.group(1)
315
+ # Pattern to match ::tab{title="..."}
316
+ tab_pattern = re.compile(r'^::tab\{title="([^"]+)"\}\s*\n(.*?)(?=^::tab\{|\Z)', re.MULTILINE | re.DOTALL)
317
+
318
+ tabs = []
319
+ for tab_match in tab_pattern.finditer(tabs_content):
320
+ title = tab_match.group(1)
321
+ tab_content = tab_match.group(2).strip()
322
+ tabs.append((title, tab_content))
323
+
324
+ if not tabs:
325
+ return match.group(0) # Return original if no tabs found
326
+
327
+ # Generate unique ID for this tab group
328
+ tab_id = hashlib.md5(match.group(0).encode()).hexdigest()[:8]
329
+
330
+ # Store tab data for later processing
331
+ tab_data_store[tab_id] = tabs
332
+
333
+ # Return a placeholder that won't be processed by markdown
334
+ placeholder = f'<div class="tab-placeholder" data-tab-id="{tab_id}"></div>'
335
+ return placeholder
336
+
337
+ processed_content = tabs_pattern.sub(replace_tabs_block, content)
338
+ return processed_content, tab_data_store
339
+
340
+ class ContentRenderer(FrankenRenderer):
341
+ def __init__(self, *extras, img_dir=None, footnotes=None, current_path=None, **kwargs):
342
+ super().__init__(*extras, img_dir=img_dir, **kwargs)
343
+ self.footnotes, self.fn_counter = footnotes or {}, 0
344
+ self.current_path = current_path # Current post path for resolving relative links and images
345
+
346
+ def render_list_item(self, token):
347
+ """Render list items with task list checkbox support"""
348
+ inner = self.render_inner(token)
349
+
350
+ # Check if this is a task list item: starts with [ ] or [x]
351
+ # Try different patterns as the structure might vary
352
+ task_pattern = re.match(r'^\s*\[([ xX])\]\s*(.*?)$', inner, re.DOTALL)
353
+ if not task_pattern:
354
+ task_pattern = re.match(r'^<p>\s*\[([ xX])\]\s*(.*?)</p>$', inner, re.DOTALL)
355
+
356
+ if task_pattern:
357
+ checked = task_pattern.group(1).lower() == 'x'
358
+ content = task_pattern.group(2).strip()
359
+
360
+ # Custom styled checkbox
361
+ if checked:
362
+ checkbox_style = 'background-color: #10b981; border-color: #10b981;'
363
+ checkmark = '<svg class="w-full h-full text-white" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="3,8 6,11 13,4"></polyline></svg>'
364
+ else:
365
+ checkbox_style = 'background-color: #6b7280; border-color: #6b7280;'
366
+ checkmark = ''
367
+
368
+ checkbox = f'''<span class="inline-flex items-center justify-center mr-3 mt-0.5" style="width: 20px; height: 20px; border-radius: 6px; border: 2px solid; {checkbox_style} flex-shrink: 0;">
369
+ {checkmark}
370
+ </span>'''
371
+
372
+ return f'<li class="task-list-item flex items-start" style="list-style: none; margin: 0.5rem 0;">{checkbox}<span class="flex-1">{content}</span></li>\n'
373
+
374
+ return f'<li>{inner}</li>\n'
375
+
376
+ def render_youtube_embed(self, token):
377
+ video_id = token.video_id
378
+ caption = getattr(token, 'caption', None)
379
+
380
+ iframe = f'''
381
+ <div class="relative w-full aspect-video my-6 rounded-lg overflow-hidden border border-slate-200 dark:border-slate-800">
382
+ <iframe
383
+ src="https://www.youtube.com/embed/{video_id}"
384
+ title="YouTube video"
385
+ frameborder="0"
386
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
387
+ allowfullscreen
388
+ class="absolute inset-0 w-full h-full">
389
+ </iframe>
390
+ </div>
391
+ '''
392
+
393
+ if caption:
394
+ return iframe + f'<p class="text-sm text-slate-500 dark:text-slate-400 text-center mt-2">{caption}</p>'
395
+ return iframe
396
+
397
+ def render_footnote_ref(self, token):
398
+ self.fn_counter += 1
399
+ n, target = self.fn_counter, token.target
400
+ content = self.footnotes.get(target, f"[Missing footnote: {target}]")
401
+ rendered = mst.markdown(content, partial(ContentRenderer, img_dir=self.img_dir, current_path=self.current_path)).strip()
402
+ if rendered.startswith('<p>') and rendered.endswith('</p>'): rendered = rendered[3:-4]
403
+ style = "text-sm leading-relaxed border-l-2 border-amber-400 dark:border-blue-400 pl-3 text-neutral-500 dark:text-neutral-400 transition-all duration-500 w-full my-2 xl:my-0"
404
+ toggle = f"on click if window.innerWidth >= 1280 then add .hl to #sn-{n} then wait 1s then remove .hl from #sn-{n} else toggle .open on me then toggle .show on #sn-{n}"
405
+ ref = Span(id=f"snref-{n}", role="doc-noteref", aria_label=f"Sidenote {n}", cls="sidenote-ref cursor-pointer", _=toggle)
406
+ note = Span(NotStr(rendered), id=f"sn-{n}", role="doc-footnote", aria_labelledby=f"snref-{n}", cls=f"sidenote {style}")
407
+ hide = lambda c: to_xml(Span(c, cls="hidden", aria_hidden="true"))
408
+ return hide(" (") + to_xml(ref) + to_xml(note) + hide(")")
409
+
410
+ def render_heading(self, token):
411
+ """Render headings with anchor IDs for TOC linking"""
412
+ level = token.level
413
+ inner = self.render_inner(token)
414
+ anchor = text_to_anchor(inner)
415
+ return f'<h{level} id="{anchor}">{inner}</h{level}>'
416
+
417
+ def render_superscript(self, token):
418
+ """Render superscript text"""
419
+ return f'<sup>{token.content}</sup>'
420
+
421
+ def render_subscript(self, token):
422
+ """Render subscript text"""
423
+ return f'<sub>{token.content}</sub>'
424
+
425
+ def render_strikethrough(self, token):
426
+ """Render strikethrough text"""
427
+ inner = self.render_inner(token)
428
+ return f'<del>{inner}</del>'
429
+
430
+ def render_inline_code_attr(self, token):
431
+ """Render inline code with Pandoc-style attributes"""
432
+ import html
433
+ code = html.escape(token.code)
434
+ attrs = token.attrs.strip()
435
+
436
+ # Parse attributes: .class, #id, key=value
437
+ classes = []
438
+ id_attr = None
439
+ other_attrs = []
440
+
441
+ for attr in re.findall(r'\.([^\s\.#]+)|#([^\s\.#]+)|([^\s\.#=]+)=([^\s\.#]+)', attrs):
442
+ if attr[0]: # .class
443
+ classes.append(attr[0])
444
+ elif attr[1]: # #id
445
+ id_attr = attr[1]
446
+ elif attr[2]: # key=value
447
+ other_attrs.append(f'{attr[2]}="{attr[3]}"')
448
+
449
+ # Build HTML
450
+ html_attrs = []
451
+ if classes:
452
+ html_attrs.append(f'class="{" ".join(classes)}"')
453
+ if id_attr:
454
+ html_attrs.append(f'id="{id_attr}"')
455
+ html_attrs.extend(other_attrs)
456
+
457
+ attr_str = ' ' + ' '.join(html_attrs) if html_attrs else ''
458
+
459
+ # Always use <span> for inline code with attributes - the presence of attributes
460
+ # indicates styling/annotation intent rather than code semantics
461
+ tag = 'span'
462
+ return f'<{tag}{attr_str}>{code}</{tag}>'
463
+
464
+ def render_block_code(self, token):
465
+ lang = getattr(token, 'language', '')
466
+ code = self.render_raw_text(token)
467
+ if lang == 'mermaid':
468
+ # Extract frontmatter from mermaid code block
469
+ frontmatter_pattern = r'^---\s*\n(.*?)\n---\s*\n'
470
+ frontmatter_match = re.match(frontmatter_pattern, code, re.DOTALL)
471
+
472
+ # Default configuration for mermaid diagrams
473
+ height = 'auto'
474
+ width = '65vw' # Default to viewport width for better visibility
475
+ min_height = '400px'
476
+ gantt_width = None # Custom Gantt width override
477
+
478
+ if frontmatter_match:
479
+ frontmatter_content = frontmatter_match.group(1)
480
+ code_without_frontmatter = code[frontmatter_match.end():]
481
+
482
+ # Parse YAML-like frontmatter (simple key: value pairs)
483
+ try:
484
+ config = {}
485
+ for line in frontmatter_content.strip().split('\n'):
486
+ if ':' in line:
487
+ key, value = line.split(':', 1)
488
+ config[key.strip()] = value.strip()
489
+
490
+ # Extract height and width if specified
491
+ if 'height' in config:
492
+ height = config['height']
493
+ min_height = height
494
+ if 'width' in config:
495
+ width = config['width']
496
+
497
+ # Handle aspect_ratio for Gantt charts
498
+ if 'aspect_ratio' in config:
499
+ aspect_value = config['aspect_ratio'].strip()
500
+ try:
501
+ # Parse ratio notation (e.g., "16:9", "21:9", "32:9")
502
+ if ':' in aspect_value:
503
+ w_ratio, h_ratio = map(float, aspect_value.split(':'))
504
+ ratio = w_ratio / h_ratio
505
+ else:
506
+ # Parse decimal notation (e.g., "1.78", "2.4")
507
+ ratio = float(aspect_value)
508
+
509
+ # Calculate Gantt width based on aspect ratio
510
+ # Base width of 1200, scaled by ratio
511
+ gantt_width = int(1200 * ratio)
512
+ except (ValueError, ZeroDivisionError) as e:
513
+ print(f"Invalid aspect_ratio format '{aspect_value}': {e}")
514
+ gantt_width = None
515
+
516
+ except Exception as e:
517
+ print(f"Error parsing mermaid frontmatter: {e}")
518
+
519
+ # Use code without frontmatter for rendering
520
+ code = code_without_frontmatter
521
+
522
+ diagram_id = f"mermaid-{hash(code) & 0xFFFFFF}"
523
+
524
+ # Determine if we need to break out of normal content flow
525
+ # This is required for viewport-based widths to properly center
526
+ break_out = 'vw' in str(width).lower()
527
+
528
+ # Build container style with proper positioning for viewport widths
529
+ if break_out:
530
+ container_style = f"width: {width}; position: relative; left: 50%; transform: translateX(-50%);"
531
+ else:
532
+ container_style = f"width: {width};"
533
+
534
+ # Escape the code for use in data attribute
535
+ escaped_code = code.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;').replace("'", '&#39;')
536
+
537
+ # Add custom Gantt width as data attribute if specified
538
+ gantt_data_attr = f' data-gantt-width="{gantt_width}"' if gantt_width else ''
539
+
540
+ return f'''<div class="mermaid-container relative border-4 rounded-md my-4 shadow-2xl" style="{container_style}">
541
+ <div class="mermaid-controls absolute top-2 right-2 z-10 flex gap-1 bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm rounded">
542
+ <button onclick="openMermaidFullscreen('{diagram_id}')" class="px-2 py-1 text-xs border rounded hover:bg-slate-100 dark:hover:bg-slate-700" title="Fullscreen">â›¶</button>
543
+ <button onclick="resetMermaidZoom('{diagram_id}')" class="px-2 py-1 text-xs border rounded hover:bg-slate-100 dark:hover:bg-slate-700" title="Reset zoom">Reset</button>
544
+ <button onclick="zoomMermaidIn('{diagram_id}')" class="px-2 py-1 text-xs border rounded hover:bg-slate-100 dark:hover:bg-slate-700" title="Zoom in">+</button>
545
+ <button onclick="zoomMermaidOut('{diagram_id}')" class="px-2 py-1 text-xs border rounded hover:bg-slate-100 dark:hover:bg-slate-700" title="Zoom out">−</button>
546
+ </div>
547
+ <div id="{diagram_id}" class="mermaid-wrapper p-4 overflow-hidden flex justify-center items-center" style="min-height: {min_height}; height: {height};" data-mermaid-code="{escaped_code}"{gantt_data_attr}><pre class="mermaid" style="width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;">{code}</pre></div>
548
+ </div>'''
549
+
550
+ # For other languages: escape HTML/XML for display, but NOT for markdown
551
+ # (markdown code blocks should show raw source)
552
+ import html
553
+ if lang and lang.lower() != 'markdown':
554
+ code = html.escape(code)
555
+ lang_class = f' class="language-{lang}"' if lang else ''
556
+ return f'<pre><code{lang_class}>{code}</code></pre>'
557
+
558
+ def render_link(self, token):
559
+ href, inner, title = token.target, self.render_inner(token), f' title="{token.title}"' if token.title else ''
560
+ # ...existing code...
561
+ is_external = href.startswith(('http://', 'https://', 'mailto:', 'tel:', '//', '#'))
562
+ is_absolute_internal = href.startswith('/') and not href.startswith('//')
563
+ is_relative = not is_external and not is_absolute_internal
564
+ if is_relative:
565
+ from pathlib import Path
566
+ original_href = href
567
+ if href.endswith('.md'):
568
+ href = href[:-3]
569
+ if self.current_path:
570
+ root = get_root_folder().resolve()
571
+ current_file_full = root / self.current_path
572
+ current_dir = current_file_full.parent
573
+ resolved = (current_dir / href).resolve()
574
+ logger.debug(f"DEBUG: original_href={original_href}, current_path={self.current_path}, current_dir={current_dir}, resolved={resolved}, root={root}")
575
+ try:
576
+ rel_path = resolved.relative_to(root)
577
+ href = f'/posts/{rel_path}'
578
+ is_absolute_internal = True
579
+ logger.debug(f"DEBUG: SUCCESS - rel_path={rel_path}, final href={href}")
580
+ except ValueError as e:
581
+ is_external = True
582
+ logger.debug(f"DEBUG: FAILED - ValueError: {e}")
583
+ else:
584
+ is_external = True
585
+ logger.debug(f"DEBUG: No current_path, treating as external")
586
+ is_internal = is_absolute_internal and '.' not in href.split('/')[-1]
587
+ hx = f' hx-get="{href}" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML show:window:top"' if is_internal else ''
588
+ ext = '' if (is_internal or is_absolute_internal) else ' target="_blank" rel="noopener noreferrer"'
589
+ # Amber/gold link styling, stands out and is accessible
590
+ link_class = (
591
+ "text-amber-600 dark:text-amber-400 underline underline-offset-2 "
592
+ "hover:text-amber-800 dark:hover:text-amber-200 font-medium transition-colors"
593
+ )
594
+ return f'<a href="{href}"{hx}{ext} class="{link_class}"{title}>{inner}</a>'
595
+
596
+
597
+ def postprocess_tabs(html, tab_data_store, img_dir, current_path, footnotes):
598
+ """Replace tab placeholders with fully rendered tab HTML"""
599
+ import hashlib
600
+
601
+ for tab_id, tabs in tab_data_store.items():
602
+ # Build HTML for this tab group
603
+ html_parts = [f'<div class="tabs-container" data-tabs-id="{tab_id}">']
604
+
605
+ # Tab buttons
606
+ html_parts.append('<div class="tabs-header">')
607
+ for i, (title, _) in enumerate(tabs):
608
+ active = 'active' if i == 0 else ''
609
+ html_parts.append(f'<button class="tab-button {active}" onclick="switchTab(\'{tab_id}\', {i})">{title}</button>')
610
+ html_parts.append('</div>')
611
+
612
+ # Tab content panels
613
+ html_parts.append('<div class="tabs-content">')
614
+ for i, (_, tab_content) in enumerate(tabs):
615
+ active = 'active' if i == 0 else ''
616
+ # Render each tab's content as fresh markdown
617
+ with ContentRenderer(YoutubeEmbed, InlineCodeAttr, Strikethrough, FootnoteRef, Superscript, Subscript, img_dir=img_dir, footnotes=footnotes, current_path=current_path) as renderer:
618
+ doc = mst.Document(tab_content)
619
+ rendered = renderer.render(doc)
620
+ html_parts.append(f'<div class="tab-panel {active}" data-tab-index="{i}">{rendered}</div>')
621
+ html_parts.append('</div>')
622
+
623
+ html_parts.append('</div>')
624
+ tab_html = '\n'.join(html_parts)
625
+
626
+ # Replace placeholder with rendered tab HTML
627
+ placeholder = f'<div class="tab-placeholder" data-tab-id="{tab_id}"></div>'
628
+ html = html.replace(placeholder, tab_html)
629
+
630
+ return html
631
+
632
+ def from_md(content, img_dir=None, current_path=None):
633
+ # Resolve img_dir from current_path if not explicitly provided
634
+ if img_dir is None and current_path:
635
+ # Convert current_path to URL path for images (e.g., demo/flat-land/chapter-01 -> /posts/demo/flat-land)
636
+ from pathlib import Path
637
+ path_parts = Path(current_path).parts
638
+ if len(path_parts) > 1:
639
+ img_dir = '/posts/' + '/'.join(path_parts[:-1])
640
+ else:
641
+ img_dir = '/posts'
642
+
643
+ content, footnotes = extract_footnotes(content)
644
+ content = preprocess_super_sub(content) # Preprocess superscript/subscript
645
+ content, tab_data_store = preprocess_tabs(content) # Preprocess tabs and get tab data
646
+
647
+ # Preprocess: convert single newlines within paragraphs to ' \n' (markdown softbreak)
648
+ # This preserves double newlines (paragraphs) and code blocks
649
+ def _preserve_newlines(md):
650
+ import re
651
+ # Don't touch code blocks (fenced or indented)
652
+ code_block = re.compile(r'(```[\s\S]*?```|~~~[\s\S]*?~~~)', re.MULTILINE)
653
+ blocks = []
654
+ def repl(m):
655
+ blocks.append(m.group(0))
656
+ return f"__CODEBLOCK_{len(blocks)-1}__"
657
+ md = code_block.sub(repl, md)
658
+ # Replace single newlines not preceded/followed by another newline with ' \n'
659
+ md = re.sub(r'(?<!\n)\n(?!\n)', ' \n', md)
660
+ # Restore code blocks
661
+ for i, block in enumerate(blocks):
662
+ md = md.replace(f"__CODEBLOCK_{i}__", block)
663
+ return md
664
+ content = _preserve_newlines(content)
665
+
666
+ mods = {'pre': 'my-4', 'p': 'text-base leading-relaxed mb-6', 'li': 'text-base leading-relaxed',
667
+ 'ul': 'uk-list uk-list-bullet space-y-2 mb-6 ml-6 text-base', 'ol': 'uk-list uk-list-decimal space-y-2 mb-6 ml-6 text-base',
668
+ 'hr': 'border-t border-border my-8', 'h1': 'text-3xl font-bold mb-6 mt-8', 'h2': 'text-2xl font-semibold mb-4 mt-6',
669
+ 'h3': 'text-xl font-semibold mb-3 mt-5', 'h4': 'text-lg font-semibold mb-2 mt-4'}
670
+
671
+ # Register custom tokens with renderer context manager
672
+ with ContentRenderer(YoutubeEmbed, InlineCodeAttr, Strikethrough, FootnoteRef, Superscript, Subscript, img_dir=img_dir, footnotes=footnotes, current_path=current_path) as renderer:
673
+ doc = mst.Document(content)
674
+ html = renderer.render(doc)
675
+
676
+ # Post-process: replace tab placeholders with rendered tabs
677
+ if tab_data_store:
678
+ html = postprocess_tabs(html, tab_data_store, img_dir, current_path, footnotes)
679
+
680
+ return Div(Link(rel="stylesheet", href="/static/sidenote.css"), NotStr(apply_classes(html, class_map_mods=mods)), cls="w-full")
681
+
682
+ # App configuration
683
+ def get_root_folder(): return get_config().get_root_folder()
684
+ def get_blog_title(): return get_config().get_blog_title()
685
+
686
+ hdrs = (
687
+ *Theme.slate.headers(highlightjs=True),
688
+ Link(rel="icon", href="/static/favicon.png"),
689
+ Script(src="https://unpkg.com/hyperscript.org@0.9.12"),
690
+ Script(src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs", type="module"),
691
+ Script("""
692
+ // Tab switching functionality (global scope)
693
+ function switchTab(tabsId, index) {
694
+ console.log('switchTab called:', tabsId, index);
695
+ const container = document.querySelector('.tabs-container[data-tabs-id="' + tabsId + '"]');
696
+ console.log('container:', container);
697
+ if (!container) return;
698
+
699
+ // Update buttons
700
+ const buttons = container.querySelectorAll('.tab-button');
701
+ buttons.forEach(function(btn, i) {
702
+ if (i === index) {
703
+ btn.classList.add('active');
704
+ } else {
705
+ btn.classList.remove('active');
706
+ }
707
+ });
708
+
709
+ // Update panels
710
+ const panels = container.querySelectorAll('.tab-panel');
711
+ panels.forEach(function(panel, i) {
712
+ if (i === index) {
713
+ panel.classList.add('active');
714
+ panel.style.position = 'relative';
715
+ panel.style.visibility = 'visible';
716
+ panel.style.opacity = '1';
717
+ panel.style.pointerEvents = 'auto';
718
+ } else {
719
+ panel.classList.remove('active');
720
+ panel.style.position = 'absolute';
721
+ panel.style.visibility = 'hidden';
722
+ panel.style.opacity = '0';
723
+ panel.style.pointerEvents = 'none';
724
+ }
725
+ });
726
+ }
727
+ window.switchTab = switchTab;
728
+
729
+ // Set tab container heights based on tallest panel
730
+ document.addEventListener('DOMContentLoaded', function() {
731
+ setTimeout(() => {
732
+ document.querySelectorAll('.tabs-container').forEach(container => {
733
+ const panels = container.querySelectorAll('.tab-panel');
734
+ let maxHeight = 0;
735
+
736
+ // Temporarily show all panels to measure their heights
737
+ panels.forEach(panel => {
738
+ const wasActive = panel.classList.contains('active');
739
+ panel.style.position = 'relative';
740
+ panel.style.visibility = 'visible';
741
+ panel.style.opacity = '1';
742
+ panel.style.pointerEvents = 'auto';
743
+
744
+ const height = panel.offsetHeight;
745
+ if (height > maxHeight) maxHeight = height;
746
+
747
+ if (!wasActive) {
748
+ panel.style.position = 'absolute';
749
+ panel.style.visibility = 'hidden';
750
+ panel.style.opacity = '0';
751
+ panel.style.pointerEvents = 'none';
752
+ }
753
+ });
754
+
755
+ // Set the content area to the max height
756
+ const tabsContent = container.querySelector('.tabs-content');
757
+ if (tabsContent && maxHeight > 0) {
758
+ tabsContent.style.minHeight = maxHeight + 'px';
759
+ }
760
+ });
761
+ }, 100);
762
+ });
763
+ """),
764
+ Script(src="/static/scripts.js", type='module'),
765
+ Link(rel="stylesheet", href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css"),
766
+ Script(src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"),
767
+ Script(src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"),
768
+ Script("""
769
+ document.addEventListener('DOMContentLoaded', function() {
770
+ renderMathInElement(document.body, {
771
+ delimiters: [
772
+ {left: '$$', right: '$$', display: true},
773
+ {left: '$', right: '$', display: false}
774
+ ],
775
+ throwOnError: false
776
+ });
777
+ });
778
+
779
+ // Re-render math after HTMX swaps
780
+ document.body.addEventListener('htmx:afterSwap', function() {
781
+ renderMathInElement(document.body, {
782
+ delimiters: [
783
+ {left: '$$', right: '$$', display: true},
784
+ {left: '$', right: '$', display: false}
785
+ ],
786
+ throwOnError: false
787
+ });
788
+ });
789
+ """),
790
+ Link(rel="preconnect", href="https://fonts.googleapis.com"),
791
+ Link(rel="preconnect", href="https://fonts.gstatic.com", crossorigin=""),
792
+ Link(rel="stylesheet", href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono&display=swap"),
793
+ Style("body { font-family: 'IBM Plex Sans', sans-serif; } code, pre { font-family: 'IBM Plex Mono', monospace; }"),
794
+ Style(".folder-chevron { display: inline-block; width: 0.45rem; height: 0.45rem; border-right: 2px solid rgb(148 163 184); border-bottom: 2px solid rgb(148 163 184); transform: rotate(-45deg); transition: transform 0.2s; } details.is-open > summary .folder-chevron { transform: rotate(45deg); } details { border: none !important; box-shadow: none !important; }"),
795
+ Style("h1, h2, h3, h4, h5, h6 { scroll-margin-top: 7rem; }"), # Offset for sticky navbar
796
+ Style("""
797
+ /* Ultra thin scrollbar styles */
798
+ * { scrollbar-width: thin; scrollbar-color: rgb(203 213 225) transparent; }
799
+ *::-webkit-scrollbar { width: 3px; height: 3px; }
800
+ *::-webkit-scrollbar-track { background: transparent; }
801
+ *::-webkit-scrollbar-thumb { background-color: rgb(203 213 225); border-radius: 2px; }
802
+ *::-webkit-scrollbar-thumb:hover { background-color: rgb(148 163 184); }
803
+ .dark *::-webkit-scrollbar-thumb { background-color: rgb(71 85 105); }
804
+ .dark *::-webkit-scrollbar-thumb:hover { background-color: rgb(100 116 139); }
805
+ .dark * { scrollbar-color: rgb(71 85 105) transparent; }
806
+
807
+ /* Tabs styles */
808
+ .tabs-container {
809
+ margin: 2rem 0;
810
+ border: 1px solid rgb(226 232 240);
811
+ border-radius: 0.5rem;
812
+ overflow: hidden;
813
+ box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
814
+ }
815
+ .dark .tabs-container {
816
+ border-color: rgb(51 65 85);
817
+ box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3);
818
+ }
819
+
820
+ .tabs-header {
821
+ display: flex;
822
+ background: rgb(248 250 252);
823
+ border-bottom: 1px solid rgb(226 232 240);
824
+ gap: 0;
825
+ }
826
+ .dark .tabs-header {
827
+ background: rgb(15 23 42);
828
+ border-bottom-color: rgb(51 65 85);
829
+ }
830
+
831
+ .tab-button {
832
+ flex: 1;
833
+ padding: 0.875rem 1.5rem;
834
+ background: transparent;
835
+ border: none;
836
+ border-bottom: 3px solid transparent;
837
+ cursor: pointer;
838
+ font-weight: 500;
839
+ font-size: 0.9375rem;
840
+ color: rgb(100 116 139);
841
+ transition: all 0.15s ease;
842
+ position: relative;
843
+ margin-bottom: -1px;
844
+ }
845
+ .dark .tab-button { color: rgb(148 163 184); }
846
+
847
+ .tab-button:hover:not(.active) {
848
+ background: rgb(241 245 249);
849
+ color: rgb(51 65 85);
850
+ }
851
+ .dark .tab-button:hover:not(.active) {
852
+ background: rgb(30 41 59);
853
+ color: rgb(226 232 240);
854
+ }
855
+
856
+ .tab-button.active {
857
+ color: rgb(15 23 42);
858
+ border-bottom-color: rgb(15 23 42);
859
+ background: white;
860
+ font-weight: 600;
861
+ }
862
+ .dark .tab-button.active {
863
+ color: rgb(248 250 252);
864
+ border-bottom-color: rgb(248 250 252);
865
+ background: rgb(2 6 23);
866
+ }
867
+
868
+ .tabs-content {
869
+ background: white;
870
+ position: relative;
871
+ }
872
+ .dark .tabs-content {
873
+ background: rgb(2 6 23);
874
+ }
875
+
876
+ .tab-panel {
877
+ padding: 1rem 1rem;
878
+ animation: fadeIn 0.2s ease-in;
879
+ position: absolute;
880
+ top: 0;
881
+ left: 0;
882
+ right: 0;
883
+ opacity: 0;
884
+ visibility: hidden;
885
+ pointer-events: none;
886
+ }
887
+ .tab-panel.active {
888
+ position: relative;
889
+ opacity: 1;
890
+ visibility: visible;
891
+ pointer-events: auto;
892
+ }
893
+
894
+ @keyframes fadeIn {
895
+ from { opacity: 0; }
896
+ to { opacity: 1; }
897
+ }
898
+
899
+ /* Remove extra margins from first/last elements in tabs */
900
+ .tab-panel > *:first-child { margin-top: 0 !important; }
901
+ .tab-panel > *:last-child { margin-bottom: 0 !important; }
902
+
903
+ /* Ensure code blocks in tabs look good */
904
+ .tab-panel pre {
905
+ border-radius: 0.375rem;
906
+ font-size: 0.875rem;
907
+ }
908
+ .tab-panel code {
909
+ font-family: 'IBM Plex Mono', monospace;
910
+ }
911
+ """),
912
+ # Script("if(!localStorage.__FRANKEN__) localStorage.__FRANKEN__ = JSON.stringify({mode: 'light'})"))
913
+ Script("""
914
+ (function () {
915
+ let franken = localStorage.__FRANKEN__
916
+ ? JSON.parse(localStorage.__FRANKEN__)
917
+ : { mode: 'light' };
918
+
919
+ if (franken.mode === 'dark') {
920
+ document.documentElement.classList.add('dark');
921
+ } else {
922
+ document.documentElement.classList.remove('dark');
923
+ }
924
+
925
+ localStorage.__FRANKEN__ = JSON.stringify(franken);
926
+ })();
927
+ """)
928
+ )
929
+
930
+
931
+ # Session/cookie-based authentication using Beforeware (conditionally enabled)
932
+ def user_auth_before(req, sess):
933
+ logger.info(f'Authenticating request for {req.url.path}')
934
+ auth = req.scope['auth'] = sess.get('auth', None)
935
+ if not auth:
936
+ sess['next'] = req.url.path
937
+ from starlette.responses import RedirectResponse
938
+ return RedirectResponse('/login', status_code=303)
939
+
940
+ # Enable auth only if username and password are configured
941
+ _config = get_config()
942
+ _auth_creds = _config.get_auth()
943
+ logger.info(f"Authentication enabled: {_auth_creds is not None and _auth_creds[0] and _auth_creds[1]}")
944
+
945
+ if _auth_creds and _auth_creds[0] and _auth_creds[1]:
946
+ beforeware = Beforeware(
947
+ user_auth_before,
948
+ skip=[
949
+ r'^/login$',
950
+ r'^/_sidebar/.*',
951
+ r'^/static/.*',
952
+ r'.*\.css',
953
+ r'.*\.js',
954
+ ]
955
+ )
956
+ else:
957
+ beforeware = None
958
+
959
+ logger.info(f'{beforeware=}')
960
+
961
+ app = FastHTML(hdrs=hdrs, before=beforeware) if beforeware else FastHTML(hdrs=hdrs)
962
+
963
+ static_dir = Path(__file__).parent / "static"
964
+
965
+ if static_dir.exists():
966
+ app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
967
+
968
+ rt = app.route
969
+
970
+
971
+ from starlette.requests import Request
972
+ from starlette.responses import RedirectResponse
973
+
974
+ @rt("/login", methods=["GET", "POST"])
975
+ async def login(request: Request):
976
+ config = get_config()
977
+ user, pwd = config.get_auth()
978
+ logger.info(f"Login attempt for user: {user}")
979
+ error = None
980
+ if request.method == "POST":
981
+ form = await request.form()
982
+ username = form.get("username", "")
983
+ password = form.get("password", "")
984
+ if username == user and password == pwd:
985
+ request.session["auth"] = username
986
+ next_url = request.session.pop("next", "/")
987
+ return RedirectResponse(next_url, status_code=303)
988
+ else:
989
+ error = "Invalid username or password."
990
+
991
+ return Div(
992
+ H2("Login", cls="uk-h2"),
993
+ Form(
994
+ Div(
995
+ Input(type="text", name="username", required=True, id="username", cls="uk-input input input-bordered w-full", placeholder="Username"),
996
+ cls="my-4"),
997
+ Div(
998
+ Input(type="password", name="password", required=True, id="password", cls="uk-input input input-bordered w-full", placeholder="Password"),
999
+ cls="my-4"),
1000
+ Button("Login", type="submit", cls="uk-btn btn btn-primary w-full"),
1001
+ enctype="multipart/form-data", method="post", cls="max-w-sm mx-auto"),
1002
+ P(error, cls="text-red-500 mt-4") if error else None,
1003
+ cls="prose mx-auto mt-24 text-center")
1004
+
1005
+ # Progressive sidebar loading: lazy posts sidebar endpoint
1006
+ @rt("/_sidebar/posts")
1007
+ def posts_sidebar_lazy():
1008
+ html = _cached_posts_sidebar_html(_posts_sidebar_fingerprint())
1009
+ return Aside(
1010
+ NotStr(html),
1011
+ cls="hidden md:block w-72 shrink-0 sticky top-24 self-start max-h-[calc(100vh-10rem)] overflow-hidden z-[1000]",
1012
+ id="posts-sidebar"
1013
+ )
1014
+
1015
+ # Route to serve static files (images, SVGs, etc.) from blog posts
1016
+ @rt("/posts/{path:path}.{ext:static}")
1017
+ def serve_post_static(path: str, ext: str):
1018
+ from starlette.responses import FileResponse
1019
+ file_path = get_root_folder() / f'{path}.{ext}'
1020
+ if file_path.exists():
1021
+ return FileResponse(file_path)
1022
+ return Response(status_code=404)
1023
+
1024
+ def theme_toggle():
1025
+ theme_script = """on load set franken to (localStorage's __FRANKEN__ or '{}') as Object
1026
+ if franken's mode is 'dark' then add .dark to <html/> end
1027
+ on click toggle .dark on <html/>
1028
+ set franken to (localStorage's __FRANKEN__ or '{}') as Object
1029
+ if the first <html/> matches .dark set franken's mode to 'dark' else set franken's mode to 'light' end
1030
+ set localStorage's __FRANKEN__ to franken as JSON"""
1031
+ return Button(UkIcon("moon", cls="dark:hidden"), UkIcon("sun", cls="hidden dark:block"),
1032
+ _=theme_script, cls="p-1 hover:scale-110 shadow-none", type="button")
1033
+
1034
+ def navbar(show_mobile_menus=False):
1035
+ """Navbar with mobile menu buttons for file tree and TOC"""
1036
+ left_section = Div(
1037
+ A(get_blog_title(), href="/"),
1038
+ cls="flex items-center gap-2"
1039
+ )
1040
+
1041
+ right_section = Div(
1042
+ theme_toggle(),
1043
+ cls="flex items-center gap-2"
1044
+ )
1045
+
1046
+ # Add mobile menu buttons if sidebars are present
1047
+ if show_mobile_menus:
1048
+ mobile_buttons = Div(
1049
+ Button(
1050
+ UkIcon("menu", cls="w-5 h-5"),
1051
+ title="Toggle file tree",
1052
+ id="mobile-posts-toggle",
1053
+ cls="md:hidden p-2 hover:bg-slate-800 rounded transition-colors",
1054
+ type="button"
1055
+ ),
1056
+ Button(
1057
+ UkIcon("list", cls="w-5 h-5"),
1058
+ title="Toggle table of contents",
1059
+ id="mobile-toc-toggle",
1060
+ cls="md:hidden p-2 hover:bg-slate-800 rounded transition-colors",
1061
+ type="button"
1062
+ ),
1063
+ cls="flex items-center gap-1"
1064
+ )
1065
+ right_section = Div(
1066
+ mobile_buttons,
1067
+ theme_toggle(),
1068
+ cls="flex items-center gap-2"
1069
+ )
1070
+
1071
+ return Div(left_section, right_section,
1072
+ cls="flex items-center justify-between bg-slate-900 text-white p-4 my-4 rounded-lg shadow-md dark:bg-slate-800")
1073
+
1074
+ def _posts_sidebar_fingerprint():
1075
+ root = get_root_folder()
1076
+ try:
1077
+ return max((p.stat().st_mtime for p in root.rglob("*.md")), default=0)
1078
+ except Exception:
1079
+ return 0
1080
+
1081
+ @lru_cache(maxsize=1)
1082
+ def _cached_posts_sidebar_html(fingerprint):
1083
+ sidebar = collapsible_sidebar(
1084
+ "menu",
1085
+ "Posts",
1086
+ get_posts(),
1087
+ is_open=False,
1088
+ data_sidebar="posts"
1089
+ )
1090
+ return to_xml(sidebar)
1091
+
1092
+ def collapsible_sidebar(icon, title, items_list, is_open=False, data_sidebar=None):
1093
+ """Reusable collapsible sidebar component with sticky header"""
1094
+ # Build the summary content
1095
+ summary_content = [
1096
+ UkIcon(icon, cls="w-5 h-5 mr-2"),
1097
+ Span(title, cls="flex-1")
1098
+ ]
1099
+
1100
+ # Sidebar styling configuration
1101
+ common_frost_style = "bg-white/10 dark:bg-slate-950/70 backdrop-blur-lg border border-slate-900/20 dark:border-slate-700/20 shadow-lg"
1102
+ summary_classes = f"flex items-center font-semibold cursor-pointer py-2 px-3 hover:bg-slate-100/80 dark:hover:bg-slate-800/80 rounded-lg select-none list-none {common_frost_style} min-h-[56px]"
1103
+ content_classes = f"p-3 {common_frost_style} rounded-lg border border-black dark:border-black overflow-y-auto max-h-[calc(100vh-18rem)]"
1104
+
1105
+ return Details(
1106
+ Summary(*summary_content, cls=summary_classes, style="margin: 0 0 0.5rem 0;"),
1107
+ Div(
1108
+ Ul(*items_list, cls="list-none"),
1109
+ cls=content_classes,
1110
+ id="sidebar-scroll-container"
1111
+ ),
1112
+ open=is_open,
1113
+ data_sidebar=data_sidebar
1114
+ )
1115
+
1116
+ def is_active_toc_item(anchor):
1117
+ """Check if a TOC item is currently active based on URL hash"""
1118
+ # This will be enhanced client-side with JavaScript
1119
+ return False
1120
+
1121
+ def extract_toc(content):
1122
+ """Extract table of contents from markdown content, excluding code blocks"""
1123
+ # Remove code blocks (both fenced and indented) to avoid false positives
1124
+ # Remove fenced code blocks (``` or ~~~)
1125
+ content_no_code = re.sub(r'^```.*?^```', '', content, flags=re.MULTILINE | re.DOTALL)
1126
+ content_no_code = re.sub(r'^~~~.*?^~~~', '', content_no_code, flags=re.MULTILINE | re.DOTALL)
1127
+
1128
+ # Parse headings from the cleaned content
1129
+ heading_pattern = re.compile(r'^(#{1,6})\s+(.+)$', re.MULTILINE)
1130
+ headings = []
1131
+ for match in heading_pattern.finditer(content_no_code):
1132
+ level = len(match.group(1))
1133
+ text = match.group(2).strip()
1134
+ # Create anchor from heading text using shared function
1135
+ anchor = text_to_anchor(text)
1136
+ headings.append((level, text, anchor))
1137
+ return headings
1138
+
1139
+ def build_toc_items(headings):
1140
+ """Build TOC items from extracted headings with active state tracking"""
1141
+ if not headings:
1142
+ return [Li("No headings found", cls="text-sm text-slate-500 dark:text-slate-400 py-1")]
1143
+
1144
+ items = []
1145
+ for level, text, anchor in headings:
1146
+ indent = "ml-0" if level == 1 else f"ml-{(level-1)*3}"
1147
+ items.append(Li(
1148
+ A(text, href=f"#{anchor}",
1149
+ cls=f"toc-link block py-1 px-2 text-sm rounded hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-700 dark:text-slate-300 hover:text-blue-600 transition-colors {indent}",
1150
+ data_anchor=anchor),
1151
+ cls="my-1"
1152
+ ))
1153
+ return items
1154
+
1155
+ def get_custom_css_links(current_path=None, section_class=None):
1156
+ """Check for custom.css or style.css in blog root and current post's directory
1157
+
1158
+ Returns list of Link/Style elements for all found CSS files, ordered from root to specific
1159
+ (so more specific styles can override general ones). Folder-specific CSS is automatically
1160
+ scoped to only apply within that folder's pages.
1161
+ """
1162
+ root = get_root_folder()
1163
+ css_elements = []
1164
+
1165
+ # First, check root directory - applies globally
1166
+ for filename in ['custom.css', 'style.css']:
1167
+ css_file = root / filename
1168
+ if css_file.exists():
1169
+ css_elements.append(Link(rel="stylesheet", href=f"/posts/{filename}"))
1170
+ break # Only one from root
1171
+
1172
+ # Then check current post's directory (if provided)
1173
+ # These are automatically scoped to only apply within the section
1174
+ if current_path and section_class:
1175
+ from pathlib import Path
1176
+ post_dir = Path(current_path).parent if '/' in current_path else Path('.')
1177
+
1178
+ if str(post_dir) != '.': # Not in root
1179
+ for filename in ['custom.css', 'style.css']:
1180
+ css_file = root / post_dir / filename
1181
+ if css_file.exists():
1182
+ # Read CSS content and wrap all rules with section scope
1183
+ css_content = css_file.read_text()
1184
+ # Wrap the entire CSS in a section-specific scope
1185
+ scoped_css = Style(f"""
1186
+ #main-content.{section_class} {{
1187
+ {css_content}
1188
+ }}
1189
+ """)
1190
+ css_elements.append(scoped_css)
1191
+ break # Only one per directory
1192
+
1193
+ return css_elements
1194
+
1195
+ def layout(*content, htmx, title=None, show_sidebar=False, toc_content=None, current_path=None):
1196
+ import time
1197
+ layout_start_time = time.time()
1198
+ logger.debug("[LAYOUT] layout() start")
1199
+ # Generate section class for CSS scoping (will be used by get_custom_css_links if needed)
1200
+ section_class = f"section-{current_path.replace('/', '-')}" if current_path else ""
1201
+ t_section = time.time()
1202
+ logger.debug(f"[LAYOUT] section_class computed in {(t_section - layout_start_time)*1000:.2f}ms")
1203
+
1204
+ # HTMX short-circuit: build only swappable fragments, never build full page chrome/sidebars tree
1205
+ if htmx and getattr(htmx, "request", None):
1206
+ if show_sidebar:
1207
+ toc_items = build_toc_items(extract_toc(toc_content)) if toc_content else []
1208
+ t_toc = time.time()
1209
+ logger.debug(f"[LAYOUT] TOC built in {(t_toc - t_section)*1000:.2f}ms")
1210
+
1211
+ toc_attrs = {
1212
+ "cls": "hidden md:block w-72 shrink-0 sticky top-24 self-start max-h-[calc(100vh-10rem)] overflow-hidden z-[1000]",
1213
+ "id": "toc-sidebar",
1214
+ "hx_swap_oob": "true",
1215
+ }
1216
+ toc_sidebar = Aside(
1217
+ collapsible_sidebar("list", "Contents", toc_items, is_open=False) if toc_items else Div(),
1218
+ **toc_attrs
1219
+ )
1220
+
1221
+ custom_css_links = get_custom_css_links(current_path, section_class)
1222
+ t_css = time.time()
1223
+ logger.debug(f"[LAYOUT] Custom CSS resolved in {(t_css - t_toc)*1000:.2f}ms")
1224
+
1225
+ main_content_container = Main(*content, cls=f"flex-1 min-w-0 px-6 py-8 space-y-8 {section_class}", id="main-content")
1226
+ t_main = time.time()
1227
+ logger.debug(f"[LAYOUT] Main content container built in {(t_main - t_css)*1000:.2f}ms")
1228
+
1229
+ result = [Title(title)]
1230
+ if custom_css_links:
1231
+ result.append(Div(*custom_css_links, id="scoped-css-container", hx_swap_oob="true"))
1232
+ else:
1233
+ result.append(Div(id="scoped-css-container", hx_swap_oob="true"))
1234
+ result.extend([main_content_container, toc_sidebar])
1235
+
1236
+ t_htmx = time.time()
1237
+ logger.debug(f"[LAYOUT] HTMX response assembled in {(t_htmx - t_main)*1000:.2f}ms")
1238
+ logger.debug(f"[LAYOUT] TOTAL layout() time {(t_htmx - layout_start_time)*1000:.2f}ms")
1239
+ return tuple(result)
1240
+
1241
+ # HTMX without sidebar
1242
+ custom_css_links = get_custom_css_links(current_path, section_class) if current_path else []
1243
+ t_css = time.time()
1244
+ logger.debug(f"[LAYOUT] Custom CSS resolved in {(t_css - t_section)*1000:.2f}ms")
1245
+
1246
+ result = [Title(title)]
1247
+ if custom_css_links:
1248
+ result.append(Div(*custom_css_links, id="scoped-css-container", hx_swap_oob="true"))
1249
+ else:
1250
+ result.append(Div(id="scoped-css-container", hx_swap_oob="true"))
1251
+ result.extend(content)
1252
+
1253
+ t_htmx = time.time()
1254
+ logger.debug(f"[LAYOUT] HTMX response assembled in {(t_htmx - layout_start_time)*1000:.2f}ms")
1255
+ logger.debug(f"[LAYOUT] TOTAL layout() time {(t_htmx - layout_start_time)*1000:.2f}ms")
1256
+ return tuple(result)
1257
+
1258
+ if show_sidebar:
1259
+ # Build TOC if content provided
1260
+ toc_items = build_toc_items(extract_toc(toc_content)) if toc_content else []
1261
+ t_toc = time.time()
1262
+ logger.debug(f"[LAYOUT] TOC built in {(t_toc - t_section)*1000:.2f}ms")
1263
+ # Right sidebar TOC component with out-of-band swap for HTMX
1264
+ toc_attrs = {
1265
+ "cls": "hidden md:block w-72 shrink-0 sticky top-24 self-start max-h-[calc(100vh-10rem)] overflow-hidden z-[1000]",
1266
+ "id": "toc-sidebar"
1267
+ }
1268
+ toc_sidebar = Aside(
1269
+ collapsible_sidebar("list", "Contents", toc_items, is_open=False) if toc_items else Div(),
1270
+ **toc_attrs
1271
+ )
1272
+ # Container for main content only (for HTMX swapping)
1273
+ # Add section class to identify the section for CSS scoping
1274
+ section_class = f"section-{current_path.replace('/', '-')}" if current_path else ""
1275
+ # Get custom CSS with folder-specific CSS automatically scoped
1276
+ custom_css_links = get_custom_css_links(current_path, section_class)
1277
+ t_css = time.time()
1278
+ logger.debug(f"[LAYOUT] Custom CSS resolved in {(t_css - t_toc)*1000:.2f}ms")
1279
+ main_content_container = Main(*content, cls=f"flex-1 min-w-0 px-6 py-8 space-y-8 {section_class}", id="main-content")
1280
+ t_main = time.time()
1281
+ logger.debug(f"[LAYOUT] Main content container built in {(t_main - t_css)*1000:.2f}ms")
1282
+ # Mobile overlay panels for posts and TOC
1283
+ mobile_posts_panel = Div(
1284
+ Div(
1285
+ Button(
1286
+ UkIcon("x", cls="w-5 h-5"),
1287
+ id="close-mobile-posts",
1288
+ cls="p-2 hover:bg-slate-200 dark:hover:bg-slate-700 rounded transition-colors ml-auto",
1289
+ type="button"
1290
+ ),
1291
+ cls="flex justify-end p-2 bg-white dark:bg-slate-950 border-b border-slate-200 dark:border-slate-800"
1292
+ ),
1293
+ Div(
1294
+ NotStr(_cached_posts_sidebar_html(_posts_sidebar_fingerprint())),
1295
+ cls="p-4 overflow-y-auto"
1296
+ ),
1297
+ id="mobile-posts-panel",
1298
+ cls="fixed inset-0 bg-white dark:bg-slate-950 z-[9999] md:hidden transform -translate-x-full transition-transform duration-300"
1299
+ )
1300
+ mobile_toc_panel = Div(
1301
+ Div(
1302
+ Button(
1303
+ UkIcon("x", cls="w-5 h-5"),
1304
+ id="close-mobile-toc",
1305
+ cls="p-2 hover:bg-slate-200 dark:hover:bg-slate-700 rounded transition-colors ml-auto",
1306
+ type="button"
1307
+ ),
1308
+ cls="flex justify-end p-2 bg-white dark:bg-slate-950 border-b border-slate-200 dark:border-slate-800"
1309
+ ),
1310
+ Div(
1311
+ collapsible_sidebar("list", "Contents", toc_items, is_open=False) if toc_items else Div(P("No table of contents available.", cls="text-slate-500 dark:text-slate-400 text-sm p-4")),
1312
+ cls="p-4 overflow-y-auto"
1313
+ ),
1314
+ id="mobile-toc-panel",
1315
+ cls="fixed inset-0 bg-white dark:bg-slate-950 z-[9999] md:hidden transform translate-x-full transition-transform duration-300"
1316
+ )
1317
+ # Full layout with all sidebars
1318
+ content_with_sidebars = Div(cls="w-full max-w-7xl mx-auto px-4 flex gap-6 flex-1")(
1319
+ # Left sidebar - lazy load with HTMX, show loader placeholder
1320
+ Aside(
1321
+ Div(
1322
+ UkIcon("loader", cls="w-5 h-5 animate-spin"),
1323
+ Span("Loading posts…", cls="ml-2 text-sm"),
1324
+ cls="flex items-center justify-center h-32 text-slate-400"
1325
+ ),
1326
+ cls="hidden md:block w-72 shrink-0 sticky top-24 self-start max-h-[calc(100vh-10rem)] overflow-hidden z-[1000]",
1327
+ id="posts-sidebar",
1328
+ hx_get="/_sidebar/posts",
1329
+ hx_trigger="load",
1330
+ hx_swap="outerHTML"
1331
+ ),
1332
+ # Main content (swappable)
1333
+ main_content_container,
1334
+ # Right sidebar - TOC (swappable out-of-band)
1335
+ toc_sidebar
1336
+ )
1337
+ t_sidebars = time.time()
1338
+ logger.debug(f"[LAYOUT] Sidebars container built in {(t_sidebars - t_main)*1000:.2f}ms")
1339
+ # Layout with sidebar for blog posts
1340
+ body_content = Div(id="page-container", cls="flex flex-col min-h-screen")(
1341
+ Div(navbar(show_mobile_menus=True), cls="w-full max-w-7xl mx-auto px-4 sticky top-0 z-50 mt-4"),
1342
+ mobile_posts_panel,
1343
+ mobile_toc_panel,
1344
+ content_with_sidebars,
1345
+ Footer(Div(f"Powered by Bloggy", cls="bg-slate-900 text-white rounded-lg p-4 my-4 dark:bg-slate-800 text-right"), # right justified footer
1346
+ cls="w-full max-w-7xl mx-auto px-6 mt-auto mb-6")
1347
+ )
1348
+ else:
1349
+ # Default layout without sidebar
1350
+ custom_css_links = get_custom_css_links(current_path, section_class) if current_path else []
1351
+ body_content = Div(id="page-container", cls="flex flex-col min-h-screen")(
1352
+ Div(navbar(), cls="w-full max-w-2xl mx-auto px-4 sticky top-0 z-50 mt-4"),
1353
+ Main(*content, cls="w-full max-w-2xl mx-auto px-6 py-8 space-y-8", id="main-content"),
1354
+ Footer(Div("Powered by Bloggy", cls="bg-slate-900 text-white rounded-lg p-4 my-4 dark:bg-slate-800 text-right"),
1355
+ cls="w-full max-w-2xl mx-auto px-6 mt-auto mb-6")
1356
+ )
1357
+ t_body = time.time()
1358
+ logger.debug(f"[LAYOUT] Body content (no sidebar) built in {(t_body - layout_start_time)*1000:.2f}ms")
1359
+ # For full page loads, return complete page
1360
+ result = [Title(title)]
1361
+ # Wrap custom CSS in a container so HTMX can swap it out later
1362
+ if custom_css_links:
1363
+ css_container = Div(*custom_css_links, id="scoped-css-container")
1364
+ result.append(css_container)
1365
+ else:
1366
+ # Even if no CSS now, add empty container for future swaps
1367
+ css_container = Div(id="scoped-css-container")
1368
+ result.append(css_container)
1369
+ result.append(body_content)
1370
+ t_end = time.time()
1371
+ logger.debug(f"[LAYOUT] FULL PAGE assembled in {(t_end - layout_start_time)*1000:.2f}ms")
1372
+ return tuple(result)
1373
+
1374
+ def build_post_tree(folder):
1375
+ import time
1376
+ start_time = time.time()
1377
+ root = get_root_folder()
1378
+ items = []
1379
+ try:
1380
+ index_file = find_index_file() if folder == root else None
1381
+ entries = []
1382
+ for item in folder.iterdir():
1383
+ if item.name == ".bloggy":
1384
+ continue
1385
+ if item.is_dir():
1386
+ if item.name.startswith('.'):
1387
+ continue
1388
+ entries.append(item)
1389
+ elif item.suffix == '.md':
1390
+ # Skip the file being used for home page (index.md takes precedence over readme.md)
1391
+ if index_file and item.resolve() == index_file.resolve():
1392
+ continue
1393
+ entries.append(item)
1394
+ config = get_bloggy_config(folder)
1395
+ entries = order_bloggy_entries(entries, config)
1396
+ logger.debug(
1397
+ "[DEBUG] build_post_tree entries for %s: %s",
1398
+ folder,
1399
+ [item.name for item in entries],
1400
+ )
1401
+ logger.debug(f"[DEBUG] Scanning directory: {folder.relative_to(root) if folder != root else '.'} - found {len(entries)} entries")
1402
+ except (OSError, PermissionError):
1403
+ return items
1404
+
1405
+ for item in entries:
1406
+ if item.is_dir():
1407
+ if item.name.startswith('.'): continue
1408
+ sub_items = build_post_tree(item)
1409
+ if sub_items:
1410
+ folder_title = slug_to_title(item.name)
1411
+ items.append(Li(Details(
1412
+ Summary(
1413
+ Span(Span(cls="folder-chevron"), cls="w-4 mr-2 flex items-center justify-center shrink-0"),
1414
+ Span(UkIcon("folder", cls="text-blue-500 w-4 h-4"), cls="w-4 mr-2 flex items-center justify-center shrink-0"),
1415
+ Span(folder_title, cls="truncate min-w-0", title=folder_title),
1416
+ cls="flex items-center font-medium cursor-pointer py-1 px-2 hover:text-blue-600 select-none list-none rounded hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors min-w-0"),
1417
+ Ul(*sub_items, cls="ml-4 pl-2 space-y-1 border-l border-slate-100 dark:border-slate-800"),
1418
+ data_folder="true"), cls="my-1"))
1419
+ elif item.suffix == '.md':
1420
+ slug = str(item.relative_to(root).with_suffix(''))
1421
+ title_start = time.time()
1422
+ title = get_post_title(item)
1423
+ title_time = (time.time() - title_start) * 1000
1424
+ if title_time > 1: # Only log if it takes more than 1ms
1425
+ logger.debug(f"[DEBUG] Getting title for {item.name} took {title_time:.2f}ms")
1426
+ items.append(Li(A(
1427
+ Span(cls="w-4 mr-2 shrink-0"),
1428
+ Span(UkIcon("file-text", cls="text-slate-400 w-4 h-4"), cls="w-4 mr-2 flex items-center justify-center shrink-0"),
1429
+ Span(title, cls="truncate min-w-0", title=title),
1430
+ href=f'/posts/{slug}',
1431
+ hx_get=f'/posts/{slug}', hx_target="#main-content", hx_push_url="true", hx_swap="outerHTML show:window:top settle:0.1s",
1432
+ cls="post-link flex items-center py-1 px-2 rounded hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-700 dark:text-slate-300 hover:text-blue-600 transition-colors min-w-0",
1433
+ data_path=slug)))
1434
+
1435
+ elapsed = (time.time() - start_time) * 1000
1436
+ logger.debug(f"[DEBUG] build_post_tree for {folder.relative_to(root) if folder != root else '.'} completed in {elapsed:.2f}ms")
1437
+ return items
1438
+
1439
+ def _posts_tree_fingerprint():
1440
+ root = get_root_folder()
1441
+ try:
1442
+ md_mtime = max((p.stat().st_mtime for p in root.rglob("*.md")), default=0)
1443
+ bloggy_mtime = max((p.stat().st_mtime for p in root.rglob(".bloggy")), default=0)
1444
+ return max(md_mtime, bloggy_mtime)
1445
+ except Exception:
1446
+ return 0
1447
+
1448
+ @lru_cache(maxsize=1)
1449
+ def _cached_build_post_tree(fingerprint):
1450
+ return build_post_tree(get_root_folder())
1451
+
1452
+ def get_posts():
1453
+ fingerprint = _posts_tree_fingerprint()
1454
+ return _cached_build_post_tree(fingerprint)
1455
+
1456
+ def not_found(htmx=None):
1457
+ """Custom 404 error page"""
1458
+ blog_title = get_blog_title()
1459
+
1460
+ content = Div(
1461
+ # Large 404 heading
1462
+ Div(
1463
+ H1("404", cls="text-9xl font-bold text-slate-300 dark:text-slate-700 mb-4"),
1464
+ cls="text-center"
1465
+ ),
1466
+
1467
+ # Main error message
1468
+ H2("Page Not Found", cls="text-3xl font-bold text-slate-800 dark:text-slate-200 mb-4 text-center"),
1469
+
1470
+ # Description
1471
+ P(
1472
+ "Oops! The page you're looking for doesn't exist. It might have been moved or deleted.",
1473
+ cls="text-lg text-slate-600 dark:text-slate-400 mb-8 text-center max-w-2xl mx-auto"
1474
+ ),
1475
+
1476
+ # Action buttons
1477
+ Div(
1478
+ A(
1479
+ UkIcon("home", cls="w-5 h-5 mr-2"),
1480
+ "Go to Home",
1481
+ href="/",
1482
+ cls="inline-flex items-center px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors mr-4"
1483
+ ),
1484
+ A(
1485
+ UkIcon("arrow-left", cls="w-5 h-5 mr-2"),
1486
+ "Go Back",
1487
+ href="javascript:history.back()",
1488
+ cls="inline-flex items-center px-6 py-3 bg-slate-200 hover:bg-slate-300 dark:bg-slate-700 dark:hover:bg-slate-600 text-slate-800 dark:text-slate-200 rounded-lg font-medium transition-colors"
1489
+ ),
1490
+ cls="flex justify-center items-center gap-4 flex-wrap"
1491
+ ),
1492
+
1493
+ # Decorative element
1494
+ Div(
1495
+ P(
1496
+ "💡 ",
1497
+ Strong("Tip:"),
1498
+ " Check the sidebar for available posts, or use the search to find what you're looking for.",
1499
+ cls="text-sm text-slate-500 dark:text-slate-500 italic"
1500
+ ),
1501
+ cls="mt-12 text-center"
1502
+ ),
1503
+
1504
+ cls="flex flex-col items-center justify-center py-16 px-6 min-h-[60vh]"
1505
+ )
1506
+
1507
+ # Return with layout, including sidebar for easy navigation
1508
+ # Store the result tuple to potentially wrap with status code
1509
+ result = layout(content, htmx=htmx, title=f"404 - Page Not Found | {blog_title}", show_sidebar=True)
1510
+ return result
1511
+
1512
+ @rt('/posts/{path:path}')
1513
+ def post_detail(path: str, htmx):
1514
+ import time
1515
+ request_start = time.time()
1516
+ logger.info(f"\n[DEBUG] ########## REQUEST START: /posts/{path} ##########")
1517
+
1518
+ file_path = get_root_folder() / f'{path}.md'
1519
+
1520
+ # Check if file exists
1521
+ if not file_path.exists():
1522
+ return not_found(htmx)
1523
+
1524
+ metadata, raw_content = parse_frontmatter(file_path)
1525
+
1526
+ # Get title from frontmatter or filename
1527
+ post_title = metadata.get('title', slug_to_title(path.split('/')[-1]))
1528
+
1529
+ # Render the markdown content with current path for relative link resolution
1530
+ md_start = time.time()
1531
+ content = from_md(raw_content, current_path=path)
1532
+ md_time = (time.time() - md_start) * 1000
1533
+ logger.debug(f"[DEBUG] Markdown rendering took {md_time:.2f}ms")
1534
+
1535
+ post_content = Div(H1(post_title, cls="text-4xl font-bold mb-8"), content)
1536
+
1537
+ # Always return complete layout with sidebar and TOC
1538
+ layout_start = time.time()
1539
+ result = layout(post_content, htmx=htmx, title=f"{post_title} - {get_blog_title()}",
1540
+ show_sidebar=True, toc_content=raw_content, current_path=path)
1541
+ layout_time = (time.time() - layout_start) * 1000
1542
+ logger.debug(f"[DEBUG] Layout generation took {layout_time:.2f}ms")
1543
+
1544
+ total_time = (time.time() - request_start) * 1000
1545
+ logger.debug(f"[DEBUG] ########## REQUEST COMPLETE: {total_time:.2f}ms TOTAL ##########\n")
1546
+
1547
+ return result
1548
+
1549
+ def find_index_file():
1550
+ """Find index.md or readme.md (case insensitive) in root folder"""
1551
+ root = get_root_folder()
1552
+
1553
+ # Try to find index.md first (case insensitive)
1554
+ for file in root.iterdir():
1555
+ if file.is_file() and file.suffix == '.md' and file.stem.lower() == 'index':
1556
+ return file
1557
+
1558
+ # Try to find readme.md (case insensitive)
1559
+ for file in root.iterdir():
1560
+ if file.is_file() and file.suffix == '.md' and file.stem.lower() == 'readme':
1561
+ return file
1562
+
1563
+ return None
1564
+
1565
+ @rt
1566
+ def index(htmx):
1567
+ import time
1568
+ request_start = time.time()
1569
+ logger.info(f"\n[DEBUG] ########## REQUEST START: / (index) ##########")
1570
+
1571
+ blog_title = get_blog_title()
1572
+
1573
+ # Try to find index.md or readme.md
1574
+ index_file = find_index_file()
1575
+
1576
+ if index_file:
1577
+ # Render the index/readme file
1578
+ metadata, raw_content = parse_frontmatter(index_file)
1579
+ page_title = metadata.get('title', blog_title)
1580
+ # Use index file's relative path from root for link resolution
1581
+ index_path = str(index_file.relative_to(get_root_folder()).with_suffix(''))
1582
+ content = from_md(raw_content, current_path=index_path)
1583
+ page_content = Div(H1(page_title, cls="text-4xl font-bold mb-8"), content)
1584
+
1585
+ layout_start = time.time()
1586
+ result = layout(page_content, htmx=htmx, title=f"{page_title} - {blog_title}",
1587
+ show_sidebar=True, toc_content=raw_content, current_path=index_path)
1588
+ layout_time = (time.time() - layout_start) * 1000
1589
+ logger.debug(f"[DEBUG] Layout generation took {layout_time:.2f}ms")
1590
+
1591
+ total_time = (time.time() - request_start) * 1000
1592
+ logger.debug(f"[DEBUG] ########## REQUEST COMPLETE: {total_time:.2f}ms TOTAL ##########\n")
1593
+
1594
+ return result
1595
+ else:
1596
+ # Default welcome message
1597
+ layout_start = time.time()
1598
+ result = layout(Div(
1599
+ H1(f"Welcome to {blog_title}!", cls="text-4xl font-bold tracking-tight mb-8"),
1600
+ P("Your personal blogging platform.", cls="text-lg text-slate-600 dark:text-slate-400 mb-4"),
1601
+ P("Browse your posts using the sidebar, or create an ",
1602
+ Strong("index.md"), " or ", Strong("README.md"),
1603
+ " file in your blog directory to customize this page.",
1604
+ cls="text-base text-slate-600 dark:text-slate-400"),
1605
+ cls="w-full"), htmx=htmx, title=f"Home - {blog_title}", show_sidebar=True)
1606
+ layout_time = (time.time() - layout_start) * 1000
1607
+ logger.debug(f"[DEBUG] Layout generation took {layout_time:.2f}ms")
1608
+
1609
+ total_time = (time.time() - request_start) * 1000
1610
+ logger.debug(f"[DEBUG] ########## REQUEST COMPLETE: {total_time:.2f}ms TOTAL ##########\n")
1611
+
1612
+ return result
1613
+
1614
+ # Catch-all route for 404 pages (must be last)
1615
+ @rt('/{path:path}')
1616
+ def catch_all(path: str, htmx):
1617
+ """Catch-all route for undefined URLs"""
1618
+ return not_found(htmx)