vyasa 0.3.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
vyasa/build.py ADDED
@@ -0,0 +1,660 @@
1
+ """Static site generator for Vyasa
2
+
3
+ This module provides functionality to convert a folder of markdown files
4
+ into a standalone static website with HTML, CSS, and JavaScript files.
5
+ """
6
+
7
+ from pathlib import Path
8
+ import shutil
9
+ from functools import partial
10
+ import mistletoe as mst
11
+ from fasthtml.common import *
12
+ from monsterui.all import *
13
+ from .core import (
14
+ parse_frontmatter, get_post_title, slug_to_title,
15
+ from_md, extract_toc, build_toc_items, text_to_anchor,
16
+ build_post_tree, ContentRenderer, extract_footnotes,
17
+ preprocess_super_sub, preprocess_tabs,
18
+ get_vyasa_config, order_vyasa_entries, _effective_abbreviations, find_folder_note_file
19
+ )
20
+ from .config import get_config, reload_config
21
+
22
+
23
+ def generate_static_html(title, body_content, blog_title, favicon_href):
24
+ """Generate complete static HTML page"""
25
+
26
+ # Static CSS (inline critical styles)
27
+ static_css = """
28
+ <style>
29
+ body { font-family: 'IBM Plex Sans', sans-serif; margin: 0; padding: 0; }
30
+ code, pre { font-family: 'IBM Plex Mono', monospace; }
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); }
41
+ details { border: none !important; box-shadow: none !important; }
42
+ h1, h2, h3, h4, h5, h6 { scroll-margin-top: 7rem; }
43
+
44
+ /* Ultra thin scrollbar styles */
45
+ * { scrollbar-width: thin; scrollbar-color: rgb(203 213 225) transparent; }
46
+ *::-webkit-scrollbar { width: 3px; height: 3px; }
47
+ *::-webkit-scrollbar-track { background: transparent; }
48
+ *::-webkit-scrollbar-thumb { background-color: rgb(203 213 225); border-radius: 2px; }
49
+ *::-webkit-scrollbar-thumb:hover { background-color: rgb(148 163 184); }
50
+ .dark *::-webkit-scrollbar-thumb { background-color: rgb(71 85 105); }
51
+ .dark *::-webkit-scrollbar-thumb:hover { background-color: rgb(100 116 139); }
52
+ .dark * { scrollbar-color: rgb(71 85 105) transparent; }
53
+
54
+ /* Tabs styles */
55
+ .tabs-container {
56
+ margin: 2rem 0;
57
+ border: 1px solid rgb(226 232 240);
58
+ border-radius: 0.5rem;
59
+ overflow: visible;
60
+ box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
61
+ }
62
+ .dark .tabs-container {
63
+ border-color: rgb(51 65 85);
64
+ box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3);
65
+ }
66
+
67
+ .tabs-header {
68
+ display: flex;
69
+ background: rgb(248 250 252);
70
+ border-bottom: 1px solid rgb(226 232 240);
71
+ gap: 0;
72
+ }
73
+ .dark .tabs-header {
74
+ background: rgb(15 23 42);
75
+ border-bottom-color: rgb(51 65 85);
76
+ }
77
+
78
+ .tab-button {
79
+ flex: 1;
80
+ padding: 0.875rem 1.5rem;
81
+ background: transparent;
82
+ border: none;
83
+ border-bottom: 3px solid transparent;
84
+ cursor: pointer;
85
+ font-weight: 500;
86
+ font-size: 0.9375rem;
87
+ color: rgb(100 116 139);
88
+ transition: all 0.15s ease;
89
+ position: relative;
90
+ margin-bottom: -1px;
91
+ }
92
+ .dark .tab-button { color: rgb(148 163 184); }
93
+
94
+ .tab-button:hover:not(.active) {
95
+ background: rgb(241 245 249);
96
+ color: rgb(51 65 85);
97
+ }
98
+ .dark .tab-button:hover:not(.active) {
99
+ background: rgb(30 41 59);
100
+ color: rgb(226 232 240);
101
+ }
102
+
103
+ .tab-button.active {
104
+ color: rgb(15 23 42);
105
+ border-bottom-color: rgb(15 23 42);
106
+ background: white;
107
+ font-weight: 600;
108
+ }
109
+ .dark .tab-button.active {
110
+ color: rgb(248 250 252);
111
+ border-bottom-color: rgb(248 250 252);
112
+ background: rgb(2 6 23);
113
+ }
114
+
115
+ .tabs-content {
116
+ background: white;
117
+ position: relative;
118
+ overflow: visible;
119
+ }
120
+ .dark .tabs-content {
121
+ background: rgb(2 6 23);
122
+ }
123
+
124
+ .tab-panel {
125
+ padding: 1rem 1rem;
126
+ animation: fadeIn 0.2s ease-in;
127
+ position: absolute;
128
+ top: 0;
129
+ left: 0;
130
+ right: 0;
131
+ opacity: 0;
132
+ visibility: hidden;
133
+ pointer-events: none;
134
+ overflow: visible;
135
+ }
136
+ .tab-panel.active {
137
+ position: relative;
138
+ opacity: 1;
139
+ visibility: visible;
140
+ pointer-events: auto;
141
+ }
142
+
143
+ @keyframes fadeIn {
144
+ from { opacity: 0; }
145
+ to { opacity: 1; }
146
+ }
147
+
148
+ /* Remove extra margins from first/last elements in tabs */
149
+ .tab-panel > *:first-child { margin-top: 0 !important; }
150
+ .tab-panel > *:last-child { margin-bottom: 0 !important; }
151
+
152
+ /* Ensure code blocks in tabs look good */
153
+ .tab-panel pre {
154
+ border-radius: 0.375rem;
155
+ font-size: 0.875rem;
156
+ }
157
+ .tab-panel code {
158
+ font-family: 'IBM Plex Mono', monospace;
159
+ }
160
+ </style>
161
+ """
162
+
163
+ # JavaScript for interactivity
164
+ static_js = """
165
+ <script>
166
+ // Theme toggle functionality
167
+ (function() {
168
+ const stored = localStorage.getItem('__FRANKEN__');
169
+ const franken = stored ? JSON.parse(stored) : {mode: 'light'};
170
+ if (franken.mode === 'dark') {
171
+ document.documentElement.classList.add('dark');
172
+ }
173
+ })();
174
+
175
+ function toggleTheme() {
176
+ const html = document.documentElement;
177
+ html.classList.toggle('dark');
178
+ const stored = localStorage.getItem('__FRANKEN__');
179
+ const franken = stored ? JSON.parse(stored) : {mode: 'light'};
180
+ franken.mode = html.classList.contains('dark') ? 'dark' : 'light';
181
+ localStorage.setItem('__FRANKEN__', JSON.stringify(franken));
182
+ }
183
+
184
+ // Tab switching functionality
185
+ function switchTab(tabsId, index) {
186
+ const container = document.querySelector('.tabs-container[data-tabs-id="' + tabsId + '"]');
187
+ if (!container) return;
188
+
189
+ // Update buttons
190
+ const buttons = container.querySelectorAll('.tab-button');
191
+ buttons.forEach(function(btn, i) {
192
+ if (i === index) {
193
+ btn.classList.add('active');
194
+ } else {
195
+ btn.classList.remove('active');
196
+ }
197
+ });
198
+
199
+ // Update panels
200
+ const panels = container.querySelectorAll('.tab-panel');
201
+ panels.forEach(function(panel, i) {
202
+ if (i === index) {
203
+ panel.classList.add('active');
204
+ panel.style.position = 'relative';
205
+ panel.style.visibility = 'visible';
206
+ panel.style.opacity = '1';
207
+ panel.style.pointerEvents = 'auto';
208
+ } else {
209
+ panel.classList.remove('active');
210
+ panel.style.position = 'absolute';
211
+ panel.style.visibility = 'hidden';
212
+ panel.style.opacity = '0';
213
+ panel.style.pointerEvents = 'none';
214
+ }
215
+ });
216
+ }
217
+ window.switchTab = switchTab;
218
+
219
+ // Set tab container heights based on tallest panel
220
+ document.addEventListener('DOMContentLoaded', function() {
221
+ setTimeout(() => {
222
+ document.querySelectorAll('.tabs-container').forEach(container => {
223
+ const panels = container.querySelectorAll('.tab-panel');
224
+ let maxHeight = 0;
225
+
226
+ panels.forEach(panel => {
227
+ const wasActive = panel.classList.contains('active');
228
+ panel.style.position = 'relative';
229
+ panel.style.visibility = 'visible';
230
+ panel.style.opacity = '1';
231
+ panel.style.pointerEvents = 'auto';
232
+
233
+ const height = panel.offsetHeight;
234
+ if (height > maxHeight) maxHeight = height;
235
+
236
+ if (!wasActive) {
237
+ panel.style.position = 'absolute';
238
+ panel.style.visibility = 'hidden';
239
+ panel.style.opacity = '0';
240
+ panel.style.pointerEvents = 'none';
241
+ }
242
+ });
243
+
244
+ const tabsContent = container.querySelector('.tabs-content');
245
+ if (tabsContent && maxHeight > 0) {
246
+ tabsContent.style.minHeight = maxHeight + 'px';
247
+ }
248
+ });
249
+ }, 100);
250
+
251
+ function replaceEscapedDollarPlaceholders(root) {
252
+ const placeholder = '@@VYASA_DOLLAR@@';
253
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
254
+ const nodes = [];
255
+ let node;
256
+ while ((node = walker.nextNode())) {
257
+ if (node.nodeValue && node.nodeValue.includes(placeholder)) {
258
+ nodes.push(node);
259
+ }
260
+ }
261
+ nodes.forEach((textNode) => {
262
+ textNode.nodeValue = textNode.nodeValue.split(placeholder).join('$');
263
+ });
264
+ }
265
+
266
+ // Initialize KaTeX rendering
267
+ if (window.renderMathInElement) {
268
+ renderMathInElement(document.body, {
269
+ delimiters: [
270
+ {left: '$$', right: '$$', display: true},
271
+ {left: '$', right: '$', display: false}
272
+ ],
273
+ throwOnError: false
274
+ });
275
+ }
276
+ replaceEscapedDollarPlaceholders(document.body);
277
+ });
278
+
279
+ // Sidenote interactions
280
+ document.addEventListener('click', function(e) {
281
+ if (e.target.classList.contains('sidenote-ref')) {
282
+ const id = e.target.id.replace('snref-', 'sn-');
283
+ const sidenote = document.getElementById(id);
284
+ if (sidenote) {
285
+ if (window.innerWidth >= 1280) {
286
+ sidenote.classList.add('hl');
287
+ setTimeout(() => sidenote.classList.remove('hl'), 1000);
288
+ } else {
289
+ e.target.classList.toggle('open');
290
+ sidenote.classList.toggle('show');
291
+ }
292
+ }
293
+ }
294
+ });
295
+ </script>
296
+ """
297
+
298
+ html = f"""<!DOCTYPE html>
299
+ <html lang="en">
300
+ <head>
301
+ <meta charset="UTF-8">
302
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
303
+ <title>{title}</title>
304
+
305
+ <!-- TailwindCSS and MonsterUI -->
306
+ <script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,container-queries"></script>
307
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.16.14/dist/css/uikit.min.css" />
308
+ <script src="https://cdn.jsdelivr.net/npm/uikit@3.16.14/dist/js/uikit.min.js"></script>
309
+ <script src="https://cdn.jsdelivr.net/npm/uikit@3.16.14/dist/js/uikit-icons.min.js"></script>
310
+
311
+ <!-- Fonts -->
312
+ <link rel="preconnect" href="https://fonts.googleapis.com">
313
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
314
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono&display=swap" rel="stylesheet">
315
+
316
+ <!-- Syntax Highlighting -->
317
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
318
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
319
+
320
+ <!-- Math Rendering -->
321
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
322
+ <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
323
+ <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
324
+
325
+ <!-- Hyperscript for interactions -->
326
+ <script src="https://unpkg.com/hyperscript.org@0.9.12"></script>
327
+
328
+ <!-- Static assets -->
329
+ <link rel="icon" href="{favicon_href}">
330
+ <link rel="stylesheet" href="/static/sidenote.css">
331
+
332
+ {static_css}
333
+ </head>
334
+ <body>
335
+ {body_content}
336
+
337
+ <!-- Mermaid diagrams -->
338
+ <script type="module">
339
+ import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
340
+ mermaid.initialize({{ startOnLoad: true, theme: 'default' }});
341
+ </script>
342
+ <script src="/static/scripts.js" type="module"></script>
343
+
344
+ {static_js}
345
+ </body>
346
+ </html>"""
347
+
348
+ return html
349
+
350
+
351
+ def build_post_tree_static(folder, root_folder):
352
+ """Build post tree with static .html links instead of HTMX"""
353
+ items = []
354
+ try:
355
+ index_file = None
356
+ if folder == root_folder:
357
+ for candidate in root_folder.iterdir():
358
+ if candidate.is_file() and candidate.suffix == '.md' and candidate.stem.lower() == 'index':
359
+ index_file = candidate
360
+ break
361
+ if index_file is None:
362
+ for candidate in root_folder.iterdir():
363
+ if candidate.is_file() and candidate.suffix == '.md' and candidate.stem.lower() == 'readme':
364
+ index_file = candidate
365
+ break
366
+
367
+ entries = []
368
+ folder_note = find_folder_note_file(folder)
369
+ for item in folder.iterdir():
370
+ if item.name == ".vyasa":
371
+ continue
372
+ if item.is_dir():
373
+ if item.name.startswith('.'):
374
+ continue
375
+ entries.append(item)
376
+ elif item.suffix == '.md':
377
+ if folder_note and item.resolve() == folder_note.resolve():
378
+ continue
379
+ if index_file and item.resolve() == index_file.resolve():
380
+ continue
381
+ entries.append(item)
382
+ entries = order_vyasa_entries(entries, get_vyasa_config(folder))
383
+ abbreviations = _effective_abbreviations(root_folder, folder)
384
+ except (OSError, PermissionError):
385
+ return items
386
+
387
+ for item in entries:
388
+ if item.is_dir():
389
+ if item.name.startswith('.'):
390
+ continue
391
+ sub_items = build_post_tree_static(item, root_folder)
392
+ folder_title = slug_to_title(item.name, abbreviations=abbreviations)
393
+ note_file = find_folder_note_file(item)
394
+ note_link = None
395
+ note_slug = None
396
+ if note_file:
397
+ note_slug = str(note_file.relative_to(root_folder).with_suffix(''))
398
+ note_link = A(
399
+ href=f'/posts/{note_slug}.html',
400
+ cls="folder-note-link truncate min-w-0 hover:underline",
401
+ title=f"Open: {folder_title}",
402
+ onclick="event.stopPropagation();",
403
+ )(folder_title)
404
+ title_node = note_link if note_link else Span(folder_title, cls="truncate min-w-0", title=folder_title)
405
+ if sub_items:
406
+ items.append(Li(Details(
407
+ Summary(
408
+ Span(Span(cls="folder-chevron"), cls="w-4 mr-2 flex items-center justify-center shrink-0"),
409
+ Span(UkIcon("folder", cls="text-blue-500 w-4 h-4"), cls="w-4 mr-2 flex items-center justify-center shrink-0"),
410
+ title_node,
411
+ 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"),
412
+ Ul(*sub_items, cls="ml-4 pl-2 space-y-1 border-l border-slate-100 dark:border-slate-800"),
413
+ data_folder="true"), cls="my-1"))
414
+ elif note_file and note_slug:
415
+ title_text = Span(folder_title, cls="truncate min-w-0", title=folder_title)
416
+ items.append(Li(A(
417
+ Span(cls="w-4 mr-2 shrink-0"),
418
+ Span(UkIcon("folder", cls="text-blue-500 w-4 h-4"), cls="w-4 mr-2 flex items-center justify-center shrink-0"),
419
+ title_text,
420
+ href=f'/posts/{note_slug}.html',
421
+ cls="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")))
422
+ elif item.suffix == '.md':
423
+ slug = str(item.relative_to(root_folder).with_suffix(''))
424
+ title = get_post_title(item, abbreviations=abbreviations)
425
+
426
+ # Use .html extension for static links
427
+ items.append(Li(A(
428
+ Span(cls="w-4 mr-2 shrink-0"),
429
+ Span(UkIcon("file-text", cls="text-slate-400 w-4 h-4"), cls="w-4 mr-2 flex items-center justify-center shrink-0"),
430
+ Span(title, cls="truncate min-w-0", title=title),
431
+ href=f'/posts/{slug}.html', # Add .html extension
432
+ cls="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")))
433
+ return items
434
+
435
+
436
+ def static_layout(content_html, blog_title, page_title, nav_tree, favicon_href, toc_items=None, current_path=None):
437
+ """Generate complete static page layout"""
438
+
439
+ # Theme toggle button
440
+ theme_toggle = '''
441
+ <button onclick="toggleTheme()" class="p-1 hover:scale-110 shadow-none" type="button">
442
+ <span uk-icon="moon" class="dark:hidden"></span>
443
+ <span uk-icon="sun" class="hidden dark:block"></span>
444
+ </button>
445
+ '''
446
+
447
+ # Navbar
448
+ navbar = f'''
449
+ <div class="flex items-center justify-between bg-slate-900 text-white p-4 my-4 rounded-lg shadow-md dark:bg-slate-800">
450
+ <a href="/index.html">{blog_title}</a>
451
+ {theme_toggle}
452
+ </div>
453
+ '''
454
+
455
+ # Build navigation sidebar
456
+ nav_html = to_xml(Ul(*nav_tree, cls="mt-2 list-none"))
457
+ posts_sidebar = f'''
458
+ <aside id="posts-sidebar" class="hidden md:block w-64 shrink-0 sticky top-24 self-start max-h-[calc(100vh-10rem)] overflow-hidden z-[1000]">
459
+ <details open>
460
+ <summary class="flex items-center font-semibold cursor-pointer py-2 px-3 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg select-none list-none bg-white dark:bg-slate-950 z-10">
461
+ <span uk-icon="menu" class="w-5 h-5 mr-2"></span>
462
+ Posts
463
+ </summary>
464
+ <div class="mt-2 p-3 bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 overflow-y-auto max-h-[calc(100vh-16rem)]">
465
+ {nav_html}
466
+ </div>
467
+ </details>
468
+ </aside>
469
+ '''
470
+
471
+ # Build TOC sidebar
472
+ toc_html = ""
473
+ if toc_items:
474
+ toc_list_html = to_xml(Ul(*toc_items, cls="mt-2 list-none"))
475
+ toc_html = f'''
476
+ <aside id="toc-sidebar" class="hidden md:block w-64 shrink-0 sticky top-24 self-start max-h-[calc(100vh-10rem)] overflow-hidden z-[1000]">
477
+ <details open>
478
+ <summary class="flex items-center font-semibold cursor-pointer py-2 px-3 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg select-none list-none bg-white dark:bg-slate-950 z-10">
479
+ <span uk-icon="list" class="w-5 h-5 mr-2"></span>
480
+ Contents
481
+ </summary>
482
+ <div class="mt-2 p-3 bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 overflow-y-auto max-h-[calc(100vh-16rem)]">
483
+ {toc_list_html}
484
+ </div>
485
+ </details>
486
+ </aside>
487
+ '''
488
+
489
+ # Main content area
490
+ main_content = f'''
491
+ <main id="main-content" class="flex-1 min-w-0 px-6 py-8 space-y-8">
492
+ {content_html}
493
+ </main>
494
+ '''
495
+
496
+ # Footer
497
+ footer = '''
498
+ <footer class="w-full max-w-7xl mx-auto px-6 mt-auto mb-6">
499
+ <div class="bg-slate-900 text-white rounded-lg p-4 my-4 dark:bg-slate-800 text-right">
500
+ Powered by Vyasa
501
+ </div>
502
+ </footer>
503
+ '''
504
+
505
+ # Complete body
506
+ body = f'''
507
+ <div id="page-container" class="flex flex-col min-h-screen">
508
+ <div class="w-full max-w-7xl mx-auto px-4 sticky top-0 z-50 mt-4">
509
+ {navbar}
510
+ </div>
511
+ <div class="w-full max-w-7xl mx-auto px-4 flex gap-6 flex-1">
512
+ {posts_sidebar}
513
+ {main_content}
514
+ {toc_html}
515
+ </div>
516
+ {footer}
517
+ </div>
518
+ '''
519
+
520
+ return generate_static_html(page_title, body, blog_title, favicon_href)
521
+
522
+
523
+ def build_static_site(input_dir=None, output_dir=None):
524
+ """
525
+ Build a complete static site from markdown files
526
+
527
+ Args:
528
+ input_dir: Path to markdown files (defaults to VYASA_ROOT or current dir)
529
+ output_dir: Path to output directory (defaults to ./dist)
530
+ """
531
+
532
+ # Initialize config
533
+ if input_dir:
534
+ import os
535
+ os.environ['VYASA_ROOT'] = str(Path(input_dir).resolve())
536
+ reload_config()
537
+
538
+ config = get_config()
539
+ root_folder = config.get_root_folder()
540
+ blog_title = config.get_blog_title()
541
+ abbreviations = _effective_abbreviations(root_folder)
542
+
543
+ # Set default output directory
544
+ if output_dir is None:
545
+ output_dir = Path.cwd() / 'dist'
546
+ else:
547
+ output_dir = Path(output_dir)
548
+
549
+ print(f"Building static site...")
550
+ print(f" Source: {root_folder}")
551
+ print(f" Output: {output_dir}")
552
+ print(f" Blog title: {blog_title}")
553
+
554
+ # Create output directory
555
+ if output_dir.exists():
556
+ shutil.rmtree(output_dir)
557
+ output_dir.mkdir(parents=True, exist_ok=True)
558
+
559
+ # Build navigation tree with static .html links
560
+ nav_tree = build_post_tree_static(root_folder, root_folder)
561
+ root_icon = root_folder / "static" / "icon.png"
562
+ favicon_href = "/static/icon.png" if root_icon.exists() else "/static/favicon.png"
563
+
564
+ # Find all markdown files (only in the specified root folder, not parent directories)
565
+ md_files = []
566
+ for md_file in root_folder.rglob('*.md'):
567
+ # Only include files that are actually inside root_folder
568
+ try:
569
+ relative_path = md_file.relative_to(root_folder)
570
+ md_files.append(md_file)
571
+ except ValueError:
572
+ # Skip files outside root_folder
573
+ continue
574
+
575
+ print(f"\nFound {len(md_files)} markdown files")
576
+
577
+ # Process each markdown file
578
+ for md_file in md_files:
579
+ relative_path = md_file.relative_to(root_folder)
580
+ print(f" Processing: {relative_path}")
581
+
582
+ # Parse frontmatter and content
583
+ metadata, raw_content = parse_frontmatter(md_file)
584
+ post_title = metadata.get('title', get_post_title(md_file, abbreviations=abbreviations))
585
+
586
+ # Render markdown to HTML
587
+ content_div = from_md(raw_content)
588
+ title_html = f'<h1 class="text-4xl font-bold mb-8">{post_title}</h1>'
589
+ content_html = title_html + to_xml(content_div)
590
+
591
+ # Extract TOC
592
+ toc_headings = extract_toc(raw_content)
593
+ toc_items = build_toc_items(toc_headings)
594
+
595
+ # Generate full page
596
+ full_html = static_layout(
597
+ content_html=content_html,
598
+ blog_title=blog_title,
599
+ page_title=f"{post_title} - {blog_title}",
600
+ nav_tree=nav_tree,
601
+ favicon_href=favicon_href,
602
+ toc_items=toc_items,
603
+ current_path=str(relative_path.with_suffix(''))
604
+ )
605
+
606
+ # Determine output path
607
+ if md_file.stem.lower() in ['index', 'readme'] and md_file.parent == root_folder:
608
+ # Root index/readme becomes index.html
609
+ output_path = output_dir / 'index.html'
610
+ else:
611
+ # Other files go in posts/ directory
612
+ output_path = output_dir / 'posts' / relative_path.with_suffix('.html')
613
+
614
+ # Create directory and write file
615
+ output_path.parent.mkdir(parents=True, exist_ok=True)
616
+ output_path.write_text(full_html, encoding='utf-8')
617
+
618
+ # Copy static assets
619
+ static_src = Path(__file__).parent / 'static'
620
+ if static_src.exists():
621
+ static_dst = output_dir / 'static'
622
+ print(f"\nCopying static assets...")
623
+ shutil.copytree(static_src, static_dst, dirs_exist_ok=True)
624
+ if root_icon.exists():
625
+ static_dst = output_dir / 'static'
626
+ static_dst.mkdir(parents=True, exist_ok=True)
627
+ shutil.copy2(root_icon, static_dst / "icon.png")
628
+
629
+ # Generate index.html if it doesn't exist
630
+ index_path = output_dir / 'index.html'
631
+ if not index_path.exists():
632
+ print("\nGenerating default index.html...")
633
+ welcome_content = f'''
634
+ <h1 class="text-4xl font-bold tracking-tight mb-8">Welcome to {blog_title}!</h1>
635
+ <p class="text-lg text-slate-600 dark:text-slate-400 mb-4">Your personal blogging platform.</p>
636
+ <p class="text-base text-slate-600 dark:text-slate-400">
637
+ Browse your posts using the sidebar, or create an <strong>index.md</strong> or
638
+ <strong>README.md</strong> file in your blog directory to customize this page.
639
+ </p>
640
+ '''
641
+
642
+ full_html = static_layout(
643
+ content_html=welcome_content,
644
+ blog_title=blog_title,
645
+ page_title=f"Home - {blog_title}",
646
+ nav_tree=nav_tree,
647
+ favicon_href=favicon_href,
648
+ toc_items=None
649
+ )
650
+
651
+ index_path.write_text(full_html, encoding='utf-8')
652
+
653
+ print(f"\nāœ… Static site built successfully!")
654
+ print(f"šŸ“ Output directory: {output_dir}")
655
+ print(f"\nTo preview the site:")
656
+ print(f" cd {output_dir}")
657
+ print(f" python -m http.server 8000")
658
+ print(f" Open http://localhost:8000")
659
+
660
+ return output_dir