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/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"""
@@ -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
- anchor = text_to_anchor(inner)
415
- return f'<h{level} id="{anchor}">{inner}</h{level}>'
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
- diagram_id = f"mermaid-{hash(code) & 0xFFFFFF}"
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
- return f'<pre><code{lang_class}>{code}</code></pre>'
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
- is_external = href.startswith(('http://', 'https://', 'mailto:', 'tel:', '//', '#'))
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="/static/favicon.png"),
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: hidden;
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
- static_dir = Path(__file__).parent / "static"
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 md:block w-72 shrink-0 sticky top-24 self-start max-h-[calc(100vh-10rem)] overflow-hidden z-[1000]",
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(get_blog_title(), href="/"),
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="md:hidden p-2 hover:bg-slate-800 rounded transition-colors",
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="md:hidden p-2 hover:bg-slate-800 rounded transition-colors",
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
- "Posts",
1573
+ "Library",
1095
1574
  get_posts(),
1096
- is_open=False,
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
- content_classes = f"p-3 {common_frost_style} rounded-lg overflow-y-auto max-h-[calc(100vh-18rem)]"
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
- Ul(*items_list, cls="list-none"),
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="sidebar-scroll-container"
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
- text = match.group(2).strip()
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
- 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
- )
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
- result.extend([main_content_container, toc_sidebar])
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
- 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
- )
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] md:hidden transform -translate-x-full transition-transform duration-300"
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 = 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"
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
- 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
- )
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(cls="w-full max-w-7xl mx-auto px-4 flex gap-6 flex-1")(
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 md:block w-72 shrink-0 sticky top-24 self-start max-h-[calc(100vh-10rem)] overflow-hidden z-[1000]",
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(navbar(show_mobile_menus=True), cls="w-full max-w-7xl mx-auto px-4 sticky top-0 z-50 mt-4"),
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(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")
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(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")
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 == '.md':
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
- post_content = Div(H1(post_title, cls="text-4xl font-bold mb-8"), content)
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()