bloggy 0.1.43__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 +707 -91
- bloggy/main.py +3 -3
- bloggy/static/scripts.js +657 -45
- bloggy-0.2.3.dist-info/METADATA +167 -0
- bloggy-0.2.3.dist-info/RECORD +13 -0
- bloggy-0.1.43.dist-info/METADATA +0 -926
- bloggy-0.1.43.dist-info/RECORD +0 -13
- {bloggy-0.1.43.dist-info → bloggy-0.2.3.dist-info}/WHEEL +0 -0
- {bloggy-0.1.43.dist-info → bloggy-0.2.3.dist-info}/entry_points.txt +0 -0
- {bloggy-0.1.43.dist-info → bloggy-0.2.3.dist-info}/licenses/LICENSE +0 -0
- {bloggy-0.1.43.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("""
|
|
@@ -812,13 +970,48 @@ hdrs = (
|
|
|
812
970
|
.sidebar-highlight.fade-out {
|
|
813
971
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0);
|
|
814
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
|
+
}
|
|
815
1008
|
|
|
816
1009
|
/* Tabs styles */
|
|
817
1010
|
.tabs-container {
|
|
818
1011
|
margin: 2rem 0;
|
|
819
1012
|
border: 1px solid rgb(226 232 240);
|
|
820
1013
|
border-radius: 0.5rem;
|
|
821
|
-
overflow:
|
|
1014
|
+
overflow: visible;
|
|
822
1015
|
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
|
823
1016
|
}
|
|
824
1017
|
.dark .tabs-container {
|
|
@@ -877,6 +1070,7 @@ hdrs = (
|
|
|
877
1070
|
.tabs-content {
|
|
878
1071
|
background: white;
|
|
879
1072
|
position: relative;
|
|
1073
|
+
overflow: visible;
|
|
880
1074
|
}
|
|
881
1075
|
.dark .tabs-content {
|
|
882
1076
|
background: rgb(2 6 23);
|
|
@@ -892,6 +1086,7 @@ hdrs = (
|
|
|
892
1086
|
opacity: 0;
|
|
893
1087
|
visibility: hidden;
|
|
894
1088
|
pointer-events: none;
|
|
1089
|
+
overflow: visible;
|
|
895
1090
|
}
|
|
896
1091
|
.tab-panel.active {
|
|
897
1092
|
position: relative;
|
|
@@ -918,6 +1113,43 @@ hdrs = (
|
|
|
918
1113
|
font-family: 'IBM Plex Mono', monospace;
|
|
919
1114
|
}
|
|
920
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
|
+
"""),
|
|
921
1153
|
# Script("if(!localStorage.__FRANKEN__) localStorage.__FRANKEN__ = JSON.stringify({mode: 'light'})"))
|
|
922
1154
|
Script("""
|
|
923
1155
|
(function () {
|
|
@@ -969,8 +1201,23 @@ logger.info(f'{beforeware=}')
|
|
|
969
1201
|
|
|
970
1202
|
app = FastHTML(hdrs=hdrs, before=beforeware) if beforeware else FastHTML(hdrs=hdrs)
|
|
971
1203
|
|
|
972
|
-
|
|
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)
|
|
973
1219
|
|
|
1220
|
+
static_dir = Path(__file__).parent / "static"
|
|
974
1221
|
if static_dir.exists():
|
|
975
1222
|
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
|
976
1223
|
|
|
@@ -978,7 +1225,7 @@ rt = app.route
|
|
|
978
1225
|
|
|
979
1226
|
|
|
980
1227
|
from starlette.requests import Request
|
|
981
|
-
from starlette.responses import RedirectResponse
|
|
1228
|
+
from starlette.responses import RedirectResponse, FileResponse, Response
|
|
982
1229
|
|
|
983
1230
|
@rt("/login", methods=["GET", "POST"])
|
|
984
1231
|
async def login(request: Request):
|
|
@@ -1017,10 +1264,88 @@ def posts_sidebar_lazy():
|
|
|
1017
1264
|
html = _cached_posts_sidebar_html(_posts_sidebar_fingerprint())
|
|
1018
1265
|
return Aside(
|
|
1019
1266
|
NotStr(html),
|
|
1020
|
-
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]",
|
|
1021
1268
|
id="posts-sidebar"
|
|
1022
1269
|
)
|
|
1023
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
|
+
|
|
1024
1349
|
# Route to serve static files (images, SVGs, etc.) from blog posts
|
|
1025
1350
|
@rt("/posts/{path:path}.{ext:static}")
|
|
1026
1351
|
def serve_post_static(path: str, ext: str):
|
|
@@ -1043,7 +1368,14 @@ def theme_toggle():
|
|
|
1043
1368
|
def navbar(show_mobile_menus=False):
|
|
1044
1369
|
"""Navbar with mobile menu buttons for file tree and TOC"""
|
|
1045
1370
|
left_section = Div(
|
|
1046
|
-
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
|
+
),
|
|
1047
1379
|
cls="flex items-center gap-2"
|
|
1048
1380
|
)
|
|
1049
1381
|
|
|
@@ -1059,14 +1391,14 @@ def navbar(show_mobile_menus=False):
|
|
|
1059
1391
|
UkIcon("menu", cls="w-5 h-5"),
|
|
1060
1392
|
title="Toggle file tree",
|
|
1061
1393
|
id="mobile-posts-toggle",
|
|
1062
|
-
cls="
|
|
1394
|
+
cls="xl:hidden p-2 hover:bg-slate-800 rounded transition-colors",
|
|
1063
1395
|
type="button"
|
|
1064
1396
|
),
|
|
1065
1397
|
Button(
|
|
1066
1398
|
UkIcon("list", cls="w-5 h-5"),
|
|
1067
1399
|
title="Toggle table of contents",
|
|
1068
1400
|
id="mobile-toc-toggle",
|
|
1069
|
-
cls="
|
|
1401
|
+
cls="xl:hidden p-2 hover:bg-slate-800 rounded transition-colors",
|
|
1070
1402
|
type="button"
|
|
1071
1403
|
),
|
|
1072
1404
|
cls="flex items-center gap-1"
|
|
@@ -1087,18 +1419,166 @@ def _posts_sidebar_fingerprint():
|
|
|
1087
1419
|
except Exception:
|
|
1088
1420
|
return 0
|
|
1089
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
|
+
|
|
1090
1562
|
@lru_cache(maxsize=1)
|
|
1091
1563
|
def _cached_posts_sidebar_html(fingerprint):
|
|
1564
|
+
sidebars_open = get_config().get_sidebars_open()
|
|
1092
1565
|
sidebar = collapsible_sidebar(
|
|
1093
1566
|
"menu",
|
|
1094
|
-
"
|
|
1567
|
+
"Library",
|
|
1095
1568
|
get_posts(),
|
|
1096
|
-
is_open=
|
|
1097
|
-
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"
|
|
1098
1578
|
)
|
|
1099
1579
|
return to_xml(sidebar)
|
|
1100
1580
|
|
|
1101
|
-
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"):
|
|
1102
1582
|
"""Reusable collapsible sidebar component with sticky header"""
|
|
1103
1583
|
# Build the summary content
|
|
1104
1584
|
summary_content = [
|
|
@@ -1109,22 +1589,45 @@ def collapsible_sidebar(icon, title, items_list, is_open=False, data_sidebar=Non
|
|
|
1109
1589
|
Span(title, cls="flex-1 leading-none")
|
|
1110
1590
|
]
|
|
1111
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
|
+
|
|
1112
1601
|
# Sidebar styling configuration
|
|
1113
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)]"
|
|
1114
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]"
|
|
1115
|
-
|
|
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"
|
|
1116
1610
|
|
|
1611
|
+
extra_content = extra_content or []
|
|
1612
|
+
content_id = "sidebar-scroll-container" if scroll_target != "list" else None
|
|
1117
1613
|
return Details(
|
|
1118
1614
|
Summary(*summary_content, cls=summary_classes, style="margin: 0 0 0.5rem 0;"),
|
|
1119
1615
|
Div(
|
|
1120
|
-
|
|
1616
|
+
*extra_content,
|
|
1617
|
+
Ul(*items_list, cls=list_classes, id="sidebar-scroll-container" if scroll_target == "list" else None),
|
|
1121
1618
|
cls=content_classes,
|
|
1122
|
-
id=
|
|
1619
|
+
id=content_id,
|
|
1620
|
+
style="will-change: auto;"
|
|
1123
1621
|
),
|
|
1124
1622
|
open=is_open,
|
|
1125
|
-
data_sidebar=data_sidebar
|
|
1623
|
+
data_sidebar=data_sidebar,
|
|
1624
|
+
style="will-change: auto;"
|
|
1126
1625
|
)
|
|
1127
1626
|
|
|
1627
|
+
@rt("/_sidebar/posts/search")
|
|
1628
|
+
def posts_sidebar_search(q: str = ""):
|
|
1629
|
+
return _render_posts_search_results(q)
|
|
1630
|
+
|
|
1128
1631
|
def is_active_toc_item(anchor):
|
|
1129
1632
|
"""Check if a TOC item is currently active based on URL hash"""
|
|
1130
1633
|
# This will be enhanced client-side with JavaScript
|
|
@@ -1140,11 +1643,13 @@ def extract_toc(content):
|
|
|
1140
1643
|
# Parse headings from the cleaned content
|
|
1141
1644
|
heading_pattern = re.compile(r'^(#{1,6})\s+(.+)$', re.MULTILINE)
|
|
1142
1645
|
headings = []
|
|
1646
|
+
counts = {}
|
|
1143
1647
|
for match in heading_pattern.finditer(content_no_code):
|
|
1144
1648
|
level = len(match.group(1))
|
|
1145
|
-
|
|
1649
|
+
raw_text = match.group(2).strip()
|
|
1650
|
+
text = _strip_inline_markdown(raw_text)
|
|
1146
1651
|
# Create anchor from heading text using shared function
|
|
1147
|
-
anchor = text_to_anchor(text)
|
|
1652
|
+
anchor = _unique_anchor(text_to_anchor(text), counts)
|
|
1148
1653
|
headings.append((level, text, anchor))
|
|
1149
1654
|
return headings
|
|
1150
1655
|
|
|
@@ -1204,7 +1709,7 @@ def get_custom_css_links(current_path=None, section_class=None):
|
|
|
1204
1709
|
|
|
1205
1710
|
return css_elements
|
|
1206
1711
|
|
|
1207
|
-
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):
|
|
1208
1713
|
import time
|
|
1209
1714
|
layout_start_time = time.time()
|
|
1210
1715
|
logger.debug("[LAYOUT] layout() start")
|
|
@@ -1212,23 +1717,30 @@ def layout(*content, htmx, title=None, show_sidebar=False, toc_content=None, cur
|
|
|
1212
1717
|
section_class = f"section-{current_path.replace('/', '-')}" if current_path else ""
|
|
1213
1718
|
t_section = time.time()
|
|
1214
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 ""
|
|
1215
1723
|
|
|
1216
1724
|
# HTMX short-circuit: build only swappable fragments, never build full page chrome/sidebars tree
|
|
1217
1725
|
if htmx and getattr(htmx, "request", None):
|
|
1218
1726
|
if show_sidebar:
|
|
1219
|
-
|
|
1220
|
-
t_toc =
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
"
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
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
|
+
)
|
|
1232
1744
|
|
|
1233
1745
|
custom_css_links = get_custom_css_links(current_path, section_class)
|
|
1234
1746
|
t_css = time.time()
|
|
@@ -1243,7 +1755,12 @@ def layout(*content, htmx, title=None, show_sidebar=False, toc_content=None, cur
|
|
|
1243
1755
|
result.append(Div(*custom_css_links, id="scoped-css-container", hx_swap_oob="true"))
|
|
1244
1756
|
else:
|
|
1245
1757
|
result.append(Div(id="scoped-css-container", hx_swap_oob="true"))
|
|
1246
|
-
|
|
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"))
|
|
1247
1764
|
|
|
1248
1765
|
t_htmx = time.time()
|
|
1249
1766
|
logger.debug(f"[LAYOUT] HTMX response assembled in {(t_htmx - t_main)*1000:.2f}ms")
|
|
@@ -1269,18 +1786,22 @@ def layout(*content, htmx, title=None, show_sidebar=False, toc_content=None, cur
|
|
|
1269
1786
|
|
|
1270
1787
|
if show_sidebar:
|
|
1271
1788
|
# Build TOC if content provided
|
|
1272
|
-
|
|
1273
|
-
t_toc =
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
"
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
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
|
+
)
|
|
1284
1805
|
# Container for main content only (for HTMX swapping)
|
|
1285
1806
|
# Add section class to identify the section for CSS scoping
|
|
1286
1807
|
section_class = f"section-{current_path.replace('/', '-')}" if current_path else ""
|
|
@@ -1307,27 +1828,33 @@ def layout(*content, htmx, title=None, show_sidebar=False, toc_content=None, cur
|
|
|
1307
1828
|
cls="p-4 overflow-y-auto"
|
|
1308
1829
|
),
|
|
1309
1830
|
id="mobile-posts-panel",
|
|
1310
|
-
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"
|
|
1311
1832
|
)
|
|
1312
|
-
mobile_toc_panel =
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
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"
|
|
1319
1844
|
),
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
cls="fixed inset-0 bg-white dark:bg-slate-950 z-[9999] md:hidden transform translate-x-full transition-transform duration-300"
|
|
1328
|
-
)
|
|
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
|
+
)
|
|
1329
1852
|
# Full layout with all sidebars
|
|
1330
|
-
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
|
+
)(
|
|
1331
1858
|
# Left sidebar - lazy load with HTMX, show loader placeholder
|
|
1332
1859
|
Aside(
|
|
1333
1860
|
Div(
|
|
@@ -1335,7 +1862,7 @@ def layout(*content, htmx, title=None, show_sidebar=False, toc_content=None, cur
|
|
|
1335
1862
|
Span("Loading posts…", cls="ml-2 text-sm"),
|
|
1336
1863
|
cls="flex items-center justify-center h-32 text-slate-400"
|
|
1337
1864
|
),
|
|
1338
|
-
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]",
|
|
1339
1866
|
id="posts-sidebar",
|
|
1340
1867
|
hx_get="/_sidebar/posts",
|
|
1341
1868
|
hx_trigger="load",
|
|
@@ -1344,27 +1871,46 @@ def layout(*content, htmx, title=None, show_sidebar=False, toc_content=None, cur
|
|
|
1344
1871
|
# Main content (swappable)
|
|
1345
1872
|
main_content_container,
|
|
1346
1873
|
# Right sidebar - TOC (swappable out-of-band)
|
|
1347
|
-
toc_sidebar
|
|
1874
|
+
toc_sidebar if toc_sidebar else None
|
|
1348
1875
|
)
|
|
1349
1876
|
t_sidebars = time.time()
|
|
1350
1877
|
logger.debug(f"[LAYOUT] Sidebars container built in {(t_sidebars - t_main)*1000:.2f}ms")
|
|
1351
1878
|
# Layout with sidebar for blog posts
|
|
1352
1879
|
body_content = Div(id="page-container", cls="flex flex-col min-h-screen")(
|
|
1353
|
-
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
|
+
),
|
|
1354
1886
|
mobile_posts_panel,
|
|
1355
|
-
mobile_toc_panel,
|
|
1887
|
+
mobile_toc_panel if mobile_toc_panel else None,
|
|
1356
1888
|
content_with_sidebars,
|
|
1357
|
-
Footer(Div(
|
|
1358
|
-
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))
|
|
1359
1893
|
)
|
|
1360
1894
|
else:
|
|
1361
1895
|
# Default layout without sidebar
|
|
1362
1896
|
custom_css_links = get_custom_css_links(current_path, section_class) if current_path else []
|
|
1363
1897
|
body_content = Div(id="page-container", cls="flex flex-col min-h-screen")(
|
|
1364
|
-
Div(
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
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))
|
|
1368
1914
|
)
|
|
1369
1915
|
t_body = time.time()
|
|
1370
1916
|
logger.debug(f"[LAYOUT] Body content (no sidebar) built in {(t_body - layout_start_time)*1000:.2f}ms")
|
|
@@ -1398,7 +1944,7 @@ def build_post_tree(folder):
|
|
|
1398
1944
|
if item.name.startswith('.'):
|
|
1399
1945
|
continue
|
|
1400
1946
|
entries.append(item)
|
|
1401
|
-
elif item.suffix
|
|
1947
|
+
elif item.suffix in ('.md', '.pdf'):
|
|
1402
1948
|
# Skip the file being used for home page (index.md takes precedence over readme.md)
|
|
1403
1949
|
if index_file and item.resolve() == index_file.resolve():
|
|
1404
1950
|
continue
|
|
@@ -1443,6 +1989,17 @@ def build_post_tree(folder):
|
|
|
1443
1989
|
hx_get=f'/posts/{slug}', hx_target="#main-content", hx_push_url="true", hx_swap="outerHTML show:window:top settle:0.1s",
|
|
1444
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",
|
|
1445
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)))
|
|
1446
2003
|
|
|
1447
2004
|
elapsed = (time.time() - start_time) * 1000
|
|
1448
2005
|
logger.debug(f"[DEBUG] build_post_tree for {folder.relative_to(root) if folder != root else '.'} completed in {elapsed:.2f}ms")
|
|
@@ -1452,8 +2009,9 @@ def _posts_tree_fingerprint():
|
|
|
1452
2009
|
root = get_root_folder()
|
|
1453
2010
|
try:
|
|
1454
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)
|
|
1455
2013
|
bloggy_mtime = max((p.stat().st_mtime for p in root.rglob(".bloggy")), default=0)
|
|
1456
|
-
return max(md_mtime, bloggy_mtime)
|
|
2014
|
+
return max(md_mtime, pdf_mtime, bloggy_mtime)
|
|
1457
2015
|
except Exception:
|
|
1458
2016
|
return 0
|
|
1459
2017
|
|
|
@@ -1491,6 +2049,10 @@ def not_found(htmx=None):
|
|
|
1491
2049
|
UkIcon("home", cls="w-5 h-5 mr-2"),
|
|
1492
2050
|
"Go to Home",
|
|
1493
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",
|
|
1494
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"
|
|
1495
2057
|
),
|
|
1496
2058
|
A(
|
|
@@ -1528,9 +2090,39 @@ def post_detail(path: str, htmx):
|
|
|
1528
2090
|
logger.info(f"\n[DEBUG] ########## REQUEST START: /posts/{path} ##########")
|
|
1529
2091
|
|
|
1530
2092
|
file_path = get_root_folder() / f'{path}.md'
|
|
2093
|
+
pdf_path = get_root_folder() / f'{path}.pdf'
|
|
1531
2094
|
|
|
1532
2095
|
# Check if file exists
|
|
1533
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)
|
|
1534
2126
|
return not_found(htmx)
|
|
1535
2127
|
|
|
1536
2128
|
metadata, raw_content = parse_frontmatter(file_path)
|
|
@@ -1544,7 +2136,31 @@ def post_detail(path: str, htmx):
|
|
|
1544
2136
|
md_time = (time.time() - md_start) * 1000
|
|
1545
2137
|
logger.debug(f"[DEBUG] Markdown rendering took {md_time:.2f}ms")
|
|
1546
2138
|
|
|
1547
|
-
|
|
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
|
+
)
|
|
1548
2164
|
|
|
1549
2165
|
# Always return complete layout with sidebar and TOC
|
|
1550
2166
|
layout_start = time.time()
|