diff --git a/custom.css b/custom.css index 87a6bae..a9e05ef 100644 --- a/custom.css +++ b/custom.css @@ -1,2 +1,3 @@ /* Override solveblog styles here. Tip: use your browser's DevTools (F12) to inspect elements and find class names. */ +/* \ No newline at end of file diff --git a/nbs/01_app.ipynb b/nbs/01_app.ipynb index 1fee285..7f8bb98 100644 --- a/nbs/01_app.ipynb +++ b/nbs/01_app.ipynb @@ -51,7 +51,13 @@ "name": "stdout", "output_type": "stream", "text": [ - "/tmp/solveblog_dev_zp56_1yz\n", + "/tmp/solveblog_dev_0bysbt16\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ "✓ solveblog project created! Next step run: `!solveblog` to start your blog.\n" ] } @@ -284,6 +290,62 @@ " return content.strip(), defs" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "b2587383", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "def extract_headings(content):\n", + " \"Extract markdown headings, return (content_with_ids, headings_list)\"\n", + " headings = []\n", + " def _slugify(txt): return re.sub(r'[^\\w\\s-]', '', txt.strip().lower()).replace(' ', '-')\n", + " def _replace(m):\n", + " hashes, text = m.group(1), m.group(2)\n", + " slug = _slugify(text)\n", + " headings.append((len(hashes), text.strip(), slug))\n", + " return f'{hashes} {text}{{id=\"{slug}\"}}'\n", + " content = re.sub(r'^(#{1,6})\\s+(.+)$', _replace, content, flags=re.MULTILINE)\n", + " return content, headings\n", + "\n", + "def toc_sidebar(headings):\n", + " \"Build a sticky TOC sidebar from heading list\"\n", + " if not headings: return ''\n", + " items = []\n", + " for level, text, slug in headings:\n", + " if level < 2: continue\n", + " indent = 'pl-' + str((level - 2) * 4)\n", + " items.append(A(text, href=f'#{slug}', cls=f'block text-sm py-1 {indent} text-muted-foreground hover:text-primary transition-colors toc-link'))\n", + " return Div(\n", + " Div(Span(\"Contents\", cls=\"text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3 block\"), *items,\n", + " cls=\"sticky top-24\"),\n", + " cls=\"hidden lg:block w-48 shrink-0 self-start\"\n", + " )\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9456b462", + "metadata": {}, + "outputs": [], + "source": [ + "#| export\n", + "def _mobile_toc(headings):\n", + " \"Build a floating mobile TOC button + slide-out panel\"\n", + " if not headings: return ''\n", + " items = [A(text, href=f'#{slug}', onclick=\"document.getElementById('toc-mobile-panel').classList.remove('open')\") for level, text, slug in headings if level >= 2]\n", + " btn = Div(\"📑\", cls=\"toc-mobile-btn\", onclick=\"document.getElementById('toc-mobile-panel').classList.toggle('open')\")\n", + " panel = Div(\n", + " Div(Span(\"Contents\"), Span(\"✕\", cls=\"toc-close\", onclick=\"document.getElementById('toc-mobile-panel').classList.remove('open')\"), cls=\"toc-panel-header\"),\n", + " Div(*items, cls=\"toc-panel-body\"),\n", + " id=\"toc-mobile-panel\", cls=\"toc-mobile-panel\"\n", + " )\n", + " return Div(btn, panel, cls=\"lg:hidden\")\n" + ] + }, { "cell_type": "code", "execution_count": null, @@ -547,15 +609,17 @@ "outputs": [], "source": [ "#| export\n", - "def layout(*content, htmx, title=None):\n", + "def layout(*content, htmx, title=None, toc=''):\n", " if htmx and htmx.request: return (Title(title), *content)\n", - " main = Main(*content, cls='w-full max-w-2xl mx-auto px-6 py-8 space-y-8', id=\"main-content\")\n", + " main = Main(*content, cls='w-full max-w-5xl mx-auto px-6 py-8 space-y-8', id=\"main-content\")\n", + "\n", " ctr_cls = 'w-full max-w-2xl mx-auto'\n", + " post_ctr = 'w-full max-w-5xl mx-auto flex gap-8 items-start px-6 py-8'\n", " return Title(title), Div(cls=\"flex flex-col min-h-screen\")(\n", " Div(navbar(), cls=f'{ctr_cls} px-4 sticky top-0 z-50 mt-4'),\n", - " main,\n", + " Div(main, toc, cls=post_ctr) if toc else main,\n", " Footer(Divider(), subscribe_form(), ftr_content, cls=f'{ctr_cls} px-6 mt-auto mb-6 space-y-6')\n", - " )" + " )\n" ] }, { @@ -692,11 +756,16 @@ "source": [ "#| export\n", "def post_article(p, slug):\n", - " content = p.content\n", + " content, headings = extract_headings(p.content)\n", " tags = Div(*p.tags.map(tag_link), cls=\"flex gap-2 flex-wrap\") if p.tags else None\n", - " return Article(H1(p.title, cls=\"text-3xl font-bold mb-3\"),\n", - " Div(Span(p.date.strftime(\"%B %d, %Y\"), cls=\"text-muted-foreground text-sm\"), tags, cls=\"flex justify-between items-center mb-8 flex-wrap gap-4\"),\n", - " from_md(content, img_dir=p.img_dir), cls=\"mb-8\")" + " toc = toc_sidebar(headings)\n", + " mobile_toc = _mobile_toc(headings)\n", + " \n", + " article = Article(H1(p.title, cls=\"text-3xl font-bold mb-3\"),\n", + " Div(Span(p.date.strftime(\"%B %d, %Y\"), cls=\"text-muted-foreground text-sm\"), tags, cls=\"flex justify-between items-center mb-8 flex-wrap gap-4\"),\n", + " Div(mobile_toc, from_md(content, img_dir=p.img_dir), cls=\"space-y-4\"), cls=\"max-w-2xl min-w-0 w-full\")\n", + "\n", + " return Div(article, toc, cls=\"flex gap-8 items-start\")\n" ] }, { @@ -738,7 +807,7 @@ " if htmx and htmx.target == \"posts-list\":\n", " tag_filt.attrs['hx-swap-oob'] = 'true'\n", " return posts_div, tag_filt\n", - " return layout(H2(\"Blog\"), tag_filt, posts_div, title=f\"{cfg.name} - Blog\", htmx=htmx)" + " return layout(H2(cfg.description or \"Blog\"), tag_filt, posts_div, title=f\"{cfg.name} - Blog\", htmx=htmx)" ] }, { @@ -754,7 +823,32 @@ " p = posts.get(slug)\n", " if not p: return layout(H1(\"Post Not Found\", cls=\"text-4xl font-bold mb-4\"), P(\"Sorry, this blog post doesn't exist.\"), title=\"Post Not Found\", htmx=htmx)\n", " if p.external_url: return Response(headers={\"HX-Redirect\": p.external_url})\n", - " return layout(post_article(p, slug), title=f\"{cfg.name} - {p.title}\", htmx=htmx)\n" + "\n", + " article, toc = post_article(p, slug)\n", + " \n", + " content = Div(article, Div(toc, cls='hidden lg:block sticky top-24 w-48 shrink-0 self-start max-h-[calc(100vh-8rem)] overflow-y-auto'), cls='flex gap-8 items-start justify-center')\n", + "\n", + "\n", + "\n", + " return layout(content, title=f\"{cfg.name} - {p.title}\", htmx=htmx), Script(\"\"\"\n", + "function initScrollspy() {\n", + " const headings = document.querySelectorAll('#main-content [id]');\n", + " const links = document.querySelectorAll('.toc-link');\n", + " if (!links.length) return;\n", + " const obs = new IntersectionObserver(entries => {\n", + " entries.forEach(e => {\n", + " if (e.isIntersecting) {\n", + " links.forEach(l => l.classList.remove('active'));\n", + " const a = document.querySelector('.toc-link[href=\"#' + e.target.id + '\"]');\n", + " if (a) a.classList.add('active');\n", + " }\n", + " });\n", + " }, {rootMargin: '-20% 0px -70% 0px'});\n", + " headings.forEach(h => { if (document.querySelector('.toc-link[href=\"#' + h.id + '\"]')) obs.observe(h); });\n", + "}\n", + "document.addEventListener('DOMContentLoaded', initScrollspy);\n", + "document.body.addEventListener('htmx:afterSwap', initScrollspy);\n", + "\"\"\")\n" ] }, { @@ -817,9 +911,25 @@ " return layout(H2(title), from_md(pages[slug][1], img_dir='/public/pages'), \n", " title=f\"{cfg.name} - {title}\", htmx=htmx)" ] + }, + { + "cell_type": "markdown", + "id": "c12c799a", + "metadata": {}, + "source": [ + "# End" + ] } ], - "metadata": {}, + "metadata": { + "solveit": { + "default_code": false, + "mode": "learning", + "use_thinking": true, + "use_tools": true, + "ver": 2 + } + }, "nbformat": 4, "nbformat_minor": 5 } diff --git a/solveblog/_modidx.py b/solveblog/_modidx.py index c6bdaf1..f658e8f 100644 --- a/solveblog/_modidx.py +++ b/solveblog/_modidx.py @@ -15,9 +15,11 @@ 'solveblog.app.Post': ('app.html#post', 'solveblog/app.py'), 'solveblog.app.Post.__init__': ('app.html#post.__init__', 'solveblog/app.py'), 'solveblog.app.Subscriber': ('app.html#subscriber', 'solveblog/app.py'), + 'solveblog.app._mobile_toc': ('app.html#_mobile_toc', 'solveblog/app.py'), 'solveblog.app.blog': ('app.html#blog', 'solveblog/app.py'), 'solveblog.app.blogpost': ('app.html#blogpost', 'solveblog/app.py'), 'solveblog.app.extract_footnotes': ('app.html#extract_footnotes', 'solveblog/app.py'), + 'solveblog.app.extract_headings': ('app.html#extract_headings', 'solveblog/app.py'), 'solveblog.app.from_md': ('app.html#from_md', 'solveblog/app.py'), 'solveblog.app.hx_attrs': ('app.html#hx_attrs', 'solveblog/app.py'), 'solveblog.app.hx_link': ('app.html#hx_link', 'solveblog/app.py'), @@ -39,6 +41,7 @@ 'solveblog.app.tag_link': ('app.html#tag_link', 'solveblog/app.py'), 'solveblog.app.tag_toggle': ('app.html#tag_toggle', 'solveblog/app.py'), 'solveblog.app.theme_toggle': ('app.html#theme_toggle', 'solveblog/app.py'), + 'solveblog.app.toc_sidebar': ('app.html#toc_sidebar', 'solveblog/app.py'), 'solveblog.app.x_icon': ('app.html#x_icon', 'solveblog/app.py')}, 'solveblog.core': { 'solveblog.core.load_cfg': ('core.html#load_cfg', 'solveblog/core.py'), 'solveblog.core.solveblog_new': ('core.html#solveblog_new', 'solveblog/core.py'), diff --git a/solveblog/app.py b/solveblog/app.py index 9eb668f..1c6d83e 100644 --- a/solveblog/app.py +++ b/solveblog/app.py @@ -1,13 +1,16 @@ -"""The main fasthtml app of solveblog""" +"""The main fasthtml app of solveblog + +Docs: https://AnswerDotAI.github.io/solveblogapp.html.md""" # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/01_app.ipynb. # %% auto #0 __all__ = ['cfg', 'db', 'subscribers', 'FootnoteRef', 'YoutubeEmbed', 'sidenote_css', 'cell_css', 'hdrs', 'app', 'rt', 'socials', - 'ftr_content', 'pages', 'posts', 'Subscriber', 'nb2md', 'span_token', 'extract_footnotes', 'ContentRenderer', - 'from_md', 'theme_toggle', 'x_icon', 'social_link', 'read', 'hx_attrs', 'hx_link', 'navbar', 'layout', - 'subscribe_form', 'Post', 'tag_label', 'tag_link', 'tag_toggle', 'tag_filter', 'post_intro', 'post_preview', - 'post_article', 'index', 'blog', 'blogpost', 'subscribe', 'rss_feed'] + 'ftr_content', 'pages', 'posts', 'Subscriber', 'nb2md', 'span_token', 'extract_footnotes', + 'extract_headings', 'toc_sidebar', 'ContentRenderer', 'from_md', 'theme_toggle', 'x_icon', 'social_link', + 'read', 'hx_attrs', 'hx_link', 'navbar', 'layout', 'subscribe_form', 'Post', 'tag_label', 'tag_link', + 'tag_toggle', 'tag_filter', 'post_intro', 'post_preview', 'post_article', 'index', 'blog', 'blogpost', + 'subscribe', 'rss_feed'] # %% ../nbs/01_app.ipynb #37a647b9 from fastcore.nbio import * @@ -86,6 +89,48 @@ def extract_footnotes(content): for m in pat.finditer(content): content = content.replace(m.group(0), '', 1) return content.strip(), defs +# %% ../nbs/01_app.ipynb #b2587383 +def extract_headings(content): + "Extract markdown headings, return (content_with_ids, headings_list)" + headings = [] + def _slugify(txt): return re.sub(r'[^\w\s-]', '', txt.strip().lower()).replace(' ', '-') + def _replace(m): + hashes, text = m.group(1), m.group(2) + slug = _slugify(text) + headings.append((len(hashes), text.strip(), slug)) + return f'{hashes} {text}{{id="{slug}"}}' + content = re.sub(r'^(#{1,6})\s+(.+)$', _replace, content, flags=re.MULTILINE) + return content, headings + +def toc_sidebar(headings): + "Build a sticky TOC sidebar from heading list" + if not headings: return '' + items = [] + for level, text, slug in headings: + if level < 2: continue + indent = 'pl-' + str((level - 2) * 4) + items.append(A(text, href=f'#{slug}', cls=f'block text-sm py-1 {indent} text-muted-foreground hover:text-primary transition-colors toc-link')) + return Div( + Div(Span("Contents", cls="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3 block"), *items, + cls="sticky top-24"), + cls="hidden lg:block w-48 shrink-0 self-start" + ) + + +# %% ../nbs/01_app.ipynb #9456b462 +def _mobile_toc(headings): + "Build a floating mobile TOC button + slide-out panel" + if not headings: return '' + items = [A(text, href=f'#{slug}', onclick="document.getElementById('toc-mobile-panel').classList.remove('open')") for level, text, slug in headings if level >= 2] + btn = Div("📑", cls="toc-mobile-btn", onclick="document.getElementById('toc-mobile-panel').classList.toggle('open')") + panel = Div( + Div(Span("Contents"), Span("✕", cls="toc-close", onclick="document.getElementById('toc-mobile-panel').classList.remove('open')"), cls="toc-panel-header"), + Div(*items, cls="toc-panel-body"), + id="toc-mobile-panel", cls="toc-mobile-panel" + ) + return Div(btn, panel, cls="lg:hidden") + + # %% ../nbs/01_app.ipynb #ae16b7fe class ContentRenderer(FrankenRenderer): def __init__(self, *extras, img_dir=None, footnotes=None, **kwargs): @@ -231,16 +276,19 @@ def navlinks(_=None): return [hx_link("Blog", "/blog", cls="hover:scale-110", _= ) # %% ../nbs/01_app.ipynb #2b13a30c -def layout(*content, htmx, title=None): +def layout(*content, htmx, title=None, toc=''): if htmx and htmx.request: return (Title(title), *content) - main = Main(*content, cls='w-full max-w-2xl mx-auto px-6 py-8 space-y-8', id="main-content") + main = Main(*content, cls='w-full max-w-5xl mx-auto px-6 py-8 space-y-8', id="main-content") + ctr_cls = 'w-full max-w-2xl mx-auto' + post_ctr = 'w-full max-w-5xl mx-auto flex gap-8 items-start px-6 py-8' return Title(title), Div(cls="flex flex-col min-h-screen")( Div(navbar(), cls=f'{ctr_cls} px-4 sticky top-0 z-50 mt-4'), - main, + Div(main, toc, cls=post_ctr) if toc else main, Footer(Divider(), subscribe_form(), ftr_content, cls=f'{ctr_cls} px-6 mt-auto mb-6 space-y-6') ) + # %% ../nbs/01_app.ipynb #f9eebe98 def subscribe_form(): return Div( @@ -305,11 +353,17 @@ def post_preview(p): # %% ../nbs/01_app.ipynb #b4e04e74 def post_article(p, slug): - content = p.content + content, headings = extract_headings(p.content) tags = Div(*p.tags.map(tag_link), cls="flex gap-2 flex-wrap") if p.tags else None - return Article(H1(p.title, cls="text-3xl font-bold mb-3"), - Div(Span(p.date.strftime("%B %d, %Y"), cls="text-muted-foreground text-sm"), tags, cls="flex justify-between items-center mb-8 flex-wrap gap-4"), - from_md(content, img_dir=p.img_dir), cls="mb-8") + toc = toc_sidebar(headings) + mobile_toc = _mobile_toc(headings) + + article = Article(H1(p.title, cls="text-3xl font-bold mb-3"), + Div(Span(p.date.strftime("%B %d, %Y"), cls="text-muted-foreground text-sm"), tags, cls="flex justify-between items-center mb-8 flex-wrap gap-4"), + Div(mobile_toc, from_md(content, img_dir=p.img_dir), cls="space-y-4"), cls="max-w-2xl min-w-0 w-full") + + return Div(article, toc, cls="flex gap-8 items-start") + # %% ../nbs/01_app.ipynb #7e30f617 @rt @@ -327,7 +381,7 @@ def blog(htmx, tags:str=None): if htmx and htmx.target == "posts-list": tag_filt.attrs['hx-swap-oob'] = 'true' return posts_div, tag_filt - return layout(H2("Blog"), tag_filt, posts_div, title=f"{cfg.name} - Blog", htmx=htmx) + return layout(H2(cfg.description or "Blog"), tag_filt, posts_div, title=f"{cfg.name} - Blog", htmx=htmx) # %% ../nbs/01_app.ipynb #8d1db015 @rt('/blog/{slug}') @@ -335,7 +389,32 @@ def blogpost(htmx, slug:str): p = posts.get(slug) if not p: return layout(H1("Post Not Found", cls="text-4xl font-bold mb-4"), P("Sorry, this blog post doesn't exist."), title="Post Not Found", htmx=htmx) if p.external_url: return Response(headers={"HX-Redirect": p.external_url}) - return layout(post_article(p, slug), title=f"{cfg.name} - {p.title}", htmx=htmx) + + article, toc = post_article(p, slug) + + content = Div(article, Div(toc, cls='hidden lg:block sticky top-24 w-48 shrink-0 self-start max-h-[calc(100vh-8rem)] overflow-y-auto'), cls='flex gap-8 items-start justify-center') + + + + return layout(content, title=f"{cfg.name} - {p.title}", htmx=htmx), Script(""" +function initScrollspy() { + const headings = document.querySelectorAll('#main-content [id]'); + const links = document.querySelectorAll('.toc-link'); + if (!links.length) return; + const obs = new IntersectionObserver(entries => { + entries.forEach(e => { + if (e.isIntersecting) { + links.forEach(l => l.classList.remove('active')); + const a = document.querySelector('.toc-link[href="#' + e.target.id + '"]'); + if (a) a.classList.add('active'); + } + }); + }, {rootMargin: '-20% 0px -70% 0px'}); + headings.forEach(h => { if (document.querySelector('.toc-link[href="#' + h.id + '"]')) obs.observe(h); }); +} +document.addEventListener('DOMContentLoaded', initScrollspy); +document.body.addEventListener('htmx:afterSwap', initScrollspy); +""") # %% ../nbs/01_app.ipynb #b065211e diff --git a/solveblog/core.py b/solveblog/core.py index 1959a00..93bd358 100644 --- a/solveblog/core.py +++ b/solveblog/core.py @@ -1,4 +1,6 @@ -"""Fill in a module description here""" +"""Fill in a module description here + +Docs: https://AnswerDotAI.github.io/solveblogcore.html.md""" # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_core.ipynb.