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/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
- return re.sub(r'[^\w\s-]', '', text.lower()).replace(' ', '-')
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\{title="([^"]+)"\}\s*\n(.*?)(?=^::tab\{|\Z)', re.MULTILINE | re.DOTALL)
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
- title = tab_match.group(1)
399
+ raw_attrs = tab_match.group(1)
321
400
  tab_content = tab_match.group(2).strip()
322
- tabs.append((title, tab_content))
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
- anchor = text_to_anchor(inner)
415
- return f'<h{level} id="{anchor}">{inner}</h{level}>'
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
- diagram_id = f"mermaid-{hash(code) & 0xFFFFFF}"
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
- return f'<pre><code{lang_class}>{code}</code></pre>'
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
- is_external = href.startswith(('http://', 'https://', 'mailto:', 'tel:', '//', '#'))
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="/static/favicon.png"),
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: hidden;
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
- static_dir = Path(__file__).parent / "static"
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 md:block w-72 shrink-0 sticky top-24 self-start max-h-[calc(100vh-10rem)] overflow-hidden z-[1000]",
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(get_blog_title(), href="/"),
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="md:hidden p-2 hover:bg-slate-800 rounded transition-colors",
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="md:hidden p-2 hover:bg-slate-800 rounded transition-colors",
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
- "Posts",
1567
+ "Library",
1095
1568
  get_posts(),
1096
- is_open=False,
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
- content_classes = f"p-3 {common_frost_style} rounded-lg overflow-y-auto max-h-[calc(100vh-18rem)]"
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
- Ul(*items_list, cls="list-none"),
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="sidebar-scroll-container"
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
- text = match.group(2).strip()
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
- toc_items = build_toc_items(extract_toc(toc_content)) if toc_content else []
1220
- t_toc = time.time()
1221
- logger.debug(f"[LAYOUT] TOC built in {(t_toc - t_section)*1000:.2f}ms")
1222
-
1223
- toc_attrs = {
1224
- "cls": "hidden md:block w-72 shrink-0 sticky top-24 self-start max-h-[calc(100vh-10rem)] overflow-hidden z-[1000]",
1225
- "id": "toc-sidebar",
1226
- "hx_swap_oob": "true",
1227
- }
1228
- toc_sidebar = Aside(
1229
- collapsible_sidebar("list", "Contents", toc_items, is_open=False) if toc_items else Div(),
1230
- **toc_attrs
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
- result.extend([main_content_container, toc_sidebar])
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
- toc_items = build_toc_items(extract_toc(toc_content)) if toc_content else []
1273
- t_toc = time.time()
1274
- logger.debug(f"[LAYOUT] TOC built in {(t_toc - t_section)*1000:.2f}ms")
1275
- # Right sidebar TOC component with out-of-band swap for HTMX
1276
- toc_attrs = {
1277
- "cls": "hidden md:block w-72 shrink-0 sticky top-24 self-start max-h-[calc(100vh-10rem)] overflow-hidden z-[1000]",
1278
- "id": "toc-sidebar"
1279
- }
1280
- toc_sidebar = Aside(
1281
- collapsible_sidebar("list", "Contents", toc_items, is_open=False) if toc_items else Div(),
1282
- **toc_attrs
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] md:hidden transform -translate-x-full transition-transform duration-300"
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 = Div(
1313
- Div(
1314
- Button(
1315
- UkIcon("x", cls="w-5 h-5"),
1316
- id="close-mobile-toc",
1317
- cls="p-2 hover:bg-slate-200 dark:hover:bg-slate-700 rounded transition-colors ml-auto",
1318
- type="button"
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
- cls="flex justify-end p-2 bg-white dark:bg-slate-950 border-b border-slate-200 dark:border-slate-800"
1321
- ),
1322
- Div(
1323
- collapsible_sidebar("list", "Contents", toc_items, is_open=False) if toc_items else Div(P("No table of contents available.", cls="text-slate-500 dark:text-slate-400 text-sm p-4")),
1324
- cls="p-4 overflow-y-auto"
1325
- ),
1326
- id="mobile-toc-panel",
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(cls="w-full max-w-7xl mx-auto px-4 flex gap-6 flex-1")(
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 md:block w-72 shrink-0 sticky top-24 self-start max-h-[calc(100vh-10rem)] overflow-hidden z-[1000]",
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(navbar(show_mobile_menus=True), cls="w-full max-w-7xl mx-auto px-4 sticky top-0 z-50 mt-4"),
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(f"Powered by Bloggy", cls="bg-slate-900 text-white rounded-lg p-4 my-4 dark:bg-slate-800 text-right"), # right justified footer
1358
- cls="w-full max-w-7xl mx-auto px-6 mt-auto mb-6")
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(navbar(), cls="w-full max-w-2xl mx-auto px-4 sticky top-0 z-50 mt-4"),
1365
- Main(*content, cls="w-full max-w-2xl mx-auto px-6 py-8 space-y-8", id="main-content"),
1366
- Footer(Div("Powered by Bloggy", cls="bg-slate-900 text-white rounded-lg p-4 my-4 dark:bg-slate-800 text-right"),
1367
- cls="w-full max-w-2xl mx-auto px-6 mt-auto mb-6")
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 == '.md':
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
- post_content = Div(H1(post_title, cls="text-4xl font-bold mb-8"), content)
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()