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