bloggy 0.1.43__py3-none-any.whl → 0.2.5__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 +713 -91
- bloggy/main.py +3 -3
- bloggy/static/scripts.js +657 -45
- bloggy/static/sidenote.css +2 -2
- bloggy-0.2.5.dist-info/METADATA +173 -0
- bloggy-0.2.5.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.5.dist-info}/WHEEL +0 -0
- {bloggy-0.1.43.dist-info → bloggy-0.2.5.dist-info}/entry_points.txt +0 -0
- {bloggy-0.1.43.dist-info → bloggy-0.2.5.dist-info}/licenses/LICENSE +0 -0
- {bloggy-0.1.43.dist-info → bloggy-0.2.5.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"""
|
|
@@ -398,6 +518,12 @@ class ContentRenderer(FrankenRenderer):
|
|
|
398
518
|
self.fn_counter += 1
|
|
399
519
|
n, target = self.fn_counter, token.target
|
|
400
520
|
content = self.footnotes.get(target, f"[Missing footnote: {target}]")
|
|
521
|
+
if "\n" in content:
|
|
522
|
+
content = content.replace("\r\n", "\n")
|
|
523
|
+
placeholder = "__BLOGGY_PARA_BREAK__"
|
|
524
|
+
content = content.replace("\n\n", f"\n{placeholder}\n")
|
|
525
|
+
content = content.replace("\n", "<br>\n")
|
|
526
|
+
content = content.replace(f"\n{placeholder}\n", "\n\n")
|
|
401
527
|
rendered = mst.markdown(content, partial(ContentRenderer, img_dir=self.img_dir, current_path=self.current_path)).strip()
|
|
402
528
|
if rendered.startswith('<p>') and rendered.endswith('</p>'): rendered = rendered[3:-4]
|
|
403
529
|
style = "text-sm leading-relaxed border-l-2 border-amber-400 dark:border-blue-400 pl-3 text-neutral-500 dark:text-neutral-400 transition-all duration-500 w-full my-2 xl:my-0"
|
|
@@ -409,10 +535,12 @@ class ContentRenderer(FrankenRenderer):
|
|
|
409
535
|
|
|
410
536
|
def render_heading(self, token):
|
|
411
537
|
"""Render headings with anchor IDs for TOC linking"""
|
|
538
|
+
import html
|
|
412
539
|
level = token.level
|
|
413
540
|
inner = self.render_inner(token)
|
|
414
|
-
|
|
415
|
-
|
|
541
|
+
plain = _plain_text_from_html(inner)
|
|
542
|
+
anchor = _unique_anchor(text_to_anchor(plain), self.heading_counts)
|
|
543
|
+
return f'<h{level} id="{anchor}">{html.escape(plain)}</h{level}>'
|
|
416
544
|
|
|
417
545
|
def render_superscript(self, token):
|
|
418
546
|
"""Render superscript text"""
|
|
@@ -519,7 +647,8 @@ class ContentRenderer(FrankenRenderer):
|
|
|
519
647
|
# Use code without frontmatter for rendering
|
|
520
648
|
code = code_without_frontmatter
|
|
521
649
|
|
|
522
|
-
|
|
650
|
+
self.mermaid_counter += 1
|
|
651
|
+
diagram_id = f"mermaid-{abs(hash(code)) & 0xFFFFFF}-{self.mermaid_counter}"
|
|
523
652
|
|
|
524
653
|
# Determine if we need to break out of normal content flow
|
|
525
654
|
# This is required for viewport-based widths to properly center
|
|
@@ -550,17 +679,46 @@ class ContentRenderer(FrankenRenderer):
|
|
|
550
679
|
# For other languages: escape HTML/XML for display, but NOT for markdown
|
|
551
680
|
# (markdown code blocks should show raw source)
|
|
552
681
|
import html
|
|
682
|
+
raw_code = code
|
|
683
|
+
code = html.unescape(code)
|
|
553
684
|
if lang and lang.lower() != 'markdown':
|
|
554
685
|
code = html.escape(code)
|
|
555
686
|
lang_class = f' class="language-{lang}"' if lang else ''
|
|
556
|
-
|
|
687
|
+
icon_html = to_xml(UkIcon("copy", cls="w-4 h-4"))
|
|
688
|
+
code_id = f"codeblock-{abs(hash(raw_code)) & 0xFFFFFF}"
|
|
689
|
+
toast_id = f"{code_id}-toast"
|
|
690
|
+
textarea_id = f"{code_id}-clipboard"
|
|
691
|
+
escaped_raw = html.escape(raw_code)
|
|
692
|
+
return (
|
|
693
|
+
'<div class="code-block relative my-4">'
|
|
694
|
+
f'<button type="button" class="code-copy-button absolute top-2 right-2 '
|
|
695
|
+
'inline-flex items-center justify-center rounded border border-slate-200 '
|
|
696
|
+
'dark:border-slate-700 bg-white/80 dark:bg-slate-900/70 '
|
|
697
|
+
'text-slate-600 dark:text-slate-300 hover:text-slate-900 '
|
|
698
|
+
'dark:hover:text-white hover:border-slate-300 dark:hover:border-slate-500 '
|
|
699
|
+
f'transition-colors" aria-label="Copy code" '
|
|
700
|
+
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();}}}})()"'
|
|
701
|
+
'>'
|
|
702
|
+
f'{icon_html}<span class="sr-only">Copy code</span></button>'
|
|
703
|
+
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>'
|
|
704
|
+
f'<textarea id="{textarea_id}" class="absolute left-[-9999px] top-0 opacity-0 pointer-events-none">{escaped_raw}</textarea>'
|
|
705
|
+
f'<pre><code{lang_class}>{code}</code></pre>'
|
|
706
|
+
'</div>'
|
|
707
|
+
)
|
|
557
708
|
|
|
558
709
|
def render_link(self, token):
|
|
559
710
|
href, inner, title = token.target, self.render_inner(token), f' title="{token.title}"' if token.title else ''
|
|
560
711
|
# ...existing code...
|
|
561
|
-
|
|
712
|
+
is_hash = href.startswith('#')
|
|
713
|
+
is_external = href.startswith(('http://', 'https://', 'mailto:', 'tel:', '//'))
|
|
562
714
|
is_absolute_internal = href.startswith('/') and not href.startswith('//')
|
|
563
715
|
is_relative = not is_external and not is_absolute_internal
|
|
716
|
+
if is_hash:
|
|
717
|
+
link_class = (
|
|
718
|
+
"text-amber-600 dark:text-amber-400 underline underline-offset-2 "
|
|
719
|
+
"hover:text-amber-800 dark:hover:text-amber-200 font-medium transition-colors"
|
|
720
|
+
)
|
|
721
|
+
return f'<a href="{href}" class="{link_class}"{title}>{inner}</a>'
|
|
564
722
|
if is_relative:
|
|
565
723
|
from pathlib import Path
|
|
566
724
|
original_href = href
|
|
@@ -585,7 +743,7 @@ class ContentRenderer(FrankenRenderer):
|
|
|
585
743
|
logger.debug(f"DEBUG: No current_path, treating as external")
|
|
586
744
|
is_internal = is_absolute_internal and '.' not in href.split('/')[-1]
|
|
587
745
|
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"'
|
|
746
|
+
ext = '' if (is_internal or is_absolute_internal or is_hash) else ' target="_blank" rel="noopener noreferrer"'
|
|
589
747
|
# Amber/gold link styling, stands out and is accessible
|
|
590
748
|
link_class = (
|
|
591
749
|
"text-amber-600 dark:text-amber-400 underline underline-offset-2 "
|
|
@@ -632,7 +790,7 @@ def postprocess_tabs(html, tab_data_store, img_dir, current_path, footnotes):
|
|
|
632
790
|
def from_md(content, img_dir=None, current_path=None):
|
|
633
791
|
# Resolve img_dir from current_path if not explicitly provided
|
|
634
792
|
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)
|
|
793
|
+
# Convert current_path to URL path for images (e.g., demo/books/flat-land/chapter-01 -> /posts/demo/books/flat-land)
|
|
636
794
|
from pathlib import Path
|
|
637
795
|
path_parts = Path(current_path).parts
|
|
638
796
|
if len(path_parts) > 1:
|
|
@@ -666,7 +824,8 @@ def from_md(content, img_dir=None, current_path=None):
|
|
|
666
824
|
mods = {'pre': 'my-4', 'p': 'text-base leading-relaxed mb-6', 'li': 'text-base leading-relaxed',
|
|
667
825
|
'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
826
|
'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'
|
|
827
|
+
'h3': 'text-xl font-semibold mb-3 mt-5', 'h4': 'text-lg font-semibold mb-2 mt-4',
|
|
828
|
+
'table': 'uk-table uk-table-striped uk-table-hover uk-table-divider uk-table-middle my-6'}
|
|
670
829
|
|
|
671
830
|
# Register custom tokens with renderer context manager
|
|
672
831
|
with ContentRenderer(YoutubeEmbed, InlineCodeAttr, Strikethrough, FootnoteRef, Superscript, Subscript, img_dir=img_dir, footnotes=footnotes, current_path=current_path) as renderer:
|
|
@@ -682,10 +841,15 @@ def from_md(content, img_dir=None, current_path=None):
|
|
|
682
841
|
# App configuration
|
|
683
842
|
def get_root_folder(): return get_config().get_root_folder()
|
|
684
843
|
def get_blog_title(): return get_config().get_blog_title()
|
|
844
|
+
def get_favicon_href():
|
|
845
|
+
root_icon = get_root_folder() / "static" / "icon.png"
|
|
846
|
+
if root_icon.exists():
|
|
847
|
+
return "/static/icon.png"
|
|
848
|
+
return "/static/favicon.png"
|
|
685
849
|
|
|
686
850
|
hdrs = (
|
|
687
851
|
*Theme.slate.headers(highlightjs=True),
|
|
688
|
-
Link(rel="icon", href=
|
|
852
|
+
Link(rel="icon", href=get_favicon_href()),
|
|
689
853
|
Script(src="https://unpkg.com/hyperscript.org@0.9.12"),
|
|
690
854
|
Script(src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs", type="module"),
|
|
691
855
|
Script("""
|
|
@@ -812,13 +976,48 @@ hdrs = (
|
|
|
812
976
|
.sidebar-highlight.fade-out {
|
|
813
977
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0);
|
|
814
978
|
}
|
|
979
|
+
|
|
980
|
+
/* PDF focus mode */
|
|
981
|
+
body.pdf-focus {
|
|
982
|
+
overflow: hidden;
|
|
983
|
+
}
|
|
984
|
+
body.pdf-focus #site-navbar,
|
|
985
|
+
body.pdf-focus #site-footer,
|
|
986
|
+
body.pdf-focus #posts-sidebar,
|
|
987
|
+
body.pdf-focus #toc-sidebar,
|
|
988
|
+
body.pdf-focus #mobile-posts-panel,
|
|
989
|
+
body.pdf-focus #mobile-toc-panel {
|
|
990
|
+
display: none !important;
|
|
991
|
+
}
|
|
992
|
+
body.pdf-focus #content-with-sidebars {
|
|
993
|
+
max-width: none !important;
|
|
994
|
+
width: 100vw !important;
|
|
995
|
+
padding: 0 !important;
|
|
996
|
+
margin: 0 !important;
|
|
997
|
+
gap: 0 !important;
|
|
998
|
+
}
|
|
999
|
+
body.pdf-focus #main-content {
|
|
1000
|
+
padding: 1rem !important;
|
|
1001
|
+
}
|
|
1002
|
+
body.pdf-focus .pdf-viewer {
|
|
1003
|
+
height: calc(100vh - 6rem) !important;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
.layout-fluid {
|
|
1007
|
+
--layout-breakpoint: 1280px;
|
|
1008
|
+
--layout-blend: 240px;
|
|
1009
|
+
max-width: calc(
|
|
1010
|
+
100% - (100% - var(--layout-max-width))
|
|
1011
|
+
* clamp(0, (100vw - var(--layout-breakpoint)) / var(--layout-blend), 1)
|
|
1012
|
+
) !important;
|
|
1013
|
+
}
|
|
815
1014
|
|
|
816
1015
|
/* Tabs styles */
|
|
817
1016
|
.tabs-container {
|
|
818
1017
|
margin: 2rem 0;
|
|
819
1018
|
border: 1px solid rgb(226 232 240);
|
|
820
1019
|
border-radius: 0.5rem;
|
|
821
|
-
overflow:
|
|
1020
|
+
overflow: visible;
|
|
822
1021
|
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
|
823
1022
|
}
|
|
824
1023
|
.dark .tabs-container {
|
|
@@ -877,6 +1076,7 @@ hdrs = (
|
|
|
877
1076
|
.tabs-content {
|
|
878
1077
|
background: white;
|
|
879
1078
|
position: relative;
|
|
1079
|
+
overflow: visible;
|
|
880
1080
|
}
|
|
881
1081
|
.dark .tabs-content {
|
|
882
1082
|
background: rgb(2 6 23);
|
|
@@ -892,6 +1092,7 @@ hdrs = (
|
|
|
892
1092
|
opacity: 0;
|
|
893
1093
|
visibility: hidden;
|
|
894
1094
|
pointer-events: none;
|
|
1095
|
+
overflow: visible;
|
|
895
1096
|
}
|
|
896
1097
|
.tab-panel.active {
|
|
897
1098
|
position: relative;
|
|
@@ -918,6 +1119,43 @@ hdrs = (
|
|
|
918
1119
|
font-family: 'IBM Plex Mono', monospace;
|
|
919
1120
|
}
|
|
920
1121
|
"""),
|
|
1122
|
+
# Custom table stripe styling for punchier colors
|
|
1123
|
+
Style("""
|
|
1124
|
+
.uk-table-striped tbody tr:nth-of-type(odd) {
|
|
1125
|
+
background-color: rgba(71, 85, 105, 0.08);
|
|
1126
|
+
}
|
|
1127
|
+
.dark .uk-table-striped tbody tr:nth-of-type(odd) {
|
|
1128
|
+
background-color: rgba(148, 163, 184, 0.12);
|
|
1129
|
+
}
|
|
1130
|
+
.uk-table-striped tbody tr:hover {
|
|
1131
|
+
background-color: rgba(59, 130, 246, 0.1);
|
|
1132
|
+
}
|
|
1133
|
+
.dark .uk-table-striped tbody tr:hover {
|
|
1134
|
+
background-color: rgba(59, 130, 246, 0.15);
|
|
1135
|
+
}
|
|
1136
|
+
.uk-table thead {
|
|
1137
|
+
border-bottom: 2px solid rgba(71, 85, 105, 0.3);
|
|
1138
|
+
}
|
|
1139
|
+
.dark .uk-table thead {
|
|
1140
|
+
border-bottom: 2px solid rgba(148, 163, 184, 0.4);
|
|
1141
|
+
}
|
|
1142
|
+
.uk-table thead th {
|
|
1143
|
+
font-weight: 600;
|
|
1144
|
+
font-size: 1.25rem;
|
|
1145
|
+
color: rgb(51, 65, 85);
|
|
1146
|
+
}
|
|
1147
|
+
.dark .uk-table thead th {
|
|
1148
|
+
color: rgb(226, 232, 240);
|
|
1149
|
+
}
|
|
1150
|
+
.uk-table th:not(:last-child),
|
|
1151
|
+
.uk-table td:not(:last-child) {
|
|
1152
|
+
border-right: 1px solid rgba(71, 85, 105, 0.15);
|
|
1153
|
+
}
|
|
1154
|
+
.dark .uk-table th:not(:last-child),
|
|
1155
|
+
.dark .uk-table td:not(:last-child) {
|
|
1156
|
+
border-right: 1px solid rgba(148, 163, 184, 0.2);
|
|
1157
|
+
}
|
|
1158
|
+
"""),
|
|
921
1159
|
# Script("if(!localStorage.__FRANKEN__) localStorage.__FRANKEN__ = JSON.stringify({mode: 'light'})"))
|
|
922
1160
|
Script("""
|
|
923
1161
|
(function () {
|
|
@@ -969,8 +1207,23 @@ logger.info(f'{beforeware=}')
|
|
|
969
1207
|
|
|
970
1208
|
app = FastHTML(hdrs=hdrs, before=beforeware) if beforeware else FastHTML(hdrs=hdrs)
|
|
971
1209
|
|
|
972
|
-
|
|
1210
|
+
def _favicon_icon_path():
|
|
1211
|
+
root_icon = get_root_folder() / "static" / "icon.png"
|
|
1212
|
+
if root_icon.exists():
|
|
1213
|
+
return root_icon
|
|
1214
|
+
package_favicon = Path(__file__).parent / "static" / "favicon.png"
|
|
1215
|
+
if package_favicon.exists():
|
|
1216
|
+
return package_favicon
|
|
1217
|
+
return None
|
|
1218
|
+
|
|
1219
|
+
@app.route("/static/icon.png")
|
|
1220
|
+
async def favicon_icon():
|
|
1221
|
+
path = _favicon_icon_path()
|
|
1222
|
+
if path and path.exists():
|
|
1223
|
+
return FileResponse(path)
|
|
1224
|
+
return Response(status_code=404)
|
|
973
1225
|
|
|
1226
|
+
static_dir = Path(__file__).parent / "static"
|
|
974
1227
|
if static_dir.exists():
|
|
975
1228
|
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
|
976
1229
|
|
|
@@ -978,7 +1231,7 @@ rt = app.route
|
|
|
978
1231
|
|
|
979
1232
|
|
|
980
1233
|
from starlette.requests import Request
|
|
981
|
-
from starlette.responses import RedirectResponse
|
|
1234
|
+
from starlette.responses import RedirectResponse, FileResponse, Response
|
|
982
1235
|
|
|
983
1236
|
@rt("/login", methods=["GET", "POST"])
|
|
984
1237
|
async def login(request: Request):
|
|
@@ -1017,10 +1270,88 @@ def posts_sidebar_lazy():
|
|
|
1017
1270
|
html = _cached_posts_sidebar_html(_posts_sidebar_fingerprint())
|
|
1018
1271
|
return Aside(
|
|
1019
1272
|
NotStr(html),
|
|
1020
|
-
cls="hidden
|
|
1273
|
+
cls="hidden xl:block w-72 shrink-0 sticky top-24 self-start max-h-[calc(100vh-10rem)] overflow-hidden z-[1000]",
|
|
1021
1274
|
id="posts-sidebar"
|
|
1022
1275
|
)
|
|
1023
1276
|
|
|
1277
|
+
# Route to serve raw markdown for LLM-friendly access
|
|
1278
|
+
@rt("/posts/{path:path}.md")
|
|
1279
|
+
def serve_post_markdown(path: str):
|
|
1280
|
+
from starlette.responses import FileResponse
|
|
1281
|
+
file_path = get_root_folder() / f'{path}.md'
|
|
1282
|
+
if file_path.exists():
|
|
1283
|
+
return FileResponse(file_path, media_type="text/markdown; charset=utf-8")
|
|
1284
|
+
return Response(status_code=404)
|
|
1285
|
+
|
|
1286
|
+
@rt("/search/gather")
|
|
1287
|
+
def gather_search_results(htmx, q: str = ""):
|
|
1288
|
+
import html
|
|
1289
|
+
matches, regex_error = _find_search_matches(q, limit=200)
|
|
1290
|
+
if not matches:
|
|
1291
|
+
content = Div(
|
|
1292
|
+
H1("Search Results", cls="text-3xl font-bold mb-6"),
|
|
1293
|
+
P("No matching posts found.", cls="text-slate-600 dark:text-slate-400"),
|
|
1294
|
+
P(regex_error, cls="text-amber-600 dark:text-amber-400 text-sm") if regex_error else None
|
|
1295
|
+
)
|
|
1296
|
+
return layout(content, htmx=htmx, title="Search Results", show_sidebar=True)
|
|
1297
|
+
|
|
1298
|
+
root = get_root_folder()
|
|
1299
|
+
sections = []
|
|
1300
|
+
copy_parts = [f"# Search Results: {q.strip() or 'All'}\n"]
|
|
1301
|
+
if regex_error:
|
|
1302
|
+
copy_parts.append(f"> {regex_error}\n")
|
|
1303
|
+
for idx, item in enumerate(matches):
|
|
1304
|
+
rel = item.relative_to(root).as_posix()
|
|
1305
|
+
if item.suffix == ".pdf":
|
|
1306
|
+
slug = item.relative_to(root).with_suffix("").as_posix()
|
|
1307
|
+
pdf_href = f"/posts/{slug}.pdf"
|
|
1308
|
+
sections.extend([
|
|
1309
|
+
H2(rel, cls="text-xl font-semibold mb-2"),
|
|
1310
|
+
P(
|
|
1311
|
+
"PDF file: ",
|
|
1312
|
+
A(rel, href=pdf_href, cls="text-blue-600 hover:underline"),
|
|
1313
|
+
cls="text-sm text-slate-600 dark:text-slate-300"
|
|
1314
|
+
),
|
|
1315
|
+
Hr(cls="my-6 border-slate-200 dark:border-slate-800") if idx < len(matches) - 1 else None
|
|
1316
|
+
])
|
|
1317
|
+
copy_parts.append(f"\n---\n\n## {rel}\n\n[PDF file]({pdf_href})\n")
|
|
1318
|
+
continue
|
|
1319
|
+
try:
|
|
1320
|
+
raw_md = item.read_text(encoding="utf-8")
|
|
1321
|
+
except Exception:
|
|
1322
|
+
raw_md = ""
|
|
1323
|
+
sections.extend([
|
|
1324
|
+
H2(rel, cls="text-xl font-semibold mb-2"),
|
|
1325
|
+
Pre(html.escape(raw_md), cls="text-xs font-mono whitespace-pre-wrap text-slate-700 dark:text-slate-300"),
|
|
1326
|
+
Hr(cls="my-6 border-slate-200 dark:border-slate-800") if idx < len(matches) - 1 else None
|
|
1327
|
+
])
|
|
1328
|
+
copy_parts.append(f"\n---\n\n## {rel}\n\n{raw_md}\n")
|
|
1329
|
+
|
|
1330
|
+
copy_text = "".join(copy_parts)
|
|
1331
|
+
content = Div(
|
|
1332
|
+
H1(f"Search Results: {q.strip() or 'All'}", cls="text-3xl font-bold mb-6"),
|
|
1333
|
+
P(regex_error, cls="text-amber-600 dark:text-amber-400 text-sm mb-4") if regex_error else None,
|
|
1334
|
+
Button(
|
|
1335
|
+
UkIcon("copy", cls="w-5 h-5"),
|
|
1336
|
+
Span("Copy all results", cls="text-sm font-semibold"),
|
|
1337
|
+
type="button",
|
|
1338
|
+
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();}})()",
|
|
1339
|
+
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"
|
|
1340
|
+
),
|
|
1341
|
+
Div(
|
|
1342
|
+
"Copied!",
|
|
1343
|
+
id="gather-toast",
|
|
1344
|
+
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"
|
|
1345
|
+
),
|
|
1346
|
+
Textarea(
|
|
1347
|
+
copy_text,
|
|
1348
|
+
id="gather-clipboard",
|
|
1349
|
+
cls="absolute left-[-9999px] top-0 opacity-0 pointer-events-none"
|
|
1350
|
+
),
|
|
1351
|
+
*sections
|
|
1352
|
+
)
|
|
1353
|
+
return layout(content, htmx=htmx, title="Search Results", show_sidebar=True)
|
|
1354
|
+
|
|
1024
1355
|
# Route to serve static files (images, SVGs, etc.) from blog posts
|
|
1025
1356
|
@rt("/posts/{path:path}.{ext:static}")
|
|
1026
1357
|
def serve_post_static(path: str, ext: str):
|
|
@@ -1043,7 +1374,14 @@ def theme_toggle():
|
|
|
1043
1374
|
def navbar(show_mobile_menus=False):
|
|
1044
1375
|
"""Navbar with mobile menu buttons for file tree and TOC"""
|
|
1045
1376
|
left_section = Div(
|
|
1046
|
-
A(
|
|
1377
|
+
A(
|
|
1378
|
+
get_blog_title(),
|
|
1379
|
+
href="/",
|
|
1380
|
+
hx_get="/",
|
|
1381
|
+
hx_target="#main-content",
|
|
1382
|
+
hx_push_url="true",
|
|
1383
|
+
hx_swap="outerHTML show:window:top settle:0.1s"
|
|
1384
|
+
),
|
|
1047
1385
|
cls="flex items-center gap-2"
|
|
1048
1386
|
)
|
|
1049
1387
|
|
|
@@ -1059,14 +1397,14 @@ def navbar(show_mobile_menus=False):
|
|
|
1059
1397
|
UkIcon("menu", cls="w-5 h-5"),
|
|
1060
1398
|
title="Toggle file tree",
|
|
1061
1399
|
id="mobile-posts-toggle",
|
|
1062
|
-
cls="
|
|
1400
|
+
cls="xl:hidden p-2 hover:bg-slate-800 rounded transition-colors",
|
|
1063
1401
|
type="button"
|
|
1064
1402
|
),
|
|
1065
1403
|
Button(
|
|
1066
1404
|
UkIcon("list", cls="w-5 h-5"),
|
|
1067
1405
|
title="Toggle table of contents",
|
|
1068
1406
|
id="mobile-toc-toggle",
|
|
1069
|
-
cls="
|
|
1407
|
+
cls="xl:hidden p-2 hover:bg-slate-800 rounded transition-colors",
|
|
1070
1408
|
type="button"
|
|
1071
1409
|
),
|
|
1072
1410
|
cls="flex items-center gap-1"
|
|
@@ -1087,18 +1425,166 @@ def _posts_sidebar_fingerprint():
|
|
|
1087
1425
|
except Exception:
|
|
1088
1426
|
return 0
|
|
1089
1427
|
|
|
1428
|
+
def _normalize_search_text(text):
|
|
1429
|
+
text = (text or "").lower()
|
|
1430
|
+
text = text.replace("-", " ").replace("_", " ")
|
|
1431
|
+
return " ".join(text.split())
|
|
1432
|
+
|
|
1433
|
+
def _parse_search_query(query):
|
|
1434
|
+
trimmed = (query or "").strip()
|
|
1435
|
+
if len(trimmed) >= 2 and trimmed.startswith("/") and trimmed.endswith("/"):
|
|
1436
|
+
pattern = trimmed[1:-1].strip()
|
|
1437
|
+
if not pattern:
|
|
1438
|
+
return None, ""
|
|
1439
|
+
try:
|
|
1440
|
+
return re.compile(pattern, re.IGNORECASE), ""
|
|
1441
|
+
except re.error:
|
|
1442
|
+
return None, "Invalid regex. Showing normal matches instead."
|
|
1443
|
+
return None, ""
|
|
1444
|
+
|
|
1445
|
+
@lru_cache(maxsize=256)
|
|
1446
|
+
def _cached_search_matches(fingerprint, query, limit):
|
|
1447
|
+
return _find_search_matches_uncached(query, limit)
|
|
1448
|
+
|
|
1449
|
+
def _find_search_matches(query, limit=40):
|
|
1450
|
+
fingerprint = _posts_sidebar_fingerprint()
|
|
1451
|
+
return _cached_search_matches(fingerprint, query, limit)
|
|
1452
|
+
|
|
1453
|
+
def _find_search_matches_uncached(query, limit=40):
|
|
1454
|
+
trimmed = (query or "").strip()
|
|
1455
|
+
if not trimmed:
|
|
1456
|
+
return [], ""
|
|
1457
|
+
regex, regex_error = _parse_search_query(trimmed)
|
|
1458
|
+
query_norm = _normalize_search_text(trimmed) if not regex else ""
|
|
1459
|
+
root = get_root_folder()
|
|
1460
|
+
index_file = find_index_file()
|
|
1461
|
+
results = []
|
|
1462
|
+
for item in chain(root.rglob("*.md"), root.rglob("*.pdf")):
|
|
1463
|
+
if any(part.startswith('.') for part in item.relative_to(root).parts):
|
|
1464
|
+
continue
|
|
1465
|
+
if ".bloggy" in item.parts:
|
|
1466
|
+
continue
|
|
1467
|
+
if index_file and item.resolve() == index_file.resolve():
|
|
1468
|
+
continue
|
|
1469
|
+
rel = item.relative_to(root).with_suffix("")
|
|
1470
|
+
if regex:
|
|
1471
|
+
haystack = f"{item.name} {rel.as_posix()}"
|
|
1472
|
+
is_match = regex.search(haystack)
|
|
1473
|
+
else:
|
|
1474
|
+
haystack = _normalize_search_text(f"{item.name} {rel.as_posix()}")
|
|
1475
|
+
is_match = query_norm in haystack
|
|
1476
|
+
if is_match:
|
|
1477
|
+
results.append(item)
|
|
1478
|
+
if len(results) >= limit:
|
|
1479
|
+
break
|
|
1480
|
+
return tuple(results), regex_error
|
|
1481
|
+
|
|
1482
|
+
def _render_posts_search_results(query):
|
|
1483
|
+
trimmed = (query or "").strip()
|
|
1484
|
+
if not trimmed:
|
|
1485
|
+
return Ul(
|
|
1486
|
+
Li("Type to search file names.", cls="text-[0.7rem] text-center text-slate-500 dark:text-slate-400 bg-transparent"),
|
|
1487
|
+
cls="posts-search-results-list space-y-1 bg-white/0 dark:bg-slate-950/0"
|
|
1488
|
+
)
|
|
1489
|
+
|
|
1490
|
+
matches, regex_error = _find_search_matches(trimmed)
|
|
1491
|
+
if not matches:
|
|
1492
|
+
return Ul(
|
|
1493
|
+
Li(f'No matches for "{trimmed}".', cls="text-xs text-slate-500 dark:text-slate-400 bg-transparent"),
|
|
1494
|
+
Li(regex_error, cls="text-[0.7rem] text-center text-amber-600 dark:text-amber-400") if regex_error else None,
|
|
1495
|
+
cls="posts-search-results-list space-y-1 bg-white/0 dark:bg-slate-950/0"
|
|
1496
|
+
)
|
|
1497
|
+
|
|
1498
|
+
root = get_root_folder()
|
|
1499
|
+
items = []
|
|
1500
|
+
gather_href = f"/search/gather?q={quote_plus(trimmed)}"
|
|
1501
|
+
items.append(Li(
|
|
1502
|
+
A(
|
|
1503
|
+
Span(UkIcon("layers", cls="w-4 h-4 text-slate-400"), cls="w-4 mr-2 flex items-center justify-center shrink-0"),
|
|
1504
|
+
Span("Gather all search results for LLM", cls="truncate min-w-0 text-xs text-slate-600 dark:text-slate-300"),
|
|
1505
|
+
href=gather_href,
|
|
1506
|
+
hx_get=gather_href,
|
|
1507
|
+
hx_target="#main-content",
|
|
1508
|
+
hx_push_url="true",
|
|
1509
|
+
hx_swap="outerHTML show:window:top settle:0.1s",
|
|
1510
|
+
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"
|
|
1511
|
+
),
|
|
1512
|
+
cls="bg-transparent"
|
|
1513
|
+
))
|
|
1514
|
+
for item in matches:
|
|
1515
|
+
slug = str(item.relative_to(root).with_suffix(""))
|
|
1516
|
+
if item.suffix == ".pdf":
|
|
1517
|
+
display = item.relative_to(root).as_posix()
|
|
1518
|
+
else:
|
|
1519
|
+
display = item.relative_to(root).with_suffix("").as_posix()
|
|
1520
|
+
items.append(Li(
|
|
1521
|
+
A(
|
|
1522
|
+
Span(UkIcon("search", cls="w-4 h-4 text-slate-400"), cls="w-4 mr-2 flex items-center justify-center shrink-0"),
|
|
1523
|
+
Span(display, cls="truncate min-w-0 font-mono text-xs text-slate-600 dark:text-slate-300", title=display),
|
|
1524
|
+
href=f'/posts/{slug}',
|
|
1525
|
+
hx_get=f'/posts/{slug}', hx_target="#main-content", hx_push_url="true", hx_swap="outerHTML show:window:top settle:0.1s",
|
|
1526
|
+
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"
|
|
1527
|
+
)
|
|
1528
|
+
))
|
|
1529
|
+
if regex_error:
|
|
1530
|
+
items.append(Li(regex_error, cls="text-[0.7rem] text-center text-amber-600 dark:text-amber-400 mt-1 bg-transparent"))
|
|
1531
|
+
return Ul(*items, cls="posts-search-results-list space-y-1 bg-white/0 dark:bg-slate-950/0")
|
|
1532
|
+
|
|
1533
|
+
def _posts_search_block():
|
|
1534
|
+
return Div(
|
|
1535
|
+
Div("Filter", cls="text-xs uppercase tracking-widest text-slate-500 dark:text-slate-400 mb-2"),
|
|
1536
|
+
Div(
|
|
1537
|
+
Input(
|
|
1538
|
+
type="search",
|
|
1539
|
+
name="q",
|
|
1540
|
+
placeholder="Search file names…",
|
|
1541
|
+
autocomplete="off",
|
|
1542
|
+
data_placeholder_cycle="1",
|
|
1543
|
+
data_placeholder_primary="Search file names…",
|
|
1544
|
+
data_placeholder_alt="Search regex with /pattern/ syntax",
|
|
1545
|
+
data_search_key="posts",
|
|
1546
|
+
hx_get="/_sidebar/posts/search",
|
|
1547
|
+
hx_trigger="input changed delay:300ms",
|
|
1548
|
+
hx_target="next .posts-search-results",
|
|
1549
|
+
hx_swap="innerHTML",
|
|
1550
|
+
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"
|
|
1551
|
+
),
|
|
1552
|
+
Button(
|
|
1553
|
+
"×",
|
|
1554
|
+
type="button",
|
|
1555
|
+
aria_label="Clear search",
|
|
1556
|
+
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"
|
|
1557
|
+
),
|
|
1558
|
+
cls="relative"
|
|
1559
|
+
),
|
|
1560
|
+
Div(
|
|
1561
|
+
_render_posts_search_results(""),
|
|
1562
|
+
id="posts-search-results",
|
|
1563
|
+
cls="posts-search-results mt-4 max-h-64 overflow-y-auto bg-white/0 dark:bg-slate-950/0"
|
|
1564
|
+
),
|
|
1565
|
+
cls="posts-search-block sticky top-0 z-10 bg-white/20 dark:bg-slate-950/70 mb-3"
|
|
1566
|
+
)
|
|
1567
|
+
|
|
1090
1568
|
@lru_cache(maxsize=1)
|
|
1091
1569
|
def _cached_posts_sidebar_html(fingerprint):
|
|
1570
|
+
sidebars_open = get_config().get_sidebars_open()
|
|
1092
1571
|
sidebar = collapsible_sidebar(
|
|
1093
1572
|
"menu",
|
|
1094
|
-
"
|
|
1573
|
+
"Library",
|
|
1095
1574
|
get_posts(),
|
|
1096
|
-
is_open=
|
|
1097
|
-
data_sidebar="posts"
|
|
1575
|
+
is_open=sidebars_open,
|
|
1576
|
+
data_sidebar="posts",
|
|
1577
|
+
shortcut_key="Z",
|
|
1578
|
+
extra_content=[
|
|
1579
|
+
_posts_search_block(),
|
|
1580
|
+
Div(cls="h-px w-full bg-slate-200/80 dark:bg-slate-700/70 my-2"),
|
|
1581
|
+
Div("Posts", cls="text-xs uppercase tracking-widest text-slate-500 dark:text-slate-400 mb-1")
|
|
1582
|
+
],
|
|
1583
|
+
scroll_target="list"
|
|
1098
1584
|
)
|
|
1099
1585
|
return to_xml(sidebar)
|
|
1100
1586
|
|
|
1101
|
-
def collapsible_sidebar(icon, title, items_list, is_open=False, data_sidebar=None):
|
|
1587
|
+
def collapsible_sidebar(icon, title, items_list, is_open=False, data_sidebar=None, shortcut_key=None, extra_content=None, scroll_target="container"):
|
|
1102
1588
|
"""Reusable collapsible sidebar component with sticky header"""
|
|
1103
1589
|
# Build the summary content
|
|
1104
1590
|
summary_content = [
|
|
@@ -1109,22 +1595,45 @@ def collapsible_sidebar(icon, title, items_list, is_open=False, data_sidebar=Non
|
|
|
1109
1595
|
Span(title, cls="flex-1 leading-none")
|
|
1110
1596
|
]
|
|
1111
1597
|
|
|
1598
|
+
# Add keyboard shortcut indicator if provided
|
|
1599
|
+
if shortcut_key:
|
|
1600
|
+
summary_content.append(
|
|
1601
|
+
Kbd(
|
|
1602
|
+
shortcut_key,
|
|
1603
|
+
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)]"
|
|
1604
|
+
)
|
|
1605
|
+
)
|
|
1606
|
+
|
|
1112
1607
|
# Sidebar styling configuration
|
|
1113
1608
|
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
1609
|
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
|
-
|
|
1610
|
+
if scroll_target == "list":
|
|
1611
|
+
content_classes = f"p-3 {common_frost_style} rounded-lg max-h-[calc(100vh-18rem)] flex flex-col overflow-hidden min-h-0"
|
|
1612
|
+
list_classes = "list-none pt-2 flex-1 min-h-0 overflow-y-auto sidebar-scroll-container"
|
|
1613
|
+
else:
|
|
1614
|
+
content_classes = f"p-3 {common_frost_style} rounded-lg overflow-y-auto max-h-[calc(100vh-18rem)] sidebar-scroll-container"
|
|
1615
|
+
list_classes = "list-none pt-4"
|
|
1116
1616
|
|
|
1617
|
+
extra_content = extra_content or []
|
|
1618
|
+
content_id = "sidebar-scroll-container" if scroll_target != "list" else None
|
|
1117
1619
|
return Details(
|
|
1118
1620
|
Summary(*summary_content, cls=summary_classes, style="margin: 0 0 0.5rem 0;"),
|
|
1119
1621
|
Div(
|
|
1120
|
-
|
|
1622
|
+
*extra_content,
|
|
1623
|
+
Ul(*items_list, cls=list_classes, id="sidebar-scroll-container" if scroll_target == "list" else None),
|
|
1121
1624
|
cls=content_classes,
|
|
1122
|
-
id=
|
|
1625
|
+
id=content_id,
|
|
1626
|
+
style="will-change: auto;"
|
|
1123
1627
|
),
|
|
1124
1628
|
open=is_open,
|
|
1125
|
-
data_sidebar=data_sidebar
|
|
1629
|
+
data_sidebar=data_sidebar,
|
|
1630
|
+
style="will-change: auto;"
|
|
1126
1631
|
)
|
|
1127
1632
|
|
|
1633
|
+
@rt("/_sidebar/posts/search")
|
|
1634
|
+
def posts_sidebar_search(q: str = ""):
|
|
1635
|
+
return _render_posts_search_results(q)
|
|
1636
|
+
|
|
1128
1637
|
def is_active_toc_item(anchor):
|
|
1129
1638
|
"""Check if a TOC item is currently active based on URL hash"""
|
|
1130
1639
|
# This will be enhanced client-side with JavaScript
|
|
@@ -1140,11 +1649,13 @@ def extract_toc(content):
|
|
|
1140
1649
|
# Parse headings from the cleaned content
|
|
1141
1650
|
heading_pattern = re.compile(r'^(#{1,6})\s+(.+)$', re.MULTILINE)
|
|
1142
1651
|
headings = []
|
|
1652
|
+
counts = {}
|
|
1143
1653
|
for match in heading_pattern.finditer(content_no_code):
|
|
1144
1654
|
level = len(match.group(1))
|
|
1145
|
-
|
|
1655
|
+
raw_text = match.group(2).strip()
|
|
1656
|
+
text = _strip_inline_markdown(raw_text)
|
|
1146
1657
|
# Create anchor from heading text using shared function
|
|
1147
|
-
anchor = text_to_anchor(text)
|
|
1658
|
+
anchor = _unique_anchor(text_to_anchor(text), counts)
|
|
1148
1659
|
headings.append((level, text, anchor))
|
|
1149
1660
|
return headings
|
|
1150
1661
|
|
|
@@ -1204,7 +1715,7 @@ def get_custom_css_links(current_path=None, section_class=None):
|
|
|
1204
1715
|
|
|
1205
1716
|
return css_elements
|
|
1206
1717
|
|
|
1207
|
-
def layout(*content, htmx, title=None, show_sidebar=False, toc_content=None, current_path=None):
|
|
1718
|
+
def layout(*content, htmx, title=None, show_sidebar=False, toc_content=None, current_path=None, show_toc=True):
|
|
1208
1719
|
import time
|
|
1209
1720
|
layout_start_time = time.time()
|
|
1210
1721
|
logger.debug("[LAYOUT] layout() start")
|
|
@@ -1212,23 +1723,30 @@ def layout(*content, htmx, title=None, show_sidebar=False, toc_content=None, cur
|
|
|
1212
1723
|
section_class = f"section-{current_path.replace('/', '-')}" if current_path else ""
|
|
1213
1724
|
t_section = time.time()
|
|
1214
1725
|
logger.debug(f"[LAYOUT] section_class computed in {(t_section - layout_start_time)*1000:.2f}ms")
|
|
1726
|
+
layout_config = _resolve_layout_config(current_path)
|
|
1727
|
+
layout_max_class, layout_max_style = _width_class_and_style(layout_config.get("layout_max_width"), "max")
|
|
1728
|
+
layout_fluid_class = "layout-fluid" if layout_max_style else ""
|
|
1215
1729
|
|
|
1216
1730
|
# HTMX short-circuit: build only swappable fragments, never build full page chrome/sidebars tree
|
|
1217
1731
|
if htmx and getattr(htmx, "request", None):
|
|
1218
1732
|
if show_sidebar:
|
|
1219
|
-
|
|
1220
|
-
t_toc =
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
"
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1733
|
+
toc_sidebar = None
|
|
1734
|
+
t_toc = t_section
|
|
1735
|
+
if show_toc:
|
|
1736
|
+
toc_items = build_toc_items(extract_toc(toc_content)) if toc_content else []
|
|
1737
|
+
t_toc = time.time()
|
|
1738
|
+
logger.debug(f"[LAYOUT] TOC built in {(t_toc - t_section)*1000:.2f}ms")
|
|
1739
|
+
|
|
1740
|
+
sidebars_open = get_config().get_sidebars_open()
|
|
1741
|
+
toc_attrs = {
|
|
1742
|
+
"cls": "hidden xl:block w-72 shrink-0 sticky top-24 self-start max-h-[calc(100vh-10rem)] overflow-hidden z-[1000]",
|
|
1743
|
+
"id": "toc-sidebar",
|
|
1744
|
+
"hx_swap_oob": "true",
|
|
1745
|
+
}
|
|
1746
|
+
toc_sidebar = Aside(
|
|
1747
|
+
collapsible_sidebar("list", "Contents", toc_items, is_open=sidebars_open, shortcut_key="X") if toc_items else Div(),
|
|
1748
|
+
**toc_attrs
|
|
1749
|
+
)
|
|
1232
1750
|
|
|
1233
1751
|
custom_css_links = get_custom_css_links(current_path, section_class)
|
|
1234
1752
|
t_css = time.time()
|
|
@@ -1243,7 +1761,12 @@ def layout(*content, htmx, title=None, show_sidebar=False, toc_content=None, cur
|
|
|
1243
1761
|
result.append(Div(*custom_css_links, id="scoped-css-container", hx_swap_oob="true"))
|
|
1244
1762
|
else:
|
|
1245
1763
|
result.append(Div(id="scoped-css-container", hx_swap_oob="true"))
|
|
1246
|
-
|
|
1764
|
+
if toc_sidebar:
|
|
1765
|
+
result.extend([main_content_container, toc_sidebar])
|
|
1766
|
+
else:
|
|
1767
|
+
result.append(main_content_container)
|
|
1768
|
+
result.append(Div(id="toc-sidebar", hx_swap_oob="true"))
|
|
1769
|
+
result.append(Div(id="mobile-toc-panel", hx_swap_oob="true"))
|
|
1247
1770
|
|
|
1248
1771
|
t_htmx = time.time()
|
|
1249
1772
|
logger.debug(f"[LAYOUT] HTMX response assembled in {(t_htmx - t_main)*1000:.2f}ms")
|
|
@@ -1269,18 +1792,22 @@ def layout(*content, htmx, title=None, show_sidebar=False, toc_content=None, cur
|
|
|
1269
1792
|
|
|
1270
1793
|
if show_sidebar:
|
|
1271
1794
|
# Build TOC if content provided
|
|
1272
|
-
|
|
1273
|
-
t_toc =
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
"
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1795
|
+
toc_sidebar = None
|
|
1796
|
+
t_toc = t_section
|
|
1797
|
+
if show_toc:
|
|
1798
|
+
toc_items = build_toc_items(extract_toc(toc_content)) if toc_content else []
|
|
1799
|
+
t_toc = time.time()
|
|
1800
|
+
logger.debug(f"[LAYOUT] TOC built in {(t_toc - t_section)*1000:.2f}ms")
|
|
1801
|
+
# Right sidebar TOC component with out-of-band swap for HTMX
|
|
1802
|
+
sidebars_open = get_config().get_sidebars_open()
|
|
1803
|
+
toc_attrs = {
|
|
1804
|
+
"cls": "hidden xl:block w-72 shrink-0 sticky top-24 self-start max-h-[calc(100vh-10rem)] overflow-hidden z-[1000]",
|
|
1805
|
+
"id": "toc-sidebar"
|
|
1806
|
+
}
|
|
1807
|
+
toc_sidebar = Aside(
|
|
1808
|
+
collapsible_sidebar("list", "Contents", toc_items, is_open=sidebars_open, shortcut_key="X") if toc_items else Div(),
|
|
1809
|
+
**toc_attrs
|
|
1810
|
+
)
|
|
1284
1811
|
# Container for main content only (for HTMX swapping)
|
|
1285
1812
|
# Add section class to identify the section for CSS scoping
|
|
1286
1813
|
section_class = f"section-{current_path.replace('/', '-')}" if current_path else ""
|
|
@@ -1307,27 +1834,33 @@ def layout(*content, htmx, title=None, show_sidebar=False, toc_content=None, cur
|
|
|
1307
1834
|
cls="p-4 overflow-y-auto"
|
|
1308
1835
|
),
|
|
1309
1836
|
id="mobile-posts-panel",
|
|
1310
|
-
cls="fixed inset-0 bg-white dark:bg-slate-950 z-[9999]
|
|
1837
|
+
cls="fixed inset-0 bg-white dark:bg-slate-950 z-[9999] xl:hidden transform -translate-x-full transition-transform duration-300"
|
|
1311
1838
|
)
|
|
1312
|
-
mobile_toc_panel =
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1839
|
+
mobile_toc_panel = None
|
|
1840
|
+
if show_toc:
|
|
1841
|
+
mobile_toc_panel = Div(
|
|
1842
|
+
Div(
|
|
1843
|
+
Button(
|
|
1844
|
+
UkIcon("x", cls="w-5 h-5"),
|
|
1845
|
+
id="close-mobile-toc",
|
|
1846
|
+
cls="p-2 hover:bg-slate-200 dark:hover:bg-slate-700 rounded transition-colors ml-auto",
|
|
1847
|
+
type="button"
|
|
1848
|
+
),
|
|
1849
|
+
cls="flex justify-end p-2 bg-white dark:bg-slate-950 border-b border-slate-200 dark:border-slate-800"
|
|
1319
1850
|
),
|
|
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
|
-
)
|
|
1851
|
+
Div(
|
|
1852
|
+
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")),
|
|
1853
|
+
cls="p-4 overflow-y-auto"
|
|
1854
|
+
),
|
|
1855
|
+
id="mobile-toc-panel",
|
|
1856
|
+
cls="fixed inset-0 bg-white dark:bg-slate-950 z-[9999] xl:hidden transform translate-x-full transition-transform duration-300"
|
|
1857
|
+
)
|
|
1329
1858
|
# Full layout with all sidebars
|
|
1330
|
-
content_with_sidebars = Div(
|
|
1859
|
+
content_with_sidebars = Div(
|
|
1860
|
+
cls=f"layout-container {layout_fluid_class} w-full {layout_max_class} mx-auto px-4 flex gap-6 flex-1".strip(),
|
|
1861
|
+
id="content-with-sidebars",
|
|
1862
|
+
**_style_attr(layout_max_style)
|
|
1863
|
+
)(
|
|
1331
1864
|
# Left sidebar - lazy load with HTMX, show loader placeholder
|
|
1332
1865
|
Aside(
|
|
1333
1866
|
Div(
|
|
@@ -1335,7 +1868,7 @@ def layout(*content, htmx, title=None, show_sidebar=False, toc_content=None, cur
|
|
|
1335
1868
|
Span("Loading posts…", cls="ml-2 text-sm"),
|
|
1336
1869
|
cls="flex items-center justify-center h-32 text-slate-400"
|
|
1337
1870
|
),
|
|
1338
|
-
cls="hidden
|
|
1871
|
+
cls="hidden xl:block w-72 shrink-0 sticky top-24 self-start max-h-[calc(100vh-10rem)] overflow-hidden z-[1000]",
|
|
1339
1872
|
id="posts-sidebar",
|
|
1340
1873
|
hx_get="/_sidebar/posts",
|
|
1341
1874
|
hx_trigger="load",
|
|
@@ -1344,27 +1877,46 @@ def layout(*content, htmx, title=None, show_sidebar=False, toc_content=None, cur
|
|
|
1344
1877
|
# Main content (swappable)
|
|
1345
1878
|
main_content_container,
|
|
1346
1879
|
# Right sidebar - TOC (swappable out-of-band)
|
|
1347
|
-
toc_sidebar
|
|
1880
|
+
toc_sidebar if toc_sidebar else None
|
|
1348
1881
|
)
|
|
1349
1882
|
t_sidebars = time.time()
|
|
1350
1883
|
logger.debug(f"[LAYOUT] Sidebars container built in {(t_sidebars - t_main)*1000:.2f}ms")
|
|
1351
1884
|
# Layout with sidebar for blog posts
|
|
1352
1885
|
body_content = Div(id="page-container", cls="flex flex-col min-h-screen")(
|
|
1353
|
-
Div(
|
|
1886
|
+
Div(
|
|
1887
|
+
navbar(show_mobile_menus=True),
|
|
1888
|
+
cls=f"layout-container {layout_fluid_class} w-full {layout_max_class} mx-auto px-4 sticky top-0 z-50 mt-4".strip(),
|
|
1889
|
+
id="site-navbar",
|
|
1890
|
+
**_style_attr(layout_max_style)
|
|
1891
|
+
),
|
|
1354
1892
|
mobile_posts_panel,
|
|
1355
|
-
mobile_toc_panel,
|
|
1893
|
+
mobile_toc_panel if mobile_toc_panel else None,
|
|
1356
1894
|
content_with_sidebars,
|
|
1357
|
-
Footer(Div(
|
|
1358
|
-
cls="
|
|
1895
|
+
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
|
|
1896
|
+
cls=f"layout-container {layout_fluid_class} w-full {layout_max_class} mx-auto px-6 mt-auto mb-6".strip(),
|
|
1897
|
+
id="site-footer",
|
|
1898
|
+
**_style_attr(layout_max_style))
|
|
1359
1899
|
)
|
|
1360
1900
|
else:
|
|
1361
1901
|
# Default layout without sidebar
|
|
1362
1902
|
custom_css_links = get_custom_css_links(current_path, section_class) if current_path else []
|
|
1363
1903
|
body_content = Div(id="page-container", cls="flex flex-col min-h-screen")(
|
|
1364
|
-
Div(
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1904
|
+
Div(
|
|
1905
|
+
navbar(),
|
|
1906
|
+
cls=f"layout-container {layout_fluid_class} w-full {layout_max_class} mx-auto px-4 sticky top-0 z-50 mt-4".strip(),
|
|
1907
|
+
id="site-navbar",
|
|
1908
|
+
**_style_attr(layout_max_style)
|
|
1909
|
+
),
|
|
1910
|
+
Main(
|
|
1911
|
+
*content,
|
|
1912
|
+
cls=f"layout-container {layout_fluid_class} w-full {layout_max_class} mx-auto px-6 py-8 space-y-8".strip(),
|
|
1913
|
+
id="main-content",
|
|
1914
|
+
**_style_attr(layout_max_style)
|
|
1915
|
+
),
|
|
1916
|
+
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"),
|
|
1917
|
+
cls=f"layout-container {layout_fluid_class} w-full {layout_max_class} mx-auto px-6 mt-auto mb-6".strip(),
|
|
1918
|
+
id="site-footer",
|
|
1919
|
+
**_style_attr(layout_max_style))
|
|
1368
1920
|
)
|
|
1369
1921
|
t_body = time.time()
|
|
1370
1922
|
logger.debug(f"[LAYOUT] Body content (no sidebar) built in {(t_body - layout_start_time)*1000:.2f}ms")
|
|
@@ -1398,7 +1950,7 @@ def build_post_tree(folder):
|
|
|
1398
1950
|
if item.name.startswith('.'):
|
|
1399
1951
|
continue
|
|
1400
1952
|
entries.append(item)
|
|
1401
|
-
elif item.suffix
|
|
1953
|
+
elif item.suffix in ('.md', '.pdf'):
|
|
1402
1954
|
# Skip the file being used for home page (index.md takes precedence over readme.md)
|
|
1403
1955
|
if index_file and item.resolve() == index_file.resolve():
|
|
1404
1956
|
continue
|
|
@@ -1443,6 +1995,17 @@ def build_post_tree(folder):
|
|
|
1443
1995
|
hx_get=f'/posts/{slug}', hx_target="#main-content", hx_push_url="true", hx_swap="outerHTML show:window:top settle:0.1s",
|
|
1444
1996
|
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
1997
|
data_path=slug)))
|
|
1998
|
+
elif item.suffix == '.pdf':
|
|
1999
|
+
slug = str(item.relative_to(root).with_suffix(''))
|
|
2000
|
+
title = slug_to_title(item.stem)
|
|
2001
|
+
items.append(Li(A(
|
|
2002
|
+
Span(cls="w-4 mr-2 shrink-0"),
|
|
2003
|
+
Span(UkIcon("file-text", cls="text-slate-400 w-4 h-4"), cls="w-4 mr-2 flex items-center justify-center shrink-0"),
|
|
2004
|
+
Span(f"{title} (PDF)", cls="truncate min-w-0", title=title),
|
|
2005
|
+
href=f'/posts/{slug}',
|
|
2006
|
+
hx_get=f'/posts/{slug}', hx_target="#main-content", hx_push_url="true", hx_swap="outerHTML show:window:top settle:0.1s",
|
|
2007
|
+
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",
|
|
2008
|
+
data_path=slug)))
|
|
1446
2009
|
|
|
1447
2010
|
elapsed = (time.time() - start_time) * 1000
|
|
1448
2011
|
logger.debug(f"[DEBUG] build_post_tree for {folder.relative_to(root) if folder != root else '.'} completed in {elapsed:.2f}ms")
|
|
@@ -1452,8 +2015,9 @@ def _posts_tree_fingerprint():
|
|
|
1452
2015
|
root = get_root_folder()
|
|
1453
2016
|
try:
|
|
1454
2017
|
md_mtime = max((p.stat().st_mtime for p in root.rglob("*.md")), default=0)
|
|
2018
|
+
pdf_mtime = max((p.stat().st_mtime for p in root.rglob("*.pdf")), default=0)
|
|
1455
2019
|
bloggy_mtime = max((p.stat().st_mtime for p in root.rglob(".bloggy")), default=0)
|
|
1456
|
-
return max(md_mtime, bloggy_mtime)
|
|
2020
|
+
return max(md_mtime, pdf_mtime, bloggy_mtime)
|
|
1457
2021
|
except Exception:
|
|
1458
2022
|
return 0
|
|
1459
2023
|
|
|
@@ -1491,6 +2055,10 @@ def not_found(htmx=None):
|
|
|
1491
2055
|
UkIcon("home", cls="w-5 h-5 mr-2"),
|
|
1492
2056
|
"Go to Home",
|
|
1493
2057
|
href="/",
|
|
2058
|
+
hx_get="/",
|
|
2059
|
+
hx_target="#main-content",
|
|
2060
|
+
hx_push_url="true",
|
|
2061
|
+
hx_swap="outerHTML show:window:top settle:0.1s",
|
|
1494
2062
|
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
2063
|
),
|
|
1496
2064
|
A(
|
|
@@ -1528,9 +2096,39 @@ def post_detail(path: str, htmx):
|
|
|
1528
2096
|
logger.info(f"\n[DEBUG] ########## REQUEST START: /posts/{path} ##########")
|
|
1529
2097
|
|
|
1530
2098
|
file_path = get_root_folder() / f'{path}.md'
|
|
2099
|
+
pdf_path = get_root_folder() / f'{path}.pdf'
|
|
1531
2100
|
|
|
1532
2101
|
# Check if file exists
|
|
1533
2102
|
if not file_path.exists():
|
|
2103
|
+
if pdf_path.exists():
|
|
2104
|
+
post_title = f"{slug_to_title(Path(path).name)} (PDF)"
|
|
2105
|
+
pdf_src = f"/posts/{path}.pdf"
|
|
2106
|
+
pdf_content = Div(
|
|
2107
|
+
Div(
|
|
2108
|
+
H1(post_title, cls="text-4xl font-bold"),
|
|
2109
|
+
Button(
|
|
2110
|
+
"Focus PDF",
|
|
2111
|
+
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",
|
|
2112
|
+
type="button",
|
|
2113
|
+
data_pdf_focus_toggle="true",
|
|
2114
|
+
data_pdf_focus_label="Focus PDF",
|
|
2115
|
+
data_pdf_exit_label="Exit focus",
|
|
2116
|
+
aria_pressed="false"
|
|
2117
|
+
),
|
|
2118
|
+
cls="flex items-center justify-between gap-4 flex-wrap mb-6"
|
|
2119
|
+
),
|
|
2120
|
+
NotStr(
|
|
2121
|
+
f'<object data="{pdf_src}" type="application/pdf" '
|
|
2122
|
+
'class="pdf-viewer w-full h-[calc(100vh-14rem)] rounded-lg border border-slate-200 '
|
|
2123
|
+
'dark:border-slate-700 bg-white dark:bg-slate-900">'
|
|
2124
|
+
'<p class="p-4 text-sm text-slate-600 dark:text-slate-300">'
|
|
2125
|
+
'PDF preview not available. '
|
|
2126
|
+
f'<a href="{pdf_src}" class="text-blue-600 hover:underline">Download PDF</a>.'
|
|
2127
|
+
'</p></object>'
|
|
2128
|
+
)
|
|
2129
|
+
)
|
|
2130
|
+
return layout(pdf_content, htmx=htmx, title=f"{post_title} - {get_blog_title()}",
|
|
2131
|
+
show_sidebar=True, toc_content=None, current_path=path, show_toc=False)
|
|
1534
2132
|
return not_found(htmx)
|
|
1535
2133
|
|
|
1536
2134
|
metadata, raw_content = parse_frontmatter(file_path)
|
|
@@ -1544,7 +2142,31 @@ def post_detail(path: str, htmx):
|
|
|
1544
2142
|
md_time = (time.time() - md_start) * 1000
|
|
1545
2143
|
logger.debug(f"[DEBUG] Markdown rendering took {md_time:.2f}ms")
|
|
1546
2144
|
|
|
1547
|
-
|
|
2145
|
+
copy_button = Button(
|
|
2146
|
+
UkIcon("copy", cls="w-4 h-4"),
|
|
2147
|
+
type="button",
|
|
2148
|
+
title="Copy raw markdown",
|
|
2149
|
+
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();}})()",
|
|
2150
|
+
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"
|
|
2151
|
+
)
|
|
2152
|
+
post_content = Div(
|
|
2153
|
+
Div(
|
|
2154
|
+
H1(post_title, cls="text-4xl font-bold"),
|
|
2155
|
+
copy_button,
|
|
2156
|
+
cls="flex flex-col gap-3 md:flex-row md:items-center md:justify-between mb-8"
|
|
2157
|
+
),
|
|
2158
|
+
Div(
|
|
2159
|
+
"Copied Raw Markdown!",
|
|
2160
|
+
id="raw-md-toast",
|
|
2161
|
+
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"
|
|
2162
|
+
),
|
|
2163
|
+
Textarea(
|
|
2164
|
+
raw_content,
|
|
2165
|
+
id="raw-md-clipboard",
|
|
2166
|
+
cls="absolute left-[-9999px] top-0 opacity-0 pointer-events-none"
|
|
2167
|
+
),
|
|
2168
|
+
content
|
|
2169
|
+
)
|
|
1548
2170
|
|
|
1549
2171
|
# Always return complete layout with sidebar and TOC
|
|
1550
2172
|
layout_start = time.time()
|