bloggy 0.1.39__tar.gz → 0.1.57__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bloggy
3
- Version: 0.1.39
3
+ Version: 0.1.57
4
4
  Summary: A lightweight, elegant blogging platform built with FastHTML
5
5
  Home-page: https://github.com/yeshwanth/bloggy
6
6
  Author: Yeshwanth
@@ -44,6 +44,15 @@ title: Bloggy - A FastHTML Blogging Platform
44
44
 
45
45
  A lightweight, elegant blogging platform built with FastHTML that renders Markdown files into beautiful web pages with advanced features.
46
46
 
47
+ Simple Table
48
+
49
+ | Feature | Description |
50
+ |-----------------------------|--------------------------------------------------|
51
+ | FastHTML Integration | Built on FastHTML for high performance and ease of use |
52
+ | Advanced Markdown Support | Footnotes as sidenotes, YouTube embeds, task lists, Mermaid diagrams, math notation, tabbed content, and more |
53
+ | Modern UI | Responsive design, dark mode, three-panel layout, HTMX navigation |
54
+ | Interactive Diagrams | Zoomable, pannable Mermaid diagrams with fullscreen support |
55
+
47
56
  ## Architecture Overview
48
57
 
49
58
  ```mermaid
@@ -295,6 +304,7 @@ stateDiagram-v2
295
304
  - **Mermaid Frontmatter**: Configure diagram size with YAML frontmatter (width, height, min-height)
296
305
  - **Tabbed Content**: Create multi-tab sections using `:::tabs` and `::tab{title="..."}` syntax with smooth transitions
297
306
  - **Relative Links**: Full support for relative markdown links (`./file.md`, `../other.md`) with automatic path resolution
307
+ - **Plain-Text Headings**: Inline markdown in headings is stripped for clean display and consistent anchor slugs
298
308
  - **Math Notation**: KaTeX support for inline `$E=mc^2$` and block `$$` math equations, auto-renders after HTMX swaps
299
309
  - **Superscript & Subscript**: Use `^text^` for superscript and `~text~` for subscript (preprocessed before rendering)
300
310
  - **Strikethrough**: Use `~~text~~` for strikethrough formatting
@@ -307,7 +317,9 @@ stateDiagram-v2
307
317
  - **Dark Mode**: Automatic theme switching with localStorage persistence and instant visual feedback
308
318
  - **HTMX Navigation**: Fast, SPA-like navigation without full page reloads using `hx-get`, `hx-target`, and `hx-push-url`
309
319
  - **Collapsible Folders**: Organize posts in nested directories with chevron indicators and smooth expand/collapse
320
+ - **Sidebar Search**: HTMX-powered filename search with results shown below the search bar (tree stays intact)
310
321
  - **Auto-Generated TOC**: Table of contents automatically extracted from headings with scroll-based active highlighting
322
+ - **TOC Autoscroll + Accurate Highlights**: Active TOC item stays in view and highlight logic handles duplicate headings
311
323
  - **Mobile Menus**: Slide-in panels for posts and TOC on mobile devices with smooth transitions
312
324
  - **Sticky Navigation**: Navbar stays at top while scrolling, with mobile menu toggles
313
325
  - **Active Link Highlighting**: Current post and TOC section highlighted with blue accents
@@ -328,8 +340,13 @@ stateDiagram-v2
328
340
  - **Performance Logging**: Debug-level logging tracks render times and bottlenecks to `/tmp/bloggy_core.log`
329
341
  - **Custom 404 Page**: Elegant error page with navigation options and helpful tips
330
342
  - **Static File Serving**: Serves images and assets from blog directories via `/posts/{path}.{ext}` routes
343
+ - **Raw Markdown Access**: Append `.md` to any post URL (e.g. `/posts/demo.md`) to fetch source content
331
344
  - **Optional Authentication**: Session-based auth with Beforeware when username/password configured
332
345
 
346
+ ### Quick Usage Examples
347
+ - Sidebar search: type a filename fragment like `write up` or `write-up` to filter results without collapsing the tree
348
+ - Raw markdown: fetch a post's source via `/posts/demo.md`
349
+
333
350
  ## Content Writing Features
334
351
 
335
352
  ### Footnotes as Sidenotes
@@ -609,6 +626,24 @@ password = "hunter2"
609
626
 
610
627
  All settings in the `.bloggy` file are optional. The configuration is managed by the `Config` class in `bloggy/config.py`.
611
628
 
629
+ ### Custom Sidebar Ordering
630
+
631
+ Place a `.bloggy` file in any folder to control the sidebar order for that folder. `.bloggy` uses TOML format. Use `order` to pin items first, then `sort` and `folders_first` for the remainder.
632
+
633
+ ```toml
634
+ # Items listed in order are shown first. Use exact names (include extensions).
635
+ order = ["todo.md", "static-build.md", "docs"]
636
+
637
+ # Sorting for items not listed in order
638
+ sort = "name_asc" # name_asc, name_desc, mtime_asc, mtime_desc
639
+ folders_first = true
640
+ folders_always_first = false
641
+ ```
642
+
643
+ Notes:
644
+ - `folders_first` only affects the items not listed in `order`.
645
+ - `folders_always_first` moves all folders to the top after ordering/sorting, while preserving their relative order.
646
+
612
647
  ### Environment Variables
613
648
 
614
649
  You can also use environment variables as a fallback:
@@ -5,6 +5,15 @@ title: Bloggy - A FastHTML Blogging Platform
5
5
 
6
6
  A lightweight, elegant blogging platform built with FastHTML that renders Markdown files into beautiful web pages with advanced features.
7
7
 
8
+ Simple Table
9
+
10
+ | Feature | Description |
11
+ |-----------------------------|--------------------------------------------------|
12
+ | FastHTML Integration | Built on FastHTML for high performance and ease of use |
13
+ | Advanced Markdown Support | Footnotes as sidenotes, YouTube embeds, task lists, Mermaid diagrams, math notation, tabbed content, and more |
14
+ | Modern UI | Responsive design, dark mode, three-panel layout, HTMX navigation |
15
+ | Interactive Diagrams | Zoomable, pannable Mermaid diagrams with fullscreen support |
16
+
8
17
  ## Architecture Overview
9
18
 
10
19
  ```mermaid
@@ -256,6 +265,7 @@ stateDiagram-v2
256
265
  - **Mermaid Frontmatter**: Configure diagram size with YAML frontmatter (width, height, min-height)
257
266
  - **Tabbed Content**: Create multi-tab sections using `:::tabs` and `::tab{title="..."}` syntax with smooth transitions
258
267
  - **Relative Links**: Full support for relative markdown links (`./file.md`, `../other.md`) with automatic path resolution
268
+ - **Plain-Text Headings**: Inline markdown in headings is stripped for clean display and consistent anchor slugs
259
269
  - **Math Notation**: KaTeX support for inline `$E=mc^2$` and block `$$` math equations, auto-renders after HTMX swaps
260
270
  - **Superscript & Subscript**: Use `^text^` for superscript and `~text~` for subscript (preprocessed before rendering)
261
271
  - **Strikethrough**: Use `~~text~~` for strikethrough formatting
@@ -268,7 +278,9 @@ stateDiagram-v2
268
278
  - **Dark Mode**: Automatic theme switching with localStorage persistence and instant visual feedback
269
279
  - **HTMX Navigation**: Fast, SPA-like navigation without full page reloads using `hx-get`, `hx-target`, and `hx-push-url`
270
280
  - **Collapsible Folders**: Organize posts in nested directories with chevron indicators and smooth expand/collapse
281
+ - **Sidebar Search**: HTMX-powered filename search with results shown below the search bar (tree stays intact)
271
282
  - **Auto-Generated TOC**: Table of contents automatically extracted from headings with scroll-based active highlighting
283
+ - **TOC Autoscroll + Accurate Highlights**: Active TOC item stays in view and highlight logic handles duplicate headings
272
284
  - **Mobile Menus**: Slide-in panels for posts and TOC on mobile devices with smooth transitions
273
285
  - **Sticky Navigation**: Navbar stays at top while scrolling, with mobile menu toggles
274
286
  - **Active Link Highlighting**: Current post and TOC section highlighted with blue accents
@@ -289,8 +301,13 @@ stateDiagram-v2
289
301
  - **Performance Logging**: Debug-level logging tracks render times and bottlenecks to `/tmp/bloggy_core.log`
290
302
  - **Custom 404 Page**: Elegant error page with navigation options and helpful tips
291
303
  - **Static File Serving**: Serves images and assets from blog directories via `/posts/{path}.{ext}` routes
304
+ - **Raw Markdown Access**: Append `.md` to any post URL (e.g. `/posts/demo.md`) to fetch source content
292
305
  - **Optional Authentication**: Session-based auth with Beforeware when username/password configured
293
306
 
307
+ ### Quick Usage Examples
308
+ - Sidebar search: type a filename fragment like `write up` or `write-up` to filter results without collapsing the tree
309
+ - Raw markdown: fetch a post's source via `/posts/demo.md`
310
+
294
311
  ## Content Writing Features
295
312
 
296
313
  ### Footnotes as Sidenotes
@@ -570,6 +587,24 @@ password = "hunter2"
570
587
 
571
588
  All settings in the `.bloggy` file are optional. The configuration is managed by the `Config` class in `bloggy/config.py`.
572
589
 
590
+ ### Custom Sidebar Ordering
591
+
592
+ Place a `.bloggy` file in any folder to control the sidebar order for that folder. `.bloggy` uses TOML format. Use `order` to pin items first, then `sort` and `folders_first` for the remainder.
593
+
594
+ ```toml
595
+ # Items listed in order are shown first. Use exact names (include extensions).
596
+ order = ["todo.md", "static-build.md", "docs"]
597
+
598
+ # Sorting for items not listed in order
599
+ sort = "name_asc" # name_asc, name_desc, mtime_asc, mtime_desc
600
+ folders_first = true
601
+ folders_always_first = false
602
+ ```
603
+
604
+ Notes:
605
+ - `folders_first` only affects the items not listed in `order`.
606
+ - `folders_always_first` moves all folders to the top after ordering/sorting, while preserving their relative order.
607
+
573
608
  ### Environment Variables
574
609
 
575
610
  You can also use environment variables as a fallback:
@@ -1,4 +1,4 @@
1
- __version__ = "0.1.39"
1
+ __version__ = "0.1.57"
2
2
 
3
3
  from .core import app, rt, get_root_folder, get_blog_title
4
4
 
@@ -14,7 +14,8 @@ from .core import (
14
14
  parse_frontmatter, get_post_title, slug_to_title,
15
15
  from_md, extract_toc, build_toc_items, text_to_anchor,
16
16
  build_post_tree, ContentRenderer, extract_footnotes,
17
- preprocess_super_sub, preprocess_tabs
17
+ preprocess_super_sub, preprocess_tabs,
18
+ get_bloggy_config, order_bloggy_entries
18
19
  )
19
20
  from .config import get_config, reload_config
20
21
 
@@ -27,8 +28,16 @@ def generate_static_html(title, body_content, blog_title):
27
28
  <style>
28
29
  body { font-family: 'IBM Plex Sans', sans-serif; margin: 0; padding: 0; }
29
30
  code, pre { font-family: 'IBM Plex Mono', monospace; }
30
- .folder-chevron { transition: transform 0.2s; display: inline-block; }
31
- details[open] > summary > .folder-chevron { transform: rotate(90deg); }
31
+ .folder-chevron {
32
+ display: inline-block;
33
+ width: 0.45rem;
34
+ height: 0.45rem;
35
+ border-right: 2px solid rgb(148 163 184);
36
+ border-bottom: 2px solid rgb(148 163 184);
37
+ transform: rotate(-45deg);
38
+ transition: transform 0.2s;
39
+ }
40
+ details.is-open > summary .folder-chevron { transform: rotate(45deg); }
32
41
  details { border: none !important; box-shadow: none !important; }
33
42
  h1, h2, h3, h4, h5, h6 { scroll-margin-top: 7rem; }
34
43
 
@@ -323,8 +332,32 @@ def generate_static_html(title, body_content, blog_title):
323
332
  def build_post_tree_static(folder, root_folder):
324
333
  """Build post tree with static .html links instead of HTMX"""
325
334
  items = []
326
- try:
327
- entries = sorted(folder.iterdir(), key=lambda x: (not x.is_dir(), x.name))
335
+ try:
336
+ index_file = None
337
+ if folder == root_folder:
338
+ for candidate in root_folder.iterdir():
339
+ if candidate.is_file() and candidate.suffix == '.md' and candidate.stem.lower() == 'index':
340
+ index_file = candidate
341
+ break
342
+ if index_file is None:
343
+ for candidate in root_folder.iterdir():
344
+ if candidate.is_file() and candidate.suffix == '.md' and candidate.stem.lower() == 'readme':
345
+ index_file = candidate
346
+ break
347
+
348
+ entries = []
349
+ for item in folder.iterdir():
350
+ if item.name == ".bloggy":
351
+ continue
352
+ if item.is_dir():
353
+ if item.name.startswith('.'):
354
+ continue
355
+ entries.append(item)
356
+ elif item.suffix == '.md':
357
+ if index_file and item.resolve() == index_file.resolve():
358
+ continue
359
+ entries.append(item)
360
+ entries = order_bloggy_entries(entries, get_bloggy_config(folder))
328
361
  except (OSError, PermissionError):
329
362
  return items
330
363
 
@@ -337,18 +370,13 @@ def build_post_tree_static(folder, root_folder):
337
370
  folder_title = slug_to_title(item.name)
338
371
  items.append(Li(Details(
339
372
  Summary(
340
- Span(UkIcon("chevron-right", cls="folder-chevron w-4 h-4 text-slate-400"), cls="w-4 mr-2 flex items-center justify-center shrink-0"),
373
+ Span(Span(cls="folder-chevron"), cls="w-4 mr-2 flex items-center justify-center shrink-0"),
341
374
  Span(UkIcon("folder", cls="text-blue-500 w-4 h-4"), cls="w-4 mr-2 flex items-center justify-center shrink-0"),
342
375
  Span(folder_title, cls="truncate min-w-0", title=folder_title),
343
376
  cls="flex items-center font-medium cursor-pointer py-1 px-2 hover:text-blue-600 select-none list-none rounded hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors min-w-0"),
344
- Ul(*sub_items, cls="ml-2 pl-2 space-y-1 border-l border-slate-100 dark:border-slate-800"), open=False), cls="my-1"))
377
+ Ul(*sub_items, cls="ml-4 pl-2 space-y-1 border-l border-slate-100 dark:border-slate-800"),
378
+ data_folder="true"), cls="my-1"))
345
379
  elif item.suffix == '.md':
346
- # Skip the file being used for home page
347
- if item.parent == root_folder:
348
- # Check if this is index.md or readme.md (case insensitive)
349
- if item.stem.lower() in ['index', 'readme']:
350
- continue
351
-
352
380
  slug = str(item.relative_to(root_folder).with_suffix(''))
353
381
  title = get_post_title(item)
354
382
 
@@ -112,6 +112,14 @@ class BloggyConfig:
112
112
  user = self.get('username', 'BLOGGY_USER', None)
113
113
  pwd = self.get('password', 'BLOGGY_PASSWORD', None)
114
114
  return user, pwd
115
+
116
+ def get_sidebars_open(self) -> bool:
117
+ """Get whether sidebars should be open by default."""
118
+ value = self.get('sidebars_open', 'BLOGGY_SIDEBARS_OPEN', True)
119
+ # Handle string values from environment variables
120
+ if isinstance(value, str):
121
+ return value.lower() in ('true', '1', 'yes', 'on')
122
+ return bool(value)
115
123
 
116
124
 
117
125
 
@@ -1,4 +1,4 @@
1
- import re, frontmatter, mistletoe as mst, pathlib, os
1
+ import re, frontmatter, mistletoe as mst, pathlib, os, tomllib
2
2
  from functools import partial
3
3
  from functools import lru_cache
4
4
  from pathlib import Path
@@ -22,9 +22,35 @@ slug_to_title = lambda s: ' '.join(
22
22
  for word in s.replace('-', ' ').replace('_', ' ').split()
23
23
  )
24
24
 
25
+ def _strip_inline_markdown(text):
26
+ cleaned = text or ""
27
+ cleaned = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', cleaned)
28
+ cleaned = re.sub(r'\[([^\]]+)\]\[[^\]]*\]', r'\1', cleaned)
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
+ return cleaned
36
+
37
+ def _plain_text_from_html(text):
38
+ import html
39
+ cleaned = re.sub(r'<[^>]+>', '', text or "")
40
+ return html.unescape(cleaned)
41
+
42
+ def _unique_anchor(base, counts):
43
+ if not base:
44
+ base = "section"
45
+ current = counts.get(base, 0) + 1
46
+ counts[base] = current
47
+ return base if current == 1 else f"{base}-{current}"
48
+
25
49
  def text_to_anchor(text):
26
50
  """Convert text to anchor slug"""
27
- return re.sub(r'[^\w\s-]', '', text.lower()).replace(' ', '-')
51
+ cleaned = _strip_inline_markdown(text)
52
+ cleaned = _plain_text_from_html(cleaned)
53
+ return re.sub(r'[^\w\s-]', '', cleaned.lower()).replace(' ', '-')
28
54
 
29
55
  # Cache for parsed frontmatter to avoid re-reading files
30
56
  _frontmatter_cache = {}
@@ -62,6 +88,151 @@ def get_post_title(file_path):
62
88
  metadata, _ = parse_frontmatter(file_path)
63
89
  return metadata.get('title', slug_to_title(file_path.stem))
64
90
 
91
+ @lru_cache(maxsize=128)
92
+ def _cached_bloggy_config(path_str, mtime):
93
+ path = Path(path_str)
94
+ try:
95
+ with path.open("rb") as f:
96
+ return tomllib.load(f)
97
+ except Exception:
98
+ return {}
99
+
100
+ def _normalize_bloggy_config(parsed):
101
+ config = {
102
+ "order": [],
103
+ "sort": "name_asc",
104
+ "folders_first": True,
105
+ "folders_always_first": False,
106
+ }
107
+ if not isinstance(parsed, dict):
108
+ return config
109
+
110
+ order = parsed.get("order")
111
+ if order is not None:
112
+ if isinstance(order, (list, tuple)):
113
+ config["order"] = [str(item).strip() for item in order if str(item).strip()]
114
+ else:
115
+ config["order"] = []
116
+
117
+ sort = parsed.get("sort")
118
+ if isinstance(sort, str) and sort in ("name_asc", "name_desc", "mtime_asc", "mtime_desc"):
119
+ config["sort"] = sort
120
+
121
+ folders_first = parsed.get("folders_first")
122
+ if isinstance(folders_first, bool):
123
+ config["folders_first"] = folders_first
124
+ elif isinstance(folders_first, str):
125
+ lowered = folders_first.lower()
126
+ if lowered in ("true", "false"):
127
+ config["folders_first"] = lowered == "true"
128
+
129
+ folders_always_first = parsed.get("folders_always_first")
130
+ if isinstance(folders_always_first, bool):
131
+ config["folders_always_first"] = folders_always_first
132
+ elif isinstance(folders_always_first, str):
133
+ lowered = folders_always_first.lower()
134
+ if lowered in ("true", "false"):
135
+ config["folders_always_first"] = lowered == "true"
136
+
137
+ return config
138
+
139
+ def get_bloggy_config(folder):
140
+ bloggy_path = folder / ".bloggy"
141
+ if not bloggy_path.exists():
142
+ return _normalize_bloggy_config({})
143
+ try:
144
+ mtime = bloggy_path.stat().st_mtime
145
+ except OSError:
146
+ return _normalize_bloggy_config({})
147
+ parsed = _cached_bloggy_config(str(bloggy_path), mtime)
148
+ config = _normalize_bloggy_config(parsed)
149
+ logger.debug(
150
+ "[DEBUG] .bloggy config for %s: order=%s sort=%s folders_first=%s",
151
+ folder,
152
+ config.get("order"),
153
+ config.get("sort"),
154
+ config.get("folders_first"),
155
+ )
156
+ return config
157
+
158
+ def order_bloggy_entries(entries, config):
159
+ if not entries:
160
+ return []
161
+
162
+ order_list = [name.strip().rstrip("/") for name in config.get("order", []) if str(name).strip()]
163
+ if not order_list:
164
+ sorted_entries = _sort_bloggy_entries(entries, config.get("sort"), config.get("folders_first", True))
165
+ if config.get("folders_always_first"):
166
+ sorted_entries = _group_folders_first(sorted_entries)
167
+ logger.debug(
168
+ "[DEBUG] .bloggy order empty; sorted entries: %s",
169
+ [item.name for item in sorted_entries],
170
+ )
171
+ return sorted_entries
172
+
173
+ exact_map = {}
174
+ stem_map = {}
175
+ for item in entries:
176
+ exact_map.setdefault(item.name, item)
177
+ if item.suffix == ".md":
178
+ stem_map.setdefault(item.stem, item)
179
+
180
+ ordered = []
181
+ used = set()
182
+ for name in order_list:
183
+ if name in exact_map:
184
+ item = exact_map[name]
185
+ elif name in stem_map:
186
+ item = stem_map[name]
187
+ else:
188
+ item = None
189
+ if item and item not in used:
190
+ ordered.append(item)
191
+ used.add(item)
192
+
193
+ remaining = [item for item in entries if item not in used]
194
+ remaining_sorted = _sort_bloggy_entries(
195
+ remaining,
196
+ config.get("sort"),
197
+ config.get("folders_first", True)
198
+ )
199
+ combined = ordered + remaining_sorted
200
+ if config.get("folders_always_first"):
201
+ combined = _group_folders_first(combined)
202
+ logger.debug(
203
+ "[DEBUG] .bloggy ordered=%s remaining=%s",
204
+ [item.name for item in ordered],
205
+ [item.name for item in remaining_sorted],
206
+ )
207
+ return combined
208
+
209
+ def _group_folders_first(entries):
210
+ folders = [item for item in entries if item.is_dir()]
211
+ files = [item for item in entries if not item.is_dir()]
212
+ return folders + files
213
+
214
+ def _sort_bloggy_entries(entries, sort_method, folders_first):
215
+ method = sort_method or "name_asc"
216
+ reverse = method.endswith("desc")
217
+ by_mtime = method.startswith("mtime")
218
+
219
+ def sort_key(item):
220
+ if by_mtime:
221
+ try:
222
+ return item.stat().st_mtime
223
+ except OSError:
224
+ return 0
225
+ return item.name.lower()
226
+
227
+ if folders_first:
228
+ folders = [item for item in entries if item.is_dir()]
229
+ files = [item for item in entries if not item.is_dir()]
230
+ folders_sorted = sorted(folders, key=sort_key, reverse=reverse)
231
+ files_sorted = sorted(files, key=sort_key, reverse=reverse)
232
+ return folders_sorted + files_sorted
233
+
234
+ return sorted(entries, key=sort_key, reverse=reverse)
235
+
65
236
  # Markdown rendering setup
66
237
  try: FrankenRenderer
67
238
  except NameError:
@@ -197,6 +368,7 @@ class ContentRenderer(FrankenRenderer):
197
368
  super().__init__(*extras, img_dir=img_dir, **kwargs)
198
369
  self.footnotes, self.fn_counter = footnotes or {}, 0
199
370
  self.current_path = current_path # Current post path for resolving relative links and images
371
+ self.heading_counts = {}
200
372
 
201
373
  def render_list_item(self, token):
202
374
  """Render list items with task list checkbox support"""
@@ -264,10 +436,12 @@ class ContentRenderer(FrankenRenderer):
264
436
 
265
437
  def render_heading(self, token):
266
438
  """Render headings with anchor IDs for TOC linking"""
439
+ import html
267
440
  level = token.level
268
441
  inner = self.render_inner(token)
269
- anchor = text_to_anchor(inner)
270
- return f'<h{level} id="{anchor}">{inner}</h{level}>'
442
+ plain = _plain_text_from_html(inner)
443
+ anchor = _unique_anchor(text_to_anchor(plain), self.heading_counts)
444
+ return f'<h{level} id="{anchor}">{html.escape(plain)}</h{level}>'
271
445
 
272
446
  def render_superscript(self, token):
273
447
  """Render superscript text"""
@@ -521,7 +695,8 @@ def from_md(content, img_dir=None, current_path=None):
521
695
  mods = {'pre': 'my-4', 'p': 'text-base leading-relaxed mb-6', 'li': 'text-base leading-relaxed',
522
696
  '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',
523
697
  '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',
524
- 'h3': 'text-xl font-semibold mb-3 mt-5', 'h4': 'text-lg font-semibold mb-2 mt-4'}
698
+ 'h3': 'text-xl font-semibold mb-3 mt-5', 'h4': 'text-lg font-semibold mb-2 mt-4',
699
+ 'table': 'uk-table uk-table-striped uk-table-hover uk-table-divider uk-table-middle my-6'}
525
700
 
526
701
  # Register custom tokens with renderer context manager
527
702
  with ContentRenderer(YoutubeEmbed, InlineCodeAttr, Strikethrough, FootnoteRef, Superscript, Subscript, img_dir=img_dir, footnotes=footnotes, current_path=current_path) as renderer:
@@ -646,7 +821,7 @@ hdrs = (
646
821
  Link(rel="preconnect", href="https://fonts.gstatic.com", crossorigin=""),
647
822
  Link(rel="stylesheet", href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono&display=swap"),
648
823
  Style("body { font-family: 'IBM Plex Sans', sans-serif; } code, pre { font-family: 'IBM Plex Mono', monospace; }"),
649
- Style(".folder-chevron { transition: transform 0.2s; display: inline-block; } details[open] > summary > .folder-chevron { transform: rotate(90deg); } details { border: none !important; box-shadow: none !important; }"),
824
+ Style(".folder-chevron { display: inline-block; width: 0.45rem; height: 0.45rem; border-right: 2px solid rgb(148 163 184); border-bottom: 2px solid rgb(148 163 184); transform: rotate(-45deg); transition: transform 0.2s; } details.is-open > summary .folder-chevron { transform: rotate(45deg); } details { border: none !important; box-shadow: none !important; }"),
650
825
  Style("h1, h2, h3, h4, h5, h6 { scroll-margin-top: 7rem; }"), # Offset for sticky navbar
651
826
  Style("""
652
827
  /* Ultra thin scrollbar styles */
@@ -658,6 +833,15 @@ hdrs = (
658
833
  .dark *::-webkit-scrollbar-thumb { background-color: rgb(71 85 105); }
659
834
  .dark *::-webkit-scrollbar-thumb:hover { background-color: rgb(100 116 139); }
660
835
  .dark * { scrollbar-color: rgb(71 85 105) transparent; }
836
+
837
+ /* Sidebar active link highlight */
838
+ .sidebar-highlight {
839
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.35);
840
+ transition: box-shadow 10s ease, background-color 10s ease;
841
+ }
842
+ .sidebar-highlight.fade-out {
843
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0);
844
+ }
661
845
 
662
846
  /* Tabs styles */
663
847
  .tabs-container {
@@ -764,6 +948,43 @@ hdrs = (
764
948
  font-family: 'IBM Plex Mono', monospace;
765
949
  }
766
950
  """),
951
+ # Custom table stripe styling for punchier colors
952
+ Style("""
953
+ .uk-table-striped tbody tr:nth-of-type(odd) {
954
+ background-color: rgba(71, 85, 105, 0.08);
955
+ }
956
+ .dark .uk-table-striped tbody tr:nth-of-type(odd) {
957
+ background-color: rgba(148, 163, 184, 0.12);
958
+ }
959
+ .uk-table-striped tbody tr:hover {
960
+ background-color: rgba(59, 130, 246, 0.1);
961
+ }
962
+ .dark .uk-table-striped tbody tr:hover {
963
+ background-color: rgba(59, 130, 246, 0.15);
964
+ }
965
+ .uk-table thead {
966
+ border-bottom: 2px solid rgba(71, 85, 105, 0.3);
967
+ }
968
+ .dark .uk-table thead {
969
+ border-bottom: 2px solid rgba(148, 163, 184, 0.4);
970
+ }
971
+ .uk-table thead th {
972
+ font-weight: 600;
973
+ font-size: 1.25rem;
974
+ color: rgb(51, 65, 85);
975
+ }
976
+ .dark .uk-table thead th {
977
+ color: rgb(226, 232, 240);
978
+ }
979
+ .uk-table th:not(:last-child),
980
+ .uk-table td:not(:last-child) {
981
+ border-right: 1px solid rgba(71, 85, 105, 0.15);
982
+ }
983
+ .dark .uk-table th:not(:last-child),
984
+ .dark .uk-table td:not(:last-child) {
985
+ border-right: 1px solid rgba(148, 163, 184, 0.2);
986
+ }
987
+ """),
767
988
  # Script("if(!localStorage.__FRANKEN__) localStorage.__FRANKEN__ = JSON.stringify({mode: 'light'})"))
768
989
  Script("""
769
990
  (function () {
@@ -867,6 +1088,15 @@ def posts_sidebar_lazy():
867
1088
  id="posts-sidebar"
868
1089
  )
869
1090
 
1091
+ # Route to serve raw markdown for LLM-friendly access
1092
+ @rt("/posts/{path:path}.md")
1093
+ def serve_post_markdown(path: str):
1094
+ from starlette.responses import FileResponse
1095
+ file_path = get_root_folder() / f'{path}.md'
1096
+ if file_path.exists():
1097
+ return FileResponse(file_path, media_type="text/markdown; charset=utf-8")
1098
+ return Response(status_code=404)
1099
+
870
1100
  # Route to serve static files (images, SVGs, etc.) from blog posts
871
1101
  @rt("/posts/{path:path}.{ext:static}")
872
1102
  def serve_post_static(path: str, ext: str):
@@ -933,41 +1163,152 @@ def _posts_sidebar_fingerprint():
933
1163
  except Exception:
934
1164
  return 0
935
1165
 
1166
+ def _normalize_search_text(text):
1167
+ text = (text or "").lower()
1168
+ text = text.replace("-", " ").replace("_", " ")
1169
+ return " ".join(text.split())
1170
+
1171
+ def _search_post_files(query, limit=40):
1172
+ query = _normalize_search_text(query)
1173
+ if not query:
1174
+ return []
1175
+ root = get_root_folder()
1176
+ index_file = find_index_file()
1177
+ results = []
1178
+ for item in root.rglob("*.md"):
1179
+ if any(part.startswith('.') for part in item.relative_to(root).parts):
1180
+ continue
1181
+ if ".bloggy" in item.parts:
1182
+ continue
1183
+ if index_file and item.resolve() == index_file.resolve():
1184
+ continue
1185
+ rel = item.relative_to(root).with_suffix("")
1186
+ haystack = _normalize_search_text(f"{item.name} {rel.as_posix()}")
1187
+ if query in haystack:
1188
+ results.append(item)
1189
+ if len(results) >= limit:
1190
+ break
1191
+ return results
1192
+
1193
+ def _render_posts_search_results(query):
1194
+ trimmed = (query or "").strip()
1195
+ if not trimmed:
1196
+ return Ul(
1197
+ Li("Type to search file names.", cls="text-[0.7rem] text-center text-slate-500 dark:text-slate-400 bg-transparent"),
1198
+ cls="posts-search-results-list space-y-1 bg-white/0 dark:bg-slate-950/0"
1199
+ )
1200
+
1201
+ matches = _search_post_files(trimmed)
1202
+ if not matches:
1203
+ return Ul(
1204
+ Li(f'No matches for "{trimmed}".', cls="text-xs text-slate-500 dark:text-slate-400 bg-transparent"),
1205
+ cls="posts-search-results-list space-y-1 bg-white/0 dark:bg-slate-950/0"
1206
+ )
1207
+
1208
+ root = get_root_folder()
1209
+ items = []
1210
+ for item in matches:
1211
+ slug = str(item.relative_to(root).with_suffix(""))
1212
+ display = item.relative_to(root).with_suffix("").as_posix()
1213
+ items.append(Li(
1214
+ A(
1215
+ Span(UkIcon("search", cls="w-4 h-4 text-slate-400"), cls="w-4 mr-2 flex items-center justify-center shrink-0"),
1216
+ Span(display, cls="truncate min-w-0 font-mono text-xs text-slate-600 dark:text-slate-300", title=display),
1217
+ href=f'/posts/{slug}',
1218
+ hx_get=f'/posts/{slug}', hx_target="#main-content", hx_push_url="true", hx_swap="outerHTML show:window:top settle:0.1s",
1219
+ 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"
1220
+ )
1221
+ ))
1222
+ return Ul(*items, cls="posts-search-results-list space-y-1 bg-white/0 dark:bg-slate-950/0")
1223
+
1224
+ def _posts_search_block():
1225
+ return Div(
1226
+ Input(
1227
+ type="search",
1228
+ name="q",
1229
+ placeholder="Search file names…",
1230
+ autocomplete="off",
1231
+ hx_get="/_sidebar/posts/search",
1232
+ hx_trigger="input changed delay:300ms",
1233
+ hx_target="next .posts-search-results",
1234
+ hx_swap="innerHTML",
1235
+ 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"
1236
+ ),
1237
+ Div(
1238
+ _render_posts_search_results(""),
1239
+ cls="posts-search-results mt-4 max-h-64 overflow-y-auto bg-white/0 dark:bg-slate-950/0"
1240
+ ),
1241
+ cls="posts-search-block sticky top-0 z-10 bg-white/20 dark:bg-slate-950/70 backdrop-blur-lg shadow-[0_24px_60px_-40px_rgba(15,23,42,0.45)] dark:shadow-[0_28px_70px_-45px_rgba(2,6,23,0.85)]"
1242
+ )
1243
+
936
1244
  @lru_cache(maxsize=1)
937
1245
  def _cached_posts_sidebar_html(fingerprint):
1246
+ sidebars_open = get_config().get_sidebars_open()
938
1247
  sidebar = collapsible_sidebar(
939
1248
  "menu",
940
1249
  "Posts",
941
1250
  get_posts(),
942
- is_open=False,
943
- data_sidebar="posts"
1251
+ is_open=sidebars_open,
1252
+ data_sidebar="posts",
1253
+ shortcut_key="Z",
1254
+ extra_content=[
1255
+ _posts_search_block(),
1256
+ Div(cls="h-px w-full bg-slate-200/80 dark:bg-slate-700/70")
1257
+ ],
1258
+ scroll_target="list"
944
1259
  )
945
1260
  return to_xml(sidebar)
946
1261
 
947
- def collapsible_sidebar(icon, title, items_list, is_open=False, data_sidebar=None):
1262
+ def collapsible_sidebar(icon, title, items_list, is_open=False, data_sidebar=None, shortcut_key=None, extra_content=None, scroll_target="container"):
948
1263
  """Reusable collapsible sidebar component with sticky header"""
949
1264
  # Build the summary content
950
1265
  summary_content = [
951
- UkIcon(icon, cls="w-5 h-5 mr-2"),
952
- Span(title, cls="flex-1")
1266
+ Span(
1267
+ UkIcon(icon, cls="w-5 h-5 block"),
1268
+ cls="flex items-center justify-center w-5 h-5 shrink-0 leading-none"
1269
+ ),
1270
+ Span(title, cls="flex-1 leading-none")
953
1271
  ]
954
1272
 
1273
+ # Add keyboard shortcut indicator if provided
1274
+ if shortcut_key:
1275
+ summary_content.append(
1276
+ Kbd(
1277
+ shortcut_key,
1278
+ 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)]"
1279
+ )
1280
+ )
1281
+
955
1282
  # Sidebar styling configuration
956
- common_frost_style = "bg-white/10 dark:bg-slate-950/70 backdrop-blur-lg border border-slate-900/20 dark:border-slate-700/20 shadow-lg"
957
- summary_classes = f"flex items-center font-semibold cursor-pointer py-2 px-3 hover:bg-slate-100/80 dark:hover:bg-slate-800/80 rounded-lg select-none list-none {common_frost_style} min-h-[56px]"
958
- content_classes = f"p-3 {common_frost_style} rounded-lg border border-black dark:border-black overflow-y-auto max-h-[calc(100vh-18rem)]"
1283
+ 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)]"
1284
+ 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]"
1285
+ if scroll_target == "list":
1286
+ content_classes = f"p-3 {common_frost_style} rounded-lg max-h-[calc(100vh-18rem)] flex flex-col gap-4 overflow-hidden min-h-0"
1287
+ list_classes = "list-none pt-4 flex-1 min-h-0 overflow-y-auto sidebar-scroll-container"
1288
+ else:
1289
+ content_classes = f"p-3 {common_frost_style} rounded-lg overflow-y-auto max-h-[calc(100vh-18rem)] sidebar-scroll-container"
1290
+ list_classes = "list-none pt-4"
959
1291
 
1292
+ extra_content = extra_content or []
1293
+ content_id = "sidebar-scroll-container" if scroll_target != "list" else None
960
1294
  return Details(
961
1295
  Summary(*summary_content, cls=summary_classes, style="margin: 0 0 0.5rem 0;"),
962
1296
  Div(
963
- Ul(*items_list, cls="list-none"),
1297
+ *extra_content,
1298
+ Ul(*items_list, cls=list_classes, id="sidebar-scroll-container" if scroll_target == "list" else None),
964
1299
  cls=content_classes,
965
- id="sidebar-scroll-container"
1300
+ id=content_id,
1301
+ style="will-change: auto;"
966
1302
  ),
967
1303
  open=is_open,
968
- data_sidebar=data_sidebar
1304
+ data_sidebar=data_sidebar,
1305
+ style="will-change: auto;"
969
1306
  )
970
1307
 
1308
+ @rt("/_sidebar/posts/search")
1309
+ def posts_sidebar_search(q: str = ""):
1310
+ return _render_posts_search_results(q)
1311
+
971
1312
  def is_active_toc_item(anchor):
972
1313
  """Check if a TOC item is currently active based on URL hash"""
973
1314
  # This will be enhanced client-side with JavaScript
@@ -983,11 +1324,13 @@ def extract_toc(content):
983
1324
  # Parse headings from the cleaned content
984
1325
  heading_pattern = re.compile(r'^(#{1,6})\s+(.+)$', re.MULTILINE)
985
1326
  headings = []
1327
+ counts = {}
986
1328
  for match in heading_pattern.finditer(content_no_code):
987
1329
  level = len(match.group(1))
988
- text = match.group(2).strip()
1330
+ raw_text = match.group(2).strip()
1331
+ text = _strip_inline_markdown(raw_text)
989
1332
  # Create anchor from heading text using shared function
990
- anchor = text_to_anchor(text)
1333
+ anchor = _unique_anchor(text_to_anchor(text), counts)
991
1334
  headings.append((level, text, anchor))
992
1335
  return headings
993
1336
 
@@ -1063,13 +1406,14 @@ def layout(*content, htmx, title=None, show_sidebar=False, toc_content=None, cur
1063
1406
  t_toc = time.time()
1064
1407
  logger.debug(f"[LAYOUT] TOC built in {(t_toc - t_section)*1000:.2f}ms")
1065
1408
 
1409
+ sidebars_open = get_config().get_sidebars_open()
1066
1410
  toc_attrs = {
1067
1411
  "cls": "hidden md:block w-72 shrink-0 sticky top-24 self-start max-h-[calc(100vh-10rem)] overflow-hidden z-[1000]",
1068
1412
  "id": "toc-sidebar",
1069
1413
  "hx_swap_oob": "true",
1070
1414
  }
1071
1415
  toc_sidebar = Aside(
1072
- collapsible_sidebar("list", "Contents", toc_items, is_open=False) if toc_items else Div(),
1416
+ collapsible_sidebar("list", "Contents", toc_items, is_open=sidebars_open, shortcut_key="X") if toc_items else Div(),
1073
1417
  **toc_attrs
1074
1418
  )
1075
1419
 
@@ -1116,12 +1460,13 @@ def layout(*content, htmx, title=None, show_sidebar=False, toc_content=None, cur
1116
1460
  t_toc = time.time()
1117
1461
  logger.debug(f"[LAYOUT] TOC built in {(t_toc - t_section)*1000:.2f}ms")
1118
1462
  # Right sidebar TOC component with out-of-band swap for HTMX
1463
+ sidebars_open = get_config().get_sidebars_open()
1119
1464
  toc_attrs = {
1120
1465
  "cls": "hidden md:block w-72 shrink-0 sticky top-24 self-start max-h-[calc(100vh-10rem)] overflow-hidden z-[1000]",
1121
1466
  "id": "toc-sidebar"
1122
1467
  }
1123
1468
  toc_sidebar = Aside(
1124
- collapsible_sidebar("list", "Contents", toc_items, is_open=False) if toc_items else Div(),
1469
+ collapsible_sidebar("list", "Contents", toc_items, is_open=sidebars_open, shortcut_key="X") if toc_items else Div(),
1125
1470
  **toc_attrs
1126
1471
  )
1127
1472
  # Container for main content only (for HTMX swapping)
@@ -1163,7 +1508,7 @@ def layout(*content, htmx, title=None, show_sidebar=False, toc_content=None, cur
1163
1508
  cls="flex justify-end p-2 bg-white dark:bg-slate-950 border-b border-slate-200 dark:border-slate-800"
1164
1509
  ),
1165
1510
  Div(
1166
- 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")),
1511
+ 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")),
1167
1512
  cls="p-4 overflow-y-auto"
1168
1513
  ),
1169
1514
  id="mobile-toc-panel",
@@ -1231,8 +1576,28 @@ def build_post_tree(folder):
1231
1576
  start_time = time.time()
1232
1577
  root = get_root_folder()
1233
1578
  items = []
1234
- try:
1235
- entries = sorted(folder.iterdir(), key=lambda x: (not x.is_dir(), x.name))
1579
+ try:
1580
+ index_file = find_index_file() if folder == root else None
1581
+ entries = []
1582
+ for item in folder.iterdir():
1583
+ if item.name == ".bloggy":
1584
+ continue
1585
+ if item.is_dir():
1586
+ if item.name.startswith('.'):
1587
+ continue
1588
+ entries.append(item)
1589
+ elif item.suffix == '.md':
1590
+ # Skip the file being used for home page (index.md takes precedence over readme.md)
1591
+ if index_file and item.resolve() == index_file.resolve():
1592
+ continue
1593
+ entries.append(item)
1594
+ config = get_bloggy_config(folder)
1595
+ entries = order_bloggy_entries(entries, config)
1596
+ logger.debug(
1597
+ "[DEBUG] build_post_tree entries for %s: %s",
1598
+ folder,
1599
+ [item.name for item in entries],
1600
+ )
1236
1601
  logger.debug(f"[DEBUG] Scanning directory: {folder.relative_to(root) if folder != root else '.'} - found {len(entries)} entries")
1237
1602
  except (OSError, PermissionError):
1238
1603
  return items
@@ -1245,18 +1610,13 @@ def build_post_tree(folder):
1245
1610
  folder_title = slug_to_title(item.name)
1246
1611
  items.append(Li(Details(
1247
1612
  Summary(
1248
- Span(UkIcon("chevron-right", cls="folder-chevron w-4 h-4 text-slate-400"), cls="w-4 mr-1 flex items-center justify-center shrink-0"),
1249
- Span(UkIcon("folder", cls="text-blue-500 w-4 h-4"), cls="w-4 mr-1 flex items-center justify-center shrink-0"),
1613
+ Span(Span(cls="folder-chevron"), cls="w-4 mr-2 flex items-center justify-center shrink-0"),
1614
+ Span(UkIcon("folder", cls="text-blue-500 w-4 h-4"), cls="w-4 mr-2 flex items-center justify-center shrink-0"),
1250
1615
  Span(folder_title, cls="truncate min-w-0", title=folder_title),
1251
1616
  cls="flex items-center font-medium cursor-pointer py-1 px-2 hover:text-blue-600 select-none list-none rounded hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors min-w-0"),
1252
- Ul(*sub_items, cls="ml-2 pl-2 space-y-1 border-l border-slate-100 dark:border-slate-800"), open=False), cls="my-1"))
1617
+ Ul(*sub_items, cls="ml-4 pl-2 space-y-1 border-l border-slate-100 dark:border-slate-800"),
1618
+ data_folder="true"), cls="my-1"))
1253
1619
  elif item.suffix == '.md':
1254
- # Skip the file being used for home page (index.md takes precedence over readme.md)
1255
- if item.parent == root:
1256
- index_file = find_index_file()
1257
- if index_file and item.resolve() == index_file.resolve():
1258
- continue
1259
-
1260
1620
  slug = str(item.relative_to(root).with_suffix(''))
1261
1621
  title_start = time.time()
1262
1622
  title = get_post_title(item)
@@ -1279,7 +1639,9 @@ def build_post_tree(folder):
1279
1639
  def _posts_tree_fingerprint():
1280
1640
  root = get_root_folder()
1281
1641
  try:
1282
- return max((p.stat().st_mtime for p in root.rglob("*.md")), default=0)
1642
+ md_mtime = max((p.stat().st_mtime for p in root.rglob("*.md")), default=0)
1643
+ bloggy_mtime = max((p.stat().st_mtime for p in root.rglob(".bloggy")), default=0)
1644
+ return max(md_mtime, bloggy_mtime)
1283
1645
  except Exception:
1284
1646
  return 0
1285
1647
 
@@ -89,8 +89,20 @@ def cli():
89
89
  print(f"Serving at: http://{host}:{port}")
90
90
  if host == '0.0.0.0':
91
91
  print(f"Server accessible from network at: http://<your-ip>:{port}")
92
+
93
+ # Configure reload to watch markdown files in the blog directory
94
+ reload_kwargs = {}
95
+ if reload:
96
+ blog_root = config.get_root_folder()
97
+ reload_kwargs = {
98
+ "reload": True,
99
+ "reload_dirs": [str(blog_root)],
100
+ "reload_includes": ["*.md"]
101
+ }
102
+ else:
103
+ reload_kwargs = {"reload": False}
92
104
 
93
- uvicorn.run("bloggy.main:app", host=host, port=port, reload=reload)
105
+ uvicorn.run("bloggy.main:app", host=host, port=port, **reload_kwargs)
94
106
 
95
107
  if __name__ == "__main__":
96
108
  cli()
@@ -346,7 +346,8 @@ function revealInSidebar(rootElement = document) {
346
346
  return;
347
347
  }
348
348
 
349
- const currentPath = window.location.pathname.replace(/^\/posts\//, '');
349
+ // Decode the URL path to handle special characters and spaces
350
+ const currentPath = decodeURIComponent(window.location.pathname.replace(/^\/posts\//, ''));
350
351
  const activeLink = rootElement.querySelector(`.post-link[data-path="${currentPath}"]`);
351
352
 
352
353
  if (activeLink) {
@@ -375,20 +376,33 @@ function revealInSidebar(rootElement = document) {
375
376
  }
376
377
 
377
378
  // Highlight the active link temporarily
378
- activeLink.classList.add('ring-2', 'ring-blue-500', 'ring-offset-2');
379
- setTimeout(() => {
380
- activeLink.classList.remove('ring-2', 'ring-blue-500', 'ring-offset-2');
381
- }, 1500);
379
+ activeLink.classList.remove('fade-out');
380
+ activeLink.classList.add('sidebar-highlight');
381
+ requestAnimationFrame(() => {
382
+ setTimeout(() => {
383
+ activeLink.classList.add('fade-out');
384
+ setTimeout(() => {
385
+ activeLink.classList.remove('sidebar-highlight', 'fade-out');
386
+ }, 10000);
387
+ }, 1000);
388
+ });
382
389
  }
383
390
  }
384
391
 
385
392
  function initPostsSidebarAutoReveal() {
386
393
  const postSidebars = document.querySelectorAll('details[data-sidebar="posts"]');
394
+
387
395
  postSidebars.forEach((sidebar) => {
388
396
  if (sidebar.dataset.revealBound === 'true') {
389
397
  return;
390
398
  }
391
399
  sidebar.dataset.revealBound = 'true';
400
+
401
+ // Reveal immediately if sidebar is already open
402
+ if (sidebar.open) {
403
+ revealInSidebar(sidebar);
404
+ }
405
+
392
406
  sidebar.addEventListener('toggle', () => {
393
407
  if (!sidebar.open) {
394
408
  return;
@@ -398,6 +412,23 @@ function initPostsSidebarAutoReveal() {
398
412
  });
399
413
  }
400
414
 
415
+ function initFolderChevronState(rootElement = document) {
416
+ rootElement.querySelectorAll('details[data-folder="true"]').forEach((details) => {
417
+ details.classList.toggle('is-open', details.open);
418
+ });
419
+ }
420
+
421
+ document.addEventListener('toggle', (event) => {
422
+ const details = event.target;
423
+ if (!(details instanceof HTMLDetailsElement)) {
424
+ return;
425
+ }
426
+ if (!details.matches('details[data-folder="true"]')) {
427
+ return;
428
+ }
429
+ details.classList.toggle('is-open', details.open);
430
+ }, true);
431
+
401
432
  // Update active post link in sidebar
402
433
  function updateActivePostLink() {
403
434
  const currentPath = window.location.pathname.replace(/^\/posts\//, '');
@@ -414,17 +445,27 @@ function updateActivePostLink() {
414
445
  }
415
446
 
416
447
  // Update active TOC link based on scroll position
448
+ let lastActiveTocAnchor = null;
417
449
  function updateActiveTocLink() {
418
450
  const headings = document.querySelectorAll('h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]');
419
451
  const tocLinks = document.querySelectorAll('.toc-link');
420
452
 
421
453
  let activeHeading = null;
454
+ let nearestBelow = null;
455
+ let nearestBelowTop = Infinity;
456
+ const offset = 140;
422
457
  headings.forEach(heading => {
423
458
  const rect = heading.getBoundingClientRect();
424
- if (rect.top <= 100) {
459
+ if (rect.top <= offset) {
425
460
  activeHeading = heading;
461
+ } else if (rect.top < nearestBelowTop) {
462
+ nearestBelowTop = rect.top;
463
+ nearestBelow = heading;
426
464
  }
427
465
  });
466
+ if (!activeHeading && nearestBelow) {
467
+ activeHeading = nearestBelow;
468
+ }
428
469
 
429
470
  tocLinks.forEach(link => {
430
471
  const anchor = link.getAttribute('data-anchor');
@@ -434,6 +475,14 @@ function updateActiveTocLink() {
434
475
  link.classList.remove('bg-blue-50', 'dark:bg-blue-900/20', 'text-blue-600', 'dark:text-blue-400', 'font-semibold');
435
476
  }
436
477
  });
478
+
479
+ const activeId = activeHeading ? activeHeading.id : null;
480
+ if (activeId && activeId !== lastActiveTocAnchor) {
481
+ document.querySelectorAll(`.toc-link[data-anchor="${activeId}"]`).forEach(link => {
482
+ link.scrollIntoView({ block: 'nearest' });
483
+ });
484
+ lastActiveTocAnchor = activeId;
485
+ }
437
486
  }
438
487
 
439
488
  // Listen for scroll events to update active TOC link
@@ -448,8 +497,50 @@ window.addEventListener('scroll', () => {
448
497
  }
449
498
  });
450
499
 
500
+ // Sync TOC highlight on hash changes and TOC clicks
501
+ window.addEventListener('hashchange', () => {
502
+ requestAnimationFrame(updateActiveTocLink);
503
+ });
504
+
505
+ document.addEventListener('click', (event) => {
506
+ const link = event.target.closest('.toc-link');
507
+ if (!link) {
508
+ return;
509
+ }
510
+ const anchor = link.getAttribute('data-anchor');
511
+ if (!anchor) {
512
+ return;
513
+ }
514
+ requestAnimationFrame(() => {
515
+ document.querySelectorAll('.toc-link').forEach(item => {
516
+ item.classList.toggle(
517
+ 'bg-blue-50',
518
+ item.getAttribute('data-anchor') === anchor
519
+ );
520
+ item.classList.toggle(
521
+ 'dark:bg-blue-900/20',
522
+ item.getAttribute('data-anchor') === anchor
523
+ );
524
+ item.classList.toggle(
525
+ 'text-blue-600',
526
+ item.getAttribute('data-anchor') === anchor
527
+ );
528
+ item.classList.toggle(
529
+ 'dark:text-blue-400',
530
+ item.getAttribute('data-anchor') === anchor
531
+ );
532
+ item.classList.toggle(
533
+ 'font-semibold',
534
+ item.getAttribute('data-anchor') === anchor
535
+ );
536
+ });
537
+ lastActiveTocAnchor = anchor;
538
+ updateActiveTocLink();
539
+ });
540
+ });
541
+
451
542
  // Re-run mermaid on HTMX content swaps
452
- document.body.addEventListener('htmx:afterSwap', function() {
543
+ document.body.addEventListener('htmx:afterSwap', function(event) {
453
544
  mermaid.run().then(() => {
454
545
  setTimeout(initMermaidInteraction, 100);
455
546
  });
@@ -457,6 +548,7 @@ document.body.addEventListener('htmx:afterSwap', function() {
457
548
  updateActiveTocLink();
458
549
  initMobileMenus(); // Reinitialize mobile menu handlers
459
550
  initPostsSidebarAutoReveal();
551
+ initFolderChevronState();
460
552
  });
461
553
 
462
554
  // Watch for theme changes and re-render mermaid diagrams
@@ -556,10 +648,44 @@ function initMobileMenus() {
556
648
  }
557
649
  }
558
650
 
651
+ // Keyboard shortcuts for toggling sidebars
652
+ function initKeyboardShortcuts() {
653
+ // Prewarm the selectors to avoid lazy compilation delays
654
+ const postsSidebars = document.querySelectorAll('details[data-sidebar="posts"]');
655
+ const tocSidebar = document.querySelector('#toc-sidebar details');
656
+
657
+ document.addEventListener('keydown', (e) => {
658
+ // Skip if user is typing in an input field
659
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) {
660
+ return;
661
+ }
662
+
663
+ // Z: Toggle posts panel
664
+ if (e.key === 'z' || e.key === 'Z') {
665
+ e.preventDefault();
666
+ const postsSidebars = document.querySelectorAll('details[data-sidebar="posts"]');
667
+ postsSidebars.forEach(sidebar => {
668
+ sidebar.open = !sidebar.open;
669
+ });
670
+ }
671
+
672
+ // X: Toggle TOC panel
673
+ if (e.key === 'x' || e.key === 'X') {
674
+ e.preventDefault();
675
+ const tocSidebar = document.querySelector('#toc-sidebar details');
676
+ if (tocSidebar) {
677
+ tocSidebar.open = !tocSidebar.open;
678
+ }
679
+ }
680
+ });
681
+ }
682
+
559
683
  // Initialize on page load
560
684
  document.addEventListener('DOMContentLoaded', () => {
561
685
  updateActivePostLink();
562
686
  updateActiveTocLink();
563
687
  initMobileMenus();
564
688
  initPostsSidebarAutoReveal();
689
+ initFolderChevronState();
690
+ initKeyboardShortcuts();
565
691
  });
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bloggy
3
- Version: 0.1.39
3
+ Version: 0.1.57
4
4
  Summary: A lightweight, elegant blogging platform built with FastHTML
5
5
  Home-page: https://github.com/yeshwanth/bloggy
6
6
  Author: Yeshwanth
@@ -44,6 +44,15 @@ title: Bloggy - A FastHTML Blogging Platform
44
44
 
45
45
  A lightweight, elegant blogging platform built with FastHTML that renders Markdown files into beautiful web pages with advanced features.
46
46
 
47
+ Simple Table
48
+
49
+ | Feature | Description |
50
+ |-----------------------------|--------------------------------------------------|
51
+ | FastHTML Integration | Built on FastHTML for high performance and ease of use |
52
+ | Advanced Markdown Support | Footnotes as sidenotes, YouTube embeds, task lists, Mermaid diagrams, math notation, tabbed content, and more |
53
+ | Modern UI | Responsive design, dark mode, three-panel layout, HTMX navigation |
54
+ | Interactive Diagrams | Zoomable, pannable Mermaid diagrams with fullscreen support |
55
+
47
56
  ## Architecture Overview
48
57
 
49
58
  ```mermaid
@@ -295,6 +304,7 @@ stateDiagram-v2
295
304
  - **Mermaid Frontmatter**: Configure diagram size with YAML frontmatter (width, height, min-height)
296
305
  - **Tabbed Content**: Create multi-tab sections using `:::tabs` and `::tab{title="..."}` syntax with smooth transitions
297
306
  - **Relative Links**: Full support for relative markdown links (`./file.md`, `../other.md`) with automatic path resolution
307
+ - **Plain-Text Headings**: Inline markdown in headings is stripped for clean display and consistent anchor slugs
298
308
  - **Math Notation**: KaTeX support for inline `$E=mc^2$` and block `$$` math equations, auto-renders after HTMX swaps
299
309
  - **Superscript & Subscript**: Use `^text^` for superscript and `~text~` for subscript (preprocessed before rendering)
300
310
  - **Strikethrough**: Use `~~text~~` for strikethrough formatting
@@ -307,7 +317,9 @@ stateDiagram-v2
307
317
  - **Dark Mode**: Automatic theme switching with localStorage persistence and instant visual feedback
308
318
  - **HTMX Navigation**: Fast, SPA-like navigation without full page reloads using `hx-get`, `hx-target`, and `hx-push-url`
309
319
  - **Collapsible Folders**: Organize posts in nested directories with chevron indicators and smooth expand/collapse
320
+ - **Sidebar Search**: HTMX-powered filename search with results shown below the search bar (tree stays intact)
310
321
  - **Auto-Generated TOC**: Table of contents automatically extracted from headings with scroll-based active highlighting
322
+ - **TOC Autoscroll + Accurate Highlights**: Active TOC item stays in view and highlight logic handles duplicate headings
311
323
  - **Mobile Menus**: Slide-in panels for posts and TOC on mobile devices with smooth transitions
312
324
  - **Sticky Navigation**: Navbar stays at top while scrolling, with mobile menu toggles
313
325
  - **Active Link Highlighting**: Current post and TOC section highlighted with blue accents
@@ -328,8 +340,13 @@ stateDiagram-v2
328
340
  - **Performance Logging**: Debug-level logging tracks render times and bottlenecks to `/tmp/bloggy_core.log`
329
341
  - **Custom 404 Page**: Elegant error page with navigation options and helpful tips
330
342
  - **Static File Serving**: Serves images and assets from blog directories via `/posts/{path}.{ext}` routes
343
+ - **Raw Markdown Access**: Append `.md` to any post URL (e.g. `/posts/demo.md`) to fetch source content
331
344
  - **Optional Authentication**: Session-based auth with Beforeware when username/password configured
332
345
 
346
+ ### Quick Usage Examples
347
+ - Sidebar search: type a filename fragment like `write up` or `write-up` to filter results without collapsing the tree
348
+ - Raw markdown: fetch a post's source via `/posts/demo.md`
349
+
333
350
  ## Content Writing Features
334
351
 
335
352
  ### Footnotes as Sidenotes
@@ -609,6 +626,24 @@ password = "hunter2"
609
626
 
610
627
  All settings in the `.bloggy` file are optional. The configuration is managed by the `Config` class in `bloggy/config.py`.
611
628
 
629
+ ### Custom Sidebar Ordering
630
+
631
+ Place a `.bloggy` file in any folder to control the sidebar order for that folder. `.bloggy` uses TOML format. Use `order` to pin items first, then `sort` and `folders_first` for the remainder.
632
+
633
+ ```toml
634
+ # Items listed in order are shown first. Use exact names (include extensions).
635
+ order = ["todo.md", "static-build.md", "docs"]
636
+
637
+ # Sorting for items not listed in order
638
+ sort = "name_asc" # name_asc, name_desc, mtime_asc, mtime_desc
639
+ folders_first = true
640
+ folders_always_first = false
641
+ ```
642
+
643
+ Notes:
644
+ - `folders_first` only affects the items not listed in `order`.
645
+ - `folders_always_first` moves all folders to the top after ordering/sorting, while preserving their relative order.
646
+
612
647
  ### Environment Variables
613
648
 
614
649
  You can also use environment variables as a fallback:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "bloggy"
7
- version = "0.1.39"
7
+ version = "0.1.57"
8
8
  description = "A lightweight, elegant blogging platform built with FastHTML"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,6 +1,6 @@
1
1
  [DEFAULT]
2
2
  lib_name = bloggy
3
- version = 0.1.39
3
+ version = 0.1.57
4
4
  min_python = 3.9
5
5
  license = apache2
6
6
  status = 4
File without changes
File without changes
File without changes
File without changes