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.
- {bloggy-0.1.39/bloggy.egg-info → bloggy-0.1.57}/PKG-INFO +36 -1
- {bloggy-0.1.39 → bloggy-0.1.57}/README.md +35 -0
- {bloggy-0.1.39 → bloggy-0.1.57}/bloggy/__init__.py +1 -1
- {bloggy-0.1.39 → bloggy-0.1.57}/bloggy/build.py +41 -13
- {bloggy-0.1.39 → bloggy-0.1.57}/bloggy/config.py +8 -0
- {bloggy-0.1.39 → bloggy-0.1.57}/bloggy/core.py +396 -34
- {bloggy-0.1.39 → bloggy-0.1.57}/bloggy/main.py +13 -1
- {bloggy-0.1.39 → bloggy-0.1.57}/bloggy/static/scripts.js +133 -7
- {bloggy-0.1.39 → bloggy-0.1.57/bloggy.egg-info}/PKG-INFO +36 -1
- {bloggy-0.1.39 → bloggy-0.1.57}/pyproject.toml +1 -1
- {bloggy-0.1.39 → bloggy-0.1.57}/settings.ini +1 -1
- {bloggy-0.1.39 → bloggy-0.1.57}/LICENSE +0 -0
- {bloggy-0.1.39 → bloggy-0.1.57}/MANIFEST.in +0 -0
- {bloggy-0.1.39 → bloggy-0.1.57}/bloggy/static/sidenote.css +0 -0
- {bloggy-0.1.39 → bloggy-0.1.57}/bloggy.egg-info/SOURCES.txt +0 -0
- {bloggy-0.1.39 → bloggy-0.1.57}/bloggy.egg-info/dependency_links.txt +0 -0
- {bloggy-0.1.39 → bloggy-0.1.57}/bloggy.egg-info/entry_points.txt +0 -0
- {bloggy-0.1.39 → bloggy-0.1.57}/bloggy.egg-info/not-zip-safe +0 -0
- {bloggy-0.1.39 → bloggy-0.1.57}/bloggy.egg-info/requires.txt +0 -0
- {bloggy-0.1.39 → bloggy-0.1.57}/bloggy.egg-info/top_level.txt +0 -0
- {bloggy-0.1.39 → bloggy-0.1.57}/setup.cfg +0 -0
- {bloggy-0.1.39 → bloggy-0.1.57}/setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bloggy
|
|
3
|
-
Version: 0.1.
|
|
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:
|
|
@@ -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 {
|
|
31
|
-
|
|
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
|
-
|
|
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(
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
|
|
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 {
|
|
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=
|
|
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
|
-
|
|
952
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
-
|
|
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(
|
|
1249
|
-
Span(UkIcon("folder", cls="text-blue-500 w-4 h-4"), cls="w-4 mr-
|
|
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-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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.
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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 <=
|
|
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.
|
|
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:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|