bloggy 0.1.40__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bloggy/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ __version__ = "0.1.40"
2
+
3
+ from .core import app, rt, get_root_folder, get_blog_title
4
+
5
+ __all__ = ['app', 'rt', 'get_root_folder', 'get_blog_title', '__version__']
bloggy/build.py ADDED
@@ -0,0 +1,608 @@
1
+ """Static site generator for Bloggy
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_bloggy_config, order_bloggy_entries
19
+ )
20
+ from .config import get_config, reload_config
21
+
22
+
23
+ def generate_static_html(title, body_content, blog_title):
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: hidden;
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
+ }
119
+ .dark .tabs-content {
120
+ background: rgb(2 6 23);
121
+ }
122
+
123
+ .tab-panel {
124
+ padding: 1rem 1rem;
125
+ animation: fadeIn 0.2s ease-in;
126
+ position: absolute;
127
+ top: 0;
128
+ left: 0;
129
+ right: 0;
130
+ opacity: 0;
131
+ visibility: hidden;
132
+ pointer-events: none;
133
+ }
134
+ .tab-panel.active {
135
+ position: relative;
136
+ opacity: 1;
137
+ visibility: visible;
138
+ pointer-events: auto;
139
+ }
140
+
141
+ @keyframes fadeIn {
142
+ from { opacity: 0; }
143
+ to { opacity: 1; }
144
+ }
145
+
146
+ /* Remove extra margins from first/last elements in tabs */
147
+ .tab-panel > *:first-child { margin-top: 0 !important; }
148
+ .tab-panel > *:last-child { margin-bottom: 0 !important; }
149
+
150
+ /* Ensure code blocks in tabs look good */
151
+ .tab-panel pre {
152
+ border-radius: 0.375rem;
153
+ font-size: 0.875rem;
154
+ }
155
+ .tab-panel code {
156
+ font-family: 'IBM Plex Mono', monospace;
157
+ }
158
+ </style>
159
+ """
160
+
161
+ # JavaScript for interactivity
162
+ static_js = """
163
+ <script>
164
+ // Theme toggle functionality
165
+ (function() {
166
+ const stored = localStorage.getItem('__FRANKEN__');
167
+ const franken = stored ? JSON.parse(stored) : {mode: 'light'};
168
+ if (franken.mode === 'dark') {
169
+ document.documentElement.classList.add('dark');
170
+ }
171
+ })();
172
+
173
+ function toggleTheme() {
174
+ const html = document.documentElement;
175
+ html.classList.toggle('dark');
176
+ const stored = localStorage.getItem('__FRANKEN__');
177
+ const franken = stored ? JSON.parse(stored) : {mode: 'light'};
178
+ franken.mode = html.classList.contains('dark') ? 'dark' : 'light';
179
+ localStorage.setItem('__FRANKEN__', JSON.stringify(franken));
180
+ }
181
+
182
+ // Tab switching functionality
183
+ function switchTab(tabsId, index) {
184
+ const container = document.querySelector('.tabs-container[data-tabs-id="' + tabsId + '"]');
185
+ if (!container) return;
186
+
187
+ // Update buttons
188
+ const buttons = container.querySelectorAll('.tab-button');
189
+ buttons.forEach(function(btn, i) {
190
+ if (i === index) {
191
+ btn.classList.add('active');
192
+ } else {
193
+ btn.classList.remove('active');
194
+ }
195
+ });
196
+
197
+ // Update panels
198
+ const panels = container.querySelectorAll('.tab-panel');
199
+ panels.forEach(function(panel, i) {
200
+ if (i === index) {
201
+ panel.classList.add('active');
202
+ panel.style.position = 'relative';
203
+ panel.style.visibility = 'visible';
204
+ panel.style.opacity = '1';
205
+ panel.style.pointerEvents = 'auto';
206
+ } else {
207
+ panel.classList.remove('active');
208
+ panel.style.position = 'absolute';
209
+ panel.style.visibility = 'hidden';
210
+ panel.style.opacity = '0';
211
+ panel.style.pointerEvents = 'none';
212
+ }
213
+ });
214
+ }
215
+ window.switchTab = switchTab;
216
+
217
+ // Set tab container heights based on tallest panel
218
+ document.addEventListener('DOMContentLoaded', function() {
219
+ setTimeout(() => {
220
+ document.querySelectorAll('.tabs-container').forEach(container => {
221
+ const panels = container.querySelectorAll('.tab-panel');
222
+ let maxHeight = 0;
223
+
224
+ panels.forEach(panel => {
225
+ const wasActive = panel.classList.contains('active');
226
+ panel.style.position = 'relative';
227
+ panel.style.visibility = 'visible';
228
+ panel.style.opacity = '1';
229
+ panel.style.pointerEvents = 'auto';
230
+
231
+ const height = panel.offsetHeight;
232
+ if (height > maxHeight) maxHeight = height;
233
+
234
+ if (!wasActive) {
235
+ panel.style.position = 'absolute';
236
+ panel.style.visibility = 'hidden';
237
+ panel.style.opacity = '0';
238
+ panel.style.pointerEvents = 'none';
239
+ }
240
+ });
241
+
242
+ const tabsContent = container.querySelector('.tabs-content');
243
+ if (tabsContent && maxHeight > 0) {
244
+ tabsContent.style.minHeight = maxHeight + 'px';
245
+ }
246
+ });
247
+ }, 100);
248
+
249
+ // Initialize KaTeX rendering
250
+ if (window.renderMathInElement) {
251
+ renderMathInElement(document.body, {
252
+ delimiters: [
253
+ {left: '$$', right: '$$', display: true},
254
+ {left: '$', right: '$', display: false}
255
+ ],
256
+ throwOnError: false
257
+ });
258
+ }
259
+ });
260
+
261
+ // Sidenote interactions
262
+ document.addEventListener('click', function(e) {
263
+ if (e.target.classList.contains('sidenote-ref')) {
264
+ const id = e.target.id.replace('snref-', 'sn-');
265
+ const sidenote = document.getElementById(id);
266
+ if (sidenote) {
267
+ if (window.innerWidth >= 1280) {
268
+ sidenote.classList.add('hl');
269
+ setTimeout(() => sidenote.classList.remove('hl'), 1000);
270
+ } else {
271
+ e.target.classList.toggle('open');
272
+ sidenote.classList.toggle('show');
273
+ }
274
+ }
275
+ }
276
+ });
277
+ </script>
278
+ """
279
+
280
+ html = f"""<!DOCTYPE html>
281
+ <html lang="en">
282
+ <head>
283
+ <meta charset="UTF-8">
284
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
285
+ <title>{title}</title>
286
+
287
+ <!-- TailwindCSS and MonsterUI -->
288
+ <script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,container-queries"></script>
289
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.16.14/dist/css/uikit.min.css" />
290
+ <script src="https://cdn.jsdelivr.net/npm/uikit@3.16.14/dist/js/uikit.min.js"></script>
291
+ <script src="https://cdn.jsdelivr.net/npm/uikit@3.16.14/dist/js/uikit-icons.min.js"></script>
292
+
293
+ <!-- Fonts -->
294
+ <link rel="preconnect" href="https://fonts.googleapis.com">
295
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
296
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono&display=swap" rel="stylesheet">
297
+
298
+ <!-- Syntax Highlighting -->
299
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
300
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
301
+
302
+ <!-- Math Rendering -->
303
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
304
+ <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
305
+ <script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
306
+
307
+ <!-- Hyperscript for interactions -->
308
+ <script src="https://unpkg.com/hyperscript.org@0.9.12"></script>
309
+
310
+ <!-- Static assets -->
311
+ <link rel="stylesheet" href="/static/sidenote.css">
312
+
313
+ {static_css}
314
+ </head>
315
+ <body>
316
+ {body_content}
317
+
318
+ <!-- Mermaid diagrams -->
319
+ <script type="module">
320
+ import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
321
+ mermaid.initialize({{ startOnLoad: true, theme: 'default' }});
322
+ </script>
323
+ <script src="/static/scripts.js" type="module"></script>
324
+
325
+ {static_js}
326
+ </body>
327
+ </html>"""
328
+
329
+ return html
330
+
331
+
332
+ def build_post_tree_static(folder, root_folder):
333
+ """Build post tree with static .html links instead of HTMX"""
334
+ items = []
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))
361
+ except (OSError, PermissionError):
362
+ return items
363
+
364
+ for item in entries:
365
+ if item.is_dir():
366
+ if item.name.startswith('.'):
367
+ continue
368
+ sub_items = build_post_tree_static(item, root_folder)
369
+ if sub_items:
370
+ folder_title = slug_to_title(item.name)
371
+ items.append(Li(Details(
372
+ Summary(
373
+ Span(Span(cls="folder-chevron"), cls="w-4 mr-2 flex items-center justify-center shrink-0"),
374
+ Span(UkIcon("folder", cls="text-blue-500 w-4 h-4"), cls="w-4 mr-2 flex items-center justify-center shrink-0"),
375
+ Span(folder_title, cls="truncate min-w-0", title=folder_title),
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"),
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"))
379
+ elif item.suffix == '.md':
380
+ slug = str(item.relative_to(root_folder).with_suffix(''))
381
+ title = get_post_title(item)
382
+
383
+ # Use .html extension for static links
384
+ items.append(Li(A(
385
+ Span(cls="w-4 mr-2 shrink-0"),
386
+ Span(UkIcon("file-text", cls="text-slate-400 w-4 h-4"), cls="w-4 mr-2 flex items-center justify-center shrink-0"),
387
+ Span(title, cls="truncate min-w-0", title=title),
388
+ href=f'/posts/{slug}.html', # Add .html extension
389
+ 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")))
390
+ return items
391
+
392
+
393
+ def static_layout(content_html, blog_title, page_title, nav_tree, toc_items=None, current_path=None):
394
+ """Generate complete static page layout"""
395
+
396
+ # Theme toggle button
397
+ theme_toggle = '''
398
+ <button onclick="toggleTheme()" class="p-1 hover:scale-110 shadow-none" type="button">
399
+ <span uk-icon="moon" class="dark:hidden"></span>
400
+ <span uk-icon="sun" class="hidden dark:block"></span>
401
+ </button>
402
+ '''
403
+
404
+ # Navbar
405
+ navbar = f'''
406
+ <div class="flex items-center justify-between bg-slate-900 text-white p-4 my-4 rounded-lg shadow-md dark:bg-slate-800">
407
+ <a href="/index.html">{blog_title}</a>
408
+ {theme_toggle}
409
+ </div>
410
+ '''
411
+
412
+ # Build navigation sidebar
413
+ nav_html = to_xml(Ul(*nav_tree, cls="mt-2 list-none"))
414
+ posts_sidebar = f'''
415
+ <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]">
416
+ <details open>
417
+ <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">
418
+ <span uk-icon="menu" class="w-5 h-5 mr-2"></span>
419
+ Posts
420
+ </summary>
421
+ <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)]">
422
+ {nav_html}
423
+ </div>
424
+ </details>
425
+ </aside>
426
+ '''
427
+
428
+ # Build TOC sidebar
429
+ toc_html = ""
430
+ if toc_items:
431
+ toc_list_html = to_xml(Ul(*toc_items, cls="mt-2 list-none"))
432
+ toc_html = f'''
433
+ <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]">
434
+ <details open>
435
+ <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">
436
+ <span uk-icon="list" class="w-5 h-5 mr-2"></span>
437
+ Contents
438
+ </summary>
439
+ <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)]">
440
+ {toc_list_html}
441
+ </div>
442
+ </details>
443
+ </aside>
444
+ '''
445
+
446
+ # Main content area
447
+ main_content = f'''
448
+ <main id="main-content" class="flex-1 min-w-0 px-6 py-8 space-y-8">
449
+ {content_html}
450
+ </main>
451
+ '''
452
+
453
+ # Footer
454
+ footer = '''
455
+ <footer class="w-full max-w-7xl mx-auto px-6 mt-auto mb-6">
456
+ <div class="bg-slate-900 text-white rounded-lg p-4 my-4 dark:bg-slate-800 text-right">
457
+ Powered by Bloggy
458
+ </div>
459
+ </footer>
460
+ '''
461
+
462
+ # Complete body
463
+ body = f'''
464
+ <div id="page-container" class="flex flex-col min-h-screen">
465
+ <div class="w-full max-w-7xl mx-auto px-4 sticky top-0 z-50 mt-4">
466
+ {navbar}
467
+ </div>
468
+ <div class="w-full max-w-7xl mx-auto px-4 flex gap-6 flex-1">
469
+ {posts_sidebar}
470
+ {main_content}
471
+ {toc_html}
472
+ </div>
473
+ {footer}
474
+ </div>
475
+ '''
476
+
477
+ return generate_static_html(page_title, body, blog_title)
478
+
479
+
480
+ def build_static_site(input_dir=None, output_dir=None):
481
+ """
482
+ Build a complete static site from markdown files
483
+
484
+ Args:
485
+ input_dir: Path to markdown files (defaults to BLOGGY_ROOT or current dir)
486
+ output_dir: Path to output directory (defaults to ./dist)
487
+ """
488
+
489
+ # Initialize config
490
+ if input_dir:
491
+ import os
492
+ os.environ['BLOGGY_ROOT'] = str(Path(input_dir).resolve())
493
+ reload_config()
494
+
495
+ config = get_config()
496
+ root_folder = config.get_root_folder()
497
+ blog_title = config.get_blog_title()
498
+
499
+ # Set default output directory
500
+ if output_dir is None:
501
+ output_dir = Path.cwd() / 'dist'
502
+ else:
503
+ output_dir = Path(output_dir)
504
+
505
+ print(f"Building static site...")
506
+ print(f" Source: {root_folder}")
507
+ print(f" Output: {output_dir}")
508
+ print(f" Blog title: {blog_title}")
509
+
510
+ # Create output directory
511
+ if output_dir.exists():
512
+ shutil.rmtree(output_dir)
513
+ output_dir.mkdir(parents=True, exist_ok=True)
514
+
515
+ # Build navigation tree with static .html links
516
+ nav_tree = build_post_tree_static(root_folder, root_folder)
517
+
518
+ # Find all markdown files (only in the specified root folder, not parent directories)
519
+ md_files = []
520
+ for md_file in root_folder.rglob('*.md'):
521
+ # Only include files that are actually inside root_folder
522
+ try:
523
+ relative_path = md_file.relative_to(root_folder)
524
+ md_files.append(md_file)
525
+ except ValueError:
526
+ # Skip files outside root_folder
527
+ continue
528
+
529
+ print(f"\nFound {len(md_files)} markdown files")
530
+
531
+ # Process each markdown file
532
+ for md_file in md_files:
533
+ relative_path = md_file.relative_to(root_folder)
534
+ print(f" Processing: {relative_path}")
535
+
536
+ # Parse frontmatter and content
537
+ metadata, raw_content = parse_frontmatter(md_file)
538
+ post_title = metadata.get('title', get_post_title(md_file))
539
+
540
+ # Render markdown to HTML
541
+ content_div = from_md(raw_content)
542
+ title_html = f'<h1 class="text-4xl font-bold mb-8">{post_title}</h1>'
543
+ content_html = title_html + to_xml(content_div)
544
+
545
+ # Extract TOC
546
+ toc_headings = extract_toc(raw_content)
547
+ toc_items = build_toc_items(toc_headings)
548
+
549
+ # Generate full page
550
+ full_html = static_layout(
551
+ content_html=content_html,
552
+ blog_title=blog_title,
553
+ page_title=f"{post_title} - {blog_title}",
554
+ nav_tree=nav_tree,
555
+ toc_items=toc_items,
556
+ current_path=str(relative_path.with_suffix(''))
557
+ )
558
+
559
+ # Determine output path
560
+ if md_file.stem.lower() in ['index', 'readme'] and md_file.parent == root_folder:
561
+ # Root index/readme becomes index.html
562
+ output_path = output_dir / 'index.html'
563
+ else:
564
+ # Other files go in posts/ directory
565
+ output_path = output_dir / 'posts' / relative_path.with_suffix('.html')
566
+
567
+ # Create directory and write file
568
+ output_path.parent.mkdir(parents=True, exist_ok=True)
569
+ output_path.write_text(full_html, encoding='utf-8')
570
+
571
+ # Copy static assets
572
+ static_src = Path(__file__).parent / 'static'
573
+ if static_src.exists():
574
+ static_dst = output_dir / 'static'
575
+ print(f"\nCopying static assets...")
576
+ shutil.copytree(static_src, static_dst, dirs_exist_ok=True)
577
+
578
+ # Generate index.html if it doesn't exist
579
+ index_path = output_dir / 'index.html'
580
+ if not index_path.exists():
581
+ print("\nGenerating default index.html...")
582
+ welcome_content = f'''
583
+ <h1 class="text-4xl font-bold tracking-tight mb-8">Welcome to {blog_title}!</h1>
584
+ <p class="text-lg text-slate-600 dark:text-slate-400 mb-4">Your personal blogging platform.</p>
585
+ <p class="text-base text-slate-600 dark:text-slate-400">
586
+ Browse your posts using the sidebar, or create an <strong>index.md</strong> or
587
+ <strong>README.md</strong> file in your blog directory to customize this page.
588
+ </p>
589
+ '''
590
+
591
+ full_html = static_layout(
592
+ content_html=welcome_content,
593
+ blog_title=blog_title,
594
+ page_title=f"Home - {blog_title}",
595
+ nav_tree=nav_tree,
596
+ toc_items=None
597
+ )
598
+
599
+ index_path.write_text(full_html, encoding='utf-8')
600
+
601
+ print(f"\nāœ… Static site built successfully!")
602
+ print(f"šŸ“ Output directory: {output_dir}")
603
+ print(f"\nTo preview the site:")
604
+ print(f" cd {output_dir}")
605
+ print(f" python -m http.server 8000")
606
+ print(f" Open http://localhost:8000")
607
+
608
+ return output_dir