bloggy 0.1.40__py3-none-any.whl → 0.2.3__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 +1 -1
- bloggy/build.py +15 -4
- bloggy/config.py +8 -0
- bloggy/core.py +723 -95
- bloggy/main.py +14 -2
- bloggy/static/scripts.js +667 -49
- bloggy-0.2.3.dist-info/METADATA +167 -0
- bloggy-0.2.3.dist-info/RECORD +13 -0
- bloggy-0.1.40.dist-info/METADATA +0 -926
- bloggy-0.1.40.dist-info/RECORD +0 -13
- {bloggy-0.1.40.dist-info → bloggy-0.2.3.dist-info}/WHEEL +0 -0
- {bloggy-0.1.40.dist-info → bloggy-0.2.3.dist-info}/entry_points.txt +0 -0
- {bloggy-0.1.40.dist-info → bloggy-0.2.3.dist-info}/licenses/LICENSE +0 -0
- {bloggy-0.1.40.dist-info → bloggy-0.2.3.dist-info}/top_level.txt +0 -0
bloggy/core.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import re, frontmatter, mistletoe as mst, pathlib, os, tomllib
|
|
2
|
+
from itertools import chain
|
|
3
|
+
from urllib.parse import quote_plus
|
|
2
4
|
from functools import partial
|
|
3
5
|
from functools import lru_cache
|
|
4
6
|
from pathlib import Path
|
|
@@ -22,9 +24,35 @@ slug_to_title = lambda s: ' '.join(
|
|
|
22
24
|
for word in s.replace('-', ' ').replace('_', ' ').split()
|
|
23
25
|
)
|
|
24
26
|
|
|
27
|
+
def _strip_inline_markdown(text):
|
|
28
|
+
cleaned = text or ""
|
|
29
|
+
cleaned = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', cleaned)
|
|
30
|
+
cleaned = re.sub(r'\[([^\]]+)\]\[[^\]]*\]', r'\1', cleaned)
|
|
31
|
+
cleaned = re.sub(r'`([^`]+)`', r'\1', cleaned)
|
|
32
|
+
cleaned = re.sub(r'\*\*([^*]+)\*\*', r'\1', cleaned)
|
|
33
|
+
cleaned = re.sub(r'__([^_]+)__', r'\1', cleaned)
|
|
34
|
+
cleaned = re.sub(r'\*([^*]+)\*', r'\1', cleaned)
|
|
35
|
+
cleaned = re.sub(r'_([^_]+)_', r'\1', cleaned)
|
|
36
|
+
cleaned = re.sub(r'~~([^~]+)~~', r'\1', cleaned)
|
|
37
|
+
return cleaned
|
|
38
|
+
|
|
39
|
+
def _plain_text_from_html(text):
|
|
40
|
+
import html
|
|
41
|
+
cleaned = re.sub(r'<[^>]+>', '', text or "")
|
|
42
|
+
return html.unescape(cleaned)
|
|
43
|
+
|
|
44
|
+
def _unique_anchor(base, counts):
|
|
45
|
+
if not base:
|
|
46
|
+
base = "section"
|
|
47
|
+
current = counts.get(base, 0) + 1
|
|
48
|
+
counts[base] = current
|
|
49
|
+
return base if current == 1 else f"{base}-{current}"
|
|
50
|
+
|
|
25
51
|
def text_to_anchor(text):
|
|
26
52
|
"""Convert text to anchor slug"""
|
|
27
|
-
|
|
53
|
+
cleaned = _strip_inline_markdown(text)
|
|
54
|
+
cleaned = _plain_text_from_html(cleaned)
|
|
55
|
+
return re.sub(r'[^\w\s-]', '', cleaned.lower()).replace(' ', '-')
|
|
28
56
|
|
|
29
57
|
# Cache for parsed frontmatter to avoid re-reading files
|
|
30
58
|
_frontmatter_cache = {}
|
|
@@ -77,6 +105,7 @@ def _normalize_bloggy_config(parsed):
|
|
|
77
105
|
"sort": "name_asc",
|
|
78
106
|
"folders_first": True,
|
|
79
107
|
"folders_always_first": False,
|
|
108
|
+
"layout_max_width": None,
|
|
80
109
|
}
|
|
81
110
|
if not isinstance(parsed, dict):
|
|
82
111
|
return config
|
|
@@ -108,6 +137,14 @@ def _normalize_bloggy_config(parsed):
|
|
|
108
137
|
if lowered in ("true", "false"):
|
|
109
138
|
config["folders_always_first"] = lowered == "true"
|
|
110
139
|
|
|
140
|
+
for key in ("layout_max_width",):
|
|
141
|
+
value = parsed.get(key)
|
|
142
|
+
if isinstance(value, (int, float)):
|
|
143
|
+
value = str(value)
|
|
144
|
+
if isinstance(value, str):
|
|
145
|
+
value = value.strip()
|
|
146
|
+
config[key] = value if value else None
|
|
147
|
+
|
|
111
148
|
return config
|
|
112
149
|
|
|
113
150
|
def get_bloggy_config(folder):
|
|
@@ -129,6 +166,42 @@ def get_bloggy_config(folder):
|
|
|
129
166
|
)
|
|
130
167
|
return config
|
|
131
168
|
|
|
169
|
+
def _coerce_config_str(value):
|
|
170
|
+
if value is None:
|
|
171
|
+
return None
|
|
172
|
+
if isinstance(value, (int, float)):
|
|
173
|
+
return str(value)
|
|
174
|
+
if isinstance(value, str):
|
|
175
|
+
cleaned = value.strip()
|
|
176
|
+
return cleaned if cleaned else None
|
|
177
|
+
return str(value)
|
|
178
|
+
|
|
179
|
+
def _width_class_and_style(value, kind):
|
|
180
|
+
if not value:
|
|
181
|
+
return "", ""
|
|
182
|
+
val = value.strip()
|
|
183
|
+
lowered = val.lower()
|
|
184
|
+
if lowered in ("default", "auto", "none"):
|
|
185
|
+
return "", ""
|
|
186
|
+
if kind == "max":
|
|
187
|
+
if val.startswith("max-w-"):
|
|
188
|
+
return val, ""
|
|
189
|
+
if re.match(r'^\d+(\.\d+)?$', val):
|
|
190
|
+
val = f"{val}px"
|
|
191
|
+
return "", f"--layout-max-width: {val};"
|
|
192
|
+
return "", ""
|
|
193
|
+
|
|
194
|
+
def _style_attr(style_value):
|
|
195
|
+
if not style_value:
|
|
196
|
+
return {}
|
|
197
|
+
return {"style": style_value}
|
|
198
|
+
|
|
199
|
+
def _resolve_layout_config(current_path):
|
|
200
|
+
config = get_config()
|
|
201
|
+
return {
|
|
202
|
+
"layout_max_width": _coerce_config_str(config.get("layout_max_width", "BLOGGY_LAYOUT_MAX_WIDTH", "75vw")),
|
|
203
|
+
}
|
|
204
|
+
|
|
132
205
|
def order_bloggy_entries(entries, config):
|
|
133
206
|
if not entries:
|
|
134
207
|
return []
|
|
@@ -312,23 +385,68 @@ def preprocess_tabs(content):
|
|
|
312
385
|
|
|
313
386
|
def replace_tabs_block(match):
|
|
314
387
|
tabs_content = match.group(1)
|
|
315
|
-
# Pattern to match ::tab{title="..."}
|
|
316
|
-
tab_pattern = re.compile(r'^::tab\{
|
|
317
|
-
|
|
388
|
+
# Pattern to match ::tab{title="..." ...}
|
|
389
|
+
tab_pattern = re.compile(r'^::tab\{([^\}]+)\}\s*\n(.*?)(?=^::tab\{|\Z)', re.MULTILINE | re.DOTALL)
|
|
390
|
+
|
|
391
|
+
def parse_attrs(raw_attrs):
|
|
392
|
+
attrs = {}
|
|
393
|
+
for key, value in re.findall(r'([a-zA-Z0-9_-]+)\s*=\s*"([^"]*)"', raw_attrs):
|
|
394
|
+
attrs[key] = value
|
|
395
|
+
return attrs
|
|
396
|
+
|
|
318
397
|
tabs = []
|
|
319
398
|
for tab_match in tab_pattern.finditer(tabs_content):
|
|
320
|
-
|
|
399
|
+
raw_attrs = tab_match.group(1)
|
|
321
400
|
tab_content = tab_match.group(2).strip()
|
|
322
|
-
|
|
401
|
+
attrs = parse_attrs(raw_attrs)
|
|
402
|
+
title = attrs.get('title')
|
|
403
|
+
if not title:
|
|
404
|
+
continue
|
|
405
|
+
tabs.append({'title': title, 'content': tab_content, 'attrs': attrs})
|
|
323
406
|
|
|
324
407
|
if not tabs:
|
|
325
408
|
return match.group(0) # Return original if no tabs found
|
|
409
|
+
|
|
410
|
+
title_map = {tab['title']: tab for tab in tabs}
|
|
411
|
+
index_map = {str(i): tab for i, tab in enumerate(tabs)}
|
|
412
|
+
|
|
413
|
+
def fence_wrap(content):
|
|
414
|
+
backtick_runs = re.findall(r'`+', content)
|
|
415
|
+
max_run = max((len(run) for run in backtick_runs), default=0)
|
|
416
|
+
fence_len = max(4, max_run + 1)
|
|
417
|
+
fence = '`' * fence_len
|
|
418
|
+
return f'{fence}\n{content}\n{fence}'
|
|
419
|
+
|
|
420
|
+
def resolve_tab_content(tab, stack=None):
|
|
421
|
+
stack = stack or set()
|
|
422
|
+
copy_from = tab.get('attrs', {}).get('copy-from')
|
|
423
|
+
if not copy_from:
|
|
424
|
+
return tab['content']
|
|
425
|
+
if copy_from in stack:
|
|
426
|
+
return tab['content']
|
|
427
|
+
source_tab = None
|
|
428
|
+
if copy_from.startswith('index:'):
|
|
429
|
+
index_key = copy_from.split(':', 1)[1].strip()
|
|
430
|
+
source_tab = index_map.get(index_key)
|
|
431
|
+
elif copy_from.isdigit():
|
|
432
|
+
source_tab = index_map.get(copy_from)
|
|
433
|
+
else:
|
|
434
|
+
source_tab = title_map.get(copy_from)
|
|
435
|
+
if not source_tab:
|
|
436
|
+
return tab['content']
|
|
437
|
+
stack.add(copy_from)
|
|
438
|
+
resolved = resolve_tab_content(source_tab, stack)
|
|
439
|
+
stack.remove(copy_from)
|
|
440
|
+
return fence_wrap(resolved)
|
|
441
|
+
|
|
442
|
+
for tab in tabs:
|
|
443
|
+
tab['content'] = resolve_tab_content(tab)
|
|
326
444
|
|
|
327
445
|
# Generate unique ID for this tab group
|
|
328
446
|
tab_id = hashlib.md5(match.group(0).encode()).hexdigest()[:8]
|
|
329
447
|
|
|
330
448
|
# Store tab data for later processing
|
|
331
|
-
tab_data_store[tab_id] = tabs
|
|
449
|
+
tab_data_store[tab_id] = [(tab['title'], tab['content']) for tab in tabs]
|
|
332
450
|
|
|
333
451
|
# Return a placeholder that won't be processed by markdown
|
|
334
452
|
placeholder = f'<div class="tab-placeholder" data-tab-id="{tab_id}"></div>'
|
|
@@ -342,6 +460,8 @@ class ContentRenderer(FrankenRenderer):
|
|
|
342
460
|
super().__init__(*extras, img_dir=img_dir, **kwargs)
|
|
343
461
|
self.footnotes, self.fn_counter = footnotes or {}, 0
|
|
344
462
|
self.current_path = current_path # Current post path for resolving relative links and images
|
|
463
|
+
self.heading_counts = {}
|
|
464
|
+
self.mermaid_counter = 0
|
|
345
465
|
|
|
346
466
|
def render_list_item(self, token):
|
|
347
467
|
"""Render list items with task list checkbox support"""
|
|
@@ -409,10 +529,12 @@ class ContentRenderer(FrankenRenderer):
|
|
|
409
529
|
|
|
410
530
|
def render_heading(self, token):
|
|
411
531
|
"""Render headings with anchor IDs for TOC linking"""
|
|
532
|
+
import html
|
|
412
533
|
level = token.level
|
|
413
534
|
inner = self.render_inner(token)
|
|
414
|
-
|
|
415
|
-
|
|
535
|
+
plain = _plain_text_from_html(inner)
|
|
536
|
+
anchor = _unique_anchor(text_to_anchor(plain), self.heading_counts)
|
|
537
|
+
return f'<h{level} id="{anchor}">{html.escape(plain)}</h{level}>'
|
|
416
538
|
|
|
417
539
|
def render_superscript(self, token):
|
|
418
540
|
"""Render superscript text"""
|
|
@@ -519,7 +641,8 @@ class ContentRenderer(FrankenRenderer):
|
|
|
519
641
|
# Use code without frontmatter for rendering
|
|
520
642
|
code = code_without_frontmatter
|
|
521
643
|
|
|
522
|
-
|
|
644
|
+
self.mermaid_counter += 1
|
|
645
|
+
diagram_id = f"mermaid-{abs(hash(code)) & 0xFFFFFF}-{self.mermaid_counter}"
|
|
523
646
|
|
|
524
647
|
# Determine if we need to break out of normal content flow
|
|
525
648
|
# This is required for viewport-based widths to properly center
|
|
@@ -550,17 +673,46 @@ class ContentRenderer(FrankenRenderer):
|
|
|
550
673
|
# For other languages: escape HTML/XML for display, but NOT for markdown
|
|
551
674
|
# (markdown code blocks should show raw source)
|
|
552
675
|
import html
|
|
676
|
+
raw_code = code
|
|
677
|
+
code = html.unescape(code)
|
|
553
678
|
if lang and lang.lower() != 'markdown':
|
|
554
679
|
code = html.escape(code)
|
|
555
680
|
lang_class = f' class="language-{lang}"' if lang else ''
|
|
556
|
-
|
|
681
|
+
icon_html = to_xml(UkIcon("copy", cls="w-4 h-4"))
|
|
682
|
+
code_id = f"codeblock-{abs(hash(raw_code)) & 0xFFFFFF}"
|
|
683
|
+
toast_id = f"{code_id}-toast"
|
|
684
|
+
textarea_id = f"{code_id}-clipboard"
|
|
685
|
+
escaped_raw = html.escape(raw_code)
|
|
686
|
+
return (
|
|
687
|
+
'<div class="code-block relative my-4">'
|
|
688
|
+
f'<button type="button" class="code-copy-button absolute top-2 right-2 '
|
|
689
|
+
'inline-flex items-center justify-center rounded border border-slate-200 '
|
|
690
|
+
'dark:border-slate-700 bg-white/80 dark:bg-slate-900/70 '
|
|
691
|
+
'text-slate-600 dark:text-slate-300 hover:text-slate-900 '
|
|
692
|
+
'dark:hover:text-white hover:border-slate-300 dark:hover:border-slate-500 '
|
|
693
|
+
f'transition-colors" aria-label="Copy code" '
|
|
694
|
+
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();}}}})()"'
|
|
695
|
+
'>'
|
|
696
|
+
f'{icon_html}<span class="sr-only">Copy code</span></button>'
|
|
697
|
+
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>'
|
|
698
|
+
f'<textarea id="{textarea_id}" class="absolute left-[-9999px] top-0 opacity-0 pointer-events-none">{escaped_raw}</textarea>'
|
|
699
|
+
f'<pre><code{lang_class}>{code}</code></pre>'
|
|
700
|
+
'</div>'
|
|
701
|
+
)
|
|
557
702
|
|
|
558
703
|
def render_link(self, token):
|
|
559
704
|
href, inner, title = token.target, self.render_inner(token), f' title="{token.title}"' if token.title else ''
|
|
560
705
|
# ...existing code...
|
|
561
|
-
|
|
706
|
+
is_hash = href.startswith('#')
|
|
707
|
+
is_external = href.startswith(('http://', 'https://', 'mailto:', 'tel:', '//'))
|
|
562
708
|
is_absolute_internal = href.startswith('/') and not href.startswith('//')
|
|
563
709
|
is_relative = not is_external and not is_absolute_internal
|
|
710
|
+
if is_hash:
|
|
711
|
+
link_class = (
|
|
712
|
+
"text-amber-600 dark:text-amber-400 underline underline-offset-2 "
|
|
713
|
+
"hover:text-amber-800 dark:hover:text-amber-200 font-medium transition-colors"
|
|
714
|
+
)
|
|
715
|
+
return f'<a href="{href}" class="{link_class}"{title}>{inner}</a>'
|
|
564
716
|
if is_relative:
|
|
565
717
|
from pathlib import Path
|
|
566
718
|
original_href = href
|
|
@@ -585,7 +737,7 @@ class ContentRenderer(FrankenRenderer):
|
|
|
585
737
|
logger.debug(f"DEBUG: No current_path, treating as external")
|
|
586
738
|
is_internal = is_absolute_internal and '.' not in href.split('/')[-1]
|
|
587
739
|
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"'
|
|
740
|
+
ext = '' if (is_internal or is_absolute_internal or is_hash) else ' target="_blank" rel="noopener noreferrer"'
|
|
589
741
|
# Amber/gold link styling, stands out and is accessible
|
|
590
742
|
link_class = (
|
|
591
743
|
"text-amber-600 dark:text-amber-400 underline underline-offset-2 "
|
|
@@ -632,7 +784,7 @@ def postprocess_tabs(html, tab_data_store, img_dir, current_path, footnotes):
|
|
|
632
784
|
def from_md(content, img_dir=None, current_path=None):
|
|
633
785
|
# Resolve img_dir from current_path if not explicitly provided
|
|
634
786
|
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)
|
|
787
|
+
# Convert current_path to URL path for images (e.g., demo/books/flat-land/chapter-01 -> /posts/demo/books/flat-land)
|
|
636
788
|
from pathlib import Path
|
|
637
789
|
path_parts = Path(current_path).parts
|
|
638
790
|
if len(path_parts) > 1:
|
|
@@ -666,7 +818,8 @@ def from_md(content, img_dir=None, current_path=None):
|
|
|
666
818
|
mods = {'pre': 'my-4', 'p': 'text-base leading-relaxed mb-6', 'li': 'text-base leading-relaxed',
|
|
667
819
|
'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
820
|
'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'
|
|
821
|
+
'h3': 'text-xl font-semibold mb-3 mt-5', 'h4': 'text-lg font-semibold mb-2 mt-4',
|
|
822
|
+
'table': 'uk-table uk-table-striped uk-table-hover uk-table-divider uk-table-middle my-6'}
|
|
670
823
|
|
|
671
824
|
# Register custom tokens with renderer context manager
|
|
672
825
|
with ContentRenderer(YoutubeEmbed, InlineCodeAttr, Strikethrough, FootnoteRef, Superscript, Subscript, img_dir=img_dir, footnotes=footnotes, current_path=current_path) as renderer:
|
|
@@ -682,10 +835,15 @@ def from_md(content, img_dir=None, current_path=None):
|
|
|
682
835
|
# App configuration
|
|
683
836
|
def get_root_folder(): return get_config().get_root_folder()
|
|
684
837
|
def get_blog_title(): return get_config().get_blog_title()
|
|
838
|
+
def get_favicon_href():
|
|
839
|
+
root_icon = get_root_folder() / "static" / "icon.png"
|
|
840
|
+
if root_icon.exists():
|
|
841
|
+
return "/static/icon.png"
|
|
842
|
+
return "/static/favicon.png"
|
|
685
843
|
|
|
686
844
|
hdrs = (
|
|
687
845
|
*Theme.slate.headers(highlightjs=True),
|
|
688
|
-
Link(rel="icon", href=
|
|
846
|
+
Link(rel="icon", href=get_favicon_href()),
|
|
689
847
|
Script(src="https://unpkg.com/hyperscript.org@0.9.12"),
|
|
690
848
|
Script(src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs", type="module"),
|
|
691
849
|
Script("""
|
|
@@ -803,13 +961,57 @@ hdrs = (
|
|
|
803
961
|
.dark *::-webkit-scrollbar-thumb { background-color: rgb(71 85 105); }
|
|
804
962
|
.dark *::-webkit-scrollbar-thumb:hover { background-color: rgb(100 116 139); }
|
|
805
963
|
.dark * { scrollbar-color: rgb(71 85 105) transparent; }
|
|
964
|
+
|
|
965
|
+
/* Sidebar active link highlight */
|
|
966
|
+
.sidebar-highlight {
|
|
967
|
+
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.35);
|
|
968
|
+
transition: box-shadow 10s ease, background-color 10s ease;
|
|
969
|
+
}
|
|
970
|
+
.sidebar-highlight.fade-out {
|
|
971
|
+
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/* PDF focus mode */
|
|
975
|
+
body.pdf-focus {
|
|
976
|
+
overflow: hidden;
|
|
977
|
+
}
|
|
978
|
+
body.pdf-focus #site-navbar,
|
|
979
|
+
body.pdf-focus #site-footer,
|
|
980
|
+
body.pdf-focus #posts-sidebar,
|
|
981
|
+
body.pdf-focus #toc-sidebar,
|
|
982
|
+
body.pdf-focus #mobile-posts-panel,
|
|
983
|
+
body.pdf-focus #mobile-toc-panel {
|
|
984
|
+
display: none !important;
|
|
985
|
+
}
|
|
986
|
+
body.pdf-focus #content-with-sidebars {
|
|
987
|
+
max-width: none !important;
|
|
988
|
+
width: 100vw !important;
|
|
989
|
+
padding: 0 !important;
|
|
990
|
+
margin: 0 !important;
|
|
991
|
+
gap: 0 !important;
|
|
992
|
+
}
|
|
993
|
+
body.pdf-focus #main-content {
|
|
994
|
+
padding: 1rem !important;
|
|
995
|
+
}
|
|
996
|
+
body.pdf-focus .pdf-viewer {
|
|
997
|
+
height: calc(100vh - 6rem) !important;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
.layout-fluid {
|
|
1001
|
+
--layout-breakpoint: 1280px;
|
|
1002
|
+
--layout-blend: 240px;
|
|
1003
|
+
max-width: calc(
|
|
1004
|
+
100% - (100% - var(--layout-max-width))
|
|
1005
|
+
* clamp(0, (100vw - var(--layout-breakpoint)) / var(--layout-blend), 1)
|
|
1006
|
+
) !important;
|
|
1007
|
+
}
|
|
806
1008
|
|
|
807
1009
|
/* Tabs styles */
|
|
808
1010
|
.tabs-container {
|
|
809
1011
|
margin: 2rem 0;
|
|
810
1012
|
border: 1px solid rgb(226 232 240);
|
|
811
1013
|
border-radius: 0.5rem;
|
|
812
|
-
overflow:
|
|
1014
|
+
overflow: visible;
|
|
813
1015
|
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
|
814
1016
|
}
|
|
815
1017
|
.dark .tabs-container {
|
|
@@ -868,6 +1070,7 @@ hdrs = (
|
|
|
868
1070
|
.tabs-content {
|
|
869
1071
|
background: white;
|
|
870
1072
|
position: relative;
|
|
1073
|
+
overflow: visible;
|
|
871
1074
|
}
|
|
872
1075
|
.dark .tabs-content {
|
|
873
1076
|
background: rgb(2 6 23);
|
|
@@ -883,6 +1086,7 @@ hdrs = (
|
|
|
883
1086
|
opacity: 0;
|
|
884
1087
|
visibility: hidden;
|
|
885
1088
|
pointer-events: none;
|
|
1089
|
+
overflow: visible;
|
|
886
1090
|
}
|
|
887
1091
|
.tab-panel.active {
|
|
888
1092
|
position: relative;
|
|
@@ -909,6 +1113,43 @@ hdrs = (
|
|
|
909
1113
|
font-family: 'IBM Plex Mono', monospace;
|
|
910
1114
|
}
|
|
911
1115
|
"""),
|
|
1116
|
+
# Custom table stripe styling for punchier colors
|
|
1117
|
+
Style("""
|
|
1118
|
+
.uk-table-striped tbody tr:nth-of-type(odd) {
|
|
1119
|
+
background-color: rgba(71, 85, 105, 0.08);
|
|
1120
|
+
}
|
|
1121
|
+
.dark .uk-table-striped tbody tr:nth-of-type(odd) {
|
|
1122
|
+
background-color: rgba(148, 163, 184, 0.12);
|
|
1123
|
+
}
|
|
1124
|
+
.uk-table-striped tbody tr:hover {
|
|
1125
|
+
background-color: rgba(59, 130, 246, 0.1);
|
|
1126
|
+
}
|
|
1127
|
+
.dark .uk-table-striped tbody tr:hover {
|
|
1128
|
+
background-color: rgba(59, 130, 246, 0.15);
|
|
1129
|
+
}
|
|
1130
|
+
.uk-table thead {
|
|
1131
|
+
border-bottom: 2px solid rgba(71, 85, 105, 0.3);
|
|
1132
|
+
}
|
|
1133
|
+
.dark .uk-table thead {
|
|
1134
|
+
border-bottom: 2px solid rgba(148, 163, 184, 0.4);
|
|
1135
|
+
}
|
|
1136
|
+
.uk-table thead th {
|
|
1137
|
+
font-weight: 600;
|
|
1138
|
+
font-size: 1.25rem;
|
|
1139
|
+
color: rgb(51, 65, 85);
|
|
1140
|
+
}
|
|
1141
|
+
.dark .uk-table thead th {
|
|
1142
|
+
color: rgb(226, 232, 240);
|
|
1143
|
+
}
|
|
1144
|
+
.uk-table th:not(:last-child),
|
|
1145
|
+
.uk-table td:not(:last-child) {
|
|
1146
|
+
border-right: 1px solid rgba(71, 85, 105, 0.15);
|
|
1147
|
+
}
|
|
1148
|
+
.dark .uk-table th:not(:last-child),
|
|
1149
|
+
.dark .uk-table td:not(:last-child) {
|
|
1150
|
+
border-right: 1px solid rgba(148, 163, 184, 0.2);
|
|
1151
|
+
}
|
|
1152
|
+
"""),
|
|
912
1153
|
# Script("if(!localStorage.__FRANKEN__) localStorage.__FRANKEN__ = JSON.stringify({mode: 'light'})"))
|
|
913
1154
|
Script("""
|
|
914
1155
|
(function () {
|
|
@@ -960,8 +1201,23 @@ logger.info(f'{beforeware=}')
|
|
|
960
1201
|
|
|
961
1202
|
app = FastHTML(hdrs=hdrs, before=beforeware) if beforeware else FastHTML(hdrs=hdrs)
|
|
962
1203
|
|
|
963
|
-
|
|
1204
|
+
def _favicon_icon_path():
|
|
1205
|
+
root_icon = get_root_folder() / "static" / "icon.png"
|
|
1206
|
+
if root_icon.exists():
|
|
1207
|
+
return root_icon
|
|
1208
|
+
package_favicon = Path(__file__).parent / "static" / "favicon.png"
|
|
1209
|
+
if package_favicon.exists():
|
|
1210
|
+
return package_favicon
|
|
1211
|
+
return None
|
|
1212
|
+
|
|
1213
|
+
@app.route("/static/icon.png")
|
|
1214
|
+
async def favicon_icon():
|
|
1215
|
+
path = _favicon_icon_path()
|
|
1216
|
+
if path and path.exists():
|
|
1217
|
+
return FileResponse(path)
|
|
1218
|
+
return Response(status_code=404)
|
|
964
1219
|
|
|
1220
|
+
static_dir = Path(__file__).parent / "static"
|
|
965
1221
|
if static_dir.exists():
|
|
966
1222
|
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
|
967
1223
|
|
|
@@ -969,7 +1225,7 @@ rt = app.route
|
|
|
969
1225
|
|
|
970
1226
|
|
|
971
1227
|
from starlette.requests import Request
|
|
972
|
-
from starlette.responses import RedirectResponse
|
|
1228
|
+
from starlette.responses import RedirectResponse, FileResponse, Response
|
|
973
1229
|
|
|
974
1230
|
@rt("/login", methods=["GET", "POST"])
|
|
975
1231
|
async def login(request: Request):
|
|
@@ -1008,10 +1264,88 @@ def posts_sidebar_lazy():
|
|
|
1008
1264
|
html = _cached_posts_sidebar_html(_posts_sidebar_fingerprint())
|
|
1009
1265
|
return Aside(
|
|
1010
1266
|
NotStr(html),
|
|
1011
|
-
cls="hidden
|
|
1267
|
+
cls="hidden xl:block w-72 shrink-0 sticky top-24 self-start max-h-[calc(100vh-10rem)] overflow-hidden z-[1000]",
|
|
1012
1268
|
id="posts-sidebar"
|
|
1013
1269
|
)
|
|
1014
1270
|
|
|
1271
|
+
# Route to serve raw markdown for LLM-friendly access
|
|
1272
|
+
@rt("/posts/{path:path}.md")
|
|
1273
|
+
def serve_post_markdown(path: str):
|
|
1274
|
+
from starlette.responses import FileResponse
|
|
1275
|
+
file_path = get_root_folder() / f'{path}.md'
|
|
1276
|
+
if file_path.exists():
|
|
1277
|
+
return FileResponse(file_path, media_type="text/markdown; charset=utf-8")
|
|
1278
|
+
return Response(status_code=404)
|
|
1279
|
+
|
|
1280
|
+
@rt("/search/gather")
|
|
1281
|
+
def gather_search_results(htmx, q: str = ""):
|
|
1282
|
+
import html
|
|
1283
|
+
matches, regex_error = _find_search_matches(q, limit=200)
|
|
1284
|
+
if not matches:
|
|
1285
|
+
content = Div(
|
|
1286
|
+
H1("Search Results", cls="text-3xl font-bold mb-6"),
|
|
1287
|
+
P("No matching posts found.", cls="text-slate-600 dark:text-slate-400"),
|
|
1288
|
+
P(regex_error, cls="text-amber-600 dark:text-amber-400 text-sm") if regex_error else None
|
|
1289
|
+
)
|
|
1290
|
+
return layout(content, htmx=htmx, title="Search Results", show_sidebar=True)
|
|
1291
|
+
|
|
1292
|
+
root = get_root_folder()
|
|
1293
|
+
sections = []
|
|
1294
|
+
copy_parts = [f"# Search Results: {q.strip() or 'All'}\n"]
|
|
1295
|
+
if regex_error:
|
|
1296
|
+
copy_parts.append(f"> {regex_error}\n")
|
|
1297
|
+
for idx, item in enumerate(matches):
|
|
1298
|
+
rel = item.relative_to(root).as_posix()
|
|
1299
|
+
if item.suffix == ".pdf":
|
|
1300
|
+
slug = item.relative_to(root).with_suffix("").as_posix()
|
|
1301
|
+
pdf_href = f"/posts/{slug}.pdf"
|
|
1302
|
+
sections.extend([
|
|
1303
|
+
H2(rel, cls="text-xl font-semibold mb-2"),
|
|
1304
|
+
P(
|
|
1305
|
+
"PDF file: ",
|
|
1306
|
+
A(rel, href=pdf_href, cls="text-blue-600 hover:underline"),
|
|
1307
|
+
cls="text-sm text-slate-600 dark:text-slate-300"
|
|
1308
|
+
),
|
|
1309
|
+
Hr(cls="my-6 border-slate-200 dark:border-slate-800") if idx < len(matches) - 1 else None
|
|
1310
|
+
])
|
|
1311
|
+
copy_parts.append(f"\n---\n\n## {rel}\n\n[PDF file]({pdf_href})\n")
|
|
1312
|
+
continue
|
|
1313
|
+
try:
|
|
1314
|
+
raw_md = item.read_text(encoding="utf-8")
|
|
1315
|
+
except Exception:
|
|
1316
|
+
raw_md = ""
|
|
1317
|
+
sections.extend([
|
|
1318
|
+
H2(rel, cls="text-xl font-semibold mb-2"),
|
|
1319
|
+
Pre(html.escape(raw_md), cls="text-xs font-mono whitespace-pre-wrap text-slate-700 dark:text-slate-300"),
|
|
1320
|
+
Hr(cls="my-6 border-slate-200 dark:border-slate-800") if idx < len(matches) - 1 else None
|
|
1321
|
+
])
|
|
1322
|
+
copy_parts.append(f"\n---\n\n## {rel}\n\n{raw_md}\n")
|
|
1323
|
+
|
|
1324
|
+
copy_text = "".join(copy_parts)
|
|
1325
|
+
content = Div(
|
|
1326
|
+
H1(f"Search Results: {q.strip() or 'All'}", cls="text-3xl font-bold mb-6"),
|
|
1327
|
+
P(regex_error, cls="text-amber-600 dark:text-amber-400 text-sm mb-4") if regex_error else None,
|
|
1328
|
+
Button(
|
|
1329
|
+
UkIcon("copy", cls="w-5 h-5"),
|
|
1330
|
+
Span("Copy all results", cls="text-sm font-semibold"),
|
|
1331
|
+
type="button",
|
|
1332
|
+
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();}})()",
|
|
1333
|
+
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"
|
|
1334
|
+
),
|
|
1335
|
+
Div(
|
|
1336
|
+
"Copied!",
|
|
1337
|
+
id="gather-toast",
|
|
1338
|
+
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"
|
|
1339
|
+
),
|
|
1340
|
+
Textarea(
|
|
1341
|
+
copy_text,
|
|
1342
|
+
id="gather-clipboard",
|
|
1343
|
+
cls="absolute left-[-9999px] top-0 opacity-0 pointer-events-none"
|
|
1344
|
+
),
|
|
1345
|
+
*sections
|
|
1346
|
+
)
|
|
1347
|
+
return layout(content, htmx=htmx, title="Search Results", show_sidebar=True)
|
|
1348
|
+
|
|
1015
1349
|
# Route to serve static files (images, SVGs, etc.) from blog posts
|
|
1016
1350
|
@rt("/posts/{path:path}.{ext:static}")
|
|
1017
1351
|
def serve_post_static(path: str, ext: str):
|
|
@@ -1034,7 +1368,14 @@ def theme_toggle():
|
|
|
1034
1368
|
def navbar(show_mobile_menus=False):
|
|
1035
1369
|
"""Navbar with mobile menu buttons for file tree and TOC"""
|
|
1036
1370
|
left_section = Div(
|
|
1037
|
-
A(
|
|
1371
|
+
A(
|
|
1372
|
+
get_blog_title(),
|
|
1373
|
+
href="/",
|
|
1374
|
+
hx_get="/",
|
|
1375
|
+
hx_target="#main-content",
|
|
1376
|
+
hx_push_url="true",
|
|
1377
|
+
hx_swap="outerHTML show:window:top settle:0.1s"
|
|
1378
|
+
),
|
|
1038
1379
|
cls="flex items-center gap-2"
|
|
1039
1380
|
)
|
|
1040
1381
|
|
|
@@ -1050,14 +1391,14 @@ def navbar(show_mobile_menus=False):
|
|
|
1050
1391
|
UkIcon("menu", cls="w-5 h-5"),
|
|
1051
1392
|
title="Toggle file tree",
|
|
1052
1393
|
id="mobile-posts-toggle",
|
|
1053
|
-
cls="
|
|
1394
|
+
cls="xl:hidden p-2 hover:bg-slate-800 rounded transition-colors",
|
|
1054
1395
|
type="button"
|
|
1055
1396
|
),
|
|
1056
1397
|
Button(
|
|
1057
1398
|
UkIcon("list", cls="w-5 h-5"),
|
|
1058
1399
|
title="Toggle table of contents",
|
|
1059
1400
|
id="mobile-toc-toggle",
|
|
1060
|
-
cls="
|
|
1401
|
+
cls="xl:hidden p-2 hover:bg-slate-800 rounded transition-colors",
|
|
1061
1402
|
type="button"
|
|
1062
1403
|
),
|
|
1063
1404
|
cls="flex items-center gap-1"
|
|
@@ -1078,41 +1419,215 @@ def _posts_sidebar_fingerprint():
|
|
|
1078
1419
|
except Exception:
|
|
1079
1420
|
return 0
|
|
1080
1421
|
|
|
1422
|
+
def _normalize_search_text(text):
|
|
1423
|
+
text = (text or "").lower()
|
|
1424
|
+
text = text.replace("-", " ").replace("_", " ")
|
|
1425
|
+
return " ".join(text.split())
|
|
1426
|
+
|
|
1427
|
+
def _parse_search_query(query):
|
|
1428
|
+
trimmed = (query or "").strip()
|
|
1429
|
+
if len(trimmed) >= 2 and trimmed.startswith("/") and trimmed.endswith("/"):
|
|
1430
|
+
pattern = trimmed[1:-1].strip()
|
|
1431
|
+
if not pattern:
|
|
1432
|
+
return None, ""
|
|
1433
|
+
try:
|
|
1434
|
+
return re.compile(pattern, re.IGNORECASE), ""
|
|
1435
|
+
except re.error:
|
|
1436
|
+
return None, "Invalid regex. Showing normal matches instead."
|
|
1437
|
+
return None, ""
|
|
1438
|
+
|
|
1439
|
+
@lru_cache(maxsize=256)
|
|
1440
|
+
def _cached_search_matches(fingerprint, query, limit):
|
|
1441
|
+
return _find_search_matches_uncached(query, limit)
|
|
1442
|
+
|
|
1443
|
+
def _find_search_matches(query, limit=40):
|
|
1444
|
+
fingerprint = _posts_sidebar_fingerprint()
|
|
1445
|
+
return _cached_search_matches(fingerprint, query, limit)
|
|
1446
|
+
|
|
1447
|
+
def _find_search_matches_uncached(query, limit=40):
|
|
1448
|
+
trimmed = (query or "").strip()
|
|
1449
|
+
if not trimmed:
|
|
1450
|
+
return [], ""
|
|
1451
|
+
regex, regex_error = _parse_search_query(trimmed)
|
|
1452
|
+
query_norm = _normalize_search_text(trimmed) if not regex else ""
|
|
1453
|
+
root = get_root_folder()
|
|
1454
|
+
index_file = find_index_file()
|
|
1455
|
+
results = []
|
|
1456
|
+
for item in chain(root.rglob("*.md"), root.rglob("*.pdf")):
|
|
1457
|
+
if any(part.startswith('.') for part in item.relative_to(root).parts):
|
|
1458
|
+
continue
|
|
1459
|
+
if ".bloggy" in item.parts:
|
|
1460
|
+
continue
|
|
1461
|
+
if index_file and item.resolve() == index_file.resolve():
|
|
1462
|
+
continue
|
|
1463
|
+
rel = item.relative_to(root).with_suffix("")
|
|
1464
|
+
if regex:
|
|
1465
|
+
haystack = f"{item.name} {rel.as_posix()}"
|
|
1466
|
+
is_match = regex.search(haystack)
|
|
1467
|
+
else:
|
|
1468
|
+
haystack = _normalize_search_text(f"{item.name} {rel.as_posix()}")
|
|
1469
|
+
is_match = query_norm in haystack
|
|
1470
|
+
if is_match:
|
|
1471
|
+
results.append(item)
|
|
1472
|
+
if len(results) >= limit:
|
|
1473
|
+
break
|
|
1474
|
+
return tuple(results), regex_error
|
|
1475
|
+
|
|
1476
|
+
def _render_posts_search_results(query):
|
|
1477
|
+
trimmed = (query or "").strip()
|
|
1478
|
+
if not trimmed:
|
|
1479
|
+
return Ul(
|
|
1480
|
+
Li("Type to search file names.", cls="text-[0.7rem] text-center text-slate-500 dark:text-slate-400 bg-transparent"),
|
|
1481
|
+
cls="posts-search-results-list space-y-1 bg-white/0 dark:bg-slate-950/0"
|
|
1482
|
+
)
|
|
1483
|
+
|
|
1484
|
+
matches, regex_error = _find_search_matches(trimmed)
|
|
1485
|
+
if not matches:
|
|
1486
|
+
return Ul(
|
|
1487
|
+
Li(f'No matches for "{trimmed}".', cls="text-xs text-slate-500 dark:text-slate-400 bg-transparent"),
|
|
1488
|
+
Li(regex_error, cls="text-[0.7rem] text-center text-amber-600 dark:text-amber-400") if regex_error else None,
|
|
1489
|
+
cls="posts-search-results-list space-y-1 bg-white/0 dark:bg-slate-950/0"
|
|
1490
|
+
)
|
|
1491
|
+
|
|
1492
|
+
root = get_root_folder()
|
|
1493
|
+
items = []
|
|
1494
|
+
gather_href = f"/search/gather?q={quote_plus(trimmed)}"
|
|
1495
|
+
items.append(Li(
|
|
1496
|
+
A(
|
|
1497
|
+
Span(UkIcon("layers", cls="w-4 h-4 text-slate-400"), cls="w-4 mr-2 flex items-center justify-center shrink-0"),
|
|
1498
|
+
Span("Gather all search results for LLM", cls="truncate min-w-0 text-xs text-slate-600 dark:text-slate-300"),
|
|
1499
|
+
href=gather_href,
|
|
1500
|
+
hx_get=gather_href,
|
|
1501
|
+
hx_target="#main-content",
|
|
1502
|
+
hx_push_url="true",
|
|
1503
|
+
hx_swap="outerHTML show:window:top settle:0.1s",
|
|
1504
|
+
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"
|
|
1505
|
+
),
|
|
1506
|
+
cls="bg-transparent"
|
|
1507
|
+
))
|
|
1508
|
+
for item in matches:
|
|
1509
|
+
slug = str(item.relative_to(root).with_suffix(""))
|
|
1510
|
+
if item.suffix == ".pdf":
|
|
1511
|
+
display = item.relative_to(root).as_posix()
|
|
1512
|
+
else:
|
|
1513
|
+
display = item.relative_to(root).with_suffix("").as_posix()
|
|
1514
|
+
items.append(Li(
|
|
1515
|
+
A(
|
|
1516
|
+
Span(UkIcon("search", cls="w-4 h-4 text-slate-400"), cls="w-4 mr-2 flex items-center justify-center shrink-0"),
|
|
1517
|
+
Span(display, cls="truncate min-w-0 font-mono text-xs text-slate-600 dark:text-slate-300", title=display),
|
|
1518
|
+
href=f'/posts/{slug}',
|
|
1519
|
+
hx_get=f'/posts/{slug}', hx_target="#main-content", hx_push_url="true", hx_swap="outerHTML show:window:top settle:0.1s",
|
|
1520
|
+
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"
|
|
1521
|
+
)
|
|
1522
|
+
))
|
|
1523
|
+
if regex_error:
|
|
1524
|
+
items.append(Li(regex_error, cls="text-[0.7rem] text-center text-amber-600 dark:text-amber-400 mt-1 bg-transparent"))
|
|
1525
|
+
return Ul(*items, cls="posts-search-results-list space-y-1 bg-white/0 dark:bg-slate-950/0")
|
|
1526
|
+
|
|
1527
|
+
def _posts_search_block():
|
|
1528
|
+
return Div(
|
|
1529
|
+
Div("Filter", cls="text-xs uppercase tracking-widest text-slate-500 dark:text-slate-400 mb-2"),
|
|
1530
|
+
Div(
|
|
1531
|
+
Input(
|
|
1532
|
+
type="search",
|
|
1533
|
+
name="q",
|
|
1534
|
+
placeholder="Search file names…",
|
|
1535
|
+
autocomplete="off",
|
|
1536
|
+
data_placeholder_cycle="1",
|
|
1537
|
+
data_placeholder_primary="Search file names…",
|
|
1538
|
+
data_placeholder_alt="Search regex with /pattern/ syntax",
|
|
1539
|
+
data_search_key="posts",
|
|
1540
|
+
hx_get="/_sidebar/posts/search",
|
|
1541
|
+
hx_trigger="input changed delay:300ms",
|
|
1542
|
+
hx_target="next .posts-search-results",
|
|
1543
|
+
hx_swap="innerHTML",
|
|
1544
|
+
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"
|
|
1545
|
+
),
|
|
1546
|
+
Button(
|
|
1547
|
+
"×",
|
|
1548
|
+
type="button",
|
|
1549
|
+
aria_label="Clear search",
|
|
1550
|
+
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"
|
|
1551
|
+
),
|
|
1552
|
+
cls="relative"
|
|
1553
|
+
),
|
|
1554
|
+
Div(
|
|
1555
|
+
_render_posts_search_results(""),
|
|
1556
|
+
id="posts-search-results",
|
|
1557
|
+
cls="posts-search-results mt-4 max-h-64 overflow-y-auto bg-white/0 dark:bg-slate-950/0"
|
|
1558
|
+
),
|
|
1559
|
+
cls="posts-search-block sticky top-0 z-10 bg-white/20 dark:bg-slate-950/70 mb-3"
|
|
1560
|
+
)
|
|
1561
|
+
|
|
1081
1562
|
@lru_cache(maxsize=1)
|
|
1082
1563
|
def _cached_posts_sidebar_html(fingerprint):
|
|
1564
|
+
sidebars_open = get_config().get_sidebars_open()
|
|
1083
1565
|
sidebar = collapsible_sidebar(
|
|
1084
1566
|
"menu",
|
|
1085
|
-
"
|
|
1567
|
+
"Library",
|
|
1086
1568
|
get_posts(),
|
|
1087
|
-
is_open=
|
|
1088
|
-
data_sidebar="posts"
|
|
1569
|
+
is_open=sidebars_open,
|
|
1570
|
+
data_sidebar="posts",
|
|
1571
|
+
shortcut_key="Z",
|
|
1572
|
+
extra_content=[
|
|
1573
|
+
_posts_search_block(),
|
|
1574
|
+
Div(cls="h-px w-full bg-slate-200/80 dark:bg-slate-700/70 my-2"),
|
|
1575
|
+
Div("Posts", cls="text-xs uppercase tracking-widest text-slate-500 dark:text-slate-400 mb-1")
|
|
1576
|
+
],
|
|
1577
|
+
scroll_target="list"
|
|
1089
1578
|
)
|
|
1090
1579
|
return to_xml(sidebar)
|
|
1091
1580
|
|
|
1092
|
-
def collapsible_sidebar(icon, title, items_list, is_open=False, data_sidebar=None):
|
|
1581
|
+
def collapsible_sidebar(icon, title, items_list, is_open=False, data_sidebar=None, shortcut_key=None, extra_content=None, scroll_target="container"):
|
|
1093
1582
|
"""Reusable collapsible sidebar component with sticky header"""
|
|
1094
1583
|
# Build the summary content
|
|
1095
1584
|
summary_content = [
|
|
1096
|
-
|
|
1097
|
-
|
|
1585
|
+
Span(
|
|
1586
|
+
UkIcon(icon, cls="w-5 h-5 block"),
|
|
1587
|
+
cls="flex items-center justify-center w-5 h-5 shrink-0 leading-none"
|
|
1588
|
+
),
|
|
1589
|
+
Span(title, cls="flex-1 leading-none")
|
|
1098
1590
|
]
|
|
1099
1591
|
|
|
1592
|
+
# Add keyboard shortcut indicator if provided
|
|
1593
|
+
if shortcut_key:
|
|
1594
|
+
summary_content.append(
|
|
1595
|
+
Kbd(
|
|
1596
|
+
shortcut_key,
|
|
1597
|
+
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)]"
|
|
1598
|
+
)
|
|
1599
|
+
)
|
|
1600
|
+
|
|
1100
1601
|
# Sidebar styling configuration
|
|
1101
|
-
common_frost_style = "bg-white/
|
|
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
|
-
|
|
1602
|
+
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)]"
|
|
1603
|
+
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]"
|
|
1604
|
+
if scroll_target == "list":
|
|
1605
|
+
content_classes = f"p-3 {common_frost_style} rounded-lg max-h-[calc(100vh-18rem)] flex flex-col overflow-hidden min-h-0"
|
|
1606
|
+
list_classes = "list-none pt-2 flex-1 min-h-0 overflow-y-auto sidebar-scroll-container"
|
|
1607
|
+
else:
|
|
1608
|
+
content_classes = f"p-3 {common_frost_style} rounded-lg overflow-y-auto max-h-[calc(100vh-18rem)] sidebar-scroll-container"
|
|
1609
|
+
list_classes = "list-none pt-4"
|
|
1104
1610
|
|
|
1611
|
+
extra_content = extra_content or []
|
|
1612
|
+
content_id = "sidebar-scroll-container" if scroll_target != "list" else None
|
|
1105
1613
|
return Details(
|
|
1106
1614
|
Summary(*summary_content, cls=summary_classes, style="margin: 0 0 0.5rem 0;"),
|
|
1107
1615
|
Div(
|
|
1108
|
-
|
|
1616
|
+
*extra_content,
|
|
1617
|
+
Ul(*items_list, cls=list_classes, id="sidebar-scroll-container" if scroll_target == "list" else None),
|
|
1109
1618
|
cls=content_classes,
|
|
1110
|
-
id=
|
|
1619
|
+
id=content_id,
|
|
1620
|
+
style="will-change: auto;"
|
|
1111
1621
|
),
|
|
1112
1622
|
open=is_open,
|
|
1113
|
-
data_sidebar=data_sidebar
|
|
1623
|
+
data_sidebar=data_sidebar,
|
|
1624
|
+
style="will-change: auto;"
|
|
1114
1625
|
)
|
|
1115
1626
|
|
|
1627
|
+
@rt("/_sidebar/posts/search")
|
|
1628
|
+
def posts_sidebar_search(q: str = ""):
|
|
1629
|
+
return _render_posts_search_results(q)
|
|
1630
|
+
|
|
1116
1631
|
def is_active_toc_item(anchor):
|
|
1117
1632
|
"""Check if a TOC item is currently active based on URL hash"""
|
|
1118
1633
|
# This will be enhanced client-side with JavaScript
|
|
@@ -1128,11 +1643,13 @@ def extract_toc(content):
|
|
|
1128
1643
|
# Parse headings from the cleaned content
|
|
1129
1644
|
heading_pattern = re.compile(r'^(#{1,6})\s+(.+)$', re.MULTILINE)
|
|
1130
1645
|
headings = []
|
|
1646
|
+
counts = {}
|
|
1131
1647
|
for match in heading_pattern.finditer(content_no_code):
|
|
1132
1648
|
level = len(match.group(1))
|
|
1133
|
-
|
|
1649
|
+
raw_text = match.group(2).strip()
|
|
1650
|
+
text = _strip_inline_markdown(raw_text)
|
|
1134
1651
|
# Create anchor from heading text using shared function
|
|
1135
|
-
anchor = text_to_anchor(text)
|
|
1652
|
+
anchor = _unique_anchor(text_to_anchor(text), counts)
|
|
1136
1653
|
headings.append((level, text, anchor))
|
|
1137
1654
|
return headings
|
|
1138
1655
|
|
|
@@ -1192,7 +1709,7 @@ def get_custom_css_links(current_path=None, section_class=None):
|
|
|
1192
1709
|
|
|
1193
1710
|
return css_elements
|
|
1194
1711
|
|
|
1195
|
-
def layout(*content, htmx, title=None, show_sidebar=False, toc_content=None, current_path=None):
|
|
1712
|
+
def layout(*content, htmx, title=None, show_sidebar=False, toc_content=None, current_path=None, show_toc=True):
|
|
1196
1713
|
import time
|
|
1197
1714
|
layout_start_time = time.time()
|
|
1198
1715
|
logger.debug("[LAYOUT] layout() start")
|
|
@@ -1200,23 +1717,30 @@ def layout(*content, htmx, title=None, show_sidebar=False, toc_content=None, cur
|
|
|
1200
1717
|
section_class = f"section-{current_path.replace('/', '-')}" if current_path else ""
|
|
1201
1718
|
t_section = time.time()
|
|
1202
1719
|
logger.debug(f"[LAYOUT] section_class computed in {(t_section - layout_start_time)*1000:.2f}ms")
|
|
1720
|
+
layout_config = _resolve_layout_config(current_path)
|
|
1721
|
+
layout_max_class, layout_max_style = _width_class_and_style(layout_config.get("layout_max_width"), "max")
|
|
1722
|
+
layout_fluid_class = "layout-fluid" if layout_max_style else ""
|
|
1203
1723
|
|
|
1204
1724
|
# HTMX short-circuit: build only swappable fragments, never build full page chrome/sidebars tree
|
|
1205
1725
|
if htmx and getattr(htmx, "request", None):
|
|
1206
1726
|
if show_sidebar:
|
|
1207
|
-
|
|
1208
|
-
t_toc =
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
"
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1727
|
+
toc_sidebar = None
|
|
1728
|
+
t_toc = t_section
|
|
1729
|
+
if show_toc:
|
|
1730
|
+
toc_items = build_toc_items(extract_toc(toc_content)) if toc_content else []
|
|
1731
|
+
t_toc = time.time()
|
|
1732
|
+
logger.debug(f"[LAYOUT] TOC built in {(t_toc - t_section)*1000:.2f}ms")
|
|
1733
|
+
|
|
1734
|
+
sidebars_open = get_config().get_sidebars_open()
|
|
1735
|
+
toc_attrs = {
|
|
1736
|
+
"cls": "hidden xl:block w-72 shrink-0 sticky top-24 self-start max-h-[calc(100vh-10rem)] overflow-hidden z-[1000]",
|
|
1737
|
+
"id": "toc-sidebar",
|
|
1738
|
+
"hx_swap_oob": "true",
|
|
1739
|
+
}
|
|
1740
|
+
toc_sidebar = Aside(
|
|
1741
|
+
collapsible_sidebar("list", "Contents", toc_items, is_open=sidebars_open, shortcut_key="X") if toc_items else Div(),
|
|
1742
|
+
**toc_attrs
|
|
1743
|
+
)
|
|
1220
1744
|
|
|
1221
1745
|
custom_css_links = get_custom_css_links(current_path, section_class)
|
|
1222
1746
|
t_css = time.time()
|
|
@@ -1231,7 +1755,12 @@ def layout(*content, htmx, title=None, show_sidebar=False, toc_content=None, cur
|
|
|
1231
1755
|
result.append(Div(*custom_css_links, id="scoped-css-container", hx_swap_oob="true"))
|
|
1232
1756
|
else:
|
|
1233
1757
|
result.append(Div(id="scoped-css-container", hx_swap_oob="true"))
|
|
1234
|
-
|
|
1758
|
+
if toc_sidebar:
|
|
1759
|
+
result.extend([main_content_container, toc_sidebar])
|
|
1760
|
+
else:
|
|
1761
|
+
result.append(main_content_container)
|
|
1762
|
+
result.append(Div(id="toc-sidebar", hx_swap_oob="true"))
|
|
1763
|
+
result.append(Div(id="mobile-toc-panel", hx_swap_oob="true"))
|
|
1235
1764
|
|
|
1236
1765
|
t_htmx = time.time()
|
|
1237
1766
|
logger.debug(f"[LAYOUT] HTMX response assembled in {(t_htmx - t_main)*1000:.2f}ms")
|
|
@@ -1257,18 +1786,22 @@ def layout(*content, htmx, title=None, show_sidebar=False, toc_content=None, cur
|
|
|
1257
1786
|
|
|
1258
1787
|
if show_sidebar:
|
|
1259
1788
|
# Build TOC if content provided
|
|
1260
|
-
|
|
1261
|
-
t_toc =
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
"
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1789
|
+
toc_sidebar = None
|
|
1790
|
+
t_toc = t_section
|
|
1791
|
+
if show_toc:
|
|
1792
|
+
toc_items = build_toc_items(extract_toc(toc_content)) if toc_content else []
|
|
1793
|
+
t_toc = time.time()
|
|
1794
|
+
logger.debug(f"[LAYOUT] TOC built in {(t_toc - t_section)*1000:.2f}ms")
|
|
1795
|
+
# Right sidebar TOC component with out-of-band swap for HTMX
|
|
1796
|
+
sidebars_open = get_config().get_sidebars_open()
|
|
1797
|
+
toc_attrs = {
|
|
1798
|
+
"cls": "hidden xl:block w-72 shrink-0 sticky top-24 self-start max-h-[calc(100vh-10rem)] overflow-hidden z-[1000]",
|
|
1799
|
+
"id": "toc-sidebar"
|
|
1800
|
+
}
|
|
1801
|
+
toc_sidebar = Aside(
|
|
1802
|
+
collapsible_sidebar("list", "Contents", toc_items, is_open=sidebars_open, shortcut_key="X") if toc_items else Div(),
|
|
1803
|
+
**toc_attrs
|
|
1804
|
+
)
|
|
1272
1805
|
# Container for main content only (for HTMX swapping)
|
|
1273
1806
|
# Add section class to identify the section for CSS scoping
|
|
1274
1807
|
section_class = f"section-{current_path.replace('/', '-')}" if current_path else ""
|
|
@@ -1295,27 +1828,33 @@ def layout(*content, htmx, title=None, show_sidebar=False, toc_content=None, cur
|
|
|
1295
1828
|
cls="p-4 overflow-y-auto"
|
|
1296
1829
|
),
|
|
1297
1830
|
id="mobile-posts-panel",
|
|
1298
|
-
cls="fixed inset-0 bg-white dark:bg-slate-950 z-[9999]
|
|
1831
|
+
cls="fixed inset-0 bg-white dark:bg-slate-950 z-[9999] xl:hidden transform -translate-x-full transition-transform duration-300"
|
|
1299
1832
|
)
|
|
1300
|
-
mobile_toc_panel =
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1833
|
+
mobile_toc_panel = None
|
|
1834
|
+
if show_toc:
|
|
1835
|
+
mobile_toc_panel = Div(
|
|
1836
|
+
Div(
|
|
1837
|
+
Button(
|
|
1838
|
+
UkIcon("x", cls="w-5 h-5"),
|
|
1839
|
+
id="close-mobile-toc",
|
|
1840
|
+
cls="p-2 hover:bg-slate-200 dark:hover:bg-slate-700 rounded transition-colors ml-auto",
|
|
1841
|
+
type="button"
|
|
1842
|
+
),
|
|
1843
|
+
cls="flex justify-end p-2 bg-white dark:bg-slate-950 border-b border-slate-200 dark:border-slate-800"
|
|
1307
1844
|
),
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
cls="fixed inset-0 bg-white dark:bg-slate-950 z-[9999] md:hidden transform translate-x-full transition-transform duration-300"
|
|
1316
|
-
)
|
|
1845
|
+
Div(
|
|
1846
|
+
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")),
|
|
1847
|
+
cls="p-4 overflow-y-auto"
|
|
1848
|
+
),
|
|
1849
|
+
id="mobile-toc-panel",
|
|
1850
|
+
cls="fixed inset-0 bg-white dark:bg-slate-950 z-[9999] xl:hidden transform translate-x-full transition-transform duration-300"
|
|
1851
|
+
)
|
|
1317
1852
|
# Full layout with all sidebars
|
|
1318
|
-
content_with_sidebars = Div(
|
|
1853
|
+
content_with_sidebars = Div(
|
|
1854
|
+
cls=f"layout-container {layout_fluid_class} w-full {layout_max_class} mx-auto px-4 flex gap-6 flex-1".strip(),
|
|
1855
|
+
id="content-with-sidebars",
|
|
1856
|
+
**_style_attr(layout_max_style)
|
|
1857
|
+
)(
|
|
1319
1858
|
# Left sidebar - lazy load with HTMX, show loader placeholder
|
|
1320
1859
|
Aside(
|
|
1321
1860
|
Div(
|
|
@@ -1323,7 +1862,7 @@ def layout(*content, htmx, title=None, show_sidebar=False, toc_content=None, cur
|
|
|
1323
1862
|
Span("Loading posts…", cls="ml-2 text-sm"),
|
|
1324
1863
|
cls="flex items-center justify-center h-32 text-slate-400"
|
|
1325
1864
|
),
|
|
1326
|
-
cls="hidden
|
|
1865
|
+
cls="hidden xl:block w-72 shrink-0 sticky top-24 self-start max-h-[calc(100vh-10rem)] overflow-hidden z-[1000]",
|
|
1327
1866
|
id="posts-sidebar",
|
|
1328
1867
|
hx_get="/_sidebar/posts",
|
|
1329
1868
|
hx_trigger="load",
|
|
@@ -1332,27 +1871,46 @@ def layout(*content, htmx, title=None, show_sidebar=False, toc_content=None, cur
|
|
|
1332
1871
|
# Main content (swappable)
|
|
1333
1872
|
main_content_container,
|
|
1334
1873
|
# Right sidebar - TOC (swappable out-of-band)
|
|
1335
|
-
toc_sidebar
|
|
1874
|
+
toc_sidebar if toc_sidebar else None
|
|
1336
1875
|
)
|
|
1337
1876
|
t_sidebars = time.time()
|
|
1338
1877
|
logger.debug(f"[LAYOUT] Sidebars container built in {(t_sidebars - t_main)*1000:.2f}ms")
|
|
1339
1878
|
# Layout with sidebar for blog posts
|
|
1340
1879
|
body_content = Div(id="page-container", cls="flex flex-col min-h-screen")(
|
|
1341
|
-
Div(
|
|
1880
|
+
Div(
|
|
1881
|
+
navbar(show_mobile_menus=True),
|
|
1882
|
+
cls=f"layout-container {layout_fluid_class} w-full {layout_max_class} mx-auto px-4 sticky top-0 z-50 mt-4".strip(),
|
|
1883
|
+
id="site-navbar",
|
|
1884
|
+
**_style_attr(layout_max_style)
|
|
1885
|
+
),
|
|
1342
1886
|
mobile_posts_panel,
|
|
1343
|
-
mobile_toc_panel,
|
|
1887
|
+
mobile_toc_panel if mobile_toc_panel else None,
|
|
1344
1888
|
content_with_sidebars,
|
|
1345
|
-
Footer(Div(
|
|
1346
|
-
cls="
|
|
1889
|
+
Footer(Div(NotStr('Powered by <a href="https://github.com/sizhky/bloggy" class="underline hover:text-white/80" target="_blank" rel="noopener noreferrer">Bloggy</a> and ❤️'), cls="bg-slate-900 text-white rounded-lg p-4 my-4 dark:bg-slate-800 text-right"), # right justified footer
|
|
1890
|
+
cls=f"layout-container {layout_fluid_class} w-full {layout_max_class} mx-auto px-6 mt-auto mb-6".strip(),
|
|
1891
|
+
id="site-footer",
|
|
1892
|
+
**_style_attr(layout_max_style))
|
|
1347
1893
|
)
|
|
1348
1894
|
else:
|
|
1349
1895
|
# Default layout without sidebar
|
|
1350
1896
|
custom_css_links = get_custom_css_links(current_path, section_class) if current_path else []
|
|
1351
1897
|
body_content = Div(id="page-container", cls="flex flex-col min-h-screen")(
|
|
1352
|
-
Div(
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1898
|
+
Div(
|
|
1899
|
+
navbar(),
|
|
1900
|
+
cls=f"layout-container {layout_fluid_class} w-full {layout_max_class} mx-auto px-4 sticky top-0 z-50 mt-4".strip(),
|
|
1901
|
+
id="site-navbar",
|
|
1902
|
+
**_style_attr(layout_max_style)
|
|
1903
|
+
),
|
|
1904
|
+
Main(
|
|
1905
|
+
*content,
|
|
1906
|
+
cls=f"layout-container {layout_fluid_class} w-full {layout_max_class} mx-auto px-6 py-8 space-y-8".strip(),
|
|
1907
|
+
id="main-content",
|
|
1908
|
+
**_style_attr(layout_max_style)
|
|
1909
|
+
),
|
|
1910
|
+
Footer(Div(NotStr('Powered by <a href="https://github.com/sizhky/bloggy" class="underline hover:text-white/80" target="_blank" rel="noopener noreferrer">Bloggy</a> and ❤️'), cls="bg-slate-900 text-white rounded-lg p-4 my-4 dark:bg-slate-800 text-right"),
|
|
1911
|
+
cls=f"layout-container {layout_fluid_class} w-full {layout_max_class} mx-auto px-6 mt-auto mb-6".strip(),
|
|
1912
|
+
id="site-footer",
|
|
1913
|
+
**_style_attr(layout_max_style))
|
|
1356
1914
|
)
|
|
1357
1915
|
t_body = time.time()
|
|
1358
1916
|
logger.debug(f"[LAYOUT] Body content (no sidebar) built in {(t_body - layout_start_time)*1000:.2f}ms")
|
|
@@ -1386,7 +1944,7 @@ def build_post_tree(folder):
|
|
|
1386
1944
|
if item.name.startswith('.'):
|
|
1387
1945
|
continue
|
|
1388
1946
|
entries.append(item)
|
|
1389
|
-
elif item.suffix
|
|
1947
|
+
elif item.suffix in ('.md', '.pdf'):
|
|
1390
1948
|
# Skip the file being used for home page (index.md takes precedence over readme.md)
|
|
1391
1949
|
if index_file and item.resolve() == index_file.resolve():
|
|
1392
1950
|
continue
|
|
@@ -1431,6 +1989,17 @@ def build_post_tree(folder):
|
|
|
1431
1989
|
hx_get=f'/posts/{slug}', hx_target="#main-content", hx_push_url="true", hx_swap="outerHTML show:window:top settle:0.1s",
|
|
1432
1990
|
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
1991
|
data_path=slug)))
|
|
1992
|
+
elif item.suffix == '.pdf':
|
|
1993
|
+
slug = str(item.relative_to(root).with_suffix(''))
|
|
1994
|
+
title = slug_to_title(item.stem)
|
|
1995
|
+
items.append(Li(A(
|
|
1996
|
+
Span(cls="w-4 mr-2 shrink-0"),
|
|
1997
|
+
Span(UkIcon("file-text", cls="text-slate-400 w-4 h-4"), cls="w-4 mr-2 flex items-center justify-center shrink-0"),
|
|
1998
|
+
Span(f"{title} (PDF)", cls="truncate min-w-0", title=title),
|
|
1999
|
+
href=f'/posts/{slug}',
|
|
2000
|
+
hx_get=f'/posts/{slug}', hx_target="#main-content", hx_push_url="true", hx_swap="outerHTML show:window:top settle:0.1s",
|
|
2001
|
+
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",
|
|
2002
|
+
data_path=slug)))
|
|
1434
2003
|
|
|
1435
2004
|
elapsed = (time.time() - start_time) * 1000
|
|
1436
2005
|
logger.debug(f"[DEBUG] build_post_tree for {folder.relative_to(root) if folder != root else '.'} completed in {elapsed:.2f}ms")
|
|
@@ -1440,8 +2009,9 @@ def _posts_tree_fingerprint():
|
|
|
1440
2009
|
root = get_root_folder()
|
|
1441
2010
|
try:
|
|
1442
2011
|
md_mtime = max((p.stat().st_mtime for p in root.rglob("*.md")), default=0)
|
|
2012
|
+
pdf_mtime = max((p.stat().st_mtime for p in root.rglob("*.pdf")), default=0)
|
|
1443
2013
|
bloggy_mtime = max((p.stat().st_mtime for p in root.rglob(".bloggy")), default=0)
|
|
1444
|
-
return max(md_mtime, bloggy_mtime)
|
|
2014
|
+
return max(md_mtime, pdf_mtime, bloggy_mtime)
|
|
1445
2015
|
except Exception:
|
|
1446
2016
|
return 0
|
|
1447
2017
|
|
|
@@ -1479,6 +2049,10 @@ def not_found(htmx=None):
|
|
|
1479
2049
|
UkIcon("home", cls="w-5 h-5 mr-2"),
|
|
1480
2050
|
"Go to Home",
|
|
1481
2051
|
href="/",
|
|
2052
|
+
hx_get="/",
|
|
2053
|
+
hx_target="#main-content",
|
|
2054
|
+
hx_push_url="true",
|
|
2055
|
+
hx_swap="outerHTML show:window:top settle:0.1s",
|
|
1482
2056
|
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
2057
|
),
|
|
1484
2058
|
A(
|
|
@@ -1516,9 +2090,39 @@ def post_detail(path: str, htmx):
|
|
|
1516
2090
|
logger.info(f"\n[DEBUG] ########## REQUEST START: /posts/{path} ##########")
|
|
1517
2091
|
|
|
1518
2092
|
file_path = get_root_folder() / f'{path}.md'
|
|
2093
|
+
pdf_path = get_root_folder() / f'{path}.pdf'
|
|
1519
2094
|
|
|
1520
2095
|
# Check if file exists
|
|
1521
2096
|
if not file_path.exists():
|
|
2097
|
+
if pdf_path.exists():
|
|
2098
|
+
post_title = f"{slug_to_title(Path(path).name)} (PDF)"
|
|
2099
|
+
pdf_src = f"/posts/{path}.pdf"
|
|
2100
|
+
pdf_content = Div(
|
|
2101
|
+
Div(
|
|
2102
|
+
H1(post_title, cls="text-4xl font-bold"),
|
|
2103
|
+
Button(
|
|
2104
|
+
"Focus PDF",
|
|
2105
|
+
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",
|
|
2106
|
+
type="button",
|
|
2107
|
+
data_pdf_focus_toggle="true",
|
|
2108
|
+
data_pdf_focus_label="Focus PDF",
|
|
2109
|
+
data_pdf_exit_label="Exit focus",
|
|
2110
|
+
aria_pressed="false"
|
|
2111
|
+
),
|
|
2112
|
+
cls="flex items-center justify-between gap-4 flex-wrap mb-6"
|
|
2113
|
+
),
|
|
2114
|
+
NotStr(
|
|
2115
|
+
f'<object data="{pdf_src}" type="application/pdf" '
|
|
2116
|
+
'class="pdf-viewer w-full h-[calc(100vh-14rem)] rounded-lg border border-slate-200 '
|
|
2117
|
+
'dark:border-slate-700 bg-white dark:bg-slate-900">'
|
|
2118
|
+
'<p class="p-4 text-sm text-slate-600 dark:text-slate-300">'
|
|
2119
|
+
'PDF preview not available. '
|
|
2120
|
+
f'<a href="{pdf_src}" class="text-blue-600 hover:underline">Download PDF</a>.'
|
|
2121
|
+
'</p></object>'
|
|
2122
|
+
)
|
|
2123
|
+
)
|
|
2124
|
+
return layout(pdf_content, htmx=htmx, title=f"{post_title} - {get_blog_title()}",
|
|
2125
|
+
show_sidebar=True, toc_content=None, current_path=path, show_toc=False)
|
|
1522
2126
|
return not_found(htmx)
|
|
1523
2127
|
|
|
1524
2128
|
metadata, raw_content = parse_frontmatter(file_path)
|
|
@@ -1532,7 +2136,31 @@ def post_detail(path: str, htmx):
|
|
|
1532
2136
|
md_time = (time.time() - md_start) * 1000
|
|
1533
2137
|
logger.debug(f"[DEBUG] Markdown rendering took {md_time:.2f}ms")
|
|
1534
2138
|
|
|
1535
|
-
|
|
2139
|
+
copy_button = Button(
|
|
2140
|
+
UkIcon("copy", cls="w-4 h-4"),
|
|
2141
|
+
type="button",
|
|
2142
|
+
title="Copy raw markdown",
|
|
2143
|
+
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();}})()",
|
|
2144
|
+
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"
|
|
2145
|
+
)
|
|
2146
|
+
post_content = Div(
|
|
2147
|
+
Div(
|
|
2148
|
+
H1(post_title, cls="text-4xl font-bold"),
|
|
2149
|
+
copy_button,
|
|
2150
|
+
cls="flex flex-col gap-3 md:flex-row md:items-center md:justify-between mb-8"
|
|
2151
|
+
),
|
|
2152
|
+
Div(
|
|
2153
|
+
"Copied Raw Markdown!",
|
|
2154
|
+
id="raw-md-toast",
|
|
2155
|
+
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"
|
|
2156
|
+
),
|
|
2157
|
+
Textarea(
|
|
2158
|
+
raw_content,
|
|
2159
|
+
id="raw-md-clipboard",
|
|
2160
|
+
cls="absolute left-[-9999px] top-0 opacity-0 pointer-events-none"
|
|
2161
|
+
),
|
|
2162
|
+
content
|
|
2163
|
+
)
|
|
1536
2164
|
|
|
1537
2165
|
# Always return complete layout with sidebar and TOC
|
|
1538
2166
|
layout_start = time.time()
|