vyasa 0.3.6__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.
vyasa/core.py ADDED
@@ -0,0 +1,2825 @@
1
+ import re, mistletoe as mst, pathlib, os
2
+ import json
3
+ from dataclasses import dataclass
4
+ from itertools import chain
5
+ from urllib.parse import quote_plus
6
+ from functools import partial
7
+ from functools import lru_cache
8
+ from pathlib import Path
9
+ from fasthtml.common import *
10
+ from fasthtml.common import Beforeware
11
+ from fasthtml.jupyter import *
12
+ from monsterui.all import *
13
+ from starlette.staticfiles import StaticFiles
14
+ from .config import get_config
15
+ from .helpers import (
16
+ slug_to_title,
17
+ _strip_inline_markdown,
18
+ _plain_text_from_html,
19
+ text_to_anchor,
20
+ _unique_anchor,
21
+ parse_frontmatter,
22
+ get_post_title,
23
+ get_vyasa_config,
24
+ order_vyasa_entries,
25
+ _effective_abbreviations,
26
+ find_folder_note_file,
27
+ )
28
+ from .layout_helpers import (
29
+ _resolve_layout_config,
30
+ _width_class_and_style,
31
+ _style_attr,
32
+ )
33
+ from loguru import logger
34
+ from fastsql import Database
35
+
36
+ # disable debug level logs to stdout
37
+ logger.remove()
38
+ logger.add(sys.stdout, level="INFO")
39
+ logfile = Path("/tmp/vyasa_core.log")
40
+ logger.add(logfile, rotation="10 MB", retention="10 days", level="DEBUG")
41
+
42
+ # Markdown rendering setup
43
+ try: FrankenRenderer
44
+ except NameError:
45
+ class FrankenRenderer(mst.HTMLRenderer):
46
+ def __init__(self, *args, img_dir=None, **kwargs):
47
+ super().__init__(*args, **kwargs)
48
+ self.img_dir = img_dir
49
+
50
+ def render_image(self, token):
51
+ tpl = '<img src="{}" alt="{}"{} class="max-w-full h-auto rounded-lg mb-6">'
52
+ title = f' title="{token.title}"' if hasattr(token, 'title') else ''
53
+ src = token.src
54
+ # Only prepend img_dir if src is relative and img_dir is provided
55
+ if self.img_dir and not src.startswith(('http://', 'https://', '/', 'attachment:', 'blob:', 'data:')):
56
+ src = f'{self.img_dir}/{src}'
57
+ return tpl.format(src, token.children[0].content if token.children else '', title)
58
+
59
+ def span_token(name, pat, attr, prec=5):
60
+ class T(mst.span_token.SpanToken):
61
+ precedence, parse_inner, parse_group, pattern = prec, False, 1, re.compile(pat)
62
+ def __init__(self, match):
63
+ setattr(self, attr, match.group(1))
64
+ # Optional second parameter
65
+ if hasattr(match, 'lastindex') and match.lastindex and match.lastindex >= 2:
66
+ if name == 'YoutubeEmbed':
67
+ self.caption = match.group(2) if match.group(2) else None
68
+ elif name == 'MermaidEmbed':
69
+ self.option = match.group(2) if match.group(2) else None
70
+ T.__name__ = name
71
+ return T
72
+
73
+ FootnoteRef = span_token('FootnoteRef', r'\[\^([^\]]+)\](?!:)', 'target')
74
+ YoutubeEmbed = span_token(
75
+ 'YoutubeEmbed',
76
+ r'\[yt:([a-zA-Z0-9_-]+)(?:\|(.+))?\]',
77
+ 'video_id',
78
+ 6
79
+ )
80
+
81
+ # Superscript and Subscript tokens with higher precedence
82
+ class Superscript(mst.span_token.SpanToken):
83
+ pattern = re.compile(r'\^([^\^]+?)\^')
84
+ parse_inner = False
85
+ parse_group = 1
86
+ precedence = 7
87
+ def __init__(self, match):
88
+ self.content = match.group(1)
89
+ self.children = []
90
+
91
+ class Subscript(mst.span_token.SpanToken):
92
+ pattern = re.compile(r'~([^~]+?)~')
93
+ parse_inner = False
94
+ parse_group = 1
95
+ precedence = 7
96
+ def __init__(self, match):
97
+ self.content = match.group(1)
98
+ self.children = []
99
+
100
+ # Inline code with Pandoc-style attributes: `code`{.class #id}
101
+ class InlineCodeAttr(mst.span_token.SpanToken):
102
+ pattern = re.compile(r'`([^`]+)`\{([^\}]+)\}')
103
+ parse_inner = False
104
+ parse_group = 1
105
+ precedence = 8 # Higher than other inline elements
106
+ def __init__(self, match):
107
+ self.code = match.group(1)
108
+ self.attrs = match.group(2)
109
+ self.children = []
110
+
111
+ # Strikethrough: ~~text~~
112
+ class Strikethrough(mst.span_token.SpanToken):
113
+ pattern = re.compile(r'~~(.+?)~~')
114
+ parse_inner = True
115
+ parse_group = 1
116
+ precedence = 7
117
+ def __init__(self, match):
118
+ self.children = []
119
+
120
+ def preprocess_super_sub(content):
121
+ """Convert superscript and subscript syntax to HTML before markdown rendering"""
122
+ # Handle superscript ^text^
123
+ content = re.sub(r'\^([^\^\n]+?)\^', r'<sup>\1</sup>', content)
124
+ # Handle subscript ~text~ (but not strikethrough ~~text~~)
125
+ content = re.sub(r'(?<!~)~([^~\n]+?)~(?!~)', r'<sub>\1</sub>', content)
126
+ return content
127
+
128
+ def extract_footnotes(content):
129
+ pat = re.compile(r'^\[\^([^\]]+)\]:\s*(.+?)(?=(?:^|\n)\[\^|\n\n|\Z)', re.MULTILINE | re.DOTALL)
130
+ defs = {m.group(1): m.group(2).strip() for m in pat.finditer(content)}
131
+ for m in pat.finditer(content): content = content.replace(m.group(0), '', 1)
132
+ return content.strip(), defs
133
+
134
+ def preprocess_tabs(content):
135
+ """Convert :::tabs syntax to placeholder tokens, store tab data for later processing"""
136
+ import hashlib
137
+ import base64
138
+
139
+ # Storage for tab data (will be processed after main markdown rendering)
140
+ tab_data_store = {}
141
+
142
+ # Pattern to match :::tabs...:::
143
+ tabs_pattern = re.compile(r'^:::tabs\s*\n(.*?)^:::', re.MULTILINE | re.DOTALL)
144
+
145
+ def replace_tabs_block(match):
146
+ tabs_content = match.group(1)
147
+ # Pattern to match ::tab{title="..." ...}
148
+ tab_pattern = re.compile(r'^::tab\{([^\}]+)\}\s*\n(.*?)(?=^::tab\{|\Z)', re.MULTILINE | re.DOTALL)
149
+
150
+ def parse_attrs(raw_attrs):
151
+ attrs = {}
152
+ for key, value in re.findall(r'([a-zA-Z0-9_-]+)\s*=\s*"([^"]*)"', raw_attrs):
153
+ attrs[key] = value
154
+ return attrs
155
+
156
+ tabs = []
157
+ for tab_match in tab_pattern.finditer(tabs_content):
158
+ raw_attrs = tab_match.group(1)
159
+ tab_content = tab_match.group(2).strip()
160
+ attrs = parse_attrs(raw_attrs)
161
+ title = attrs.get('title')
162
+ if not title:
163
+ continue
164
+ tabs.append({'title': title, 'content': tab_content, 'attrs': attrs})
165
+
166
+ if not tabs:
167
+ return match.group(0) # Return original if no tabs found
168
+
169
+ title_map = {tab['title']: tab for tab in tabs}
170
+ index_map = {str(i): tab for i, tab in enumerate(tabs)}
171
+
172
+ def fence_wrap(content):
173
+ backtick_runs = re.findall(r'`+', content)
174
+ max_run = max((len(run) for run in backtick_runs), default=0)
175
+ fence_len = max(4, max_run + 1)
176
+ fence = '`' * fence_len
177
+ return f'{fence}\n{content}\n{fence}'
178
+
179
+ def resolve_tab_content(tab, stack=None):
180
+ stack = stack or set()
181
+ copy_from = tab.get('attrs', {}).get('copy-from')
182
+ if not copy_from:
183
+ return tab['content']
184
+ if copy_from in stack:
185
+ return tab['content']
186
+ source_tab = None
187
+ if copy_from.startswith('index:'):
188
+ index_key = copy_from.split(':', 1)[1].strip()
189
+ source_tab = index_map.get(index_key)
190
+ elif copy_from.isdigit():
191
+ source_tab = index_map.get(copy_from)
192
+ else:
193
+ source_tab = title_map.get(copy_from)
194
+ if not source_tab:
195
+ return tab['content']
196
+ stack.add(copy_from)
197
+ resolved = resolve_tab_content(source_tab, stack)
198
+ stack.remove(copy_from)
199
+ return fence_wrap(resolved)
200
+
201
+ for tab in tabs:
202
+ tab['content'] = resolve_tab_content(tab)
203
+
204
+ # Generate unique ID for this tab group
205
+ tab_id = hashlib.md5(match.group(0).encode()).hexdigest()[:8]
206
+
207
+ # Store tab data for later processing
208
+ tab_data_store[tab_id] = [(tab['title'], tab['content']) for tab in tabs]
209
+
210
+ # Return a placeholder that won't be processed by markdown
211
+ placeholder = f'<div class="tab-placeholder" data-tab-id="{tab_id}"></div>'
212
+ return placeholder
213
+
214
+ processed_content = tabs_pattern.sub(replace_tabs_block, content)
215
+ return processed_content, tab_data_store
216
+
217
+ class ContentRenderer(FrankenRenderer):
218
+ def __init__(self, *extras, img_dir=None, footnotes=None, current_path=None, **kwargs):
219
+ super().__init__(*extras, img_dir=img_dir, **kwargs)
220
+ self.footnotes, self.fn_counter = footnotes or {}, 0
221
+ self.current_path = current_path # Current post path for resolving relative links and images
222
+ self.heading_counts = {}
223
+ self.mermaid_counter = 0
224
+
225
+ def render_list_item(self, token):
226
+ """Render list items with task list checkbox support"""
227
+ inner = self.render_inner(token)
228
+
229
+ # Check if this is a task list item: starts with [ ] or [x]
230
+ # Try different patterns as the structure might vary
231
+ task_pattern = re.match(r'^\s*\[([ xX])\]\s*(.*?)$', inner, re.DOTALL)
232
+ if not task_pattern:
233
+ task_pattern = re.match(r'^<p>\s*\[([ xX])\]\s*(.*?)</p>$', inner, re.DOTALL)
234
+
235
+ if task_pattern:
236
+ checked = task_pattern.group(1).lower() == 'x'
237
+ content = task_pattern.group(2).strip()
238
+
239
+ # Custom styled checkbox
240
+ if checked:
241
+ checkbox_style = 'background-color: #10b981; border-color: #10b981;'
242
+ 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>'
243
+ else:
244
+ checkbox_style = 'background-color: #6b7280; border-color: #6b7280;'
245
+ checkmark = ''
246
+
247
+ 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;">
248
+ {checkmark}
249
+ </span>'''
250
+
251
+ 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'
252
+
253
+ return f'<li>{inner}</li>\n'
254
+
255
+
256
+ def render_youtube_embed(self, token):
257
+ video_id = token.video_id
258
+ caption = getattr(token, 'caption', None)
259
+
260
+ iframe = f'''
261
+ <div class="relative w-full aspect-video my-6 rounded-lg overflow-hidden border border-slate-200 dark:border-slate-800">
262
+ <iframe
263
+ src="https://www.youtube.com/embed/{video_id}"
264
+ title="YouTube video"
265
+ frameborder="0"
266
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
267
+ allowfullscreen
268
+ class="absolute inset-0 w-full h-full">
269
+ </iframe>
270
+ </div>
271
+ '''
272
+
273
+ if caption:
274
+ return iframe + f'<p class="text-sm text-slate-500 dark:text-slate-400 text-center mt-2">{caption}</p>'
275
+ return iframe
276
+
277
+ def render_footnote_ref(self, token):
278
+ self.fn_counter += 1
279
+ n, target = self.fn_counter, token.target
280
+ content = self.footnotes.get(target, f"[Missing footnote: {target}]")
281
+ if "\n" in content:
282
+ content = content.replace("\r\n", "\n")
283
+ placeholder = "__VYASA_PARA_BREAK__"
284
+ content = content.replace("\n\n", f"\n{placeholder}\n")
285
+ content = content.replace("\n", "<br>\n")
286
+ content = content.replace(f"\n{placeholder}\n", "\n\n")
287
+ rendered = mst.markdown(content, partial(ContentRenderer, img_dir=self.img_dir, current_path=self.current_path)).strip()
288
+ if rendered.startswith('<p>') and rendered.endswith('</p>'): rendered = rendered[3:-4]
289
+ 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"
290
+ 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}"
291
+ ref = Span(id=f"snref-{n}", role="doc-noteref", aria_label=f"Sidenote {n}", cls="sidenote-ref cursor-pointer", _=toggle)
292
+ note = Span(NotStr(rendered), id=f"sn-{n}", role="doc-footnote", aria_labelledby=f"snref-{n}", cls=f"sidenote {style}")
293
+ hide = lambda c: to_xml(Span(c, cls="hidden", aria_hidden="true"))
294
+ return hide(" (") + to_xml(ref) + to_xml(note) + hide(")")
295
+
296
+ def render_heading(self, token):
297
+ """Render headings with anchor IDs for TOC linking"""
298
+ import html
299
+ level = token.level
300
+ inner = self.render_inner(token)
301
+ plain = _plain_text_from_html(inner)
302
+ anchor = _unique_anchor(text_to_anchor(plain), self.heading_counts)
303
+ return f'<h{level} id="{anchor}">{html.escape(plain)}</h{level}>'
304
+
305
+ def render_superscript(self, token):
306
+ """Render superscript text"""
307
+ return f'<sup>{token.content}</sup>'
308
+
309
+ def render_subscript(self, token):
310
+ """Render subscript text"""
311
+ return f'<sub>{token.content}</sub>'
312
+
313
+ def render_strikethrough(self, token):
314
+ """Render strikethrough text"""
315
+ inner = self.render_inner(token)
316
+ return f'<del>{inner}</del>'
317
+
318
+ def render_inline_code_attr(self, token):
319
+ """Render inline code with Pandoc-style attributes"""
320
+ import html
321
+ code = html.escape(token.code)
322
+ attrs = token.attrs.strip()
323
+
324
+ # Parse attributes: .class, #id, key=value
325
+ classes = []
326
+ id_attr = None
327
+ other_attrs = []
328
+
329
+ for attr in re.findall(r'\.([^\s\.#]+)|#([^\s\.#]+)|([^\s\.#=]+)=([^\s\.#]+)', attrs):
330
+ if attr[0]: # .class
331
+ classes.append(attr[0])
332
+ elif attr[1]: # #id
333
+ id_attr = attr[1]
334
+ elif attr[2]: # key=value
335
+ other_attrs.append(f'{attr[2]}="{attr[3]}"')
336
+
337
+ # Build HTML
338
+ html_attrs = []
339
+ if classes:
340
+ html_attrs.append(f'class="{" ".join(classes)}"')
341
+ if id_attr:
342
+ html_attrs.append(f'id="{id_attr}"')
343
+ html_attrs.extend(other_attrs)
344
+
345
+ attr_str = ' ' + ' '.join(html_attrs) if html_attrs else ''
346
+
347
+ # Always use <span> for inline code with attributes - the presence of attributes
348
+ # indicates styling/annotation intent rather than code semantics
349
+ tag = 'span'
350
+ return f'<{tag}{attr_str}>{code}</{tag}>'
351
+
352
+ def render_block_code(self, token):
353
+ lang = getattr(token, 'language', '')
354
+ code = self.render_raw_text(token)
355
+ if lang == 'mermaid':
356
+ # Extract frontmatter from mermaid code block
357
+ frontmatter_pattern = r'^---\s*\n(.*?)\n---\s*\n'
358
+ frontmatter_match = re.match(frontmatter_pattern, code, re.DOTALL)
359
+
360
+ # Default configuration for mermaid diagrams
361
+ height = 'auto'
362
+ width = '65vw' # Default to viewport width for better visibility
363
+ min_height = '400px'
364
+ gantt_width = None # Custom Gantt width override
365
+
366
+ if frontmatter_match:
367
+ frontmatter_content = frontmatter_match.group(1)
368
+ code_without_frontmatter = code[frontmatter_match.end():]
369
+
370
+ # Parse YAML-like frontmatter (simple key: value pairs)
371
+ try:
372
+ config = {}
373
+ for line in frontmatter_content.strip().split('\n'):
374
+ if ':' in line:
375
+ key, value = line.split(':', 1)
376
+ config[key.strip()] = value.strip()
377
+
378
+ # Extract height and width if specified
379
+ if 'height' in config:
380
+ height = config['height']
381
+ min_height = height
382
+ if 'width' in config:
383
+ width = config['width']
384
+
385
+ # Handle aspect_ratio for Gantt charts
386
+ if 'aspect_ratio' in config:
387
+ aspect_value = config['aspect_ratio'].strip()
388
+ try:
389
+ # Parse ratio notation (e.g., "16:9", "21:9", "32:9")
390
+ if ':' in aspect_value:
391
+ w_ratio, h_ratio = map(float, aspect_value.split(':'))
392
+ ratio = w_ratio / h_ratio
393
+ else:
394
+ # Parse decimal notation (e.g., "1.78", "2.4")
395
+ ratio = float(aspect_value)
396
+
397
+ # Calculate Gantt width based on aspect ratio
398
+ # Base width of 1200, scaled by ratio
399
+ gantt_width = int(1200 * ratio)
400
+ except (ValueError, ZeroDivisionError) as e:
401
+ print(f"Invalid aspect_ratio format '{aspect_value}': {e}")
402
+ gantt_width = None
403
+
404
+ except Exception as e:
405
+ print(f"Error parsing mermaid frontmatter: {e}")
406
+
407
+ # Use code without frontmatter for rendering
408
+ code = code_without_frontmatter
409
+
410
+ self.mermaid_counter += 1
411
+ diagram_id = f"mermaid-{abs(hash(code)) & 0xFFFFFF}-{self.mermaid_counter}"
412
+
413
+ # Determine if we need to break out of normal content flow
414
+ # This is required for viewport-based widths to properly center
415
+ break_out = 'vw' in str(width).lower()
416
+
417
+ # Build container style with proper positioning for viewport widths
418
+ if break_out:
419
+ container_style = f"width: {width}; position: relative; left: 50%; transform: translateX(-50%);"
420
+ else:
421
+ container_style = f"width: {width};"
422
+
423
+ # Escape the code for use in data attribute
424
+ escaped_code = code.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;').replace("'", '&#39;')
425
+
426
+ # Add custom Gantt width as data attribute if specified
427
+ gantt_data_attr = f' data-gantt-width="{gantt_width}"' if gantt_width else ''
428
+
429
+ return f'''<div class="mermaid-container relative border-4 rounded-md my-4 shadow-2xl" style="{container_style}">
430
+ <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">
431
+ <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>
432
+ <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>
433
+ <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>
434
+ <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>
435
+ </div>
436
+ <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>
437
+ </div>'''
438
+
439
+ # For other languages: escape HTML/XML for display, but NOT for markdown
440
+ # (markdown code blocks should show raw source)
441
+ import html
442
+ raw_code = code
443
+ code = html.unescape(code)
444
+ if lang and lang.lower() != 'markdown':
445
+ code = html.escape(code)
446
+ lang_class = f' class="language-{lang}"' if lang else ''
447
+ icon_html = to_xml(UkIcon("copy", cls="w-4 h-4"))
448
+ code_id = f"codeblock-{abs(hash(raw_code)) & 0xFFFFFF}"
449
+ toast_id = f"{code_id}-toast"
450
+ textarea_id = f"{code_id}-clipboard"
451
+ escaped_raw = html.escape(raw_code)
452
+ return (
453
+ '<div class="code-block relative my-4">'
454
+ f'<button type="button" class="code-copy-button absolute top-2 right-2 '
455
+ 'inline-flex items-center justify-center rounded border border-slate-200 '
456
+ 'dark:border-slate-700 bg-white/80 dark:bg-slate-900/70 '
457
+ 'text-slate-600 dark:text-slate-300 hover:text-slate-900 '
458
+ 'dark:hover:text-white hover:border-slate-300 dark:hover:border-slate-500 '
459
+ f'transition-colors" aria-label="Copy code" '
460
+ f'onclick="(function(){{const el=document.getElementById(\'{textarea_id}\');const toast=document.getElementById(\'{toast_id}\');if(!el){{return;}}el.focus();el.select();const text=el.value;const done=()=>{{if(!toast){{return;}}toast.classList.remove(\'opacity-0\');toast.classList.add(\'opacity-100\');setTimeout(()=>{{toast.classList.remove(\'opacity-100\');toast.classList.add(\'opacity-0\');}},1400);}};if(navigator.clipboard&&window.isSecureContext){{navigator.clipboard.writeText(text).then(done).catch(()=>{{document.execCommand(\'copy\');done();}});}}else{{document.execCommand(\'copy\');done();}}}})()"'
461
+ '>'
462
+ f'{icon_html}<span class="sr-only">Copy code</span></button>'
463
+ f'<div id="{toast_id}" class="absolute top-2 right-10 text-xs bg-slate-900 text-white px-2 py-1 rounded opacity-0 transition-opacity duration-300">Copied</div>'
464
+ f'<textarea id="{textarea_id}" class="absolute left-[-9999px] top-0 opacity-0 pointer-events-none">{escaped_raw}</textarea>'
465
+ f'<pre><code{lang_class}>{code}</code></pre>'
466
+ '</div>'
467
+ )
468
+
469
+ def render_link(self, token):
470
+ href, inner, title = token.target, self.render_inner(token), f' title="{token.title}"' if token.title else ''
471
+ # ...existing code...
472
+ is_hash = href.startswith('#')
473
+ is_external = href.startswith(('http://', 'https://', 'mailto:', 'tel:', '//'))
474
+ is_absolute_internal = href.startswith('/') and not href.startswith('//')
475
+ is_relative = not is_external and not is_absolute_internal
476
+ if is_hash:
477
+ link_class = (
478
+ "text-amber-600 dark:text-amber-400 underline underline-offset-2 "
479
+ "hover:text-amber-800 dark:hover:text-amber-200 font-medium transition-colors"
480
+ )
481
+ return f'<a href="{href}" class="{link_class}"{title}>{inner}</a>'
482
+ if is_relative:
483
+ from pathlib import Path
484
+ original_href = href
485
+ if href.endswith('.md'):
486
+ href = href[:-3]
487
+ if self.current_path:
488
+ root = get_root_folder().resolve()
489
+ current_file_full = root / self.current_path
490
+ current_dir = current_file_full.parent
491
+ resolved = (current_dir / href).resolve()
492
+ logger.debug(f"DEBUG: original_href={original_href}, current_path={self.current_path}, current_dir={current_dir}, resolved={resolved}, root={root}")
493
+ try:
494
+ rel_path = resolved.relative_to(root)
495
+ href = f'/posts/{rel_path}'
496
+ is_absolute_internal = True
497
+ logger.debug(f"DEBUG: SUCCESS - rel_path={rel_path}, final href={href}")
498
+ except ValueError as e:
499
+ is_external = True
500
+ logger.debug(f"DEBUG: FAILED - ValueError: {e}")
501
+ else:
502
+ is_external = True
503
+ logger.debug(f"DEBUG: No current_path, treating as external")
504
+ is_internal = is_absolute_internal and '.' not in href.split('/')[-1]
505
+ hx = f' hx-get="{href}" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML show:window:top"' if is_internal else ''
506
+ ext = '' if (is_internal or is_absolute_internal or is_hash) else ' target="_blank" rel="noopener noreferrer"'
507
+ # Amber/gold link styling, stands out and is accessible
508
+ link_class = (
509
+ "text-amber-600 dark:text-amber-400 underline underline-offset-2 "
510
+ "hover:text-amber-800 dark:hover:text-amber-200 font-medium transition-colors"
511
+ )
512
+ return f'<a href="{href}"{hx}{ext} class="{link_class}"{title}>{inner}</a>'
513
+
514
+
515
+ def postprocess_tabs(html, tab_data_store, img_dir, current_path, footnotes):
516
+ """Replace tab placeholders with fully rendered tab HTML"""
517
+ import hashlib
518
+
519
+ for tab_id, tabs in tab_data_store.items():
520
+ # Build HTML for this tab group
521
+ html_parts = [f'<div class="tabs-container" data-tabs-id="{tab_id}">']
522
+
523
+ # Tab buttons
524
+ html_parts.append('<div class="tabs-header">')
525
+ for i, (title, _) in enumerate(tabs):
526
+ active = 'active' if i == 0 else ''
527
+ html_parts.append(f'<button class="tab-button {active}" onclick="switchTab(\'{tab_id}\', {i})">{title}</button>')
528
+ html_parts.append('</div>')
529
+
530
+ # Tab content panels
531
+ html_parts.append('<div class="tabs-content">')
532
+ for i, (_, tab_content) in enumerate(tabs):
533
+ active = 'active' if i == 0 else ''
534
+ # Render each tab's content as fresh markdown
535
+ with ContentRenderer(YoutubeEmbed, InlineCodeAttr, Strikethrough, FootnoteRef, Superscript, Subscript, img_dir=img_dir, footnotes=footnotes, current_path=current_path) as renderer:
536
+ doc = mst.Document(tab_content)
537
+ rendered = renderer.render(doc)
538
+ html_parts.append(f'<div class="tab-panel {active}" data-tab-index="{i}">{rendered}</div>')
539
+ html_parts.append('</div>')
540
+
541
+ html_parts.append('</div>')
542
+ tab_html = '\n'.join(html_parts)
543
+
544
+ # Replace placeholder with rendered tab HTML
545
+ placeholder = f'<div class="tab-placeholder" data-tab-id="{tab_id}"></div>'
546
+ html = html.replace(placeholder, tab_html)
547
+
548
+ return html
549
+
550
+ def from_md(content, img_dir=None, current_path=None):
551
+ # Resolve img_dir from current_path if not explicitly provided
552
+ if img_dir is None and current_path:
553
+ # Convert current_path to URL path for images (e.g., demo/books/flat-land/chapter-01 -> /posts/demo/books/flat-land)
554
+ from pathlib import Path
555
+ path_parts = Path(current_path).parts
556
+ if len(path_parts) > 1:
557
+ img_dir = '/posts/' + '/'.join(path_parts[:-1])
558
+ else:
559
+ img_dir = '/posts'
560
+
561
+ def _protect_escaped_dollar(md):
562
+ import re
563
+ # Protect fenced code blocks first
564
+ code_blocks = []
565
+ def repl(m):
566
+ code_blocks.append(m.group(0))
567
+ return f"__VYASA_CODEBLOCK_{len(code_blocks)-1}__"
568
+ md = re.sub(r'(```+|~~~+)[\s\S]*?\1', repl, md)
569
+ # Protect inline code spans (including multi-backtick)
570
+ def repl_inline(m):
571
+ code_blocks.append(m.group(0))
572
+ return f"__VYASA_CODEBLOCK_{len(code_blocks)-1}__"
573
+ md = re.sub(r'(`+)([^`]*?)\1', repl_inline, md)
574
+ # Replace escaped dollars with a placeholder to avoid KaTeX auto-render
575
+ def replace_escaped_dollar(m):
576
+ slashes = m.group(1)
577
+ # Remove one escaping backslash, keep the rest literal
578
+ return '\\' * (len(slashes) - 1) + '@@VYASA_DOLLAR@@'
579
+ md = re.sub(r'(\\+)\$', replace_escaped_dollar, md)
580
+ # Restore code blocks/spans
581
+ for i, block in enumerate(code_blocks):
582
+ md = md.replace(f"__VYASA_CODEBLOCK_{i}__", block)
583
+ return md
584
+
585
+ content = _protect_escaped_dollar(content)
586
+ content, footnotes = extract_footnotes(content)
587
+ content = preprocess_super_sub(content) # Preprocess superscript/subscript
588
+ content, tab_data_store = preprocess_tabs(content) # Preprocess tabs and get tab data
589
+
590
+ # Preprocess: convert single newlines within paragraphs to ' \n' (markdown softbreak)
591
+ # This preserves double newlines (paragraphs) and code blocks
592
+ def _preserve_newlines(md):
593
+ import re
594
+ # Don't touch code blocks (fenced or indented)
595
+ code_block = re.compile(r'(```+|~~~+)[\s\S]*?\1', re.MULTILINE)
596
+ blocks = []
597
+ def repl(m):
598
+ blocks.append(m.group(0))
599
+ return f"__CODEBLOCK_{len(blocks)-1}__"
600
+ md = code_block.sub(repl, md)
601
+ # Replace single newlines not preceded/followed by another newline with ' \n'
602
+ md = re.sub(r'(?<!\n)\n(?!\n)', ' \n', md)
603
+ # Restore code blocks
604
+ for i, block in enumerate(blocks):
605
+ md = md.replace(f"__CODEBLOCK_{i}__", block)
606
+ return md
607
+ content = _preserve_newlines(content)
608
+
609
+ mods = {'pre': 'my-4', 'p': 'text-base leading-relaxed mb-6', 'li': 'text-base leading-relaxed',
610
+ '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',
611
+ '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',
612
+ 'h3': 'text-xl font-semibold mb-3 mt-5', 'h4': 'text-lg font-semibold mb-2 mt-4',
613
+ 'table': 'uk-table uk-table-striped uk-table-hover uk-table-divider uk-table-middle my-6'}
614
+
615
+ # Register custom tokens with renderer context manager
616
+ with ContentRenderer(YoutubeEmbed, InlineCodeAttr, Strikethrough, FootnoteRef, Superscript, Subscript, img_dir=img_dir, footnotes=footnotes, current_path=current_path) as renderer:
617
+ doc = mst.Document(content)
618
+ html = renderer.render(doc)
619
+
620
+ # Post-process: replace tab placeholders with rendered tabs
621
+ if tab_data_store:
622
+ html = postprocess_tabs(html, tab_data_store, img_dir, current_path, footnotes)
623
+
624
+ return Div(Link(rel="stylesheet", href="/static/sidenote.css"), NotStr(apply_classes(html, class_map_mods=mods)), cls="w-full")
625
+
626
+ # App configuration
627
+ def get_root_folder(): return get_config().get_root_folder()
628
+ def get_blog_title(): return get_config().get_blog_title()
629
+ def get_favicon_href():
630
+ root_icon = get_root_folder() / "static" / "icon.png"
631
+ if root_icon.exists():
632
+ return "/static/icon.png"
633
+ return "/static/favicon.png"
634
+
635
+ hdrs = (
636
+ *Theme.slate.headers(highlightjs=True),
637
+ Link(rel="icon", href=get_favicon_href()),
638
+ Script(src="https://unpkg.com/hyperscript.org@0.9.12"),
639
+ Script(src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs", type="module"),
640
+ Style(
641
+ """
642
+ .chat-row-block {
643
+ padding: 14px 0;
644
+ }
645
+ .chat-panel {
646
+ background: #ffffff;
647
+ border: 1px solid #e2e8f0;
648
+ border-radius: 16px;
649
+ padding: 20px;
650
+ }
651
+ """
652
+ ),
653
+ Script("""
654
+ // Tab switching functionality (global scope)
655
+ function switchTab(tabsId, index) {
656
+ console.log('switchTab called:', tabsId, index);
657
+ const container = document.querySelector('.tabs-container[data-tabs-id="' + tabsId + '"]');
658
+ console.log('container:', container);
659
+ if (!container) return;
660
+
661
+ // Update buttons
662
+ const buttons = container.querySelectorAll('.tab-button');
663
+ buttons.forEach(function(btn, i) {
664
+ if (i === index) {
665
+ btn.classList.add('active');
666
+ } else {
667
+ btn.classList.remove('active');
668
+ }
669
+ });
670
+
671
+ // Update panels
672
+ const panels = container.querySelectorAll('.tab-panel');
673
+ panels.forEach(function(panel, i) {
674
+ if (i === index) {
675
+ panel.classList.add('active');
676
+ panel.style.position = 'relative';
677
+ panel.style.visibility = 'visible';
678
+ panel.style.opacity = '1';
679
+ panel.style.pointerEvents = 'auto';
680
+ } else {
681
+ panel.classList.remove('active');
682
+ panel.style.position = 'absolute';
683
+ panel.style.visibility = 'hidden';
684
+ panel.style.opacity = '0';
685
+ panel.style.pointerEvents = 'none';
686
+ }
687
+ });
688
+ }
689
+ window.switchTab = switchTab;
690
+
691
+ // Set tab container heights based on tallest panel
692
+ document.addEventListener('DOMContentLoaded', function() {
693
+ setTimeout(() => {
694
+ document.querySelectorAll('.tabs-container').forEach(container => {
695
+ const panels = container.querySelectorAll('.tab-panel');
696
+ let maxHeight = 0;
697
+
698
+ // Temporarily show all panels to measure their heights
699
+ panels.forEach(panel => {
700
+ const wasActive = panel.classList.contains('active');
701
+ panel.style.position = 'relative';
702
+ panel.style.visibility = 'visible';
703
+ panel.style.opacity = '1';
704
+ panel.style.pointerEvents = 'auto';
705
+
706
+ const height = panel.offsetHeight;
707
+ if (height > maxHeight) maxHeight = height;
708
+
709
+ if (!wasActive) {
710
+ panel.style.position = 'absolute';
711
+ panel.style.visibility = 'hidden';
712
+ panel.style.opacity = '0';
713
+ panel.style.pointerEvents = 'none';
714
+ }
715
+ });
716
+
717
+ // Set the content area to the max height
718
+ const tabsContent = container.querySelector('.tabs-content');
719
+ if (tabsContent && maxHeight > 0) {
720
+ tabsContent.style.minHeight = maxHeight + 'px';
721
+ }
722
+ });
723
+ }, 100);
724
+ });
725
+ """),
726
+ Script(src="/static/scripts.js", type='module'),
727
+ Link(rel="stylesheet", href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css"),
728
+ Script(src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"),
729
+ Script(src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"),
730
+ Script("""
731
+ function replaceEscapedDollarPlaceholders(root) {
732
+ const placeholder = '@@VYASA_DOLLAR@@';
733
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
734
+ const nodes = [];
735
+ let node;
736
+ while ((node = walker.nextNode())) {
737
+ if (node.nodeValue && node.nodeValue.includes(placeholder)) {
738
+ nodes.push(node);
739
+ }
740
+ }
741
+ nodes.forEach((textNode) => {
742
+ textNode.nodeValue = textNode.nodeValue.split(placeholder).join('$');
743
+ });
744
+ }
745
+
746
+ document.addEventListener('DOMContentLoaded', function() {
747
+ renderMathInElement(document.body, {
748
+ delimiters: [
749
+ {left: '$$', right: '$$', display: true},
750
+ {left: '$', right: '$', display: false}
751
+ ],
752
+ throwOnError: false
753
+ });
754
+ replaceEscapedDollarPlaceholders(document.body);
755
+ });
756
+
757
+ // Re-render math after HTMX swaps
758
+ document.body.addEventListener('htmx:afterSwap', function(event) {
759
+ renderMathInElement(document.body, {
760
+ delimiters: [
761
+ {left: '$$', right: '$$', display: true},
762
+ {left: '$', right: '$', display: false}
763
+ ],
764
+ throwOnError: false
765
+ });
766
+ replaceEscapedDollarPlaceholders(event.target || document.body);
767
+ });
768
+ """),
769
+ Link(rel="preconnect", href="https://fonts.googleapis.com"),
770
+ Link(rel="preconnect", href="https://fonts.gstatic.com", crossorigin=""),
771
+ Link(rel="stylesheet", href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono&display=swap"),
772
+ Style("body { font-family: 'IBM Plex Sans', sans-serif; } code, pre { font-family: 'IBM Plex Mono', monospace; }"),
773
+ 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; }"),
774
+ Style("h1, h2, h3, h4, h5, h6 { scroll-margin-top: 7rem; }"), # Offset for sticky navbar
775
+ Style("""
776
+ /* Ultra thin scrollbar styles */
777
+ * { scrollbar-width: thin; scrollbar-color: rgb(203 213 225) transparent; }
778
+ *::-webkit-scrollbar { width: 3px; height: 3px; }
779
+ *::-webkit-scrollbar-track { background: transparent; }
780
+ *::-webkit-scrollbar-thumb { background-color: rgb(203 213 225); border-radius: 2px; }
781
+ *::-webkit-scrollbar-thumb:hover { background-color: rgb(148 163 184); }
782
+ .dark *::-webkit-scrollbar-thumb { background-color: rgb(71 85 105); }
783
+ .dark *::-webkit-scrollbar-thumb:hover { background-color: rgb(100 116 139); }
784
+ .dark * { scrollbar-color: rgb(71 85 105) transparent; }
785
+
786
+ /* Sidebar active link highlight */
787
+ .sidebar-highlight {
788
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.35);
789
+ transition: box-shadow 10s ease, background-color 10s ease;
790
+ }
791
+ .sidebar-highlight.fade-out {
792
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0);
793
+ }
794
+
795
+ /* PDF focus mode */
796
+ body.pdf-focus {
797
+ overflow: hidden;
798
+ }
799
+ body.pdf-focus #site-navbar,
800
+ body.pdf-focus #site-footer,
801
+ body.pdf-focus #posts-sidebar,
802
+ body.pdf-focus #toc-sidebar,
803
+ body.pdf-focus #mobile-posts-panel,
804
+ body.pdf-focus #mobile-toc-panel {
805
+ display: none !important;
806
+ }
807
+ body.pdf-focus #content-with-sidebars {
808
+ max-width: none !important;
809
+ width: 100vw !important;
810
+ padding: 0 !important;
811
+ margin: 0 !important;
812
+ gap: 0 !important;
813
+ }
814
+ body.pdf-focus #main-content {
815
+ padding: 1rem !important;
816
+ }
817
+ body.pdf-focus .pdf-viewer {
818
+ height: calc(100vh - 6rem) !important;
819
+ }
820
+
821
+ .layout-fluid {
822
+ --layout-breakpoint: 1280px;
823
+ --layout-blend: 240px;
824
+ max-width: calc(
825
+ 100% - (100% - var(--layout-max-width))
826
+ * clamp(0, (100vw - var(--layout-breakpoint)) / var(--layout-blend), 1)
827
+ ) !important;
828
+ }
829
+
830
+ /* Tabs styles */
831
+ .tabs-container {
832
+ margin: 2rem 0;
833
+ border: 1px solid rgb(226 232 240);
834
+ border-radius: 0.5rem;
835
+ overflow: visible;
836
+ box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
837
+ }
838
+ .dark .tabs-container {
839
+ border-color: rgb(51 65 85);
840
+ box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3);
841
+ }
842
+
843
+ .tabs-header {
844
+ display: flex;
845
+ background: rgb(248 250 252);
846
+ border-bottom: 1px solid rgb(226 232 240);
847
+ gap: 0;
848
+ }
849
+ .dark .tabs-header {
850
+ background: rgb(15 23 42);
851
+ border-bottom-color: rgb(51 65 85);
852
+ }
853
+
854
+ .tab-button {
855
+ flex: 1;
856
+ padding: 0.875rem 1.5rem;
857
+ background: transparent;
858
+ border: none;
859
+ border-bottom: 3px solid transparent;
860
+ cursor: pointer;
861
+ font-weight: 500;
862
+ font-size: 0.9375rem;
863
+ color: rgb(100 116 139);
864
+ transition: all 0.15s ease;
865
+ position: relative;
866
+ margin-bottom: -1px;
867
+ }
868
+ .dark .tab-button { color: rgb(148 163 184); }
869
+
870
+ .tab-button:hover:not(.active) {
871
+ background: rgb(241 245 249);
872
+ color: rgb(51 65 85);
873
+ }
874
+ .dark .tab-button:hover:not(.active) {
875
+ background: rgb(30 41 59);
876
+ color: rgb(226 232 240);
877
+ }
878
+
879
+ .tab-button.active {
880
+ color: rgb(15 23 42);
881
+ border-bottom-color: rgb(15 23 42);
882
+ background: white;
883
+ font-weight: 600;
884
+ }
885
+ .dark .tab-button.active {
886
+ color: rgb(248 250 252);
887
+ border-bottom-color: rgb(248 250 252);
888
+ background: rgb(2 6 23);
889
+ }
890
+
891
+ .tabs-content {
892
+ background: white;
893
+ position: relative;
894
+ overflow: visible;
895
+ }
896
+ .dark .tabs-content {
897
+ background: rgb(2 6 23);
898
+ }
899
+
900
+ .tab-panel {
901
+ padding: 1rem 1rem;
902
+ animation: fadeIn 0.2s ease-in;
903
+ position: absolute;
904
+ top: 0;
905
+ left: 0;
906
+ right: 0;
907
+ opacity: 0;
908
+ visibility: hidden;
909
+ pointer-events: none;
910
+ overflow: visible;
911
+ }
912
+ .tab-panel.active {
913
+ position: relative;
914
+ opacity: 1;
915
+ visibility: visible;
916
+ pointer-events: auto;
917
+ }
918
+
919
+ @keyframes fadeIn {
920
+ from { opacity: 0; }
921
+ to { opacity: 1; }
922
+ }
923
+
924
+ /* Remove extra margins from first/last elements in tabs */
925
+ .tab-panel > *:first-child { margin-top: 0 !important; }
926
+ .tab-panel > *:last-child { margin-bottom: 0 !important; }
927
+
928
+ /* Ensure code blocks in tabs look good */
929
+ .tab-panel pre {
930
+ border-radius: 0.375rem;
931
+ font-size: 0.875rem;
932
+ }
933
+ .tab-panel code {
934
+ font-family: 'IBM Plex Mono', monospace;
935
+ }
936
+ """),
937
+ # Custom table stripe styling for punchier colors
938
+ Style("""
939
+ .uk-table-striped tbody tr:nth-of-type(odd) {
940
+ background-color: rgba(71, 85, 105, 0.08);
941
+ }
942
+ .dark .uk-table-striped tbody tr:nth-of-type(odd) {
943
+ background-color: rgba(148, 163, 184, 0.12);
944
+ }
945
+ .uk-table-striped tbody tr:hover {
946
+ background-color: rgba(59, 130, 246, 0.1);
947
+ }
948
+ .dark .uk-table-striped tbody tr:hover {
949
+ background-color: rgba(59, 130, 246, 0.15);
950
+ }
951
+ .uk-table thead {
952
+ border-bottom: 2px solid rgba(71, 85, 105, 0.3);
953
+ }
954
+ .dark .uk-table thead {
955
+ border-bottom: 2px solid rgba(148, 163, 184, 0.4);
956
+ }
957
+ .uk-table thead th {
958
+ font-weight: 600;
959
+ font-size: 1.25rem;
960
+ color: rgb(51, 65, 85);
961
+ }
962
+ .dark .uk-table thead th {
963
+ color: rgb(226, 232, 240);
964
+ }
965
+ .uk-table th:not(:last-child),
966
+ .uk-table td:not(:last-child) {
967
+ border-right: 1px solid rgba(71, 85, 105, 0.15);
968
+ }
969
+ .dark .uk-table th:not(:last-child),
970
+ .dark .uk-table td:not(:last-child) {
971
+ border-right: 1px solid rgba(148, 163, 184, 0.2);
972
+ }
973
+ """),
974
+ # Script("if(!localStorage.__FRANKEN__) localStorage.__FRANKEN__ = JSON.stringify({mode: 'light'})"))
975
+ Script("""
976
+ (function () {
977
+ let franken = localStorage.__FRANKEN__
978
+ ? JSON.parse(localStorage.__FRANKEN__)
979
+ : { mode: 'light' };
980
+
981
+ if (franken.mode === 'dark') {
982
+ document.documentElement.classList.add('dark');
983
+ } else {
984
+ document.documentElement.classList.remove('dark');
985
+ }
986
+
987
+ localStorage.__FRANKEN__ = JSON.stringify(franken);
988
+ })();
989
+ """)
990
+ )
991
+
992
+
993
+ # Session/cookie-based authentication using Beforeware (conditionally enabled)
994
+ _config = get_config()
995
+ _auth_creds = _config.get_auth()
996
+ _google_oauth_cfg = _config.get_google_oauth()
997
+ _auth_required = _config.get_auth_required()
998
+
999
+ @dataclass
1000
+ class RbacConfigRow:
1001
+ key: str
1002
+ value: str
1003
+
1004
+ _rbac_db = None
1005
+ _rbac_tbl = None
1006
+
1007
+ def _get_rbac_db():
1008
+ global _rbac_db, _rbac_tbl
1009
+ if _rbac_db is None:
1010
+ root = get_config().get_root_folder()
1011
+ db_path = root / ".vyasa-rbac.db"
1012
+ db_path.parent.mkdir(parents=True, exist_ok=True)
1013
+ _rbac_db = Database(f"sqlite:///{db_path}")
1014
+ _rbac_tbl = _rbac_db.create(RbacConfigRow, pk="key", name="rbac_config")
1015
+ return _rbac_db, _rbac_tbl
1016
+
1017
+ def _normalize_rbac_cfg(cfg):
1018
+ cfg = cfg or {}
1019
+ if not isinstance(cfg, dict):
1020
+ cfg = {}
1021
+ default_roles = _config._coerce_list(cfg.get("default_roles", []))
1022
+ user_roles = cfg.get("user_roles", {})
1023
+ if not isinstance(user_roles, dict):
1024
+ user_roles = {}
1025
+ role_users = cfg.get("role_users", {})
1026
+ if not isinstance(role_users, dict):
1027
+ role_users = {}
1028
+ rules = cfg.get("rules", [])
1029
+ if not isinstance(rules, list):
1030
+ rules = []
1031
+ cleaned_rules = []
1032
+ for rule in rules:
1033
+ if not isinstance(rule, dict):
1034
+ continue
1035
+ pattern = rule.get("pattern")
1036
+ roles = _config._coerce_list(rule.get("roles", []))
1037
+ if pattern and roles:
1038
+ cleaned_rules.append({"pattern": str(pattern), "roles": roles})
1039
+ return {
1040
+ "enabled": bool(cfg.get("enabled", False)),
1041
+ "default_roles": default_roles,
1042
+ "user_roles": user_roles,
1043
+ "role_users": role_users,
1044
+ "rules": cleaned_rules,
1045
+ }
1046
+
1047
+ def _rbac_db_load():
1048
+ try:
1049
+ _, tbl = _get_rbac_db()
1050
+ except Exception as exc:
1051
+ logger.warning(f"RBAC DB unavailable: {exc}")
1052
+ return None
1053
+ rows = tbl()
1054
+ if not rows:
1055
+ return None
1056
+ data = {}
1057
+ for row in rows:
1058
+ try:
1059
+ data[row.key] = json.loads(row.value)
1060
+ except Exception:
1061
+ data[row.key] = row.value
1062
+ return _normalize_rbac_cfg(data)
1063
+
1064
+ def _rbac_db_write(cfg):
1065
+ try:
1066
+ _, tbl = _get_rbac_db()
1067
+ except Exception as exc:
1068
+ logger.warning(f"RBAC DB unavailable: {exc}")
1069
+ return
1070
+ cfg = _normalize_rbac_cfg(cfg)
1071
+ existing = {row.key for row in tbl()}
1072
+ for key, value in cfg.items():
1073
+ payload = json.dumps(value, sort_keys=True)
1074
+ if key in existing:
1075
+ tbl.update(key=key, value=payload)
1076
+ else:
1077
+ tbl.insert(RbacConfigRow(key=key, value=payload))
1078
+ for key in existing - set(cfg.keys()):
1079
+ try:
1080
+ tbl.delete(key)
1081
+ except Exception:
1082
+ continue
1083
+
1084
+ def _load_rbac_cfg_from_store():
1085
+ cfg = _rbac_db_load()
1086
+ if cfg:
1087
+ return cfg
1088
+ cfg = _normalize_rbac_cfg(_config.get_rbac())
1089
+ if cfg.get("enabled") or cfg.get("rules") or cfg.get("role_users") or cfg.get("user_roles") or cfg.get("default_roles"):
1090
+ _rbac_db_write(cfg)
1091
+ return cfg
1092
+
1093
+ def _set_rbac_cfg(cfg):
1094
+ global _rbac_cfg, _rbac_rules
1095
+ _rbac_cfg = _normalize_rbac_cfg(cfg)
1096
+ if _rbac_cfg.get("enabled") and not _auth_enabled:
1097
+ logger.warning("RBAC configured without any auth provider; RBAC disabled.")
1098
+ _rbac_cfg["enabled"] = False
1099
+ _rbac_rules = []
1100
+ if _rbac_cfg.get("enabled"):
1101
+ for rule in _rbac_cfg.get("rules", []):
1102
+ pattern = rule.get("pattern")
1103
+ roles = rule.get("roles")
1104
+ if not pattern or not roles:
1105
+ continue
1106
+ try:
1107
+ compiled = re.compile(pattern)
1108
+ except re.error as exc:
1109
+ logger.warning(f"Invalid RBAC pattern {pattern!r}: {exc}")
1110
+ continue
1111
+ roles_list = _config._coerce_list(roles)
1112
+ if not roles_list:
1113
+ continue
1114
+ _rbac_rules.append((compiled, set(roles_list)))
1115
+
1116
+ def _resolve_vyasa_config_path():
1117
+ root_env = os.getenv("VYASA_ROOT")
1118
+ if root_env:
1119
+ root_path = Path(root_env) / ".vyasa"
1120
+ if root_path.exists():
1121
+ return root_path
1122
+ cwd_path = Path.cwd() / ".vyasa"
1123
+ if cwd_path.exists():
1124
+ return cwd_path
1125
+ return get_config().get_root_folder() / ".vyasa"
1126
+
1127
+ def _toml_string(value: str) -> str:
1128
+ return json.dumps(str(value))
1129
+
1130
+ def _toml_list(items):
1131
+ return "[" + ", ".join(_toml_string(item) for item in items) + "]"
1132
+
1133
+ def _toml_inline_table(mapping):
1134
+ if not mapping:
1135
+ return "{}"
1136
+ parts = []
1137
+ for key in sorted(mapping.keys()):
1138
+ parts.append(f"{_toml_string(key)} = {_toml_list(_config._coerce_list(mapping[key]))}")
1139
+ return "{ " + ", ".join(parts) + " }"
1140
+
1141
+ def _render_rbac_toml(cfg):
1142
+ cfg = _normalize_rbac_cfg(cfg)
1143
+ lines = [
1144
+ "[rbac]",
1145
+ f"enabled = {'true' if cfg.get('enabled') else 'false'}",
1146
+ f"default_roles = {_toml_list(cfg.get('default_roles', []))}",
1147
+ f"user_roles = {_toml_inline_table(cfg.get('user_roles', {}))}",
1148
+ f"role_users = {_toml_inline_table(cfg.get('role_users', {}))}",
1149
+ "",
1150
+ ]
1151
+ for rule in cfg.get("rules", []):
1152
+ lines.extend([
1153
+ "[[rbac.rules]]",
1154
+ f"pattern = {_toml_string(rule.get('pattern'))}",
1155
+ f"roles = {_toml_list(rule.get('roles', []))}",
1156
+ "",
1157
+ ])
1158
+ return "\n".join(lines).rstrip() + "\n"
1159
+
1160
+ def _write_rbac_to_vyasa(cfg):
1161
+ cfg = _normalize_rbac_cfg(cfg)
1162
+ path = _resolve_vyasa_config_path()
1163
+ try:
1164
+ text = path.read_text(encoding="utf-8") if path.exists() else ""
1165
+ except Exception:
1166
+ text = ""
1167
+ new_block = _render_rbac_toml(cfg)
1168
+ if re.search(r"(?m)^\[rbac\]", text):
1169
+ pattern = r"(?ms)^\[rbac\]\n.*?(?=^\[[^\[]|\Z)"
1170
+ text = re.sub(pattern, new_block + "\n", text)
1171
+ else:
1172
+ if text and not text.endswith("\n"):
1173
+ text += "\n"
1174
+ text += "\n" + new_block
1175
+ path.write_text(text, encoding="utf-8")
1176
+
1177
+ _google_oauth = None
1178
+ _google_oauth_enabled = False
1179
+ if _google_oauth_cfg.get("client_id") and _google_oauth_cfg.get("client_secret"):
1180
+ try:
1181
+ from authlib.integrations.starlette_client import OAuth
1182
+ _google_oauth = OAuth()
1183
+ _google_oauth.register(
1184
+ name="google",
1185
+ client_id=_google_oauth_cfg["client_id"],
1186
+ client_secret=_google_oauth_cfg["client_secret"],
1187
+ server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
1188
+ userinfo_endpoint="https://openidconnect.googleapis.com/v1/userinfo",
1189
+ client_kwargs={"scope": "openid email profile"},
1190
+ )
1191
+ _google_oauth_enabled = True
1192
+ except Exception as exc:
1193
+ logger.warning(f"Google OAuth disabled: {exc}")
1194
+
1195
+ _local_auth_enabled = bool(_auth_creds and _auth_creds[0] and _auth_creds[1])
1196
+ _auth_enabled = _local_auth_enabled or _google_oauth_enabled
1197
+ if _auth_required is None:
1198
+ _auth_required = _auth_enabled
1199
+
1200
+ _rbac_cfg = _load_rbac_cfg_from_store()
1201
+ _set_rbac_cfg(_rbac_cfg)
1202
+
1203
+ def _normalize_auth(auth):
1204
+ if not auth:
1205
+ return None
1206
+ if isinstance(auth, dict):
1207
+ return auth
1208
+ return {"provider": "local", "username": str(auth)}
1209
+
1210
+ def _get_auth_from_request(request):
1211
+ if not request:
1212
+ return None
1213
+ auth = None
1214
+ try:
1215
+ auth = request.scope.get("auth")
1216
+ except Exception:
1217
+ auth = None
1218
+ if not auth:
1219
+ try:
1220
+ auth = request.session.get("auth")
1221
+ except Exception:
1222
+ auth = None
1223
+ auth = _normalize_auth(auth) if auth else None
1224
+ if auth and _rbac_rules:
1225
+ auth["roles"] = auth.get("roles") or _resolve_roles(auth)
1226
+ return auth
1227
+
1228
+ def _get_roles_from_request(request):
1229
+ auth = _get_auth_from_request(request)
1230
+ return auth.get("roles") if auth else []
1231
+
1232
+ def _get_roles_from_auth(auth):
1233
+ auth = _normalize_auth(auth) if auth else None
1234
+ if auth and _rbac_rules:
1235
+ auth["roles"] = auth.get("roles") or _resolve_roles(auth)
1236
+ return auth.get("roles") if auth else []
1237
+
1238
+ def _resolve_roles(auth):
1239
+ auth = _normalize_auth(auth) or {}
1240
+ username = auth.get("username")
1241
+ email = auth.get("email")
1242
+ user_roles = _rbac_cfg.get("user_roles", {})
1243
+ roles = []
1244
+ if isinstance(user_roles, dict):
1245
+ if email and email in user_roles:
1246
+ roles.extend(_config._coerce_list(user_roles.get(email)))
1247
+ if username and username in user_roles:
1248
+ roles.extend(_config._coerce_list(user_roles.get(username)))
1249
+ role_users = _rbac_cfg.get("role_users", {})
1250
+ if isinstance(role_users, dict):
1251
+ for role, users in role_users.items():
1252
+ users_list = _config._coerce_list(users)
1253
+ if email and email in users_list:
1254
+ roles.append(role)
1255
+ if username and username in users_list:
1256
+ roles.append(role)
1257
+ if not roles:
1258
+ roles = _rbac_cfg.get("default_roles", []) or _google_oauth_cfg.get("default_roles", [])
1259
+ roles = [r for r in roles if r]
1260
+ if roles:
1261
+ return list(dict.fromkeys(roles))
1262
+ return []
1263
+
1264
+ def _path_requires_roles(path):
1265
+ for pattern, _roles in _rbac_rules:
1266
+ if pattern.search(path):
1267
+ return True
1268
+ return False
1269
+
1270
+ def _is_allowed(path, roles):
1271
+ if not _rbac_rules:
1272
+ return True
1273
+ roles_set = set(roles or [])
1274
+ matched_any = False
1275
+ allowed = False
1276
+ for pattern, allowed_roles in _rbac_rules:
1277
+ if pattern.search(path):
1278
+ matched_any = True
1279
+ if roles_set & allowed_roles:
1280
+ allowed = True
1281
+ return allowed if matched_any else True
1282
+
1283
+ def user_auth_before(req, sess):
1284
+ logger.info(f'Authenticating request for {req.url.path}')
1285
+ auth = sess.get('auth', None)
1286
+ if not auth:
1287
+ if _auth_required or _path_requires_roles(req.url.path):
1288
+ sess['next'] = req.url.path
1289
+ from starlette.responses import RedirectResponse
1290
+ return RedirectResponse('/login', status_code=303)
1291
+ req.scope['auth'] = None
1292
+ return None
1293
+ auth = _normalize_auth(auth)
1294
+ if _rbac_rules:
1295
+ auth["roles"] = auth.get("roles") or _resolve_roles(auth)
1296
+ if not _is_allowed(req.url.path, auth["roles"]):
1297
+ from starlette.responses import Response
1298
+ return Response("Forbidden", status_code=403)
1299
+ req.scope['auth'] = auth
1300
+ return None
1301
+
1302
+ logger.info(f"Authentication enabled: {_auth_enabled}")
1303
+ logger.info(f"RBAC enabled: {_rbac_cfg.get('enabled')}")
1304
+
1305
+ if _auth_enabled or (_rbac_cfg.get("enabled") and _rbac_rules):
1306
+ beforeware = Beforeware(
1307
+ user_auth_before,
1308
+ skip=[
1309
+ r'^/login$',
1310
+ r'^/login/google$',
1311
+ r'^/auth/google/callback$',
1312
+ r'^/_sidebar/.*',
1313
+ r'^/static/.*',
1314
+ r'^/chat/.*',
1315
+ r'.*\.css',
1316
+ r'.*\.js',
1317
+ ]
1318
+ )
1319
+ else:
1320
+ beforeware = None
1321
+
1322
+ logger.info(f'{beforeware=}')
1323
+
1324
+ app = (
1325
+ FastHTML(hdrs=hdrs, before=beforeware, exts="ws")
1326
+ if beforeware
1327
+ else FastHTML(hdrs=hdrs, exts="ws")
1328
+ )
1329
+
1330
+ def _load_pylogue_routes():
1331
+ try:
1332
+ from pylogue.core import register_routes, EchoResponder
1333
+ return register_routes, EchoResponder
1334
+ except Exception:
1335
+ pylogue_path = Path("/Users/yeshwanth/Code/Personal/pylogue/src/pylogue/core.py")
1336
+ if not pylogue_path.exists():
1337
+ logger.warning(f"Pylogue not found at {pylogue_path}")
1338
+ return None, None
1339
+ try:
1340
+ import importlib.util
1341
+
1342
+ spec = importlib.util.spec_from_file_location("pylogue.core", pylogue_path)
1343
+ if spec and spec.loader:
1344
+ module = importlib.util.module_from_spec(spec)
1345
+ spec.loader.exec_module(module)
1346
+ return module.register_routes, module.EchoResponder
1347
+ except Exception as load_exc:
1348
+ logger.warning(f"Failed to load pylogue from {pylogue_path}: {load_exc}")
1349
+ return None, None
1350
+ return None, None
1351
+
1352
+ def _favicon_icon_path():
1353
+ root_icon = get_root_folder() / "static" / "icon.png"
1354
+ if root_icon.exists():
1355
+ return root_icon
1356
+ package_favicon = Path(__file__).parent / "static" / "favicon.png"
1357
+ if package_favicon.exists():
1358
+ return package_favicon
1359
+ return None
1360
+
1361
+ @app.route("/static/icon.png")
1362
+ async def favicon_icon():
1363
+ path = _favicon_icon_path()
1364
+ if path and path.exists():
1365
+ return FileResponse(path)
1366
+ return Response(status_code=404)
1367
+
1368
+ static_dir = Path(__file__).parent / "static"
1369
+ if static_dir.exists():
1370
+ app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
1371
+
1372
+ rt = app.route
1373
+
1374
+
1375
+ from starlette.requests import Request
1376
+ from starlette.responses import RedirectResponse, FileResponse, Response
1377
+
1378
+ _pylogue_register, _PylogueResponder = _load_pylogue_routes()
1379
+ if _pylogue_register:
1380
+ try:
1381
+ from .agent import PydanticAIStreamingResponder
1382
+ _chat_responder_factory = PydanticAIStreamingResponder
1383
+ logger.info("Using PydanticAIStreamingResponder for /chat")
1384
+ except Exception as exc:
1385
+ logger.warning(f"Falling back to Pylogue responder: {exc}")
1386
+ _chat_responder_factory = _PylogueResponder
1387
+ _pylogue_register(
1388
+ app,
1389
+ responder_factory=_chat_responder_factory,
1390
+ title=f"AI Chat for {get_config().get_blog_title().capitalize()} Docs",
1391
+ subtitle="Ask a question about this blog",
1392
+ tag_line="« Blog",
1393
+ tag_line_href="/",
1394
+ base_path="chat",
1395
+ inject_headers=True
1396
+ )
1397
+
1398
+ @rt("/chat")
1399
+ def chat_redirect():
1400
+ return RedirectResponse("/chat/", status_code=307)
1401
+
1402
+ @rt("/login", methods=["GET", "POST"])
1403
+ async def login(request: Request):
1404
+ config = get_config()
1405
+ user, pwd = config.get_auth()
1406
+ logger.info(f"Login attempt for user: {user}")
1407
+ error = request.query_params.get("error")
1408
+ if request.method == "POST":
1409
+ if not _local_auth_enabled:
1410
+ return RedirectResponse("/login?error=Local+login+disabled", status_code=303)
1411
+ form = await request.form()
1412
+ username = form.get("username", "")
1413
+ password = form.get("password", "")
1414
+ if username == user and password == pwd:
1415
+ roles = _resolve_roles({"provider": "local", "username": username})
1416
+ request.session["auth"] = {
1417
+ "provider": "local",
1418
+ "username": username,
1419
+ "roles": roles,
1420
+ }
1421
+ next_url = request.session.pop("next", "/")
1422
+ return RedirectResponse(next_url, status_code=303)
1423
+ else:
1424
+ error = "Invalid username or password."
1425
+
1426
+ return Div(
1427
+ H2("Login", cls="uk-h2"),
1428
+ A(
1429
+ Span("Continue with Google", cls="text-sm font-semibold"),
1430
+ href="/login/google",
1431
+ cls="inline-flex items-center justify-center px-4 py-2 my-6 rounded-md border border-slate-700 bg-slate-800 text-slate-100 hover:bg-slate-900 hover:border-slate-900 dark:bg-slate-800/80 dark:text-slate-100 dark:hover:bg-slate-900/80 transition-colors max-w-sm mx-auto"
1432
+ ) if _google_oauth_enabled else None,
1433
+ Form(
1434
+ Div(
1435
+ Input(type="text", name="username", required=True, id="username", cls="uk-input input input-bordered w-full", placeholder="Username"),
1436
+ cls="my-4"),
1437
+ Div(
1438
+ Input(type="password", name="password", required=True, id="password", cls="uk-input input input-bordered w-full", placeholder="Password"),
1439
+ cls="my-4"),
1440
+ Button("Login", type="submit", cls="uk-btn btn btn-primary w-full"),
1441
+ enctype="multipart/form-data", method="post", cls="max-w-sm mx-auto") if _local_auth_enabled else None,
1442
+ P(error, cls="text-red-500 mt-4") if error else None,
1443
+ cls="prose mx-auto mt-24 text-center")
1444
+
1445
+ @rt("/login/google")
1446
+ async def login_google(request: Request):
1447
+ if not _google_oauth_enabled:
1448
+ return Response(status_code=404)
1449
+ next_url = request.session.get("next") or request.query_params.get("next") or "/"
1450
+ request.session["next"] = next_url
1451
+ redirect_uri = str(request.base_url).rstrip("/") + "/auth/google/callback"
1452
+ print(f"DEBUG: redirect_uri = {redirect_uri}")
1453
+ return await _google_oauth.google.authorize_redirect(request, redirect_uri)
1454
+
1455
+ @rt("/auth/google/callback")
1456
+ async def google_auth_callback(request: Request):
1457
+ if not _google_oauth_enabled:
1458
+ return Response(status_code=404)
1459
+ try:
1460
+ token = await _google_oauth.google.authorize_access_token(request)
1461
+ userinfo = token.get("userinfo")
1462
+ if not userinfo:
1463
+ try:
1464
+ userinfo = await _google_oauth.google.parse_id_token(request, token)
1465
+ except Exception as exc:
1466
+ logger.warning(f"Google OAuth id_token missing or invalid: {exc}")
1467
+ try:
1468
+ userinfo = await _google_oauth.google.userinfo(token=token)
1469
+ except Exception as userinfo_exc:
1470
+ logger.warning(f"Google OAuth userinfo fetch failed: {userinfo_exc}")
1471
+ raise
1472
+ except Exception as exc:
1473
+ logger.warning(f"Google OAuth failed: {exc}")
1474
+ return RedirectResponse("/login?error=Google+authentication+failed", status_code=303)
1475
+
1476
+ email = userinfo.get("email") if isinstance(userinfo, dict) else None
1477
+ name = userinfo.get("name") if isinstance(userinfo, dict) else None
1478
+ picture = userinfo.get("picture") if isinstance(userinfo, dict) else None
1479
+
1480
+ allowed_domains = _google_oauth_cfg.get("allowed_domains", [])
1481
+ if allowed_domains:
1482
+ if not email:
1483
+ return RedirectResponse("/login?error=Google+account+not+allowed", status_code=303)
1484
+ domain = email.split("@")[-1]
1485
+ if domain not in allowed_domains:
1486
+ return RedirectResponse("/login?error=Google+account+not+allowed", status_code=303)
1487
+
1488
+ allowed_emails = _google_oauth_cfg.get("allowed_emails", [])
1489
+ if allowed_emails:
1490
+ if not email or email not in allowed_emails:
1491
+ return RedirectResponse("/login?error=Google+account+not+allowed", status_code=303)
1492
+
1493
+ auth = {
1494
+ "provider": "google",
1495
+ "email": email,
1496
+ "name": name,
1497
+ "picture": picture,
1498
+ }
1499
+ auth["roles"] = _resolve_roles(auth)
1500
+ request.session["auth"] = auth
1501
+ next_url = request.session.pop("next", "/")
1502
+ return RedirectResponse(next_url, status_code=303)
1503
+
1504
+ @rt("/logout")
1505
+ async def logout(request: Request):
1506
+ request.session.pop("auth", None)
1507
+ request.session.pop("next", None)
1508
+ return RedirectResponse("/login", status_code=303)
1509
+
1510
+ def _parse_roles_text(text: str):
1511
+ parts = re.split(r"[,\n]+", text or "")
1512
+ return [part.strip() for part in parts if part.strip()]
1513
+
1514
+ @rt("/admin/impersonate", methods=["GET", "POST"])
1515
+ async def admin_impersonate(htmx, request: Request):
1516
+ auth = _get_auth_from_request(request)
1517
+ roles = auth.get("roles") if auth else []
1518
+ impersonator = auth.get("impersonator") if auth else None
1519
+ impersonator_roles = impersonator.get("roles") if impersonator else []
1520
+ if (not roles or "full" not in roles) and (not impersonator_roles or "full" not in impersonator_roles):
1521
+ return Response("Forbidden", status_code=403)
1522
+
1523
+ error = None
1524
+ success = None
1525
+ impersonator = request.session.get("impersonator")
1526
+ current_auth = request.session.get("auth")
1527
+
1528
+ if request.method == "POST":
1529
+ form = await request.form()
1530
+ action = form.get("action", "start")
1531
+ if action == "stop":
1532
+ if impersonator:
1533
+ request.session["auth"] = impersonator
1534
+ request.session.pop("impersonator", None)
1535
+ success = "Impersonation stopped."
1536
+ else:
1537
+ error = "Not currently impersonating."
1538
+ else:
1539
+ email = (form.get("email") or "").strip()
1540
+ if not email:
1541
+ error = "Email is required."
1542
+ else:
1543
+ if not impersonator:
1544
+ request.session["impersonator"] = current_auth
1545
+ imp_auth = {
1546
+ "provider": "impersonate",
1547
+ "email": email,
1548
+ "username": email,
1549
+ }
1550
+ if request.session.get("impersonator"):
1551
+ imp_auth["impersonator"] = request.session.get("impersonator")
1552
+ imp_auth["roles"] = _resolve_roles(imp_auth)
1553
+ request.session["auth"] = imp_auth
1554
+ success = f"Now impersonating {email}."
1555
+
1556
+ impersonating_email = None
1557
+ if impersonator and current_auth and current_auth.get("provider") == "impersonate":
1558
+ impersonating_email = current_auth.get("email") or current_auth.get("username")
1559
+
1560
+ content = Div(
1561
+ H1("Impersonate User", cls="text-3xl font-bold"),
1562
+ P("Switch the current session to a different user for RBAC testing.", cls="text-slate-600 dark:text-slate-400"),
1563
+ Div(
1564
+ P(error, cls="text-red-600") if error else None,
1565
+ P(success, cls="text-emerald-600") if success else None,
1566
+ cls="mt-4"
1567
+ ),
1568
+ Div(
1569
+ P(f"Currently impersonating: {impersonating_email}", cls="text-sm text-amber-600 dark:text-amber-400") if impersonating_email else None,
1570
+ cls="mt-2"
1571
+ ),
1572
+ Form(
1573
+ Div(
1574
+ Label("User email", cls="block text-sm font-medium mb-2"),
1575
+ Input(type="email", name="email", placeholder="user@domain.com", cls="w-full px-3 py-2 rounded-md border border-slate-200 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60"),
1576
+ cls="mt-6"
1577
+ ),
1578
+ Div(
1579
+ Button("Start Impersonation", type="submit", name="action", value="start", cls="mt-6 px-4 py-2 rounded-md bg-blue-600 text-white hover:bg-blue-700"),
1580
+ Button("Stop Impersonation", type="submit", name="action", value="stop", cls="mt-6 ml-3 px-4 py-2 rounded-md bg-slate-200 text-slate-900 hover:bg-slate-300 dark:bg-slate-700 dark:text-slate-100 dark:hover:bg-slate-600"),
1581
+ cls="flex items-center"
1582
+ ),
1583
+ method="post",
1584
+ cls="mt-4"
1585
+ ),
1586
+ cls="max-w-xl mx-auto py-10 px-6"
1587
+ )
1588
+ return layout(content, htmx=htmx, title="Impersonate", show_sidebar=False, auth=auth, htmx_nav=False)
1589
+
1590
+ @rt("/admin/rbac", methods=["GET", "POST"])
1591
+ async def admin_rbac(htmx, request: Request):
1592
+ auth = _get_auth_from_request(request)
1593
+ roles = auth.get("roles") if auth else []
1594
+ if not roles or "full" not in roles:
1595
+ return Response("Forbidden", status_code=403)
1596
+
1597
+ error = None
1598
+ success = None
1599
+ cfg = _rbac_cfg
1600
+
1601
+ if request.method == "POST":
1602
+ form = await request.form()
1603
+ enabled = form.get("enabled") == "on"
1604
+ default_roles = _parse_roles_text(form.get("default_roles", ""))
1605
+ role_users_raw = form.get("role_users_json", "{}")
1606
+ user_roles_raw = form.get("user_roles_json", "{}")
1607
+ rules_raw = form.get("rules_json", "[]")
1608
+ try:
1609
+ role_users = json.loads(role_users_raw) if role_users_raw.strip() else {}
1610
+ user_roles = json.loads(user_roles_raw) if user_roles_raw.strip() else {}
1611
+ rules = json.loads(rules_raw) if rules_raw.strip() else []
1612
+ except Exception as exc:
1613
+ error = f"Invalid JSON: {exc}"
1614
+ else:
1615
+ if not isinstance(role_users, dict):
1616
+ error = "Role users JSON must be an object."
1617
+ elif not isinstance(user_roles, dict):
1618
+ error = "User roles JSON must be an object."
1619
+ elif not isinstance(rules, list):
1620
+ error = "Rules JSON must be an array."
1621
+ else:
1622
+ new_cfg = {
1623
+ "enabled": bool(enabled),
1624
+ "default_roles": default_roles,
1625
+ "role_users": role_users,
1626
+ "user_roles": user_roles,
1627
+ "rules": rules,
1628
+ }
1629
+ try:
1630
+ _rbac_db_write(new_cfg)
1631
+ _write_rbac_to_vyasa(new_cfg)
1632
+ _set_rbac_cfg(new_cfg)
1633
+ _cached_build_post_tree.cache_clear()
1634
+ _cached_posts_sidebar_html.cache_clear()
1635
+ success = "RBAC settings saved."
1636
+ cfg = _rbac_cfg
1637
+ except Exception as exc:
1638
+ error = f"Failed to save RBAC settings: {exc}"
1639
+
1640
+ default_roles_text = ", ".join(cfg.get("default_roles", []))
1641
+ role_users_text = json.dumps(cfg.get("role_users", {}), indent=2, sort_keys=True)
1642
+ user_roles_text = json.dumps(cfg.get("user_roles", {}), indent=2, sort_keys=True)
1643
+ rules_text = json.dumps(cfg.get("rules", []), indent=2, sort_keys=True)
1644
+ preview_text = _render_rbac_toml(cfg)
1645
+
1646
+ content = Div(
1647
+ H1("RBAC Administration", cls="text-3xl font-bold"),
1648
+ P("Edits save to SQLite immediately and also update the .vyasa file for transparency.", cls="text-slate-600 dark:text-slate-400"),
1649
+ P("Rule patterns are matched against request paths (e.g. /posts/ai/...).", cls="text-slate-500 dark:text-slate-500 text-sm"),
1650
+ Div(
1651
+ P(error, cls="text-red-600") if error else None,
1652
+ P(success, cls="text-emerald-600") if success else None,
1653
+ cls="mt-4"
1654
+ ),
1655
+ Form(
1656
+ Div(
1657
+ Label(
1658
+ Input(type="checkbox", name="enabled", checked=cfg.get("enabled", False), cls="mr-2"),
1659
+ Span("Enable RBAC"),
1660
+ cls="flex items-center gap-2"
1661
+ ),
1662
+ cls="mt-6"
1663
+ ),
1664
+ Div(
1665
+ Label("Default roles (comma separated)", cls="block text-sm font-medium mb-2"),
1666
+ Input(type="text", name="default_roles", value=default_roles_text, cls="w-full px-3 py-2 rounded-md border border-slate-200 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60"),
1667
+ cls="mt-6"
1668
+ ),
1669
+ Div(
1670
+ Label("Role users JSON", cls="block text-sm font-medium mb-2"),
1671
+ Textarea(role_users_text, name="role_users_json", rows="6", cls="w-full px-3 py-2 font-mono text-xs rounded-md border border-slate-200 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60"),
1672
+ cls="mt-6"
1673
+ ),
1674
+ Div(
1675
+ Label("User roles JSON", cls="block text-sm font-medium mb-2"),
1676
+ Textarea(user_roles_text, name="user_roles_json", rows="6", cls="w-full px-3 py-2 font-mono text-xs rounded-md border border-slate-200 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60"),
1677
+ cls="mt-6"
1678
+ ),
1679
+ Div(
1680
+ Label("Rules JSON", cls="block text-sm font-medium mb-2"),
1681
+ Textarea(rules_text, name="rules_json", rows="8", cls="w-full px-3 py-2 font-mono text-xs rounded-md border border-slate-200 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60"),
1682
+ cls="mt-6"
1683
+ ),
1684
+ Button("Save RBAC", type="submit", cls="mt-6 px-4 py-2 rounded-md bg-blue-600 text-white hover:bg-blue-700"),
1685
+ method="post",
1686
+ cls="mt-4"
1687
+ ),
1688
+ Div(
1689
+ H2("Preview (.vyasa)", cls="text-xl font-semibold mt-10"),
1690
+ Pre(preview_text, cls="mt-3 p-4 rounded-md bg-slate-100 dark:bg-slate-900/60 text-xs overflow-x-auto"),
1691
+ ),
1692
+ cls="max-w-3xl mx-auto py-10 px-6"
1693
+ )
1694
+ return layout(content, htmx=htmx, title="RBAC Admin", show_sidebar=False, auth=auth, htmx_nav=False)
1695
+
1696
+ # Progressive sidebar loading: lazy posts sidebar endpoint
1697
+ @rt("/_sidebar/posts")
1698
+ def posts_sidebar_lazy(request: Request = None):
1699
+ roles = _get_roles_from_request(request)
1700
+ html = _cached_posts_sidebar_html(_posts_sidebar_fingerprint(), tuple(roles or []))
1701
+ return Aside(
1702
+ NotStr(html),
1703
+ cls="hidden xl:block w-72 shrink-0 sticky top-24 self-start max-h-[calc(100vh-10rem)] overflow-hidden z-[1000]",
1704
+ id="posts-sidebar"
1705
+ )
1706
+
1707
+ # Route to serve raw markdown for LLM-friendly access
1708
+ @rt("/posts/{path:path}.md")
1709
+ def serve_post_markdown(path: str):
1710
+ from starlette.responses import FileResponse
1711
+ file_path = get_root_folder() / f'{path}.md'
1712
+ if file_path.exists():
1713
+ return FileResponse(file_path, media_type="text/markdown; charset=utf-8")
1714
+ return Response(status_code=404)
1715
+
1716
+ @rt("/search/gather")
1717
+ def gather_search_results(htmx, q: str = "", request: Request = None):
1718
+ import html
1719
+ matches, regex_error = _find_search_matches(q, limit=200)
1720
+ roles = _get_roles_from_request(request)
1721
+ if roles is not None:
1722
+ root = get_root_folder()
1723
+ filtered = []
1724
+ for item in matches:
1725
+ slug = item.relative_to(root).with_suffix("")
1726
+ if _is_allowed(f"/posts/{slug}", roles or []):
1727
+ filtered.append(item)
1728
+ matches = filtered
1729
+ if not matches:
1730
+ content = Div(
1731
+ H1("Search Results", cls="text-3xl font-bold mb-6"),
1732
+ P("No matching posts found.", cls="text-slate-600 dark:text-slate-400"),
1733
+ P(regex_error, cls="text-amber-600 dark:text-amber-400 text-sm") if regex_error else None
1734
+ )
1735
+ return layout(content, htmx=htmx, title="Search Results", show_sidebar=True, auth=request.scope.get("auth") if request else None)
1736
+
1737
+ root = get_root_folder()
1738
+ sections = []
1739
+ copy_parts = [f"# Search Results: {q.strip() or 'All'}\n"]
1740
+ if regex_error:
1741
+ copy_parts.append(f"> {regex_error}\n")
1742
+ for idx, item in enumerate(matches):
1743
+ rel = item.relative_to(root).as_posix()
1744
+ if item.suffix == ".pdf":
1745
+ slug = item.relative_to(root).with_suffix("").as_posix()
1746
+ pdf_href = f"/posts/{slug}.pdf"
1747
+ sections.extend([
1748
+ H2(rel, cls="text-xl font-semibold mb-2"),
1749
+ P(
1750
+ "PDF file: ",
1751
+ A(rel, href=pdf_href, cls="text-blue-600 hover:underline"),
1752
+ cls="text-sm text-slate-600 dark:text-slate-300"
1753
+ ),
1754
+ Hr(cls="my-6 border-slate-200 dark:border-slate-800") if idx < len(matches) - 1 else None
1755
+ ])
1756
+ copy_parts.append(f"\n---\n\n## {rel}\n\n[PDF file]({pdf_href})\n")
1757
+ continue
1758
+ try:
1759
+ raw_md = item.read_text(encoding="utf-8")
1760
+ except Exception:
1761
+ raw_md = ""
1762
+ sections.extend([
1763
+ H2(rel, cls="text-xl font-semibold mb-2"),
1764
+ Pre(html.escape(raw_md), cls="text-xs font-mono whitespace-pre-wrap text-slate-700 dark:text-slate-300"),
1765
+ Hr(cls="my-6 border-slate-200 dark:border-slate-800") if idx < len(matches) - 1 else None
1766
+ ])
1767
+ copy_parts.append(f"\n---\n\n## {rel}\n\n{raw_md}\n")
1768
+
1769
+ copy_text = "".join(copy_parts)
1770
+ content = Div(
1771
+ H1(f"Search Results: {q.strip() or 'All'}", cls="text-3xl font-bold mb-6"),
1772
+ P(regex_error, cls="text-amber-600 dark:text-amber-400 text-sm mb-4") if regex_error else None,
1773
+ Button(
1774
+ UkIcon("copy", cls="w-5 h-5"),
1775
+ Span("Copy all results", cls="text-sm font-semibold"),
1776
+ type="button",
1777
+ onclick="(function(){const el=document.getElementById('gather-clipboard');const toast=document.getElementById('gather-toast');if(!el){return;}el.focus();el.select();const text=el.value;const done=()=>{if(!toast){return;}toast.classList.remove('opacity-0');toast.classList.add('opacity-100');setTimeout(()=>{toast.classList.remove('opacity-100');toast.classList.add('opacity-0');},1400);};if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(text).then(done).catch(()=>{document.execCommand('copy');done();});}else{document.execCommand('copy');done();}})()",
1778
+ cls="inline-flex items-center gap-2 px-3 py-2 mb-6 rounded-md border border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-200 hover:text-slate-900 dark:hover:text-white hover:border-slate-300 dark:hover:border-slate-500 transition-colors"
1779
+ ),
1780
+ Div(
1781
+ "Copied!",
1782
+ id="gather-toast",
1783
+ cls="fixed top-6 right-6 bg-slate-900 text-white text-sm px-4 py-2 rounded shadow-lg opacity-0 transition-opacity duration-300"
1784
+ ),
1785
+ Textarea(
1786
+ copy_text,
1787
+ id="gather-clipboard",
1788
+ cls="absolute left-[-9999px] top-0 opacity-0 pointer-events-none"
1789
+ ),
1790
+ *sections
1791
+ )
1792
+ return layout(content, htmx=htmx, title="Search Results", show_sidebar=True, auth=request.scope.get("auth") if request else None)
1793
+
1794
+ # Route to serve static files (images, SVGs, etc.) from blog posts
1795
+ @rt("/posts/{path:path}.{ext:static}")
1796
+ def serve_post_static(path: str, ext: str):
1797
+ from starlette.responses import FileResponse
1798
+ file_path = get_root_folder() / f'{path}.{ext}'
1799
+ if file_path.exists():
1800
+ return FileResponse(file_path)
1801
+ return Response(status_code=404)
1802
+
1803
+ def theme_toggle():
1804
+ theme_script = """on load set franken to (localStorage's __FRANKEN__ or '{}') as Object
1805
+ if franken's mode is 'dark' then add .dark to <html/> end
1806
+ on click toggle .dark on <html/>
1807
+ set franken to (localStorage's __FRANKEN__ or '{}') as Object
1808
+ if the first <html/> matches .dark set franken's mode to 'dark' else set franken's mode to 'light' end
1809
+ set localStorage's __FRANKEN__ to franken as JSON"""
1810
+ return Button(UkIcon("moon", cls="dark:hidden"), UkIcon("sun", cls="hidden dark:block"),
1811
+ _=theme_script, cls="p-1 hover:scale-110 shadow-none", type="button")
1812
+
1813
+ def navbar(show_mobile_menus=False, htmx_nav=True):
1814
+ """Navbar with mobile menu buttons for file tree and TOC"""
1815
+ home_link_attrs = {}
1816
+ if htmx_nav:
1817
+ home_link_attrs = {
1818
+ "hx_get": "/",
1819
+ "hx_target": "#main-content",
1820
+ "hx_push_url": "true",
1821
+ "hx_swap": "outerHTML show:window:top settle:0.1s",
1822
+ }
1823
+ left_section = Div(
1824
+ A(
1825
+ get_blog_title(),
1826
+ href="/",
1827
+ **home_link_attrs
1828
+ ),
1829
+ cls="flex items-center gap-2"
1830
+ )
1831
+
1832
+ right_section = Div(
1833
+ theme_toggle(),
1834
+ cls="flex items-center gap-2"
1835
+ )
1836
+
1837
+ # Add mobile menu buttons if sidebars are present
1838
+ if show_mobile_menus:
1839
+ mobile_buttons = Div(
1840
+ Button(
1841
+ UkIcon("menu", cls="w-5 h-5"),
1842
+ title="Toggle file tree",
1843
+ id="mobile-posts-toggle",
1844
+ cls="xl:hidden p-2 hover:bg-slate-800 rounded transition-colors",
1845
+ type="button"
1846
+ ),
1847
+ Button(
1848
+ UkIcon("list", cls="w-5 h-5"),
1849
+ title="Toggle table of contents",
1850
+ id="mobile-toc-toggle",
1851
+ cls="xl:hidden p-2 hover:bg-slate-800 rounded transition-colors",
1852
+ type="button"
1853
+ ),
1854
+ cls="flex items-center gap-1"
1855
+ )
1856
+ right_section = Div(
1857
+ mobile_buttons,
1858
+ theme_toggle(),
1859
+ cls="flex items-center gap-2"
1860
+ )
1861
+
1862
+ return Div(left_section, right_section,
1863
+ cls="flex items-center justify-between bg-slate-900 text-white p-4 my-4 rounded-lg shadow-md dark:bg-slate-800")
1864
+
1865
+ def _posts_sidebar_fingerprint():
1866
+ root = get_root_folder()
1867
+ try:
1868
+ return max((p.stat().st_mtime for p in root.rglob("*.md")), default=0)
1869
+ except Exception:
1870
+ return 0
1871
+
1872
+ def _normalize_search_text(text):
1873
+ text = (text or "").lower()
1874
+ text = text.replace("-", " ").replace("_", " ")
1875
+ return " ".join(text.split())
1876
+
1877
+ def _parse_search_query(query):
1878
+ trimmed = (query or "").strip()
1879
+ if len(trimmed) >= 2 and trimmed.startswith("/") and trimmed.endswith("/"):
1880
+ pattern = trimmed[1:-1].strip()
1881
+ if not pattern:
1882
+ return None, ""
1883
+ try:
1884
+ return re.compile(pattern, re.IGNORECASE), ""
1885
+ except re.error:
1886
+ return None, "Invalid regex. Showing normal matches instead."
1887
+ return None, ""
1888
+
1889
+ @lru_cache(maxsize=256)
1890
+ def _cached_search_matches(fingerprint, query, limit):
1891
+ return _find_search_matches_uncached(query, limit)
1892
+
1893
+ def _find_search_matches(query, limit=40):
1894
+ fingerprint = _posts_sidebar_fingerprint()
1895
+ return _cached_search_matches(fingerprint, query, limit)
1896
+
1897
+ def _find_search_matches_uncached(query, limit=40):
1898
+ trimmed = (query or "").strip()
1899
+ if not trimmed:
1900
+ return [], ""
1901
+ regex, regex_error = _parse_search_query(trimmed)
1902
+ query_norm = _normalize_search_text(trimmed) if not regex else ""
1903
+ root = get_root_folder()
1904
+ index_file = find_index_file()
1905
+ results = []
1906
+ for item in chain(root.rglob("*.md"), root.rglob("*.pdf")):
1907
+ if any(part.startswith('.') for part in item.relative_to(root).parts):
1908
+ continue
1909
+ if ".vyasa" in item.parts:
1910
+ continue
1911
+ if index_file and item.resolve() == index_file.resolve():
1912
+ continue
1913
+ rel = item.relative_to(root).with_suffix("")
1914
+ if regex:
1915
+ haystack = f"{item.name} {rel.as_posix()}"
1916
+ is_match = regex.search(haystack)
1917
+ else:
1918
+ haystack = _normalize_search_text(f"{item.name} {rel.as_posix()}")
1919
+ is_match = query_norm in haystack
1920
+ if is_match:
1921
+ results.append(item)
1922
+ if len(results) >= limit:
1923
+ break
1924
+ return tuple(results), regex_error
1925
+
1926
+ def _render_posts_search_results(query, roles=None):
1927
+ trimmed = (query or "").strip()
1928
+ if not trimmed:
1929
+ return Ul(
1930
+ Li("Type to search file names.", cls="text-[0.7rem] text-center text-slate-500 dark:text-slate-400 bg-transparent"),
1931
+ cls="posts-search-results-list space-y-1 bg-white/0 dark:bg-slate-950/0"
1932
+ )
1933
+
1934
+ matches, regex_error = _find_search_matches(trimmed)
1935
+ if roles is not None:
1936
+ root = get_root_folder()
1937
+ filtered = []
1938
+ for item in matches:
1939
+ slug = item.relative_to(root).with_suffix("")
1940
+ if _is_allowed(f"/posts/{slug}", roles or []):
1941
+ filtered.append(item)
1942
+ matches = filtered
1943
+ if not matches:
1944
+ return Ul(
1945
+ Li(f'No matches for "{trimmed}".', cls="text-xs text-slate-500 dark:text-slate-400 bg-transparent"),
1946
+ Li(regex_error, cls="text-[0.7rem] text-center text-amber-600 dark:text-amber-400") if regex_error else None,
1947
+ cls="posts-search-results-list space-y-1 bg-white/0 dark:bg-slate-950/0"
1948
+ )
1949
+
1950
+ root = get_root_folder()
1951
+ items = []
1952
+ gather_href = f"/search/gather?q={quote_plus(trimmed)}"
1953
+ items.append(Li(
1954
+ A(
1955
+ Span(UkIcon("layers", cls="w-4 h-4 text-slate-400"), cls="w-4 mr-2 flex items-center justify-center shrink-0"),
1956
+ Span("Gather all search results for LLM", cls="truncate min-w-0 text-xs text-slate-600 dark:text-slate-300"),
1957
+ href=gather_href,
1958
+ hx_get=gather_href,
1959
+ hx_target="#main-content",
1960
+ hx_push_url="true",
1961
+ hx_swap="outerHTML show:window:top settle:0.1s",
1962
+ cls="post-search-link flex items-center py-1 px-2 rounded bg-transparent 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"
1963
+ ),
1964
+ cls="bg-transparent"
1965
+ ))
1966
+ for item in matches:
1967
+ slug = str(item.relative_to(root).with_suffix(""))
1968
+ if item.suffix == ".pdf":
1969
+ display = item.relative_to(root).as_posix()
1970
+ else:
1971
+ display = item.relative_to(root).with_suffix("").as_posix()
1972
+ items.append(Li(
1973
+ A(
1974
+ Span(UkIcon("search", cls="w-4 h-4 text-slate-400"), cls="w-4 mr-2 flex items-center justify-center shrink-0"),
1975
+ Span(display, cls="truncate min-w-0 font-mono text-xs text-slate-600 dark:text-slate-300", title=display),
1976
+ href=f'/posts/{slug}',
1977
+ hx_get=f'/posts/{slug}', hx_target="#main-content", hx_push_url="true", hx_swap="outerHTML show:window:top settle:0.1s",
1978
+ cls="post-search-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"
1979
+ )
1980
+ ))
1981
+ if regex_error:
1982
+ items.append(Li(regex_error, cls="text-[0.7rem] text-center text-amber-600 dark:text-amber-400 mt-1 bg-transparent"))
1983
+ return Ul(*items, cls="posts-search-results-list space-y-1 bg-white/0 dark:bg-slate-950/0")
1984
+
1985
+ def _posts_search_block():
1986
+ return Div(
1987
+ Div("Filter", cls="text-xs uppercase tracking-widest text-slate-500 dark:text-slate-400 mb-2"),
1988
+ Div(
1989
+ Input(
1990
+ type="search",
1991
+ name="q",
1992
+ placeholder="Search file names…",
1993
+ autocomplete="off",
1994
+ data_placeholder_cycle="1",
1995
+ data_placeholder_primary="Search file names…",
1996
+ data_placeholder_alt="Search regex with /pattern/ syntax",
1997
+ data_search_key="posts",
1998
+ hx_get="/_sidebar/posts/search",
1999
+ hx_trigger="input changed delay:300ms",
2000
+ hx_target="next .posts-search-results",
2001
+ hx_swap="innerHTML",
2002
+ cls="w-full px-3 py-2 text-sm rounded-md border border-slate-200 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 text-slate-700 dark:text-slate-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
2003
+ ),
2004
+ Button(
2005
+ "×",
2006
+ type="button",
2007
+ aria_label="Clear search",
2008
+ cls="posts-search-clear-button absolute right-2 top-1/2 -translate-y-1/2 h-6 w-6 rounded text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
2009
+ ),
2010
+ cls="relative"
2011
+ ),
2012
+ Div(
2013
+ _render_posts_search_results(""),
2014
+ id="posts-search-results",
2015
+ cls="posts-search-results mt-4 max-h-64 overflow-y-auto bg-white/0 dark:bg-slate-950/0"
2016
+ ),
2017
+ cls="posts-search-block sticky top-0 z-10 bg-white/20 dark:bg-slate-950/70 mb-3"
2018
+ )
2019
+
2020
+ @lru_cache(maxsize=4)
2021
+ def _cached_posts_sidebar_html(fingerprint, roles_key):
2022
+ sidebars_open = get_config().get_sidebars_open()
2023
+ sidebar = collapsible_sidebar(
2024
+ "menu",
2025
+ "Library",
2026
+ get_posts(list(roles_key) if roles_key else []),
2027
+ is_open=sidebars_open,
2028
+ data_sidebar="posts",
2029
+ shortcut_key="Z",
2030
+ extra_content=[
2031
+ _posts_search_block(),
2032
+ Div(cls="h-px w-full bg-slate-200/80 dark:bg-slate-700/70 my-2"),
2033
+ Div("Posts", cls="text-xs uppercase tracking-widest text-slate-500 dark:text-slate-400 mb-1")
2034
+ ],
2035
+ scroll_target="list"
2036
+ )
2037
+ return to_xml(sidebar)
2038
+
2039
+ def _preload_posts_cache():
2040
+ try:
2041
+ _cached_build_post_tree(_posts_tree_fingerprint(), ())
2042
+ _cached_posts_sidebar_html(_posts_sidebar_fingerprint(), ())
2043
+ logger.info("Preloaded posts sidebar cache.")
2044
+ except Exception as exc:
2045
+ logger.warning(f"Failed to preload posts sidebar cache: {exc}")
2046
+
2047
+ # Warm cache on server startup to avoid first-request latency.
2048
+ if hasattr(app, "add_event_handler"):
2049
+ app.add_event_handler("startup", _preload_posts_cache)
2050
+ elif hasattr(app, "on_event"):
2051
+ app.on_event("startup")(_preload_posts_cache)
2052
+
2053
+ def collapsible_sidebar(icon, title, items_list, is_open=False, data_sidebar=None, shortcut_key=None, extra_content=None, scroll_target="container"):
2054
+ """Reusable collapsible sidebar component with sticky header"""
2055
+ # Build the summary content
2056
+ summary_content = [
2057
+ Span(
2058
+ UkIcon(icon, cls="w-5 h-5 block"),
2059
+ cls="flex items-center justify-center w-5 h-5 shrink-0 leading-none"
2060
+ ),
2061
+ Span(title, cls="flex-1 leading-none")
2062
+ ]
2063
+
2064
+ # Add keyboard shortcut indicator if provided
2065
+ if shortcut_key:
2066
+ summary_content.append(
2067
+ Kbd(
2068
+ shortcut_key,
2069
+ cls="kbd-key px-2.5 py-1.5 text-xs font-mono font-semibold bg-gradient-to-b from-slate-50 to-slate-200 dark:from-slate-700 dark:to-slate-900 text-slate-800 dark:text-slate-200 rounded-md border-2 border-slate-300 dark:border-slate-600 shadow-[0_2px_0_0_rgba(0,0,0,0.1),inset_0_1px_0_0_rgba(255,255,255,0.5)] dark:shadow-[0_2px_0_0_rgba(0,0,0,0.5),inset_0_1px_0_0_rgba(255,255,255,0.1)]"
2070
+ )
2071
+ )
2072
+
2073
+ # Sidebar styling configuration
2074
+ common_frost_style = "bg-white/20 dark:bg-slate-950/70 backdrop-blur-lg border border-slate-900/10 dark:border-slate-700/25 ring-1 ring-white/20 dark:ring-slate-900/30 shadow-[0_24px_60px_-40px_rgba(15,23,42,0.45)] dark:shadow-[0_28px_70px_-45px_rgba(2,6,23,0.85)]"
2075
+ summary_classes = f"flex items-center gap-2 font-semibold cursor-pointer py-2.5 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]"
2076
+ if scroll_target == "list":
2077
+ content_classes = f"p-3 {common_frost_style} rounded-lg max-h-[calc(100vh-18rem)] flex flex-col overflow-hidden min-h-0"
2078
+ list_classes = "list-none pt-2 flex-1 min-h-0 overflow-y-auto sidebar-scroll-container"
2079
+ else:
2080
+ content_classes = f"p-3 {common_frost_style} rounded-lg overflow-y-auto max-h-[calc(100vh-18rem)] sidebar-scroll-container"
2081
+ list_classes = "list-none pt-4"
2082
+
2083
+ extra_content = extra_content or []
2084
+ content_id = "sidebar-scroll-container" if scroll_target != "list" else None
2085
+ return Details(
2086
+ Summary(*summary_content, cls=summary_classes, style="margin: 0 0 0.5rem 0;"),
2087
+ Div(
2088
+ *extra_content,
2089
+ Ul(*items_list, cls=list_classes, id="sidebar-scroll-container" if scroll_target == "list" else None),
2090
+ cls=content_classes,
2091
+ id=content_id,
2092
+ style="will-change: auto;"
2093
+ ),
2094
+ open=is_open,
2095
+ data_sidebar=data_sidebar,
2096
+ style="will-change: auto;"
2097
+ )
2098
+
2099
+ @rt("/_sidebar/posts/search")
2100
+ def posts_sidebar_search(q: str = "", request: Request = None):
2101
+ roles = _get_roles_from_request(request)
2102
+ return _render_posts_search_results(q, roles=roles)
2103
+
2104
+ def is_active_toc_item(anchor):
2105
+ """Check if a TOC item is currently active based on URL hash"""
2106
+ # This will be enhanced client-side with JavaScript
2107
+ return False
2108
+
2109
+ def extract_toc(content):
2110
+ """Extract table of contents from markdown content, excluding code blocks"""
2111
+ # Remove code blocks (both fenced and indented) to avoid false positives
2112
+ # Remove fenced code blocks (``` or ~~~)
2113
+ content_no_code = re.sub(r'^```.*?^```', '', content, flags=re.MULTILINE | re.DOTALL)
2114
+ content_no_code = re.sub(r'^~~~.*?^~~~', '', content_no_code, flags=re.MULTILINE | re.DOTALL)
2115
+
2116
+ # Parse headings from the cleaned content
2117
+ heading_pattern = re.compile(r'^(#{1,6})\s+(.+)$', re.MULTILINE)
2118
+ headings = []
2119
+ counts = {}
2120
+ for match in heading_pattern.finditer(content_no_code):
2121
+ level = len(match.group(1))
2122
+ raw_text = match.group(2).strip()
2123
+ text = _strip_inline_markdown(raw_text)
2124
+ # Create anchor from heading text using shared function
2125
+ anchor = _unique_anchor(text_to_anchor(text), counts)
2126
+ headings.append((level, text, anchor))
2127
+ return headings
2128
+
2129
+ def build_toc_items(headings):
2130
+ """Build TOC items from extracted headings with active state tracking"""
2131
+ if not headings:
2132
+ return [Li("No headings found", cls="text-sm text-slate-500 dark:text-slate-400 py-1")]
2133
+
2134
+ items = []
2135
+ for level, text, anchor in headings:
2136
+ indent = "ml-0" if level == 1 else f"ml-{(level-1)*3}"
2137
+ items.append(Li(
2138
+ A(text, href=f"#{anchor}",
2139
+ 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}",
2140
+ data_anchor=anchor),
2141
+ cls="my-1"
2142
+ ))
2143
+ return items
2144
+
2145
+ def get_custom_css_links(current_path=None, section_class=None):
2146
+ """Check for custom.css or style.css in blog root and current post's directory
2147
+
2148
+ Returns list of Link/Style elements for all found CSS files, ordered from root to specific
2149
+ (so more specific styles can override general ones). Folder-specific CSS is automatically
2150
+ scoped to only apply within that folder's pages.
2151
+ """
2152
+ root = get_root_folder()
2153
+ css_elements = []
2154
+
2155
+ # First, check root directory - applies globally
2156
+ for filename in ['custom.css', 'style.css']:
2157
+ css_file = root / filename
2158
+ if css_file.exists():
2159
+ css_elements.append(Link(rel="stylesheet", href=f"/posts/{filename}"))
2160
+ break # Only one from root
2161
+
2162
+ # Then check current post's directory (if provided)
2163
+ # These are automatically scoped to only apply within the section
2164
+ if current_path and section_class:
2165
+ from pathlib import Path
2166
+ post_dir = Path(current_path).parent if '/' in current_path else Path('.')
2167
+
2168
+ if str(post_dir) != '.': # Not in root
2169
+ for filename in ['custom.css', 'style.css']:
2170
+ css_file = root / post_dir / filename
2171
+ if css_file.exists():
2172
+ # Read CSS content and wrap all rules with section scope
2173
+ css_content = css_file.read_text()
2174
+ # Wrap the entire CSS in a section-specific scope
2175
+ scoped_css = Style(f"""
2176
+ #main-content.{section_class} {{
2177
+ {css_content}
2178
+ }}
2179
+ """)
2180
+ css_elements.append(scoped_css)
2181
+ break # Only one per directory
2182
+
2183
+ return css_elements
2184
+
2185
+ def layout(*content, htmx, title=None, show_sidebar=False, toc_content=None, current_path=None, show_toc=True, auth=None, htmx_nav=True):
2186
+ import time
2187
+ layout_start_time = time.time()
2188
+ logger.debug("[LAYOUT] layout() start")
2189
+ # Generate section class for CSS scoping (will be used by get_custom_css_links if needed)
2190
+ section_class = f"section-{current_path.replace('/', '-')}" if current_path else ""
2191
+ t_section = time.time()
2192
+ logger.debug(f"[LAYOUT] section_class computed in {(t_section - layout_start_time)*1000:.2f}ms")
2193
+ layout_config = _resolve_layout_config(current_path)
2194
+ layout_max_class, layout_max_style = _width_class_and_style(layout_config.get("layout_max_width"), "max")
2195
+ layout_fluid_class = "layout-fluid" if layout_max_style else ""
2196
+
2197
+ def _footer_node(outer_cls, outer_style):
2198
+ logout_button = None
2199
+ if auth:
2200
+ display_name = auth.get("name") or auth.get("email") or auth.get("username") or "User"
2201
+ impersonator = auth.get("impersonator")
2202
+ if impersonator:
2203
+ original = impersonator.get("name") or impersonator.get("email") or impersonator.get("username") or "User"
2204
+ display_name = f"Impersonating {display_name} (as {original})"
2205
+ logout_button = A(
2206
+ f"Logout {display_name}",
2207
+ href="/logout",
2208
+ cls="text-sm text-white/80 hover:text-white underline"
2209
+ )
2210
+ footer_inner = Div(
2211
+ Div(logout_button, cls="flex items-center") if logout_button else Div(),
2212
+ Div(NotStr('Powered by <a href="https://github.com/sizhky/vyasa" class="underline hover:text-white/80" target="_blank" rel="noopener noreferrer">Vyasa</a> and ❤️')),
2213
+ cls="flex items-center justify-between w-full"
2214
+ )
2215
+ return Footer(
2216
+ Div(footer_inner, cls="bg-slate-900 text-white rounded-lg p-4 my-4 dark:bg-slate-800"),
2217
+ cls=outer_cls,
2218
+ id="site-footer",
2219
+ **outer_style
2220
+ )
2221
+
2222
+
2223
+ # HTMX short-circuit: build only swappable fragments, never build full page chrome/sidebars tree
2224
+ if htmx and getattr(htmx, "request", None):
2225
+ if show_sidebar:
2226
+ toc_sidebar = None
2227
+ t_toc = t_section
2228
+ if show_toc:
2229
+ toc_items = build_toc_items(extract_toc(toc_content)) if toc_content else []
2230
+ t_toc = time.time()
2231
+ logger.debug(f"[LAYOUT] TOC built in {(t_toc - t_section)*1000:.2f}ms")
2232
+
2233
+ sidebars_open = get_config().get_sidebars_open()
2234
+ toc_attrs = {
2235
+ "cls": "hidden xl:block w-72 shrink-0 sticky top-24 self-start max-h-[calc(100vh-10rem)] overflow-hidden z-[1000]",
2236
+ "id": "toc-sidebar",
2237
+ "hx_swap_oob": "true",
2238
+ }
2239
+ toc_sidebar = Aside(
2240
+ collapsible_sidebar("list", "Contents", toc_items, is_open=sidebars_open, shortcut_key="X") if toc_items else Div(),
2241
+ **toc_attrs
2242
+ )
2243
+ mobile_toc_panel = Div(
2244
+ Div(
2245
+ Button(
2246
+ UkIcon("x", cls="w-5 h-5"),
2247
+ id="close-mobile-toc",
2248
+ cls="p-2 hover:bg-slate-200 dark:hover:bg-slate-700 rounded transition-colors ml-auto",
2249
+ type="button"
2250
+ ),
2251
+ cls="flex justify-end p-2 bg-white dark:bg-slate-950 border-b border-slate-200 dark:border-slate-800"
2252
+ ),
2253
+ Div(
2254
+ collapsible_sidebar("list", "Contents", toc_items, is_open=sidebars_open, shortcut_key="X") if toc_items else Div(P("No table of contents available.", cls="text-slate-500 dark:text-slate-400 text-sm p-4")),
2255
+ cls="p-4 overflow-y-auto"
2256
+ ),
2257
+ id="mobile-toc-panel",
2258
+ cls="fixed inset-0 bg-white dark:bg-slate-950 z-[9999] xl:hidden transform translate-x-full transition-transform duration-300",
2259
+ hx_swap_oob="true"
2260
+ )
2261
+
2262
+ custom_css_links = get_custom_css_links(current_path, section_class)
2263
+ t_css = time.time()
2264
+ logger.debug(f"[LAYOUT] Custom CSS resolved in {(t_css - t_toc)*1000:.2f}ms")
2265
+
2266
+ main_content_container = Main(
2267
+ *content,
2268
+ cls=f"flex-1 min-w-0 px-6 py-8 space-y-8 {section_class}",
2269
+ id="main-content",
2270
+ hx_boost="true",
2271
+ hx_target="#main-content",
2272
+ hx_swap="outerHTML show:window:top settle:0.1s",
2273
+ )
2274
+ t_main = time.time()
2275
+ logger.debug(f"[LAYOUT] Main content container built in {(t_main - t_css)*1000:.2f}ms")
2276
+
2277
+ result = [Title(title)]
2278
+ if custom_css_links:
2279
+ result.append(Div(*custom_css_links, id="scoped-css-container", hx_swap_oob="true"))
2280
+ else:
2281
+ result.append(Div(id="scoped-css-container", hx_swap_oob="true"))
2282
+ if show_toc:
2283
+ result.append(mobile_toc_panel)
2284
+ if toc_sidebar:
2285
+ result.extend([main_content_container, toc_sidebar])
2286
+ else:
2287
+ result.append(main_content_container)
2288
+ result.append(Div(id="toc-sidebar", hx_swap_oob="true"))
2289
+ result.append(Div(id="mobile-toc-panel", hx_swap_oob="true"))
2290
+
2291
+ t_htmx = time.time()
2292
+ logger.debug(f"[LAYOUT] HTMX response assembled in {(t_htmx - t_main)*1000:.2f}ms")
2293
+ logger.debug(f"[LAYOUT] TOTAL layout() time {(t_htmx - layout_start_time)*1000:.2f}ms")
2294
+ return tuple(result)
2295
+
2296
+ # HTMX without sidebar
2297
+ custom_css_links = get_custom_css_links(current_path, section_class) if current_path else []
2298
+ t_css = time.time()
2299
+ logger.debug(f"[LAYOUT] Custom CSS resolved in {(t_css - t_section)*1000:.2f}ms")
2300
+
2301
+ result = [Title(title)]
2302
+ if custom_css_links:
2303
+ result.append(Div(*custom_css_links, id="scoped-css-container", hx_swap_oob="true"))
2304
+ else:
2305
+ result.append(Div(id="scoped-css-container", hx_swap_oob="true"))
2306
+ result.extend(content)
2307
+
2308
+ t_htmx = time.time()
2309
+ logger.debug(f"[LAYOUT] HTMX response assembled in {(t_htmx - layout_start_time)*1000:.2f}ms")
2310
+ logger.debug(f"[LAYOUT] TOTAL layout() time {(t_htmx - layout_start_time)*1000:.2f}ms")
2311
+ return tuple(result)
2312
+
2313
+ if show_sidebar:
2314
+ # Build TOC if content provided
2315
+ toc_sidebar = None
2316
+ t_toc = t_section
2317
+ if show_toc:
2318
+ toc_items = build_toc_items(extract_toc(toc_content)) if toc_content else []
2319
+ t_toc = time.time()
2320
+ logger.debug(f"[LAYOUT] TOC built in {(t_toc - t_section)*1000:.2f}ms")
2321
+ # Right sidebar TOC component with out-of-band swap for HTMX
2322
+ sidebars_open = get_config().get_sidebars_open()
2323
+ toc_attrs = {
2324
+ "cls": "hidden xl:block w-72 shrink-0 sticky top-24 self-start max-h-[calc(100vh-10rem)] overflow-hidden z-[1000]",
2325
+ "id": "toc-sidebar"
2326
+ }
2327
+ toc_sidebar = Aside(
2328
+ collapsible_sidebar("list", "Contents", toc_items, is_open=sidebars_open, shortcut_key="X") if toc_items else Div(),
2329
+ **toc_attrs
2330
+ )
2331
+ # Container for main content only (for HTMX swapping)
2332
+ # Add section class to identify the section for CSS scoping
2333
+ section_class = f"section-{current_path.replace('/', '-')}" if current_path else ""
2334
+ # Get custom CSS with folder-specific CSS automatically scoped
2335
+ custom_css_links = get_custom_css_links(current_path, section_class)
2336
+ t_css = time.time()
2337
+ logger.debug(f"[LAYOUT] Custom CSS resolved in {(t_css - t_toc)*1000:.2f}ms")
2338
+ main_content_container = Main(
2339
+ *content,
2340
+ cls=f"flex-1 min-w-0 px-6 py-8 space-y-8 {section_class}",
2341
+ id="main-content",
2342
+ hx_boost="true",
2343
+ hx_target="#main-content",
2344
+ hx_swap="outerHTML show:window:top settle:0.1s",
2345
+ )
2346
+ t_main = time.time()
2347
+ logger.debug(f"[LAYOUT] Main content container built in {(t_main - t_css)*1000:.2f}ms")
2348
+ # Mobile overlay panels for posts and TOC
2349
+ roles = _get_roles_from_auth(auth)
2350
+ roles_key = tuple(roles or [])
2351
+ mobile_posts_panel = Div(
2352
+ Div(
2353
+ Button(
2354
+ UkIcon("x", cls="w-5 h-5"),
2355
+ id="close-mobile-posts",
2356
+ cls="p-2 hover:bg-slate-200 dark:hover:bg-slate-700 rounded transition-colors ml-auto",
2357
+ type="button"
2358
+ ),
2359
+ cls="flex justify-end p-2 bg-white dark:bg-slate-950 border-b border-slate-200 dark:border-slate-800"
2360
+ ),
2361
+ Div(
2362
+ NotStr(_cached_posts_sidebar_html(_posts_sidebar_fingerprint(), roles_key)),
2363
+ cls="p-4 overflow-y-auto"
2364
+ ),
2365
+ id="mobile-posts-panel",
2366
+ cls="fixed inset-0 bg-white dark:bg-slate-950 z-[9999] xl:hidden transform -translate-x-full transition-transform duration-300"
2367
+ )
2368
+ mobile_toc_panel = None
2369
+ if show_toc:
2370
+ mobile_toc_panel = Div(
2371
+ Div(
2372
+ Button(
2373
+ UkIcon("x", cls="w-5 h-5"),
2374
+ id="close-mobile-toc",
2375
+ cls="p-2 hover:bg-slate-200 dark:hover:bg-slate-700 rounded transition-colors ml-auto",
2376
+ type="button"
2377
+ ),
2378
+ cls="flex justify-end p-2 bg-white dark:bg-slate-950 border-b border-slate-200 dark:border-slate-800"
2379
+ ),
2380
+ Div(
2381
+ collapsible_sidebar("list", "Contents", toc_items, is_open=sidebars_open, shortcut_key="X") if toc_items else Div(P("No table of contents available.", cls="text-slate-500 dark:text-slate-400 text-sm p-4")),
2382
+ cls="p-4 overflow-y-auto"
2383
+ ),
2384
+ id="mobile-toc-panel",
2385
+ cls="fixed inset-0 bg-white dark:bg-slate-950 z-[9999] xl:hidden transform translate-x-full transition-transform duration-300"
2386
+ )
2387
+ # Full layout with all sidebars
2388
+ content_with_sidebars = Div(
2389
+ cls=f"layout-container {layout_fluid_class} w-full {layout_max_class} mx-auto px-4 flex gap-6 flex-1".strip(),
2390
+ id="content-with-sidebars",
2391
+ **_style_attr(layout_max_style)
2392
+ )(
2393
+ # Left sidebar - lazy load with HTMX, show loader placeholder
2394
+ Aside(
2395
+ Div(
2396
+ UkIcon("loader", cls="w-5 h-5 animate-spin"),
2397
+ Span("Loading posts…", cls="ml-2 text-sm"),
2398
+ cls="flex items-center justify-center h-32 text-slate-400"
2399
+ ),
2400
+ cls="hidden xl:block w-72 shrink-0 sticky top-24 self-start max-h-[calc(100vh-10rem)] overflow-hidden z-[1000]",
2401
+ id="posts-sidebar",
2402
+ hx_get="/_sidebar/posts",
2403
+ hx_trigger="load",
2404
+ hx_swap="outerHTML"
2405
+ ),
2406
+ # Main content (swappable)
2407
+ main_content_container,
2408
+ # Right sidebar - TOC (swappable out-of-band)
2409
+ toc_sidebar if toc_sidebar else None
2410
+ )
2411
+ t_sidebars = time.time()
2412
+ logger.debug(f"[LAYOUT] Sidebars container built in {(t_sidebars - t_main)*1000:.2f}ms")
2413
+ # Layout with sidebar for blog posts
2414
+ body_content = Div(id="page-container", cls="flex flex-col min-h-screen")(
2415
+ Div(
2416
+ navbar(show_mobile_menus=True, htmx_nav=htmx_nav),
2417
+ cls=f"layout-container {layout_fluid_class} w-full {layout_max_class} mx-auto px-4 sticky top-0 z-50 mt-4".strip(),
2418
+ id="site-navbar",
2419
+ **_style_attr(layout_max_style)
2420
+ ),
2421
+ mobile_posts_panel,
2422
+ mobile_toc_panel if mobile_toc_panel else None,
2423
+ content_with_sidebars,
2424
+ _footer_node(
2425
+ f"layout-container {layout_fluid_class} w-full {layout_max_class} mx-auto px-6 mt-auto mb-6".strip(),
2426
+ _style_attr(layout_max_style)
2427
+ )
2428
+ )
2429
+ else:
2430
+ # Default layout without sidebar
2431
+ custom_css_links = get_custom_css_links(current_path, section_class) if current_path else []
2432
+ body_content = Div(id="page-container", cls="flex flex-col min-h-screen")(
2433
+ Div(
2434
+ navbar(htmx_nav=htmx_nav),
2435
+ cls=f"layout-container {layout_fluid_class} w-full {layout_max_class} mx-auto px-4 sticky top-0 z-50 mt-4".strip(),
2436
+ id="site-navbar",
2437
+ **_style_attr(layout_max_style)
2438
+ ),
2439
+ Main(
2440
+ *content,
2441
+ cls=f"layout-container {layout_fluid_class} w-full {layout_max_class} mx-auto px-6 py-8 space-y-8".strip(),
2442
+ id="main-content",
2443
+ hx_boost="true",
2444
+ hx_target="#main-content",
2445
+ hx_swap="outerHTML show:window:top settle:0.1s",
2446
+ **_style_attr(layout_max_style)
2447
+ ),
2448
+ _footer_node(
2449
+ f"layout-container {layout_fluid_class} w-full {layout_max_class} mx-auto px-6 mt-auto mb-6".strip(),
2450
+ _style_attr(layout_max_style)
2451
+ )
2452
+ )
2453
+ t_body = time.time()
2454
+ logger.debug(f"[LAYOUT] Body content (no sidebar) built in {(t_body - layout_start_time)*1000:.2f}ms")
2455
+ # For full page loads, return complete page
2456
+ result = [Title(title)]
2457
+ # Wrap custom CSS in a container so HTMX can swap it out later
2458
+ if custom_css_links:
2459
+ css_container = Div(*custom_css_links, id="scoped-css-container")
2460
+ result.append(css_container)
2461
+ else:
2462
+ # Even if no CSS now, add empty container for future swaps
2463
+ css_container = Div(id="scoped-css-container")
2464
+ result.append(css_container)
2465
+ result.append(body_content)
2466
+ t_end = time.time()
2467
+ logger.debug(f"[LAYOUT] FULL PAGE assembled in {(t_end - layout_start_time)*1000:.2f}ms")
2468
+ return tuple(result)
2469
+
2470
+ def build_post_tree(folder, roles=None):
2471
+ import time
2472
+ start_time = time.time()
2473
+ root = get_root_folder()
2474
+ items = []
2475
+ try:
2476
+ index_file = find_index_file() if folder == root else None
2477
+ entries = []
2478
+ folder_note = find_folder_note_file(folder)
2479
+ for item in folder.iterdir():
2480
+ if item.name == ".vyasa":
2481
+ continue
2482
+ if item.is_dir():
2483
+ if item.name.startswith('.'):
2484
+ continue
2485
+ entries.append(item)
2486
+ elif item.suffix in ('.md', '.pdf'):
2487
+ if folder_note and item.resolve() == folder_note.resolve():
2488
+ continue
2489
+ # Skip the file being used for home page (index.md takes precedence over readme.md)
2490
+ if index_file and item.resolve() == index_file.resolve():
2491
+ continue
2492
+ entries.append(item)
2493
+ config = get_vyasa_config(folder)
2494
+ entries = order_vyasa_entries(entries, config)
2495
+ abbreviations = _effective_abbreviations(root, folder)
2496
+ logger.debug(
2497
+ "[DEBUG] build_post_tree entries for %s: %s",
2498
+ folder,
2499
+ [item.name for item in entries],
2500
+ )
2501
+ logger.debug(f"[DEBUG] Scanning directory: {folder.relative_to(root) if folder != root else '.'} - found {len(entries)} entries")
2502
+ except (OSError, PermissionError):
2503
+ return items
2504
+
2505
+ for item in entries:
2506
+ if item.is_dir():
2507
+ if item.name.startswith('.'): continue
2508
+ sub_items = build_post_tree(item, roles=roles)
2509
+ folder_title = slug_to_title(item.name, abbreviations=abbreviations)
2510
+ note_file = find_folder_note_file(item)
2511
+ note_link = None
2512
+ note_slug = None
2513
+ note_allowed = False
2514
+ if note_file:
2515
+ note_slug = str(note_file.relative_to(root).with_suffix(''))
2516
+ note_path = f"/posts/{note_slug}"
2517
+ note_allowed = _is_allowed(note_path, roles or [])
2518
+ if note_allowed:
2519
+ note_link = A(
2520
+ href=f'/posts/{note_slug}',
2521
+ hx_get=f'/posts/{note_slug}', hx_target="#main-content", hx_push_url="true", hx_swap="outerHTML show:window:top settle:0.1s",
2522
+ cls="folder-note-link truncate min-w-0 hover:underline",
2523
+ title=f"Open {folder_title}",
2524
+ onclick="event.stopPropagation();",
2525
+ )(folder_title)
2526
+ if not sub_items and not note_allowed:
2527
+ continue
2528
+ title_node = note_link if note_link else Span(folder_title, cls="truncate min-w-0", title=folder_title)
2529
+ if sub_items:
2530
+ items.append(Li(Details(
2531
+ Summary(
2532
+ Span(Span(cls="folder-chevron"), cls="w-4 mr-2 flex items-center justify-center shrink-0"),
2533
+ Span(UkIcon("folder", cls="text-blue-500 w-4 h-4"), cls="w-4 mr-2 flex items-center justify-center shrink-0"),
2534
+ title_node,
2535
+ 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"),
2536
+ Ul(*sub_items, cls="ml-4 pl-2 space-y-1 border-l border-slate-100 dark:border-slate-800"),
2537
+ data_folder="true"), cls="my-1"))
2538
+ elif note_allowed and note_slug:
2539
+ title_text = Span(folder_title, cls="truncate min-w-0", title=folder_title)
2540
+ items.append(Li(A(
2541
+ Span(cls="w-4 mr-2 shrink-0"),
2542
+ Span(UkIcon("folder", cls="text-blue-500 w-4 h-4"), cls="w-4 mr-2 flex items-center justify-center shrink-0"),
2543
+ title_text,
2544
+ href=f'/posts/{note_slug}',
2545
+ hx_get=f'/posts/{note_slug}', hx_target="#main-content", hx_push_url="true", hx_swap="outerHTML show:window:top settle:0.1s",
2546
+ 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 hover:underline transition-colors min-w-0",
2547
+ data_path=note_slug)))
2548
+ elif item.suffix == '.md':
2549
+ slug = str(item.relative_to(root).with_suffix(''))
2550
+ if not _is_allowed(f"/posts/{slug}", roles or []):
2551
+ continue
2552
+ title_start = time.time()
2553
+ title = get_post_title(item, abbreviations=abbreviations)
2554
+ title_time = (time.time() - title_start) * 1000
2555
+ if title_time > 1: # Only log if it takes more than 1ms
2556
+ logger.debug(f"[DEBUG] Getting title for {item.name} took {title_time:.2f}ms")
2557
+ items.append(Li(A(
2558
+ Span(cls="w-4 mr-2 shrink-0"),
2559
+ Span(UkIcon("file-text", cls="text-slate-400 w-4 h-4"), cls="w-4 mr-2 flex items-center justify-center shrink-0"),
2560
+ Span(title, cls="truncate min-w-0", title=title),
2561
+ href=f'/posts/{slug}',
2562
+ hx_get=f'/posts/{slug}', hx_target="#main-content", hx_push_url="true", hx_swap="outerHTML show:window:top settle:0.1s",
2563
+ 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",
2564
+ data_path=slug)))
2565
+ elif item.suffix == '.pdf':
2566
+ slug = str(item.relative_to(root).with_suffix(''))
2567
+ if not _is_allowed(f"/posts/{slug}", roles or []):
2568
+ continue
2569
+ title = slug_to_title(item.stem, abbreviations=abbreviations)
2570
+ items.append(Li(A(
2571
+ Span(cls="w-4 mr-2 shrink-0"),
2572
+ Span(UkIcon("file-text", cls="text-slate-400 w-4 h-4"), cls="w-4 mr-2 flex items-center justify-center shrink-0"),
2573
+ Span(f"{title} (PDF)", cls="truncate min-w-0", title=title),
2574
+ href=f'/posts/{slug}',
2575
+ hx_get=f'/posts/{slug}', hx_target="#main-content", hx_push_url="true", hx_swap="outerHTML show:window:top settle:0.1s",
2576
+ 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",
2577
+ data_path=slug)))
2578
+
2579
+ elapsed = (time.time() - start_time) * 1000
2580
+ logger.debug(f"[DEBUG] build_post_tree for {folder.relative_to(root) if folder != root else '.'} completed in {elapsed:.2f}ms")
2581
+ return items
2582
+
2583
+ def _posts_tree_fingerprint():
2584
+ root = get_root_folder()
2585
+ try:
2586
+ md_mtime = max((p.stat().st_mtime for p in root.rglob("*.md")), default=0)
2587
+ pdf_mtime = max((p.stat().st_mtime for p in root.rglob("*.pdf")), default=0)
2588
+ vyasa_mtime = max((p.stat().st_mtime for p in root.rglob(".vyasa")), default=0)
2589
+ return max(md_mtime, pdf_mtime, vyasa_mtime)
2590
+ except Exception:
2591
+ return 0
2592
+
2593
+ @lru_cache(maxsize=4)
2594
+ def _cached_build_post_tree(fingerprint, roles_key):
2595
+ roles = list(roles_key) if roles_key else []
2596
+ return build_post_tree(get_root_folder(), roles=roles)
2597
+
2598
+ def get_posts(roles=None):
2599
+ fingerprint = _posts_tree_fingerprint()
2600
+ roles_key = tuple(roles or [])
2601
+ return _cached_build_post_tree(fingerprint, roles_key)
2602
+
2603
+ def not_found(htmx=None, auth=None):
2604
+ """Custom 404 error page"""
2605
+ blog_title = get_blog_title()
2606
+
2607
+ content = Div(
2608
+ # Large 404 heading
2609
+ Div(
2610
+ H1("404", cls="text-9xl font-bold text-slate-300 dark:text-slate-700 mb-4"),
2611
+ cls="text-center"
2612
+ ),
2613
+
2614
+ # Main error message
2615
+ H2("Page Not Found", cls="text-3xl font-bold text-slate-800 dark:text-slate-200 mb-4 text-center"),
2616
+
2617
+ # Description
2618
+ P(
2619
+ "Oops! The page you're looking for doesn't exist. It might have been moved or deleted.",
2620
+ cls="text-lg text-slate-600 dark:text-slate-400 mb-8 text-center max-w-2xl mx-auto"
2621
+ ),
2622
+
2623
+ # Action buttons
2624
+ Div(
2625
+ A(
2626
+ UkIcon("home", cls="w-5 h-5 mr-2"),
2627
+ "Go to Home",
2628
+ href="/",
2629
+ hx_get="/",
2630
+ hx_target="#main-content",
2631
+ hx_push_url="true",
2632
+ hx_swap="outerHTML show:window:top settle:0.1s",
2633
+ 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"
2634
+ ),
2635
+ A(
2636
+ UkIcon("arrow-left", cls="w-5 h-5 mr-2"),
2637
+ "Go Back",
2638
+ href="javascript:history.back()",
2639
+ 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"
2640
+ ),
2641
+ cls="flex justify-center items-center gap-4 flex-wrap"
2642
+ ),
2643
+
2644
+ # Decorative element
2645
+ Div(
2646
+ P(
2647
+ "💡 ",
2648
+ Strong("Tip:"),
2649
+ " Check the sidebar for available posts, or use the search to find what you're looking for.",
2650
+ cls="text-sm text-slate-500 dark:text-slate-500 italic"
2651
+ ),
2652
+ cls="mt-12 text-center"
2653
+ ),
2654
+
2655
+ cls="flex flex-col items-center justify-center py-16 px-6 min-h-[60vh]"
2656
+ )
2657
+
2658
+ # Return with layout, including sidebar for easy navigation
2659
+ # Store the result tuple to potentially wrap with status code
2660
+ result = layout(content, htmx=htmx, title=f"404 - Page Not Found | {blog_title}", show_sidebar=True, auth=auth)
2661
+ return result
2662
+
2663
+ @rt('/posts/{path:path}')
2664
+ def post_detail(path: str, htmx, request: Request):
2665
+ import time
2666
+ request_start = time.time()
2667
+ logger.info(f"\n[DEBUG] ########## REQUEST START: /posts/{path} ##########")
2668
+
2669
+ root = get_root_folder()
2670
+ abbreviations = _effective_abbreviations(root)
2671
+ file_path = root / f'{path}.md'
2672
+ pdf_path = root / f'{path}.pdf'
2673
+
2674
+ # Check if file exists
2675
+ if not file_path.exists():
2676
+ if pdf_path.exists():
2677
+ post_title = f"{slug_to_title(Path(path).name, abbreviations=abbreviations)} (PDF)"
2678
+ pdf_src = f"/posts/{path}.pdf"
2679
+ pdf_content = Div(
2680
+ Div(
2681
+ H1(post_title, cls="text-4xl font-bold"),
2682
+ Button(
2683
+ "Focus PDF",
2684
+ cls="pdf-focus-toggle inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium rounded-md border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors",
2685
+ type="button",
2686
+ data_pdf_focus_toggle="true",
2687
+ data_pdf_focus_label="Focus PDF",
2688
+ data_pdf_exit_label="Exit focus",
2689
+ aria_pressed="false"
2690
+ ),
2691
+ cls="flex items-center justify-between gap-4 flex-wrap mb-6"
2692
+ ),
2693
+ NotStr(
2694
+ f'<object data="{pdf_src}" type="application/pdf" '
2695
+ 'class="pdf-viewer w-full h-[calc(100vh-14rem)] rounded-lg border border-slate-200 '
2696
+ 'dark:border-slate-700 bg-white dark:bg-slate-900">'
2697
+ '<p class="p-4 text-sm text-slate-600 dark:text-slate-300">'
2698
+ 'PDF preview not available. '
2699
+ f'<a href="{pdf_src}" class="text-blue-600 hover:underline">Download PDF</a>.'
2700
+ '</p></object>'
2701
+ )
2702
+ )
2703
+ return layout(pdf_content, htmx=htmx, title=f"{post_title} - {get_blog_title()}",
2704
+ show_sidebar=True, toc_content=None, current_path=path, show_toc=False, auth=request.scope.get("auth"))
2705
+ return not_found(htmx, auth=request.scope.get("auth"))
2706
+
2707
+ metadata, raw_content = parse_frontmatter(file_path)
2708
+
2709
+ # Get title from frontmatter or filename
2710
+ post_title = metadata.get('title', slug_to_title(path.split('/')[-1], abbreviations=abbreviations))
2711
+
2712
+ # Render the markdown content with current path for relative link resolution
2713
+ md_start = time.time()
2714
+ content = from_md(raw_content, current_path=path)
2715
+ md_time = (time.time() - md_start) * 1000
2716
+ logger.debug(f"[DEBUG] Markdown rendering took {md_time:.2f}ms")
2717
+
2718
+ copy_button = Button(
2719
+ UkIcon("clipboard", cls="w-4 h-4"),
2720
+ type="button",
2721
+ title="Copy raw markdown",
2722
+ onclick="(function(){const el=document.getElementById('raw-md-clipboard');const toast=document.getElementById('raw-md-toast');if(!el){return;}el.focus();el.select();const text=el.value;const done=()=>{if(!toast){return;}toast.classList.remove('opacity-0');toast.classList.add('opacity-100');setTimeout(()=>{toast.classList.remove('opacity-100');toast.classList.add('opacity-0');},1400);};if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(text).then(done).catch(()=>{document.execCommand('copy');done();});}else{document.execCommand('copy');done();}})()",
2723
+ cls="inline-flex items-center justify-center p-2 rounded-md border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white hover:border-slate-300 dark:hover:border-slate-500 transition-colors"
2724
+ )
2725
+ post_content = Div(
2726
+ Div(
2727
+ H1(post_title, cls="text-4xl font-bold"),
2728
+ copy_button,
2729
+ cls="flex items-center gap-2 flex-wrap mb-8"
2730
+ ),
2731
+ Div(
2732
+ "Copied Raw Markdown!",
2733
+ id="raw-md-toast",
2734
+ cls="fixed top-6 right-6 bg-slate-900 text-white text-sm px-4 py-2 rounded shadow-lg opacity-0 transition-opacity duration-300"
2735
+ ),
2736
+ Textarea(
2737
+ raw_content,
2738
+ id="raw-md-clipboard",
2739
+ cls="absolute left-[-9999px] top-0 opacity-0 pointer-events-none"
2740
+ ),
2741
+ content
2742
+ )
2743
+
2744
+ # Always return complete layout with sidebar and TOC
2745
+ layout_start = time.time()
2746
+ result = layout(post_content, htmx=htmx, title=f"{post_title} - {get_blog_title()}",
2747
+ show_sidebar=True, toc_content=raw_content, current_path=path, auth=request.scope.get("auth"))
2748
+ layout_time = (time.time() - layout_start) * 1000
2749
+ logger.debug(f"[DEBUG] Layout generation took {layout_time:.2f}ms")
2750
+
2751
+ total_time = (time.time() - request_start) * 1000
2752
+ logger.debug(f"[DEBUG] ########## REQUEST COMPLETE: {total_time:.2f}ms TOTAL ##########\n")
2753
+
2754
+ return result
2755
+
2756
+ def find_index_file():
2757
+ """Find index.md or readme.md (case insensitive) in root folder"""
2758
+ root = get_root_folder()
2759
+
2760
+ # Try to find index.md first (case insensitive)
2761
+ for file in root.iterdir():
2762
+ if file.is_file() and file.suffix == '.md' and file.stem.lower() == 'index':
2763
+ return file
2764
+
2765
+ # Try to find readme.md (case insensitive)
2766
+ for file in root.iterdir():
2767
+ if file.is_file() and file.suffix == '.md' and file.stem.lower() == 'readme':
2768
+ return file
2769
+
2770
+ return None
2771
+
2772
+ @rt
2773
+ def index(htmx, request: Request):
2774
+ import time
2775
+ request_start = time.time()
2776
+ logger.info(f"\n[DEBUG] ########## REQUEST START: / (index) ##########")
2777
+
2778
+ blog_title = get_blog_title()
2779
+
2780
+ # Try to find index.md or readme.md
2781
+ index_file = find_index_file()
2782
+
2783
+ if index_file:
2784
+ # Render the index/readme file
2785
+ metadata, raw_content = parse_frontmatter(index_file)
2786
+ page_title = metadata.get('title', blog_title)
2787
+ # Use index file's relative path from root for link resolution
2788
+ index_path = str(index_file.relative_to(get_root_folder()).with_suffix(''))
2789
+ content = from_md(raw_content, current_path=index_path)
2790
+ page_content = Div(H1(page_title, cls="text-4xl font-bold mb-8"), content)
2791
+
2792
+ layout_start = time.time()
2793
+ result = layout(page_content, htmx=htmx, title=f"{page_title} - {blog_title}",
2794
+ show_sidebar=True, toc_content=raw_content, current_path=index_path, auth=request.scope.get("auth"))
2795
+ layout_time = (time.time() - layout_start) * 1000
2796
+ logger.debug(f"[DEBUG] Layout generation took {layout_time:.2f}ms")
2797
+
2798
+ total_time = (time.time() - request_start) * 1000
2799
+ logger.debug(f"[DEBUG] ########## REQUEST COMPLETE: {total_time:.2f}ms TOTAL ##########\n")
2800
+
2801
+ return result
2802
+ else:
2803
+ # Default welcome message
2804
+ layout_start = time.time()
2805
+ result = layout(Div(
2806
+ H1(f"Welcome to {blog_title}!", cls="text-4xl font-bold tracking-tight mb-8"),
2807
+ P("Your personal blogging platform.", cls="text-lg text-slate-600 dark:text-slate-400 mb-4"),
2808
+ P("Browse your posts using the sidebar, or create an ",
2809
+ Strong("index.md"), " or ", Strong("README.md"),
2810
+ " file in your blog directory to customize this page.",
2811
+ cls="text-base text-slate-600 dark:text-slate-400"),
2812
+ cls="w-full"), htmx=htmx, title=f"Home - {blog_title}", show_sidebar=True, auth=request.scope.get("auth"))
2813
+ layout_time = (time.time() - layout_start) * 1000
2814
+ logger.debug(f"[DEBUG] Layout generation took {layout_time:.2f}ms")
2815
+
2816
+ total_time = (time.time() - request_start) * 1000
2817
+ logger.debug(f"[DEBUG] ########## REQUEST COMPLETE: {total_time:.2f}ms TOTAL ##########\n")
2818
+
2819
+ return result
2820
+
2821
+ # Catch-all route for 404 pages (must be last)
2822
+ @rt('/{path:path}')
2823
+ def catch_all(path: str, htmx, request: Request):
2824
+ """Catch-all route for undefined URLs"""
2825
+ return not_found(htmx, auth=request.scope.get("auth"))