Skip to content

Instantly share code, notes, and snippets.

@EmbraceLife
Created August 28, 2026 01:42
Show Gist options
  • Select an option

  • Save EmbraceLife/a8cde2c5d59432425de937dfc27b0f7b to your computer and use it in GitHub Desktop.

Select an option

Save EmbraceLife/a8cde2c5d59432425de937dfc27b0f7b to your computer and use it in GitHub Desktop.
"""The main fasthtml app of solveblog
Docs: https://AnswerDotAI.github.io/solveblog/app.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', 'toc_css', 'mobile_toc_css',
'AUDIO_CSS', 'annotation_js', 'AUDIO_JS', 'hdrs', 'app', 'rt', 'KOKORO_VERSION', 'KOKORO_CHINESE_RE',
'socials', 'ftr_content', 'pages', 'posts', 'search_index', 'Subscriber', 'nb2md', 'nb2md_sections',
'span_token', 'extract_footnotes', 'extract_headings', 'toc_sidebar', 'tts_settings_ui', 'ContentRenderer',
'from_md', 'tts_proxy', 'kokoro_proxy', 'theme_toggle', 'x_icon', 'social_link', 'read', 'hx_attrs',
'hx_link', 'navbar', 'layout', 'subscribe_form', 'Post', 'build_search_index', 'tag_label', 'tag_link',
'tag_toggle', 'tag_filter', 'post_intro', 'post_preview', 'post_article', 'render_article_sections', 'index',
'blog', 'blogpost', 'subscribe', 'rss_feed']
# %% ../nbs/01_app.ipynb #37a647b9
from fastcore.nbio import *
from fastcore.utils import *
import json, os, re, uuid, asyncio, mistletoe as mst
from functools import lru_cache,cache
from collections import Counter
from dateutil.parser import parse as parse_date
from fastcore.ansi import ansi2html
from html import escape
from datetime import datetime, date
from fasthtml.common import *
from fastlite import *
from monsterui.all import *
from urllib.parse import quote, unquote
from . import load_cfg
# %% ../nbs/01_app.ipynb #fc5c182c
cfg = load_cfg()
cfg.dom = 'https://'+json.loads(os.environ['PUBLIC_DOMAINS'])[str(cfg.port)]
# %% ../nbs/01_app.ipynb #62bef3df
db = database("subscribers.db")
class Subscriber: id:int; email:str; created_at:str
subscribers = db.create(Subscriber, transform=True)
# %% ../nbs/01_app.ipynb #b03da416
def nb2md(path):
"Read notebook, return frontmatter Post object (like frontmatter.load for .md files)"
cells = json.loads(Path(path).read_text()).get('cells', [])
fm_src, md_cells = '', cells
if cells and cells[0]['cell_type'] == 'raw':
fm_src = ''.join(cells[0]['source'])
md_cells = cells[1:]
def cell_md(c):
src = ''.join(c['source'])
if c.get('metadata', {}).get('pinned'): return None
if c['cell_type'] == 'code':
code = f'<pre><code class="language-python">{escape(src)}</code></pre>'
if outs := render_outputs(c.get('outputs', [])): return f'<div class="cell">{code}<div class="cell-output">{outs}</div></div>'
return f'<div class="cell">{code}</div>'
if c['cell_type'] == 'raw': return f'```\n{src}\n```'
if c['cell_type'] == 'markdown':
if atts := c.get('attachments', {}):
for fname, mimes in atts.items():
mime, data = next(iter(mimes.items()))
src = src.replace(f'attachment:{fname}', f'data:{mime};base64,{data}')
if c.get('metadata', {}).get('solveit_ai'):
sep = re.split(r'##### 🤖Reply🤖<!--.*?-->\n*', src, maxsplit=1)
prompt = sep[0].strip()
if len(sep) > 1 and sep[1].strip():
response = sep[1].strip()
return f'<div class="ai-cell"><div class="ai-prompt"><span class="ai-label">PROMPT</span>\n\n{prompt}\n\n</div><div class="ai-response"><span class="ai-label">AI</span>\n\n{response}\n\n</div></div>'
return f'<div class="ai-cell"><div class="ai-prompt"><span class="ai-label">PROMPT</span>\n\n{prompt}\n\n</div></div>'
return src
rendered = [r for r in (cell_md(c) for c in md_cells) if r is not None]
md = (rendered[0] + '\n\n<!--INTRO-->\n\n' + '\n\n'.join(rendered[1:])) if len(rendered) > 1 else '\n\n'.join(rendered)
return frontmatter(f"{fm_src}\n\n{md}") if fm_src else ({}, md)
# %% ../nbs/01_app.ipynb #1874cd5c
def nb2md_sections(path):
"Read notebook, return (frontmatter, list of (cell_id, text_or_html, cell_type))"
cells = json.loads(Path(path).read_text()).get('cells', [])
fm_src, md_cells = '', cells
if cells and cells[0]['cell_type'] == 'raw':
fm_src = ''.join(cells[0]['source'])
md_cells = cells[1:]
def section_md(c):
src = ''.join(c['source'])
cid = c.get('id', '')
if c.get('metadata', {}).get('pinned'): return None
if c['cell_type'] == 'markdown':
if c.get('metadata', {}).get('solveit_ai'):
sep = re.split(r'##### 🤖Reply🤖<!--.*?-->\n*', src, maxsplit=1)
prompt = sep[0].strip()
if len(sep) > 1 and sep[1].strip():
response = sep[1].strip()
return (cid, f'<div class="ai-cell"><div class="ai-prompt"><span class="ai-label">PROMPT</span>\n\n{prompt}\n\n</div><div class="ai-response"><span class="ai-label">AI</span>\n\n{response}\n\n</div></div>', 'prompt')
return (cid, f'<div class="ai-cell"><div class="ai-prompt"><span class="ai-label">PROMPT</span>\n\n{prompt}\n\n</div></div>', 'prompt')
if atts := c.get('attachments', {}):
for fname, mimes in atts.items():
mime, data = next(iter(mimes.items()))
src = src.replace(f'attachment:{fname}', f'data:{mime};base64,{data}')
return (cid, src, 'note')
if c['cell_type'] == 'code':
code = f'<pre><code class="language-python">{escape(src)}</code></pre>'
if outs := render_outputs(c.get('outputs', [])):
return (cid, f'<div class="cell">{code}<div class="cell-output">{outs}</div></div>', 'code')
return (cid, f'<div class="cell">{code}</div>', 'code')
if c['cell_type'] == 'raw':
return (cid, f'```\n{src}\n```', 'raw')
return None
sections = [r for r in (section_md(c) for c in md_cells) if r is not None]
return frontmatter(f"{fm_src}\n\n") if fm_src else ({},), sections
# %% ../nbs/01_app.ipynb #875cf17d
def span_token(name, pat, attr, prec=5):
class T(mst.span_token.SpanToken):
precedence,parse_inner,parse_group,pattern = prec,False,1,re.compile(pat)
def __init__(self, match): setattr(self, attr, match.group(1))
T.__name__ = name
return T
# %% ../nbs/01_app.ipynb #3d1ecd05
FootnoteRef = span_token('FootnoteRef', r'\[\^([^\]]+)\](?!:)', 'target')
YoutubeEmbed = span_token('YoutubeEmbed', r'\[yt:([a-zA-Z0-9_-]+)\]', 'video_id', 6)
# %% ../nbs/01_app.ipynb #12c543fd
def extract_footnotes(content):
pat = re.compile(r'^\[\^([^\]]+)\]:\s*(.+?)(?=(?:^|\n)\[\^|\n\n|\Z)', re.MULTILINE | re.DOTALL)
defs = {m.group(1): m.group(2).strip() for m in pat.finditer(content)}
for m in pat.finditer(content): content = content.replace(m.group(0), '', 1)
return content.strip(), defs
# %% ../nbs/01_app.ipynb #7706f280
_BLOCK = re.compile(
r'(<div class="ai-cell">[\s\S]*?</div></div>' # prompt cell (2 closers)
r'|<div class="cell">[\s\S]*?</div></div>' # code cell WITH output
r'|<div class="cell"><pre[\s\S]*?</pre></div>' # code cell WITHOUT output
r'|```[\s\S]*?```)')
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}"}}'
# Split by code/prompt blocks; only scan the parts between them
parts = _BLOCK.split(content)
for i, part in enumerate(parts):
if _BLOCK.fullmatch(part): continue
parts[i] = re.sub(r'^(#{1,6})\s+(.+)$', _replace, part, flags=re.MULTILINE)
content = ''.join(parts)
return content, headings
# %% ../nbs/01_app.ipynb #cb31c7e3
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="hidden lg:block w-48 shrink-0 self-start sticky top-24 max-h-[calc(100vh-8rem)] overflow-y-auto"
)
# %% ../nbs/01_app.ipynb #062fd751
def _mobile_toc(headings):
"Build a floating mobile TOC button + slide-out panel"
if not headings: return ''
items = [A(text, href=f'#{slug}', cls="toc-link", 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 #20158ba1
def tts_settings_ui():
"Floating TTS settings button + panel (rendered on every page)"
btn = Div("🔊", cls="tts-settings-btn", title="TTS 设置",
onclick="document.querySelector('.tts-settings-panel').classList.toggle('open')")
panel = Div(
Div("TTS 设置", cls="panel-title"),
# 引擎切换(三态:Browser → Doubao → Kokoro)
Div(
Span("引擎", cls="text-sm"),
Span("🔊 Browser", cls="tts-provider-btn text-xs px-2 py-1 rounded cursor-pointer bg-background border border-border hover:border-primary transition-colors select-none",
onclick="toggleTTSProvider(this)", title="点击切换:Browser → Doubao → Kokoro"),
cls="flex items-center justify-between"
),
# 豆包 API Key
Div(
Label("豆包 API Key", _for="doubaoApiKeyInput"),
Input(type="password", id="doubaoApiKeyInput", placeholder="输入你的 API Key"),
cls="flex flex-col gap-1"
),
# 豆包音色选择
Div(
Label("豆包音色", _for="doubaoVoiceSelect"),
Select(
ft_hx('optgroup',
Option("Vivi 女声(中英混读)", value="zh_female_vv_uranus_bigtts"),
label="中英混读(默认)"),
ft_hx('optgroup',
Option("Tim 男声(美式英语)", value="en_male_tim_uranus_bigtts"),
Option("Dacey 女声(美式英语)", value="en_female_dacey_uranus_bigtts"),
Option("Stokie 女声(美式英语)", value="en_female_stokie_uranus_bigtts"),
Option("Jimmy 男声(美式英语)", value="en_male_jimmy_uranus_bigtts"),
label="英文专用"),
ft_hx('optgroup',
Option("云舟 男声(沉稳自然)", value="zh_male_m191_uranus_bigtts"),
Option("小何 女声(通用)", value="zh_female_xiaohe_uranus_bigtts"),
Option("爽快思思 女声(多情感)", value="zh_female_shuangkuaisisi_uranus_bigtts"),
label="中文专用"),
id="doubaoVoiceSelect"
),
cls="flex flex-col gap-1"
),
# ── Kokoro 设置 ──
Div(
Label("Kokoro Replicate API Key(英文)", _for="kokoroApiKeyInput"),
Input(type="password", id="kokoroApiKeyInput", placeholder="r8_... (英文音色用)"),
cls="flex flex-col gap-1"
),
Div(
Label("Kokoro OpenRouter API Key(中文)", _for="kokoroOpenrouterKeyInput"),
Input(type="password", id="kokoroOpenrouterKeyInput", placeholder="sk-or-... (中文音色用)"),
cls="flex flex-col gap-1"
),
Div(
Label("Kokoro 音色", _for="kokoroVoiceSelect"),
Select(
ft_hx('optgroup',
Option("Emma 女声(英文,默认)", value="bf_emma"),
Option("Bella 女声(英文)", value="af_bella"),
Option("Ryan 男声(英文)", value="am_ryan"),
label="英文音色(Replicate)"),
ft_hx('optgroup',
Option("Xiaoyi 女声(中文)", value="zf_xiaoyi"),
label="中文音色(OpenRouter)"),
id="kokoroVoiceSelect"
),
cls="flex flex-col gap-1"
),
# 语速
Div(
Span("语速"),
Input(type="range", id="ttsSpeedSlider", min="0.5", max="2", step="0.1", value="1"),
Span("1x", id="ttsSpeedValue"),
cls="speed-row"
),
# 缓存信息 + 清空按钮
Div(
Span(id="ttsCacheSizeDisplay", cls="text-xs text-muted-foreground"),
Button("清空缓存", id="clearTtsCacheBtn",
cls="text-xs px-2 py-1 rounded border border-border hover:border-primary cursor-pointer shadow-none"),
cls="flex items-center justify-between gap-2"
),
# 保存按钮
Button("保存设置", cls="save-btn", onclick="saveTtsSettings()"),
cls="tts-settings-panel"
)
floating_player = Div(
Span("⏸", cls="fp-play-btn", onclick="toggleFloatingPlay()"),
Span("⏹", cls="fp-stop-btn", onclick="stopPlayback()"),
Input(type="range", cls="fp-progress", min="0", max="100", value="0", step="0.1",
oninput="seekTts(this.value)"),
Span("0:00 / 0:00", cls="fp-time"),
id="floating-player", cls="floating-player"
)
return btn, panel, floating_player
# %% ../nbs/01_app.ipynb #ae16b7fe
class ContentRenderer(FrankenRenderer):
def __init__(self, *extras, img_dir=None, footnotes=None, **kwargs):
super().__init__(*extras, img_dir=img_dir, **kwargs)
self.footnotes,self.fn_counter = footnotes or {},0
def render_footnote_ref(self, token):
self.fn_counter += 1
n,target = self.fn_counter,token.target
content = self.footnotes.get(target, f"[Missing footnote: {target}]")
rendered = mst.markdown(content, partial(ContentRenderer, img_dir=self.img_dir)).strip()
if rendered.startswith('<p>') and rendered.endswith('</p>'): rendered = rendered[3:-4]
style = "text-sm leading-relaxed border-l-2 border-amber-400 dark:border-blue-400 pl-3 text-neutral-500 dark:text-neutral-400 transition-all duration-500 w-full my-2 xl:my-0"
toggle_hs = f"on click if window.innerWidth >= 1280 then add .hl to #sn-{n} then wait 1s then remove .hl from #sn-{n} else toggle .open on me then toggle .show on #sn-{n}"
ref = Span(id=f"snref-{n}", role="doc-noteref", aria_label=f"Sidenote {n}", cls="sidenote-ref cursor-pointer", _=toggle_hs)
note = Span(NotStr(rendered), id=f"sn-{n}", role="doc-footnote", aria_labelledby=f"snref-{n}", cls=f"sidenote {style}")
hide = lambda c: to_xml(Span(c, cls="hidden", aria_hidden="true"))
return hide(" (") + to_xml(ref) + to_xml(note) + hide(")")
def render_youtube_embed(self, token):
iframe = Iframe(src=f"https://www.youtube.com/embed/{token.video_id}", title="YouTube video player", frameborder="0",
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",
referrerpolicy="strict-origin-when-cross-origin", allowfullscreen=True, cls="w-full aspect-video rounded-lg my-6")
return to_xml(iframe)
def render_link(self, token):
href,inner,title = token.target,self.render_inner(token),f' title="{token.title}"' if token.title else ''
is_internal = href.startswith('/') and not href.startswith('//') and '.' not in href.split('/')[-1]
hx = f' hx-get="{href}" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML show:window:top"' if is_internal else ''
ext = '' if is_internal else ' target="_blank" rel="noopener noreferrer"'
return f'<a href="{href}"{hx}{ext} class="text-primary underline"{title}>{inner}</a>'
# %% ../nbs/01_app.ipynb #12091396
def from_md(content, img_dir='/public/posts'):
content, footnotes = extract_footnotes(content)
mods = {'pre': 'border border-gray-300 rounded-md my-4', 'p': 'text-base leading-relaxed mb-6', 'li': 'text-base leading-relaxed',
'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', 'hr': 'border-t border-border my-8'}
rendered = render_md(content, class_map_mods=mods, img_dir=img_dir, renderer=partial(ContentRenderer, FootnoteRef, YoutubeEmbed, footnotes=footnotes))
return Div(rendered, cls="w-full")
# %% ../nbs/01_app.ipynb #45f17cd0
sidenote_css = Style("""
.sidenote-ref {
color: rgb(221, 166, 55); background-color: rgba(221, 166, 55, 0.15);
display: inline-block; transition: transform 0.2s;
font-size: 0.6rem; vertical-align: top; line-height: 1;
padding: 0 0.15rem; border-radius: 0.2rem; margin-left: 0.1rem;
}
.sidenote-ref:after { content: "›"; }
.dark .sidenote-ref { color: rgb(96, 165, 250); background-color: rgba(96, 165, 250, 0.15); }
.sidenote { display: none; }
@media (max-width: 1279px) {
.sidenote-ref:after { content: "⌃"; }
.sidenote-ref { transform: rotate(180deg); }
.sidenote-ref.open { transform: rotate(0deg); }
.sidenote.show { display: block; float: left; clear: both; width: 95%; margin: 0.75rem 2.5%; position: relative; }
}
@media (min-width: 1280px) {
.sidenote { display: block; float: right; clear: right; width: 14rem; margin-right: -16rem; margin-top: 0.25rem; margin-bottom: 0.75rem; }
.sidenote.hl { background-color: rgba(221, 166, 55, 0.1); }
.dark .sidenote.hl { background-color: rgba(96, 165, 250, 0.1); }
}
""")
# %% ../nbs/01_app.ipynb #8f72d41c
cell_css = Style("""
.cell pre { margin-bottom: 0; border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
.cell-output pre, .cell-output pre code { background: #f0f0f0; }
.dark .cell-output pre, .dark .cell-output pre code { background: #1e2128; }
.cell-output { border: 1px solid rgb(209 213 219); border-top: 0; border-bottom-left-radius: 0.375rem; border-bottom-right-radius: 0.375rem; overflow: hidden; }
.ansi-red-fg { color: #e75c58; } .ansi-green-fg { color: #00a250; } .ansi-yellow-fg { color: #ddb62b; }
.ansi-blue-fg { color: #208ffb; } .ansi-magenta-fg { color: #d160c4; } .ansi-cyan-fg { color: #60c6c8; }
.ansi-white-fg { color: #c5c1b4; } .ansi-bold { font-weight: bold; }
.ai-cell { border: 1px solid rgb(209 213 219); border-radius: 0.375rem; overflow: hidden; margin: 1.5rem 0; }
.ai-prompt { padding: 0.75rem 1rem; }
.ai-prompt > p:last-child, .ai-response > p:last-child { margin-bottom: 0; }
.ai-response { padding: 0.75rem 1rem; background: #f0f0f0; border-top: 1px solid rgb(209 213 219); }
.dark .ai-response { background: #1e2128; }
.ai-prompt .ai-label, .ai-response .ai-label { float: right; font-size: 0.6rem; font-weight: 600; color: rgb(156 163 175); letter-spacing: 0.05em; margin-left: 0.5rem; }
""")
# %% ../nbs/01_app.ipynb #4490fdeb
toc_css = Style("""
.toc-link.active { color: var(--primary); font-weight: 600; }
""")
# %% ../nbs/01_app.ipynb #2c3ecdac
mobile_toc_css = Style("""
.toc-mobile-btn {
position: fixed; bottom: 10px; right: 1.5rem; z-index: 1000;
width: 3rem; height: 3rem; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 1.5rem; cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.toc-mobile-panel {
position: fixed; top: 0; right: 0; z-index: 1001;
width: 280px; height: 100vh;
background: hsl(var(--background));
transform: translateX(100%); transition: transform 0.3s ease;
overflow-y: auto; padding: 1.5rem;
box-shadow: -2px 0 8px rgba(0,0,0,0.1);
}
.toc-mobile-panel.open { transform: translateX(0); }
.toc-panel-header {
display: flex; justify-content: space-between; align-items: center;
padding-bottom: 1rem; margin-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.toc-panel-header span:last-child { cursor: pointer; font-size: 1.25rem; }
.toc-panel-body a {
display: block; padding: 0.5rem 0; font-size: 0.9rem;
color: hsl(var(--foreground));
}
.toc-panel-body a:hover { color: var(--primary); }
@media (max-width: 1023px) {
.toc-mobile-btn { display: flex; }
}
""")
# %% ../nbs/01_app.ipynb #1541d7c6
AUDIO_CSS = Style("""
.note-block { display: flex; flex-direction: column; }
.note-block > .audio-controls { order: -1; }
.audio-controls { display: inline-flex; align-items: center; gap: 6px; }
.audio-controls .pause-btn,
.audio-controls .stop-btn { display: none; }
.audio-controls.playing .play-btn { display: none; }
.audio-controls.playing .pause-btn { display: inline; }
.audio-controls.playing .stop-btn { display: inline; }
.audio-controls.paused .play-btn { display: inline; }
.audio-controls.paused .pause-btn { display: none; }
.audio-controls.paused .stop-btn { display: inline; }
.audio-controls .play-btn { cursor: pointer; }
.audio-controls .pause-btn { cursor: pointer; }
.audio-controls .stop-btn { cursor: pointer; }
/* TTS Settings Button */
.tts-settings-btn {
position: fixed; bottom: 2rem; left: 2rem; z-index: 1000;
width: 2.5rem; height: 2.5rem; border-radius: 50%;
background: hsl(var(--background)); border: 1px solid var(--border);
box-shadow: 0 2px 8px rgba(0,0,0,0.15); cursor: pointer;
display: flex; align-items: center; justify-content: center; font-size: 1.1rem;
transition: transform 0.2s, box-shadow 0.2s;
}
.tts-settings-btn:hover { transform: scale(1.1); box-shadow: 0 4px 12px rgba(0,0,0,0.2); }
/* TTS Settings Panel */
.tts-settings-panel {
position: fixed; bottom: 5rem; left: 2rem; z-index: 1001;
width: 300px; padding: 1.2rem; border-radius: 0.75rem;
background: hsl(var(--background)); border: 1px solid var(--border);
box-shadow: 0 4px 20px rgba(0,0,0,0.18);
display: none; flex-direction: column; gap: 1rem;
}
.tts-settings-panel.open { display: flex; }
.tts-settings-panel .panel-title { font-weight: 600; font-size: 1rem; }
.tts-settings-panel label {
display: flex; align-items: center; gap: 0.5rem;
font-size: 0.9rem; cursor: pointer;
}
.tts-settings-panel select,
.tts-settings-panel input[type="password"],
.tts-settings-panel input[type="text"] {
width: 100%; padding: 0.5rem; border: 1px solid var(--border);
border-radius: 0.375rem; font-size: 0.85rem;
background: transparent; color: inherit; outline: none;
transition: border-color 0.2s;
}
.tts-settings-panel select:focus,
.tts-settings-panel input:focus { border-color: var(--primary); }
.tts-settings-panel .speed-row {
display: flex; align-items: center; gap: 0.75rem; font-size: 0.85rem;
}
.tts-settings-panel input[type="range"] { flex: 1; cursor: pointer; }
.tts-settings-panel .hint {
font-size: 0.75rem; color: var(--muted-foreground);
line-height: 1.4;
}
.tts-settings-panel .save-btn {
padding: 0.55rem; border: none; border-radius: 0.375rem;
background: var(--primary); color: white; cursor: pointer;
font-size: 0.85rem; font-weight: 500; transition: opacity 0.2s;
}
.tts-settings-panel .save-btn:hover { opacity: 0.9; }
.voice-group { display: flex; flex-direction: column; gap: 0.5rem; }
.voice-group select { margin-top: 0.25rem; }
.tts-provider-btn { user-select: none; -webkit-user-select: none; }
.tts-provider-btn:active { transform: scale(0.95); }
/* 就地进度条(每节播放控件旁)*/
.tts-progress {
flex: 1;
min-width: 50px;
height: 4px;
cursor: pointer;
display: none;
accent-color: var(--primary);
}
.tts-progress.active { display: inline-block; }
/* 悬浮播放条(跟随滚动)*/
.floating-player {
position: fixed;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
z-index: 1002;
display: none;
align-items: center;
gap: 10px;
padding: 8px 18px;
background: hsl(var(--background) / 0.85);
border: 1px solid var(--border);
border-radius: 999px;
box-shadow: 0 4px 20px rgba(0,0,0,0.18);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
max-width: 92vw;
width: 420px;
}
.floating-player.visible { display: flex; }
/* 手机端:上移避开底部两个角按钮 */
@media (max-width: 1023px) {
.floating-player {
bottom: 4rem;
width: calc(100% - 2rem);
}
.tts-settings-btn {
bottom: 1rem;
left: 1.5rem;
}
/* ── TTS panel: cap height + scroll inside so it never overflows the screen ── */
.tts-settings-panel {
bottom: 4rem;
left: 1rem;
right: 1rem;
width: auto;
max-height: calc(100vh - 5rem);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 0.85rem;
gap: 0.65rem;
}
.tts-settings-panel .panel-title { font-size: 0.9rem; }
.tts-settings-panel label { font-size: 0.8rem; }
.tts-settings-panel select,
.tts-settings-panel input[type="password"],
.tts-settings-panel input[type="text"] {
padding: 0.4rem;
font-size: 0.8rem;
}
.tts-settings-panel .speed-row { font-size: 0.8rem; }
.tts-settings-panel .save-btn {
padding: 0.45rem;
font-size: 0.8rem;
}
}
.floating-player .fp-play-btn,
.floating-player .fp-stop-btn {
cursor: pointer;
font-size: 1.1rem;
user-select: none;
-webkit-user-select: none;
}
.floating-player .fp-play-btn:active,
.floating-player .fp-stop-btn:active { transform: scale(0.9); }
.floating-player .fp-progress {
flex: 1;
height: 4px;
cursor: pointer;
accent-color: var(--primary);
display: none;
}
.floating-player .fp-time {
font-size: 0.72rem;
color: var(--muted-foreground);
min-width: 78px;
text-align: right;
display: none;
}
.floating-player.doubao-mode .fp-progress,
.floating-player.doubao-mode .fp-time { display: inline; }
""")
# %% ../nbs/01_app.ipynb #9c9314fc
annotation_js = NotStr(r"""
<script>
const STORAGE_KEY = 'blog_annotations';
const EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
pruneExpired();
function saveAnnotation(annotation) {
const all = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
all.push(annotation);
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
}
function copyAllToClipboard() {
const all = pruneExpired();
if (!all.length) return;
const blocks = all.reverse().map(a => {
const title = a.title || a.url;
return `${title}\n${a.url}\n\n> "${a.text}"\n\nComment: ${a.comment}\n\n---`;
});
navigator.clipboard.writeText(blocks.join('\n')).catch(() => {
// 剪贴板不可用时静默失败,不影响标注保存
});
}
function copySelectedText(text) {
// 优先使用现代 Clipboard API
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
} else {
fallbackCopy(text);
}
}
function fallbackCopy(text) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch(e) {}
document.body.removeChild(ta);
}
function pruneExpired() {
const all = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
const cutoff = Date.now() - EXPIRY_MS;
const filtered = all.filter(a => (a.timestamp * 1000) > cutoff);
if (filtered.length !== all.length) localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered));
return filtered;
}
function positionPopup(popup, rect) {
const viewportW = window.innerWidth;
const popupW = Math.min(320, viewportW - 32);
popup.style.cssText = `
position:fixed; z-index:9999; background:#fff; border:1px solid #ddd;
border-radius:8px; padding:12px; box-shadow:0 4px 12px rgba(0,0,0,0.15);
font-family:sans-serif; width:${popupW}px;
left:${Math.max(16, Math.min(rect.left + rect.width/2 - popupW/2, viewportW - popupW - 16))}px;
top:${rect.bottom + 8}px;
`;
}
function showAnnotationPopup(range, selectedText) {
document.querySelector('.hl-popup')?.remove();
const popup = document.createElement('div');
popup.className = 'hl-popup';
popup.innerHTML = `
<textarea placeholder="Type or dictate your comment..." rows="3" style="width:100%;border:1px solid #ccc;border-radius:4px;padding:6px;font-family:sans-serif;font-size:14px;resize:none;background:#fff;color:#333"></textarea>
<div style="display:flex;gap:8px;margin-top:8px">
<button class="hl-save" style="flex:1;padding:6px 12px;background:#2563eb;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:14px">Save</button>
<button class="hl-cancel" style="flex:1;padding:6px 12px;background:#e5e7eb;color:#374151;border:none;border-radius:4px;cursor:pointer;font-size:14px">Cancel</button>
</div>
`;
positionPopup(popup, range.getBoundingClientRect());
popup.querySelector('.hl-save').onclick = () => {
const comment = popup.querySelector('textarea').value;
saveAnnotation({
id: 'hli_' + Date.now() + '_' + Math.floor(Math.random() * 1000),
url: location.href,
title: document.title,
text: selectedText,
comment: comment,
timestamp: Math.floor(Date.now() / 1000),
});
copyAllToClipboard();
try {
const span = document.createElement('span');
span.className = 'hl-yellow';
span.style.backgroundColor = 'rgba(255,255,0,0.4)';
span.style.borderRadius = '2px';
range.surroundContents(span);
} catch(e) {}
popup.remove();
window.getSelection().removeAllRanges();
};
popup.querySelector('.hl-cancel').onclick = () => {
popup.remove();
window.getSelection().removeAllRanges();
};
document.body.appendChild(popup);
popup.querySelector('textarea').focus();
}
function showChoicePopup(range, selectedText) {
document.querySelector('.hl-popup')?.remove();
const popup = document.createElement('div');
popup.className = 'hl-popup hl-choice-popup';
popup.innerHTML = `
<div style="display:flex;gap:8px">
<button class="hl-copy" style="flex:1;padding:8px 12px;background:#2563eb;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:14px;font-weight:500">Copy</button>
<button class="hl-comment" style="flex:1;padding:8px 12px;background:#e5e7eb;color:#374151;border:none;border-radius:4px;cursor:pointer;font-size:14px;font-weight:500">Comments</button>
</div>
`;
positionPopup(popup, range.getBoundingClientRect());
popup.querySelector('.hl-copy').onclick = () => {
copySelectedText(selectedText);
popup.remove();
window.getSelection().removeAllRanges();
};
popup.querySelector('.hl-comment').onclick = () => {
popup.remove();
showAnnotationPopup(range, selectedText);
};
document.body.appendChild(popup);
}
document.addEventListener('mouseup', function(e) {
if (!window.__annotationEnabled) return;
if (e.target.closest('.hl-popup')) return;
const sel = window.getSelection();
const text = sel.toString().trim();
if (!text || text.length < 3) return;
showChoicePopup(sel.getRangeAt(0), text);
});
document.addEventListener('touchend', function(e) {
if (!window.__annotationEnabled) return;
if (e.target.closest('.hl-popup')) return;
setTimeout(() => {
const sel = window.getSelection();
const text = sel.toString().trim();
if (!text || text.length < 3) return;
showChoicePopup(sel.getRangeAt(0), text);
}, 10);
});
</script>
""")
# %% ../nbs/01_app.ipynb #756b1d7d
AUDIO_JS = NotStr(r"""
<script>
(function() {
let playAllQueue = [];
let isPlayAll = false;
let gen = 0;
let currentCid = null;
let chunkIdx = 0;
let currentPlayAllText = '';
let savedBookmark = null;
let currentDoubaoAudio = null;
let doubaoLoading = false;
let activeProgressBar = null;
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
let enVoice = null, zhVoice = null;
// ========== IndexedDB 音频缓存 ==========
const CACHE_DB = 'tts_audio_cache', CACHE_STORE = 'blobs';
function openCacheDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(CACHE_DB, 1);
req.onupgradeneeded = e => { if (!e.target.result.objectStoreNames.contains(CACHE_STORE)) e.target.result.createObjectStore(CACHE_STORE); };
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function cacheGet(key) {
return openCacheDB().then(db => new Promise((resolve, reject) => {
const tx = db.transaction(CACHE_STORE, 'readonly');
const r = tx.objectStore(CACHE_STORE).get(key);
r.onsuccess = () => resolve(r.result);
r.onerror = () => reject(r.error);
}));
}
function cacheSet(key, val) {
return openCacheDB().then(db => new Promise((resolve, reject) => {
const tx = db.transaction(CACHE_STORE, 'readwrite');
tx.objectStore(CACHE_STORE).put(val, key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
}));
}
function cacheKey(text, speaker) {
let h = 5381, s = speaker + '|' + text;
for (let i = 0; i < s.length; i++) { h = ((h << 5) + h) + s.charCodeAt(i); h |= 0; }
return 'tts_' + h.toString(36) + '_len_' + s.length;
}
// Decode exact duration via Web Audio API — immune to missing MP3 headers
let decodedDuration = null;
async function decodeDuration(bytes) {
try {
const blob = new Blob([bytes], { type: 'audio/mpeg' });
const buf = await blob.arrayBuffer();
const AC = window.AudioContext || window.webkitAudioContext;
const ctx = new AC();
return await new Promise((resolve, reject) => {
ctx.decodeAudioData(buf.slice(0),
(audioBuf) => { ctx.close(); resolve(audioBuf.duration); },
(err) => { ctx.close(); reject(err); }
);
});
} catch (e) {
console.warn('decodeDuration failed, falling back to audio.duration', e);
return null;
}
}
function getEffectiveDuration() {
if (decodedDuration && isFinite(decodedDuration)) return decodedDuration;
const d = currentDoubaoAudio && currentDoubaoAudio.duration;
return (d && isFinite(d) && !isNaN(d)) ? d : null;
}
async function getCacheTotalSize() {
const db = await openCacheDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(CACHE_STORE, 'readonly');
const cursor = tx.objectStore(CACHE_STORE).openCursor();
let total = 0;
cursor.onsuccess = e => {
const c = e.target.result;
if (c) { if (c.value instanceof Uint8Array) total += c.value.byteLength; c.continue(); }
else resolve(total);
};
cursor.onerror = () => reject(cursor.error);
});
}
async function clearAllTtsCache() {
const db = await openCacheDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(CACHE_STORE, 'readwrite');
tx.objectStore(CACHE_STORE).clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function refreshCacheSizeDisplay() {
const el = document.getElementById('ttsCacheSizeDisplay');
if (!el) return;
try {
const bytes = await getCacheTotalSize();
el.textContent = bytes > 1024 ? `音频缓存: ${(bytes/1024).toFixed(1)} KB` : '音频缓存: 0 KB';
} catch { el.textContent = '音频缓存: 不可用'; }
}
window.clearTTSCache = async function() { await clearAllTtsCache(); refreshCacheSizeDisplay(); console.log('TTS cache cleared'); };
// ========== TTS Provider 管理 ==========
function getProvider() { return localStorage.getItem('tts_provider') || 'browser'; }
function setProvider(p) { localStorage.setItem('tts_provider', p); }
function initProviderButtons() {
const p = getProvider();
const label = p === 'doubao' ? '🤖 Doubao' : p === 'kokoro' ? '🎵 Kokoro' : '🔊 Browser';
document.querySelectorAll('.tts-provider-btn').forEach(btn => { btn.textContent = label; });
}
window.toggleTTSProvider = function(el) {
const order = ['browser', 'doubao', 'kokoro'];
const cur = getProvider();
const next = order[(order.indexOf(cur) + 1) % order.length];
if (next === 'doubao' && !localStorage.getItem('doubao_api_key')) {
const key = prompt('请输入豆包 TTS API Key(将保存在本地浏览器):');
if (!key) return;
localStorage.setItem('doubao_api_key', key.trim());
const input = document.getElementById('doubaoApiKeyInput');
if (input) input.value = key.trim();
}
if (next === 'kokoro') {
if (!localStorage.getItem('kokoro_api_key')) {
const key = prompt('请输入 Replicate API Key(Kokoro 英文音色,将保存在本地):');
if (!key) return;
localStorage.setItem('kokoro_api_key', key.trim());
const input = document.getElementById('kokoroApiKeyInput');
if (input) input.value = key.trim();
}
if (!localStorage.getItem('kokoro_openrouter_key')) {
const key = prompt('请输入 OpenRouter API Key(Kokoro 中文音色,将保存在本地):');
if (!key) return;
localStorage.setItem('kokoro_openrouter_key', key.trim());
const input = document.getElementById('kokoroOpenrouterKeyInput');
if (input) input.value = key.trim();
}
}
stopPlayback();
setProvider(next);
initProviderButtons();
};
// ========== 设置面板:保存 / 加载 ==========
window.saveTtsSettings = function() {
const apiKey = document.getElementById('doubaoApiKeyInput').value.trim();
const voice = document.getElementById('doubaoVoiceSelect').value;
const kokoroKey = document.getElementById('kokoroApiKeyInput').value.trim();
const kokoroOpenrouterKey = document.getElementById('kokoroOpenrouterKeyInput').value.trim();
const kokoroVoice = document.getElementById('kokoroVoiceSelect').value;
const speed = parseFloat(document.getElementById('ttsSpeedSlider').value);
if (apiKey) localStorage.setItem('doubao_api_key', apiKey);
localStorage.setItem('doubao_voice', voice);
if (kokoroKey) localStorage.setItem('kokoro_api_key', kokoroKey);
if (kokoroOpenrouterKey) localStorage.setItem('kokoro_openrouter_key', kokoroOpenrouterKey);
localStorage.setItem('kokoro_voice', kokoroVoice);
localStorage.setItem('tts_speed', speed);
alert('设置已保存');
document.querySelector('.tts-settings-panel').classList.remove('open');
};
function loadTtsSettings() {
const savedKey = localStorage.getItem('doubao_api_key') || '';
const savedVoice = localStorage.getItem('doubao_voice') || 'zh_female_vv_uranus_bigtts';
const savedKokoroKey = localStorage.getItem('kokoro_api_key') || '';
const savedKokoroVoice = localStorage.getItem('kokoro_voice') || 'bf_emma';
const savedSpeed = parseFloat(localStorage.getItem('tts_speed')) || 1.0;
const keyInput = document.getElementById('doubaoApiKeyInput');
const voiceSelect = document.getElementById('doubaoVoiceSelect');
const kokoroKeyInput = document.getElementById('kokoroApiKeyInput');
const kokoroVoiceSelect = document.getElementById('kokoroVoiceSelect');
const speedSlider = document.getElementById('ttsSpeedSlider');
const speedValue = document.getElementById('ttsSpeedValue');
const savedKokoroOrKey = localStorage.getItem('kokoro_openrouter_key') || '';
const kokoroOrKeyInput = document.getElementById('kokoroOpenrouterKeyInput');
if (kokoroOrKeyInput) kokoroOrKeyInput.value = savedKokoroOrKey;
if (keyInput) keyInput.value = savedKey;
if (voiceSelect) voiceSelect.value = savedVoice;
if (kokoroKeyInput) kokoroKeyInput.value = savedKokoroKey;
if (kokoroVoiceSelect) kokoroVoiceSelect.value = savedKokoroVoice;
if (speedSlider) speedSlider.value = savedSpeed;
if (speedValue) speedValue.textContent = savedSpeed + 'x';
}
function bindTtsSettingsUi() {
const speedSlider = document.getElementById('ttsSpeedSlider');
const speedValue = document.getElementById('ttsSpeedValue');
if (speedSlider && speedValue) {
speedSlider.addEventListener('input', () => { speedValue.textContent = speedSlider.value + 'x'; });
}
const clearBtn = document.getElementById('clearTtsCacheBtn');
if (clearBtn) {
clearBtn.addEventListener('click', async () => {
if (!confirm('确定清空所有 TTS 音频缓存?下次播放将重新调用 API 生成。')) return;
await clearAllTtsCache();
await refreshCacheSizeDisplay();
alert('TTS 音频缓存已清空。');
});
}
}
// ========== 浏览器 TTS(Web Speech API)==========
function pickVoices() {
const voices = speechSynthesis.getVoices();
if (!voices.length) return false;
const enNames = isMobile ? ['Samantha', 'Daniel'] : ['Daniel', 'Jamie', 'Aaron'];
const enFallback = isMobile ? 'Samantha' : 'Google UK English Female';
enVoice = enNames.map(n => voices.find(v => v.name.includes(n))).find(Boolean);
if (!enVoice) enVoice = voices.find(v => v.name.includes(enFallback))
|| voices.find(v => v.lang === 'en-US') || voices.find(v => v.lang.startsWith('en'));
const zhNames = isMobile ? ['美嘉', '婷婷'] : ['Meijia', 'Li-Mu', 'Yu-shu'];
const zhFallback = isMobile ? '美嘉' : 'Google 普通话';
zhVoice = zhNames.map(n => voices.find(v => v.name.includes(n))).find(Boolean);
if (!zhVoice) zhVoice = voices.find(v => v.name.includes(zhFallback))
|| voices.find(v => v.lang.startsWith('zh'));
return !!(enVoice && zhVoice);
}
function waitForVoices() {
let attempts = 0;
const timer = setInterval(() => { if (pickVoices() || ++attempts >= 20) clearInterval(timer); }, 500);
}
speechSynthesis.onvoiceschanged = pickVoices;
waitForVoices();
function detectLang(text) {
const zh = text.match(/[\u4e00-\u9fff]/g);
return (zh && zh.length > text.length * 0.3) ? 'zh' : 'en';
}
function getVoice(lang) { return lang === 'zh' ? zhVoice : enVoice; }
function chunkText(text, size) {
const chunks = [];
for (let i = 0; i < text.length; i += size) chunks.push(text.slice(i, i + size));
return chunks;
}
function speakChunks(chunks, btns, myGen, onDone) {
if (!chunks.length || myGen !== gen) { if (myGen === gen && onDone) onDone(); return; }
const [first, ...rest] = chunks;
const lang = detectLang(first);
const u = new SpeechSynthesisUtterance(first);
u.lang = lang === 'zh' ? 'zh-CN' : 'en-US';
const v = getVoice(lang);
if (v) u.voice = v;
u.onend = () => { chunkIdx++; speakChunks(rest, btns, myGen, onDone); };
u.onerror = () => { if (myGen === gen) { resetBtns(btns); if (onDone) onDone(); } };
btns.className = 'audio-controls playing';
showFloatingPlayer();
speechSynthesis.speak(u);
}
// ========== 进度条(就地 + 悬浮,仅豆包模式可跳转)==========
function showProgressBar(btns) {
const bar = btns.querySelector('.tts-progress');
if (!bar) return;
bar.classList.add('active');
bar.value = 0;
activeProgressBar = bar;
}
function hideProgressBar() {
if (activeProgressBar) { activeProgressBar.classList.remove('active'); activeProgressBar.value = 0; }
activeProgressBar = null;
}
function updateProgressBar() {
if (!currentDoubaoAudio) return;
if (!currentDoubaoAudio.duration || isNaN(currentDoubaoAudio.duration)) return;
if (activeProgressBar) activeProgressBar.value = (currentDoubaoAudio.currentTime / currentDoubaoAudio.duration) * 100;
updateFloatingProgress();
}
window.seekTts = function(value) {
if (!currentDoubaoAudio || !currentDoubaoAudio.duration || isNaN(currentDoubaoAudio.duration)) return;
currentDoubaoAudio.currentTime = (value / 100) * currentDoubaoAudio.duration;
};
// ========== 悬浮播放条(跟随滚动)==========
function showFloatingPlayer() {
const fp = document.getElementById('floating-player');
if (!fp) return;
fp.classList.toggle('doubao-mode', getProvider() !== 'browser');
fp.classList.add('visible');
setFloatingPlayIcon(true);
// 播放时把两个角按钮稍微淡化,减少视觉干扰
document.querySelector('.toc-mobile-btn')?.style.setProperty('opacity', '0.4');
document.querySelector('.tts-settings-btn')?.style.setProperty('opacity', '0.4');
}
function hideFloatingPlayer() {
const fp = document.getElementById('floating-player');
if (fp) fp.classList.remove('visible');
document.querySelector('.toc-mobile-btn')?.style.removeProperty('opacity');
document.querySelector('.tts-settings-btn')?.style.removeProperty('opacity');
}
function setFloatingPlayIcon(playing) {
const btn = document.querySelector('.fp-play-btn');
if (btn) btn.textContent = playing ? '⏸' : '▶';
}
function formatTime(sec) {
if (isNaN(sec)) return '0:00';
const m = Math.floor(sec / 60), s = Math.floor(sec % 60);
return m + ':' + (s < 10 ? '0' : '') + s;
}
function updateFloatingProgress() {
if (!currentDoubaoAudio) return;
const bar = document.querySelector('.fp-progress');
const timeEl = document.querySelector('.fp-time');
if (!bar || !timeEl) return;
if (!currentDoubaoAudio.duration || isNaN(currentDoubaoAudio.duration)) return;
bar.value = (currentDoubaoAudio.currentTime / currentDoubaoAudio.duration) * 100;
timeEl.textContent = formatTime(currentDoubaoAudio.currentTime) + ' / ' + formatTime(currentDoubaoAudio.duration);
}
window.toggleFloatingPlay = function() {
if (isPlaying()) { pauseAllAudio(); setFloatingPlayIcon(false); }
else { resumeAllAudio(); setFloatingPlayIcon(true); }
};
// ========== 豆包 TTS(带 IndexedDB 缓存)==========
async function doubaoFetch(text) {
const apiKey = localStorage.getItem('doubao_api_key');
const speaker = localStorage.getItem('doubao_voice') || 'zh_female_vv_uranus_bigtts';
const speed = parseFloat(localStorage.getItem('tts_speed')) || 1.0;
if (!apiKey) throw new Error('未设置 API Key');
// 先查缓存
const key = cacheKey(text, speaker);
const cached = await cacheGet(key).catch(() => null);
if (cached && cached.byteLength > 0) {
console.log('📦 TTS cache hit', (cached.byteLength / 1024).toFixed(1) + 'KB');
refreshCacheSizeDisplay();
return cached;
}
const speechRate = Math.round((speed - 1) * 100);
const resp = await fetch('/api/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, api_key: apiKey, speaker, speech_rate: speechRate })
});
if (!resp.ok) {
const errText = await resp.text();
throw new Error('HTTP ' + resp.status + ': ' + errText.substring(0, 200));
}
const respText = await resp.text();
const lines = respText.split('\n').filter(l => l.trim());
let allBase64 = '', lastError = null;
for (const line of lines) {
let chunk;
try { chunk = JSON.parse(line); } catch (e) { continue; }
if (chunk.data) { allBase64 += chunk.data; continue; }
if (chunk.code !== 200 && chunk.code !== 0 && chunk.message) lastError = chunk.message;
}
if (!allBase64) throw new Error('未收到音频数据' + (lastError ? ': ' + lastError : ''));
const binary = atob(allBase64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
cacheSet(key, bytes).catch(() => {});
console.log('💾 TTS cached', (bytes.byteLength / 1024).toFixed(1) + 'KB');
refreshCacheSizeDisplay();
return bytes;
}
function playDoubao(text, btns, myGen, onDone) {
if (myGen !== gen) return;
doubaoLoading = true;
btns.className = 'audio-controls playing';
doubaoFetch(text).then(bytes => {
doubaoLoading = false;
playBytesAudio(bytes, btns, myGen, onDone);
}).catch(err => {
doubaoLoading = false;
console.error('豆包 TTS 失败:', err);
if (myGen !== gen) return;
alert('豆包 TTS 失败: ' + err.message + '\n已自动回退到浏览器 TTS。');
speakChunks(chunkText(text, 1000), btns, myGen, onDone);
});
}
// ========== Kokoro TTS(Replicate 代理,超低费用)==========
async function kokoroFetch(text) {
const repKey = localStorage.getItem('kokoro_api_key');
const orKey = localStorage.getItem('kokoro_openrouter_key');
const userVoice = localStorage.getItem('kokoro_voice') || 'bf_emma';
const speed = parseFloat(localStorage.getItem('tts_speed')) || 1.0;
const isZh = /[\u4e00-\u9fff]/.test(text);
const voice = isZh ? 'zf_xiaoyi' : userVoice;
// ── 先查 Kokoro 缓存 ──
const key = cacheKey(text, 'kokoro_' + voice);
const cached = await cacheGet(key).catch(() => null);
if (cached && cached.byteLength > 0) {
console.log('📦 Kokoro cache hit', (cached.byteLength / 1024).toFixed(1) + 'KB');
refreshCacheSizeDisplay();
return cached;
}
// ── 未命中,调用 API ──
if (isZh && !orKey) throw new Error('中文文本需要 OpenRouter API Key');
if (!isZh && !repKey) throw new Error('英文文本需要 Replicate API Key');
const resp = await fetch('/api/kokoro', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text, replicate_key: repKey, openrouter_key: orKey,
voice, speed, lang: isZh ? 'zh' : 'en',
}),
});
const data = await resp.json();
if (!resp.ok || data.error) throw new Error(data.error || ('HTTP ' + resp.status));
if (!data.output) throw new Error('Kokoro 未返回音频');
// ── 统一转成 Uint8Array(data URL 或远程 URL)──
let bytes;
if (data.output.startsWith('data:')) {
const base64 = data.output.split(',')[1];
const binary = atob(base64);
bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
} else {
const audioResp = await fetch(data.output);
const buffer = await audioResp.arrayBuffer();
bytes = new Uint8Array(buffer);
}
// ── 写入缓存 ──
cacheSet(key, bytes).catch(() => {});
console.log('💾 Kokoro cached', (bytes.byteLength / 1024).toFixed(1) + 'KB');
refreshCacheSizeDisplay();
return bytes;
}
function playKokoro(text, btns, myGen, onDone) {
if (myGen !== gen) return;
doubaoLoading = true;
btns.className = 'audio-controls playing';
kokoroFetch(text).then(bytes => {
doubaoLoading = false;
playBytesAudio(bytes, btns, myGen, onDone);
}).catch(err => {
doubaoLoading = false;
console.error('Kokoro TTS 失败:', err);
if (myGen !== gen) return;
alert('Kokoro TTS 失败: ' + err.message + '\n已自动回退到浏览器 TTS。');
speakChunks(chunkText(text, 1000), btns, myGen, onDone);
});
}
// ========== 公共:从 Uint8Array 缓存播放音频(Doubao/Kokoro 共用)==========
function playBytesAudio(bytes, btns, myGen, onDone) {
if (myGen !== gen) return;
const blob = new Blob([bytes], { type: 'audio/mpeg' });
currentDoubaoAudio = new Audio(URL.createObjectURL(blob));
showProgressBar(btns);
showFloatingPlayer();
currentDoubaoAudio.ontimeupdate = updateProgressBar;
currentDoubaoAudio.onended = () => {
if (myGen === gen) {
currentDoubaoAudio = null;
hideProgressBar();
hideFloatingPlayer();
if (onDone) onDone();
}
};
currentDoubaoAudio.play().catch(err => {
console.error('音频播放失败:', err);
if (myGen === gen) { resetBtns(btns); hideProgressBar(); hideFloatingPlayer(); if (onDone) onDone(); }
});
}
function stripAsterisks(text) {
if (!text) return '';
return text
.replace(/\*\*\*(.+?)\*\*\*/g, '$1') // ***粗斜体*** → 粗斜体
.replace(/\*\*(.+?)\*\*/g, '$1') // **粗体(可含 *斜体*)** → 粗体(可含 *斜体*)
.replace(/(^|\s)\*([^*\n]+)\*(?=\s|$|[.,!?;:])/g, '$1$2'); // *斜体* → 斜体
}
// ========== 统一播放入口 ==========
async function playText(text, btns, cid, onDone) {
const clean = stripAsterisks(text);
const provider = getProvider();
const isZh = /[\u4e00-\u9fff]/.test(clean);
// 计算两个 provider 当前音色的缓存 key
const doubaoSpeaker = localStorage.getItem('doubao_voice') || 'zh_female_vv_uranus_bigtts';
const kokoroVoice = isZh ? 'zf_xiaoyi' : (localStorage.getItem('kokoro_voice') || 'bf_emma');
const doubaoKey = cacheKey(clean, doubaoSpeaker);
const kokoroKey = cacheKey(clean, 'kokoro_' + kokoroVoice);
// ── 规则 1:当前选 Kokoro,但有 Doubao 缓存 → 静默用 Doubao 缓存 ──
if (provider === 'kokoro') {
const doubaoCached = await cacheGet(doubaoKey).catch(() => null);
if (doubaoCached && doubaoCached.byteLength > 0) {
console.log('⚡ Doubao cache exists, using it instead of Kokoro API');
playBytesAudio(doubaoCached, btns, gen, onDone);
return;
}
playKokoro(clean, btns, gen, onDone);
return;
}
// ── 规则 2:当前选 Doubao,但有 Kokoro 缓存 → 弹窗确认是否覆盖 ──
if (provider === 'doubao') {
const kokoroCached = await cacheGet(kokoroKey).catch(() => null);
if (kokoroCached && kokoroCached.byteLength > 0) {
const proceed = confirm(
'这段文本已有 Kokoro 缓存。\n\n要用 Doubao 重新生成并覆盖吗?\n(将消耗一次 Doubao API 调用)'
);
if (proceed) {
playDoubao(clean, btns, gen, onDone); // 内部会写入 Doubao 缓存
} else {
playBytesAudio(kokoroCached, btns, gen, onDone);
}
return;
}
playDoubao(clean, btns, gen, onDone);
return;
}
// ── Browser 模式 ──
speakChunks(chunkText(clean, 1000), btns, gen, onDone);
}
function speakText(text, btns, cid) {
stopAllAudio(); gen++; currentCid = cid; chunkIdx = 0;
playText(text, btns, cid);
}
function resetBtns(btns) { btns.className = 'audio-controls'; }
function stopAllAudio() {
speechSynthesis.cancel();
if (currentDoubaoAudio) { currentDoubaoAudio.pause(); currentDoubaoAudio.currentTime = 0; currentDoubaoAudio = null; }
doubaoLoading = false;
hideProgressBar();
hideFloatingPlayer();
}
function pauseAllAudio() {
if (getProvider() !== 'browser') { if (currentDoubaoAudio) currentDoubaoAudio.pause(); }
else speechSynthesis.pause();
setFloatingPlayIcon(false);
}
function resumeAllAudio() {
if (getProvider() !== 'browser') { if (currentDoubaoAudio) currentDoubaoAudio.play(); }
else speechSynthesis.resume();
setFloatingPlayIcon(true);
}
function isPlaying() {
if (getProvider() !== 'browser') return !!(currentDoubaoAudio && !currentDoubaoAudio.paused) || doubaoLoading;
return speechSynthesis.speaking && !speechSynthesis.paused;
}
function isPaused() {
if (getProvider() !== 'browser') return !!(currentDoubaoAudio && currentDoubaoAudio.paused);
return speechSynthesis.paused;
}
// ========== 单节播放控制 ==========
window.togglePlay = function(el) {
const btns = el.closest('.audio-controls');
const block = el.closest('.note-block');
const cid = block.getAttribute('data-cell-id');
const text = block.dataset.text;
if (isPaused() && savedBookmark && savedBookmark.cid === cid) {
resumeAllAudio(); btns.className = 'audio-controls playing'; savedBookmark = null; return;
}
savedBookmark = null; isPlayAll = false;
speakText(text, btns, cid);
};
window.pausePlayback = function() {
pauseAllAudio();
if (currentCid) savedBookmark = { cid: currentCid };
const playing = document.querySelector('.audio-controls.playing');
if (playing) playing.className = 'audio-controls paused';
};
window.stopPlayback = function() {
gen++; chunkIdx = 0; currentCid = null; savedBookmark = null;
stopAllAudio(); isPlayAll = false; playAllQueue = [];
document.querySelectorAll('.audio-controls.playing, .audio-controls.paused').forEach(b => { b.className = 'audio-controls'; });
};
// ========== 连播控制 ==========
window.togglePlayAll = function(el) {
const btns = el.closest('.audio-controls');
if (isPaused() && savedBookmark && savedBookmark.isPlayAll) {
resumeAllAudio(); btns.className = 'audio-controls audio-playall playing'; savedBookmark = null; return;
}
savedBookmark = null;
const sections = [...document.querySelectorAll('.note-block[data-text]')];
if (!sections.length) return;
gen++; chunkIdx = 0; currentCid = null; stopAllAudio();
playAllQueue = sections.map(s => s.dataset.text);
isPlayAll = true;
playNext(btns);
};
window.pauseAll = function() {
pauseAllAudio();
const playing = document.querySelector('.audio-controls.playing');
if (playing) playing.className = 'audio-controls paused';
savedBookmark = { isPlayAll: true };
};
window.stopAll = function() { stopPlayback(); };
function playNext(playallBtns) {
if (!playAllQueue.length || !isPlayAll) {
if (playallBtns) playallBtns.className = 'audio-controls audio-playall';
isPlayAll = false; return;
}
if (playallBtns) playallBtns.className = 'audio-controls audio-playall playing';
const text = playAllQueue.shift();
currentPlayAllText = text;
playText(text, playallBtns, null, () => playNext(playallBtns));
}
// ========== 初始化 ==========
document.addEventListener('DOMContentLoaded', () => {
initProviderButtons();
loadTtsSettings();
bindTtsSettingsUi();
refreshCacheSizeDisplay();
if (document.body) document.body.addEventListener('htmx:afterSwap', () => {
initProviderButtons(); loadTtsSettings(); bindTtsSettingsUi(); refreshCacheSizeDisplay();
});
});
})();
</script>
""")
# %% ../nbs/01_app.ipynb #b8147943
_custom_css = Style(Path('custom.css').read_text()) if Path('custom.css').exists() else ''
_navbar_hide_css = Style("""
#site-navbar.hidden {
transform: translateY(-110%);
}
""")
hdrs = (*Theme.slate.headers(highlightjs=True),
Link(rel="icon", href="/public/favicon.ico"),
Script(src="https://unpkg.com/hyperscript.org@0.9.12"),
Link(rel="preconnect", href="https://fonts.googleapis.com"), Link(rel="preconnect", href="https://fonts.gstatic.com", crossorigin=""),
Link(rel="stylesheet", href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono&display=swap"),
Style("body { font-family: 'IBM Plex Sans', sans-serif; } code, pre { font-family: 'IBM Plex Mono', monospace; }"),
sidenote_css, cell_css, AUDIO_CSS, _custom_css, annotation_js, AUDIO_JS, toc_css, mobile_toc_css, _navbar_hide_css, *tts_settings_ui())
app = FastHTML(hdrs=hdrs, debug=True)
app.mount("/public", StaticFiles(directory="public"), name="public")
rt = app.route
# %% ../nbs/01_app.ipynb #5d2235f1
# %% Doubao TTS 代理路由
@rt('/api/tts')
async def tts_proxy(request):
try:
body = await request.json()
except Exception:
return Response('{"error":"invalid JSON body"}', media_type='application/json', status_code=400)
api_key = body.get("api_key", "")
text = body.get("text", "")
speaker = body.get("speaker", "zh_female_vv_uranus_bigtts")
if not api_key:
return Response('{"error":"missing api_key"}', media_type='application/json', status_code=400)
if not text:
return Response('{"error":"missing text"}', media_type='application/json', status_code=400)
headers = {
"X-Api-Key": api_key,
"X-Api-Resource-Id": "seed-tts-2.0",
"X-Api-Request-Id": str(uuid.uuid4()),
"Content-Type": "application/json",
}
speech_rate = body.get("speech_rate", 0)
payload = json.dumps({
"req_params": {
"text": text,
"speaker": speaker,
"audio_params": {"format": "mp3", "sample_rate": 24000, "speech_rate": speech_rate},
}
}).encode()
target = "https://openspeech.bytedance.com/api/v3/tts/unidirectional"
# 优先用 httpx,没有则用 urllib 兜底
try:
import httpx
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(target, headers=headers, content=payload)
return Response(resp.content, media_type="text/plain", status_code=resp.status_code)
except ImportError:
pass
import urllib.request, urllib.error
req = urllib.request.Request(target, data=payload, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return Response(resp.read(), media_type="text/plain", status_code=200)
except urllib.error.HTTPError as e:
return Response(e.read(), media_type="text/plain", status_code=e.code)
except Exception as e:
return Response(json.dumps({"error": f"proxy error: {e}"}), media_type="application/json", status_code=502)
# %% ../nbs/01_app.ipynb #32c6709b
# %% Kokoro TTS 代理路由(英文→Replicate,中文→OpenRouter)
KOKORO_VERSION = 'jaaari/kokoro-82m:f559560eb822dc509045f3921a1921234918b91739db4bf3daab2169b71c7a13'
KOKORO_CHINESE_RE = re.compile(r'[\u4e00-\u9fff]')
@rt('/api/kokoro')
async def kokoro_proxy(request):
try:
body = await request.json()
except Exception:
return Response('{"error":"invalid JSON body"}', media_type='application/json', status_code=400)
text = body.get("text", "")
if not text:
return Response('{"error":"missing text"}', media_type='application/json', status_code=400)
is_chinese = bool(KOKORO_CHINESE_RE.search(text))
try:
import httpx
if is_chinese:
return await _kokoro_openrouter(httpx, body, text)
else:
return await _kokoro_replicate(httpx, body, text)
except ImportError:
pass
# urllib fallback
if is_chinese:
return _kokoro_openrouter_urllib(body, text)
else:
return _kokoro_replicate_urllib(body, text)
async def _kokoro_replicate(client, body, text):
api_key = body.get("replicate_key") or body.get("api_key", "")
voice = body.get("voice", "bf_emma")
speed = float(body.get("speed", 0.9))
if not api_key:
return Response('{"error":"missing replicate_key"}', media_type='application/json', status_code=400)
version_hash = KOKORO_VERSION.split(':')[1]
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "Prefer": "wait"}
payload = {"version": version_hash, "input": {"text": text[:10000], "voice": voice, "speed": speed}}
try:
async with client.AsyncClient(timeout=30) as c:
resp = await c.post("https://api.replicate.com/v1/predictions", headers=headers, json=payload)
if resp.status_code >= 400:
return Response(resp.content, media_type="application/json", status_code=resp.status_code)
result = resp.json()
attempts = 0
while result.get("status") not in ("succeeded", "failed") and attempts < 60:
await asyncio.sleep(1)
pr = await c.get(f"https://api.replicate.com/v1/predictions/{result['id']}",
headers={"Authorization": f"Bearer {api_key}"})
result = pr.json()
attempts += 1
if result.get("status") == "failed":
return Response(json.dumps({"error": result.get("error", "unknown")}),
media_type="application/json", status_code=502)
if result.get("status") != "succeeded":
return Response(json.dumps({"error": "Kokoro timed out"}),
media_type="application/json", status_code=504)
return Response(json.dumps({"output": result.get("output")}), media_type="application/json")
except Exception as e:
import traceback
tb = traceback.format_exc()
print(f"[Kokoro OpenRouter ERROR] {type(e).__name__}: {e!r}")
print(tb)
return Response(json.dumps({"error": f"proxy error: {type(e).__name__}: {e!r}"}),
media_type="application/json", status_code=502)
async def _kokoro_openrouter(client, body, text):
api_key = body.get("openrouter_key", "")
voice = body.get("voice", "zf_xiaoyi")
if not voice.startswith("zf_"):
voice = "zf_xiaoyi"
if not api_key:
return Response('{"error":"missing openrouter_key"}', media_type='application/json', status_code=400)
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
payload = {"model": "hexgrad/kokoro-82m", "input": text[:10000], "voice": voice, "response_format": "mp3"}
import base64
last_err = None
for attempt in range(3):
try:
async with client.AsyncClient(timeout=60) as c:
resp = await c.post("https://openrouter.ai/api/v1/audio/speech", headers=headers, json=payload)
if resp.status_code == 429:
# 速率限制,等 2 秒后重试
await asyncio.sleep(2)
last_err = f"OpenRouter rate limited (429), attempt {attempt+1}"
continue
if resp.status_code >= 400:
return Response(resp.content, media_type="application/json", status_code=resp.status_code)
b64 = base64.b64encode(resp.content).decode()
data_url = f"data:audio/mpeg;base64,{b64}"
return Response(json.dumps({"output": data_url}), media_type="application/json")
except Exception as e:
last_err = f"{type(e).__name__}: {e!r}"
print(f"[Kokoro OpenRouter attempt {attempt+1} ERROR] {last_err}")
if attempt < 2:
await asyncio.sleep(1.5 * (attempt + 1))
import traceback
print(f"[Kokoro OpenRouter all retries failed] {last_err}")
return Response(json.dumps({"error": f"proxy error: {last_err}"}), media_type="application/json", status_code=502)
def _kokoro_replicate_urllib(body, text):
import urllib.request, urllib.error, time
api_key = body.get("replicate_key") or body.get("api_key", "")
voice = body.get("voice", "bf_emma")
speed = float(body.get("speed", 0.9))
if not api_key:
return Response('{"error":"missing replicate_key"}', media_type='application/json', status_code=400)
version_hash = KOKORO_VERSION.split(':')[1]
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "Prefer": "wait"}
payload = json.dumps({"version": version_hash, "input": {"text": text[:10000], "voice": voice, "speed": speed}}).encode()
try:
req = urllib.request.Request("https://api.replicate.com/v1/predictions", data=payload, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=30) as resp:
result = json.loads(resp.read())
attempts = 0
while result.get("status") not in ("succeeded", "failed") and attempts < 60:
time.sleep(1)
pr = urllib.request.Request(f"https://api.replicate.com/v1/predictions/{result['id']}",
headers={"Authorization": f"Bearer {api_key}"})
with urllib.request.urlopen(pr, timeout=10) as r:
result = json.loads(r.read())
attempts += 1
if result.get("status") == "failed":
return Response(json.dumps({"error": result.get("error", "unknown")}), media_type="application/json", status_code=502)
if result.get("status") != "succeeded":
return Response(json.dumps({"error": "Kokoro timed out"}), media_type="application/json", status_code=504)
return Response(json.dumps({"output": result.get("output")}), media_type="application/json")
except urllib.error.HTTPError as e:
return Response(e.read(), media_type="application/json", status_code=e.code)
except Exception as e:
return Response(json.dumps({"error": f"proxy error: {e}"}), media_type="application/json", status_code=502)
def _kokoro_openrouter_urllib(body, text):
import urllib.request, urllib.error, base64
api_key = body.get("openrouter_key", "")
voice = body.get("voice", "zf_xiaoyi")
if not api_key:
return Response('{"error":"missing openrouter_key"}', media_type='application/json', status_code=400)
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
payload = json.dumps({"model": "hexgrad/kokoro-82m", "input": text[:10000], "voice": voice, "response_format": "mp3"}).encode()
try:
req = urllib.request.Request("https://openrouter.ai/api/v1/audio/speech", data=payload, headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=60) as resp:
audio_bytes = resp.read()
b64 = base64.b64encode(audio_bytes).decode()
return Response(json.dumps({"output": f"data:audio/mpeg;base64,{b64}"}), media_type="application/json")
except urllib.error.HTTPError as e:
return Response(e.read(), media_type="application/json", status_code=e.code)
except Exception as e:
return Response(json.dumps({"error": f"proxy error: {e}"}), media_type="application/json", status_code=502)
# %% ../nbs/01_app.ipynb #2088b609
def theme_toggle():
return Button(UkIcon("moon", cls="dark:hidden"), UkIcon("sun", cls="hidden dark:block"),
_="""on click
toggle .dark on <html/>
set franken to (localStorage's __FRANKEN__ or '{}') as Object
if the first <html/> matches .dark set franken's mode to 'dark' else set franken's mode to 'light' end
set localStorage's __FRANKEN__ to franken as JSON""",
cls="p-1 hover:scale-110 shadow-none", type="button")
# %% ../nbs/01_app.ipynb #28164a2b
def x_icon(): return Svg(ft_hx("path", d="M12.6.75h2.454l-5.36 6.142L16 15.25h-4.937l-3.867-5.07-4.425 5.07H.316l5.733-6.57L0 .75h5.063l3.495 4.633L12.601.75Zm-.86 13.028h1.36L4.323 2.145H2.865z"), width=20, height=20, fill="currentColor", viewBox="0 0 16 16", aria_hidden="true")
# %% ../nbs/01_app.ipynb #5a14c5bb
def social_link(k, v):
ext = dict(rel="nofollow noindex") if k == "mail" else {} if k == "rss" else dict(target="_blank", rel="noopener noreferrer")
return A(x_icon() if k == "twitter" else UkIcon(k, width=20, height=20), href=v, aria_label=k.title(), cls="hover:text-primary transition-colors", **ext)
# %% ../nbs/01_app.ipynb #a9e6b9d9
_social_urls = dict(github='https://github.com/{}', twitter='https://x.com/{}', linkedin='https://www.linkedin.com/in/{}/')
socials = {k: _social_urls[k].format(cfg[k]) for k in _social_urls if cfg.get(k)}
socials['rss'] = '/rss.xml'
if cfg.get('email'): socials['mail'] = '/contact'
ftr_content = Div(*[social_link(k,v) for k,v in socials.items()], cls="flex justify-center gap-6 text-muted-foreground")
# %% ../nbs/01_app.ipynb #607a4561
def read(p): return nb2md(p) if p.suffix=='.ipynb' else frontmatter(p.read_text())
# %% ../nbs/01_app.ipynb #b3bd4ebc
pages = {p.stem.split('_',1)[1]: read(p) for p in Path('public/pages').ls(file_exts=['.md','.ipynb']).sorted()}
# %% ../nbs/01_app.ipynb #1c8779ee
def hx_attrs(url, target="#main-content"): return dict(hx_get=url, hx_target=target, hx_push_url="true", hx_swap="innerHTML show:window:top")
def hx_link(txt, href, cls="text-primary underline", target="#main-content", **kw): return A(txt, cls=cls, **hx_attrs(href, target), **kw)
# %% ../nbs/01_app.ipynb #3a832a32
def navbar():
menu_id,btn_id = f"menu-{unqid()}",f"btn-{unqid()}"
search_id = f"search-{unqid()}"
results_id = f"results-{unqid()}"
brand = A(Img(src="/public/nav.png", alt=cfg.name, cls="w-6 h-6 rounded-full"), Span(cfg.name), href="/", cls="flex items-center gap-2 text-lg font-bold", **hx_attrs("/"))
def navlinks(_=None): return [hx_link("Blog", "/blog", cls="hover:scale-110", _=_), *[hx_link(s.title(), f"/{s}", cls="hover:scale-110", _=_) for s in pages]]
hamburger = Button(UkIcon("menu", width=30, height=30), cls="p-0 border-0 shadow-none", _=f"on click toggle .hidden on #{menu_id}", type="button", id=btn_id)
search_container = Div(cls="search-container")(
Label(UkIcon("search", width=20, height=20), cls="search-icon md:hidden",
_for=search_id),
Input(placeholder="Search posts...", id=search_id,
cls="search-input w-40 text-sm px-3 py-1.5",
oninput=f"doSearch(this.value, '{results_id}')"),
Button("✕", cls="search-clear md:hidden", type="button",
onclick=f"this.previousElementSibling.value=''; doSearch('', '{results_id}')")
)
search_results = Div(id=results_id, cls="search-results absolute top-full left-0 right-0 max-h-96 overflow-y-auto hidden z-50")
search_js = NotStr(fr"""
<script>
const search_data = {search_index};
document.addEventListener('click', (e) => {{
const results = document.querySelector('.search-results');
if (results.classList.contains('hidden')) return;
if (e.target.closest('.search-container') || e.target.closest('.search-results')) return;
results.classList.add('hidden');
}});
document.addEventListener('keydown', (e) => {{
if (e.key === 'Escape') {{
document.querySelector('.search-results').classList.add('hidden');
}}
}});
function matchQuery(title, q) {{
const words = q.trim().split(/\s+/).filter(Boolean);
return words.every(w => {{
const tl = title.toLowerCase(), wl = w.toLowerCase();
if (tl.includes(wl)) return true;
let si = 0;
for (let c of wl)
if ((si = tl.indexOf(c, si) + 1) === 0) return false;
return true;
}});
}}
function highlight(title, q) {{
if (!q) return title;
const words = q.trim().split(/\s+/).filter(Boolean);
const tl = title.toLowerCase();
const exact = new Array(title.length).fill(false);
const fuzzy = new Array(title.length).fill(false);
for (const w of words) {{
const wl = w.toLowerCase();
// Step 1: exact substring? → PINK
const idx = tl.indexOf(wl);
if (idx !== -1) {{
for (let i = idx; i < idx + wl.length; i++) exact[i] = true;
}} else {{
// Step 2: fuzzy within a single word? → YELLOW
const textWords = tl.split(/\s+/);
let found = false;
for (const tw of textWords) {{
let si = 0, first = -1, last = -1;
for (let j = 0; j < tw.length; j++) {{
if (si < wl.length && tw[j] === wl[si]) {{
if (first === -1) first = j; last = j; si++;
}}
}}
if (si === wl.length) {{
// Found! Map back to title positions
const wordStart = tl.indexOf(tw);
for (let j = first; j <= last; j++) {{
if (tw[j - first] && wl.includes(tw[j]) && !exact[wordStart + j]) fuzzy[wordStart + j] = true;
}}
found = true; break;
}}
}}
}}
}}
let result = '';
for (let i = 0; i < title.length; i++) {{
if (exact[i]) result += `<span class="search-highlight-exact">${{title[i]}}</span>`;
else if (fuzzy[i]) result += `<span class="search-highlight">${{title[i]}}</span>`;
else result += title[i];
}}
return result;
}}
function doSearch(q, resultsId) {{
const el = document.getElementById(resultsId);
if (!q.trim()) {{ el.classList.add('hidden'); el.innerHTML=''; return; }}
const matched = search_data.filter(c =>
matchQuery(c.title, q) ||
(c.summary && matchQuery(c.summary, q)) ||
(c.headings && c.headings.some(h => matchQuery(h.text, q)))
);
const words = q.trim().split(/\s+/).filter(Boolean);
const enriched = matched.map(c => ({{
...c,
titleMatch: matchQuery(c.title, q),
summaryMatch: c.summary ? matchQuery(c.summary, q) : false,
headingMatches: c.headings
? c.headings.filter(h => {{
const hWords = h.text.toLowerCase().split(/\s+/);
return words.some(w => {{
const wl = w.toLowerCase();
return hWords.some(hWord => {{
if (hWord.includes(wl)) return true;
let si = 0;
for (const ch of wl) if ((si = hWord.indexOf(ch, si) + 1) === 0) return false;
return true;
}});
}});
}})
: []
}}));
console.log(enriched)
function scorePost(c, q) {{
const ql = q.toLowerCase();
const words = q.trim().split(/\s+/).filter(Boolean);
// Tier 1: full phrase as continuous substring
if (c.title && c.title.toLowerCase().includes(ql)) return 1000;
if (c.summary && c.summary.toLowerCase().includes(ql)) return 950;
if (c.headings && c.headings.some(h => h.text.toLowerCase().includes(ql))) return 900;
// Tier 2: each word exact independently
let exactCount = 0, exactScore = 0;
for (const w of words) {{
const wl = w.toLowerCase();
if (c.title && c.title.toLowerCase().includes(wl)) {{ exactCount++; exactScore += 10; }}
else if (c.summary && c.summary.toLowerCase().includes(wl)) {{ exactCount++; exactScore += 5; }}
else if (c.headings && c.headings.some(h => h.text.toLowerCase().includes(wl))) {{ exactCount++; exactScore += 3; }}
}}
if (words.length >= 2 && exactCount === words.length) return 100 + exactScore;
if (exactCount > 0) return 50 + exactScore;
// Tier 3: fuzzy
let fuzzyCount = 0;
const fields = [c.title || '', c.summary || '',
...(c.headings || []).map(h => h.text)].join(' ').toLowerCase();
for (const w of words) {{
const wl = w.toLowerCase();
for (const tw of fields.split(/\s+/)) {{
let si = 0;
for (let j = 0; j < tw.length && si < wl.length; j++) {{
if (tw[j] === wl[si]) si++;
}}
if (si === wl.length) {{ fuzzyCount++; console.log(` "${{w}}" matched word "${{tw}}"`); break; }}
}}
}}
if (fuzzyCount > 0) console.log(`Fuzzy: ${{c.title}} — ${{fuzzyCount}} match(es), score=${{10 + 5 * fuzzyCount}}`);
return fuzzyCount > 0 ? 10 + 5 * fuzzyCount : 0;
}}
if (!matched.length) {{ el.innerHTML = '<div class="p-3 text-sm text-muted-foreground">No posts found</div>'; el.classList.remove('hidden'); return; }}
// ── INSERT HERE (after early return, before el.innerHTML) ──
enriched.sort((a, b) => scorePost(b, q) - scorePost(a, q));
console.log(enriched.map(c => ({{title: c.title, score: scorePost(c, q)}})))
const visible = enriched.filter(c => scorePost(c, q) > 0);
el.innerHTML = visible.map(c => {{
const titleHtml = c.titleMatch
? `<a href="/blog/${{c.slug}}" hx-get="/blog/${{c.slug}}" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML show:window:top" onclick="document.getElementById('${{resultsId}}').classList.add('hidden')" class="search-result-item block p-3 cursor-pointer text-sm"><span class="font-medium">${{highlight(c.title, q)}}</span></a>`
: `<div class="search-result-item block p-3 text-sm"><span class="font-medium">${{highlight(c.title, q)}}</span></div>`;
const headingHtml = c.headingMatches.length
? `<ul class="list-disc pl-6 mt-1 space-y-0.5">${{c.headingMatches.map(h =>
`<li><a href="/blog/${{c.slug}}#${{h.slug}}" hx-get="/blog/${{c.slug}}#${{h.slug}}" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML" onclick="document.getElementById('${{resultsId}}').classList.add('hidden'); setTimeout(() => document.getElementById('${{h.slug}}')?.scrollIntoView({{behavior:'smooth'}}), 150)" class="text-sm cursor-pointer">${{highlight(h.text, q)}}</a></li>`
).join('')}}</ul>`
: '';
const summaryHtml = c.summary
? c.summaryMatch
? `<a href="/blog/${{c.slug}}" hx-get="/blog/${{c.slug}}" hx-target="#main-content" hx-push-url="true" hx-swap="innerHTML show:window:top" onclick="document.getElementById('${{resultsId}}').classList.add('hidden')" class="block text-xs text-muted-foreground pl-3 mt-0.5 mb-1 hover:underline">${{highlight(c.summary, q)}}</a>`
: `<div class="text-xs text-muted-foreground pl-3 mt-0.5 mb-1">${{highlight(c.summary, q)}}</div>`
: '';
// Line 115 (updated return):
return titleHtml + summaryHtml + headingHtml;
}}).join('');
el.classList.remove('hidden');
htmx.process(el);
}}
</script>""")
return Nav(id="site-navbar", cls="border rounded-lg shadow backdrop-blur-md bg-background/98 relative transition-transform duration-300 ease-out")(
Div(cls="search-nav-row flex items-center p-4 gap-4")(
brand, search_container,
Div(*navlinks(), theme_toggle(), cls="hidden md:flex items-center space-x-4 ml-auto"),
Div(theme_toggle(), hamburger, cls="flex md:hidden items-center gap-4 ml-auto"),
),
Div(*navlinks(_=f"on click add .hidden to #{menu_id}"), cls="flex flex-col space-y-4 p-4 text-center hidden", id=menu_id, _=f"on click elsewhere if event.target.closest('#{btn_id}') is null add .hidden to me"),
search_results, search_js
)
# %% ../nbs/01_app.ipynb #4c65aa23
def layout(*content, htmx, title=None, wide=False):
w = 'max-w-5xl' if wide else 'max-w-2xl'
main = Main(*content, cls=f'w-full {w} mx-auto px-6 py-8 space-y-8', id="main-content")
if htmx and htmx.request:
main.attrs['hx-swap-oob'] = 'true'
return (Title(title), *content, main)
ctr_cls = 'w-full max-w-2xl mx-auto'
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,
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(
P("Subscribe via ", A("RSS", href="/rss.xml", cls="text-primary underline"), " or enter your email to get notified of new posts directly in your inbox", cls="text-sm text-muted-foreground mb-2"),
Form(Input(type="email", name="email", placeholder="your@email.com", required=True, cls="flex-1 rounded-l-md"), Button("Subscribe", cls=(ButtonT.primary, "rounded-l-none rounded-r-md")), cls="flex", hx_post="/subscribe", hx_swap="outerHTML"),
cls="mt-6")
# %% ../nbs/01_app.ipynb #78b5b275
class Post:
def __init__(self, path):
self.path,self.slug = (p := Path(path)),p.stem
self.meta, self.content = read(p)
self.title,self.excerpt,self.tags = self.meta['title'],self.meta.get('excerpt',''),L(self.meta.get('tags', []))
self.date = d if isinstance(d:=self.meta['date'], date) else parse_date(str(d)).date()
self.datestr = self.date.strftime('%d %b %Y')
self.external_url = self.meta.get('external_url')
self.unlisted = self.meta.get('unlisted', False)
self.img_dir = '/'+str(self.path.parent)
# %% ../nbs/01_app.ipynb #45b855d2
posts = {p.slug: p for p in L(Path('public/posts').rglob('*')).filter(lambda p: p.suffix in ('.md','.ipynb') and not any(part.startswith('.') for part in p.parts)).map(Post).sorted(key=lambda p: p.date, reverse=True)}
# %% ../nbs/01_app.ipynb #764a3eac
def build_search_index():
"Build a JSON-serializable search index of all post titles and slugs, with heading data"
idx = []
for p in posts.values():
if p.unlisted: continue
entry = {"title": p.title, "slug": p.slug, "summary": p.content.split('<!--INTRO-->')[0].strip()}
_, hdrs = extract_headings(p.content)
# Only include h2+ headings (skip h1 which is the title)
entry["headings"] = [{"text": text, "slug": slug}
for level, text, slug in hdrs if level >= 2]
idx.append(entry)
return idx
# %% ../nbs/01_app.ipynb #f6413e00
search_index = json.dumps(build_search_index())
# %% ../nbs/01_app.ipynb #56666803
_pill_cls = "text-xs px-2 py-1 rounded border transition-all"
def tag_label(tag): return Span(tag, cls=f"{_pill_cls} bg-muted")
def tag_link(tag, target="#main-content"): return A(tag, cls=f"{_pill_cls} bg-muted hover:bg-primary/20", **hx_attrs(f"/blog?tags={quote(tag)}", target))
def tag_toggle(tag, selected, avail=None):
new = ','.join(quote(t) for t in selected ^ {tag})
hx = hx_attrs(f"/blog?tags={new}" if new else "/blog", "#posts-list")
if tag in selected: return A(tag, cls=f"{_pill_cls} bg-primary cursor-pointer text-primary-foreground", **hx)
if avail and tag in avail: return A(tag, cls=f"{_pill_cls} cursor-pointer bg-muted hover:bg-primary/20", **hx)
return Span(tag, cls=f"{_pill_cls} opacity-30 cursor-not-allowed")
# %% ../nbs/01_app.ipynb #0ea3b326
def tag_filter(selected, all_posts, filtered):
counts = Counter(all_posts.attrgot('tags').concat())
if not counts: return Div(id="tag-filter")
avail = set(filtered.attrgot('tags').concat())
tags = sorted(counts, key=lambda t: (-counts[t], t))
btns = [tag_toggle(t, selected, avail) for t in tags]
if selected: btns.append(ft_hx("button", UkIcon("x", width=12, height=12), "Clear",
cls=f"{_pill_cls} flex items-center gap-1 cursor-pointer hover:shadow-sm", **hx_attrs("/blog", "#posts-list")))
return Div(Span("Filter:", cls="text-sm font-medium mr-1"), *btns, cls="pb-4 flex flex-wrap items-center gap-2", id="tag-filter")
# %% ../nbs/01_app.ipynb #705d3e5f
def post_intro(content):
if '<!--INTRO-->' in content: return content.split('<!--INTRO-->', 1)[0].rstrip()
m = re.search(r'^##\s', content, re.MULTILINE)
return content[:m.start()].rstrip() if m else content
# %% ../nbs/01_app.ipynb #0b615cea
def post_preview(p):
intro, url = post_intro(p.content), blogpost.to(slug=p.slug)
return Div(
Div(Span(cls="flex-1 border-t border-muted"), Span(p.datestr, cls="text-sm text-muted-foreground px-4"), Span(cls="flex-1 border-t border-muted"), cls="flex items-center my-8"),
H2(hx_link(p.title, url, cls="font-bold")), from_md(intro, img_dir=p.img_dir),
Div(hx_link("↪ Keep reading", url, cls="text-sm text-primary hover:underline") if intro != p.content else Span(),
Div(*p.tags.map(partial(tag_link, target="#posts-list")), cls="flex gap-2 flex-wrap"), cls="flex justify-between items-center mt-4"))
# %% ../nbs/01_app.ipynb #b4e04e74
def post_article(p, slug):
content, headings = extract_headings(p.content)
tags = Div(*p.tags.map(tag_link), cls="flex gap-2 flex-wrap") if p.tags else None
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 justify-end")
# %% ../nbs/01_app.ipynb #77ee304d
def render_article_sections(p, slug):
"Render a blog post with per-section audio controls"
fm, sections = nb2md_sections(p.path)
content, headings = extract_headings(p.content)
tags = Div(*p.tags.map(tag_link), cls="flex gap-2 flex-wrap") if p.tags else None
toc = toc_sidebar(headings)
mobile_toc = _mobile_toc(headings)
playall_bar = Div(
Span("▶", cls="play-btn", onclick="togglePlayAll(this)"),
Span("⏸", cls="pause-btn", onclick="pauseAll(this)"),
Span("⏹", cls="stop-btn", onclick="stopAll(this)"),
Span("Play All", cls="ml-2 text-sm"),
Input(type="range", cls="tts-progress ml-2", min="0", max="100", value="0", step="0.1",
oninput="seekTts(this.value)"),
# TTS 引擎切换按钮
Span("🔊 Browser", cls="tts-provider-btn ml-auto text-xs px-2 py-1 rounded cursor-pointer bg-background border border-border hover:border-primary transition-colors select-none",
onclick="toggleTTSProvider(this)", title="点击切换 TTS 引擎"),
cls="audio-controls audio-playall flex items-center gap-2 mb-4 p-2 bg-muted rounded"
)
note_blocks = []
for cid, text, ctype in sections:
if ctype == 'note':
text_with_ids, _ = extract_headings(text)
rendered = from_md(text_with_ids, img_dir=p.img_dir)
controls = Div(
Span("▶", cls="play-btn", onclick=f"togglePlay(this,'{cid}')"),
Span("⏸", cls="pause-btn", onclick=f"pausePlayback()"),
Span("⏹", cls="stop-btn", onclick=f"stopPlayback()"),
Input(type="range", cls="tts-progress", min="0", max="100", value="0", step="0.1",
oninput="seekTts(this.value)"),
cls="audio-controls flex items-center gap-1"
)
h_match = re.match(r'^(#{1,6})\s+(.+)$', text.strip().split('\n')[0])
if h_match and hasattr(rendered, 'children') and rendered.children:
heading = rendered.children[0]
rest = rendered.children[1:]
note_blocks.append(
Div(
heading,
controls,
*rest,
cls="note-block", **{'data-cell-id': cid, 'data-text': text}
)
)
else:
note_blocks.append(
Div(controls, rendered, cls="note-block",
**{'data-cell-id': cid, 'data-text': text})
)
else:
note_blocks.append(NotStr(text))
first_section, *rest = note_blocks if note_blocks else ['']
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, playall_bar, first_section, *rest, cls="space-y-4"),
cls="max-w-2xl min-w-0 w-full"
)
return Div(article, toc, cls="flex gap-8 items-start justify-end")
# %% ../nbs/01_app.ipynb #7e30f617
@rt
def index(): return RedirectResponse('/blog', status_code=307)
# %% ../nbs/01_app.ipynb #1f1a91df
_scrollspy_script = Script("""
(function() {
if (window.__scrollspyInit) return;
window.__scrollspyInit = true;
let currentObserver = null;
function initScrollspy() {
const links = document.querySelectorAll('.toc-link');
if (!links.length) return;
const ids = [...links].map(l => l.getAttribute('href').slice(1));
const headings = ids.map(id => document.getElementById(id)).filter(Boolean);
if (currentObserver) currentObserver.disconnect();
currentObserver = new IntersectionObserver(entries => {
entries.forEach(e => {
if (e.isIntersecting) {
document.querySelectorAll('.toc-link.active').forEach(l => l.classList.remove('active'));
const matches = document.querySelectorAll('.toc-link[href="#' + e.target.id + '"]');
matches.forEach(a => { a.classList.add('active'); a.scrollIntoView({block: 'nearest'}); });
}
});
}, {rootMargin: '-20% 0px -70% 0px'});
headings.forEach(h => { if (document.querySelector('.toc-link[href="#' + h.id + '"]')) currentObserver.observe(h); });
links.forEach(l => l.addEventListener('click', () => {
links.forEach(x => x.classList.remove('active'));
l.classList.add('active');
}));
}
document.addEventListener('DOMContentLoaded', initScrollspy);
document.body.addEventListener('htmx:afterSwap', initScrollspy);
})();
""")
# %% ../nbs/01_app.ipynb #d067cb56
_navbar_autohide_script = Script("""
(function() {
if (window.__navbarAutohideInit) return;
window.__navbarAutohideInit = true;
const navbar = document.getElementById('site-navbar');
if (!navbar) return;
let lastY = window.scrollY;
let ticking = false;
let lastToggle = 0;
const THRESHOLD = 20; // 触发切换的最小滚动距离(原 8,提高到 20)
const COOLDOWN = 250; // 两次切换之间的最小间隔(ms),覆盖 CSS 过渡
const TOP_ZONE = 60; // 距顶部此距离内始终显示
function onScroll() {
const currentY = window.scrollY;
const now = performance.now();
const diff = currentY - lastY;
if (currentY < TOP_ZONE) {
// 顶部区域:始终显示,并刷新基准点
navbar.classList.remove('hidden');
lastY = currentY;
} else if (now - lastToggle > COOLDOWN) {
// 冷却结束后才允许切换
if (diff > THRESHOLD) {
navbar.classList.add('hidden');
lastY = currentY; // ← 只在状态切换时更新基准
lastToggle = now;
} else if (diff < -THRESHOLD) {
navbar.classList.remove('hidden');
lastY = currentY; // ← 只在状态切换时更新基准
lastToggle = now;
}
// 未过阈值:lastY 不变,滚动距离持续累积到阈值
}
// 冷却中:什么都不做,lastY 也不变
ticking = false;
}
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(onScroll);
ticking = true;
}
}, { passive: true });
})();
""")
# %% ../nbs/01_app.ipynb #694a3b5e
@rt
def blog(htmx, tags:str=None):
selected = {unquote(t.strip()) for t in (tags or '').split(',') if t.strip()}
all_posts = L(posts.values()).filter(lambda p: not p.unlisted)
filtered = all_posts.filter(lambda p: selected <= set(p.tags))
posts_content = Div(*filtered.map(post_preview)) if filtered else Div(P("No posts found matching those tags.", cls="text-muted-foreground"), cls="py-8 text-center")
posts_div = Div(posts_content, id="posts-list")
tag_filt = tag_filter(selected, all_posts, filtered)
if htmx and htmx.target == "posts-list":
tag_filt.attrs['hx-swap-oob'] = 'true'
return posts_div, tag_filt
return layout(H2(cfg.description or "Blog"), tag_filt, posts_div, title=f"{cfg.name} - Blog", htmx=htmx), _scrollspy_script, _navbar_autohide_script
# %% ../nbs/01_app.ipynb #36235871
@rt('/blog/{slug}')
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})
if p.meta.get('audio', True):
content = render_article_sections(p, slug)
else:
content = post_article(p, slug)
flag = 'window.__annotationEnabled=true;' if p.meta.get('annotation', True) else 'window.__annotationEnabled=false;'
_, headings = extract_headings(p.content)
return layout(content, title=f"{cfg.name} - {p.title}", htmx=htmx, wide=bool(headings)), Script(flag), _scrollspy_script, _navbar_autohide_script
# %% ../nbs/01_app.ipynb #b065211e
@rt
def subscribe(email:str):
if list(subscribers.rows_where("email = ?", [email], limit=1)): return P("You're already subscribed!", cls="text-sm text-muted-foreground mt-6")
subscribers.insert(Subscriber(email=email, created_at=datetime.now().isoformat()))
return P("Thanks for subscribing!", cls="text-sm text-green-600 mt-6")
# %% ../nbs/01_app.ipynb #c64d78d5
@rt('/rss.xml')
def rss_feed():
ps = list(posts.values())
def item(p): return f"<item><title>{p.title}</title><link>{cfg.dom}/blog/{p.slug}</link><guid>{cfg.dom}/blog/{p.slug}</guid><pubDate>{p.date.strftime('%a, %d %b %Y %H:%M:%S +0000')}</pubDate><description><![CDATA[{p.excerpt}]]></description>{''.join(f'<category>{t}</category>' for t in p.tags)}</item>"
items = ''.join(item(p) for p in ps if not p.external_url)
rss = f'<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>{cfg.name}</title><link>{cfg.dom}</link><description>{cfg.description}</description><language>en-us</language><lastBuildDate>{ps[0].date.strftime("%a, %d %b %Y %H:%M:%S +0000") if ps else ""}</lastBuildDate><atom:link href="{cfg.dom}/rss.xml" rel="self" type="application/rss+xml"/>{items}</channel></rss>'
return Response(rss, media_type="application/rss+xml")
# %% ../nbs/01_app.ipynb #32a1dad7
if cfg.get('email'):
@rt
def contact(): return RedirectResponse(f"mailto:{cfg.email}", status_code=302)
# %% ../nbs/01_app.ipynb #e58fbb9e
for slug in pages:
@rt(f'/{slug}')
def page(htmx, slug:str=slug):
title = slug.replace('-',' ').title()
return layout(H2(title), from_md(pages[slug][1], img_dir='/public/pages'),
title=f"{cfg.name} - {title}", htmx=htmx)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment