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/__init__.py +5 -0
- vyasa/agent.py +116 -0
- vyasa/build.py +660 -0
- vyasa/config.py +224 -0
- vyasa/core.py +2825 -0
- vyasa/helpers.py +349 -0
- vyasa/layout_helpers.py +40 -0
- vyasa/main.py +108 -0
- vyasa/static/scripts.js +1202 -0
- vyasa/static/sidenote.css +21 -0
- vyasa-0.3.6.dist-info/METADATA +227 -0
- vyasa-0.3.6.dist-info/RECORD +16 -0
- vyasa-0.3.6.dist-info/WHEEL +5 -0
- vyasa-0.3.6.dist-info/entry_points.txt +2 -0
- vyasa-0.3.6.dist-info/licenses/LICENSE +201 -0
- vyasa-0.3.6.dist-info/top_level.txt +1 -0
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('&', '&').replace('<', '<').replace('>', '>').replace('"', '"').replace("'", ''')
|
|
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"))
|