a55ba185a8
Blocker fixes for the image cache:
- /api/img/{id} now serves cache HITS ONLY and is restricted to ACCEPTED, CANONICAL
articles. It never fetches — the cycle (newsimg.warm) owns all fetching — so the
public endpoint has no SSRF/worker-exhaustion surface. Dropped 1-year immutable
caching (image_url can change) → public, max-age=86400.
- newsimg._safe_fetch: SSRF-safe (reuses enrich._host_is_public + _NoRedirect, http(s)
only, every redirect hop re-validated, body capped). _FetchError distinguishes
permanent refusals (negative-cached via a .fail marker) from transient errors (retry).
- _encode re-encodes only decoded RASTER images to WebP and REJECTS everything else
(SVG, undecodable, decompression bombs via MAX_IMAGE_PIXELS, pathological dimensions);
originals are never retained. prune() also sweeps stale .fail markers.
- Concurrency: fetching only runs inside the cycle lock; writes stay atomic.
Smaller fixes:
- share.py visible image has onerror→this.remove() (degrade to the text unfurl, no
broken icon when an image isn't cached yet).
- share-page Back follows history only on a SAME-ORIGIN referrer (never bounce to an
external site); menu now honors Escape + resets crossing back to desktop (HubBar parity).
Tests: private host, redirect-to-private, hostile SVG/non-image, transient-vs-permanent
failure, LRU prune, warm (accepted+canonical only, idempotent), cache-only endpoint
(404 on not-cached/unaccepted/duplicate, never fetches), share chrome parity. 441 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
463 lines
23 KiB
Python
463 lines
23 KiB
Python
"""Server-rendered share/landing page for /a/<id>.
|
||
|
||
A *pointer* page (never a republished article): the story's own title, "why it's
|
||
uplifting" note, and image, wrapped with rich OpenGraph/Twitter meta and a
|
||
prominent link to the original source. Social scrapers don't run JS, so this is
|
||
plain server-rendered HTML. All dynamic values are HTML-escaped.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from html import escape
|
||
|
||
|
||
def _tag(name: str, content: str | None, attr: str = "property") -> str:
|
||
if not content:
|
||
return ""
|
||
return f'<meta {attr}="{escape(name)}" content="{escape(content)}">'
|
||
|
||
|
||
# --- Shared top bar -----------------------------------------------------------------
|
||
# A static replica of the SPA's HubBar so server-rendered share pages (/a/<id>, the
|
||
# digest) carry the SAME toolbar as the rest of the site. These pages can't run the
|
||
# Svelte component, so this is kept in sync with frontend/src/lib/components/HubBar.svelte
|
||
# (+ HubShell's borderless Back) BY HAND — change both together. `active` highlights a
|
||
# section ('' = none, as on an article). The account glyph is the signed-out state;
|
||
# _TOP_BAR_JS swaps in the cached avatar for signed-in readers, just like HubBar.
|
||
_TOP_NAV = (("/", "Home", "home"), ("/news", "News", "news"),
|
||
("/play", "Games", "games"), ("/art", "Art", "art"))
|
||
|
||
|
||
def _nav_links(active: str) -> str:
|
||
return "".join(f'<a class="{"on" if k == active else ""}" href="{href}">{label}</a>'
|
||
for href, label, k in _TOP_NAV)
|
||
|
||
|
||
def _top_bar_html(active: str = "") -> str:
|
||
return (
|
||
'<header class="bar">'
|
||
'<a class="brand" href="/" aria-label="upbeatBytes home"><img src="/logo.svg" alt="upbeatBytes"></a>'
|
||
'<div class="bar-end">'
|
||
f'<nav class="nav">{_nav_links(active)}<span class="nav-soon">Entertainment</span></nav>'
|
||
'<a class="acct" href="/account" aria-label="Your account">'
|
||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#C98A2E" stroke-width="2" aria-hidden="true">'
|
||
'<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 4-6 8-6s8 2 8 6"/></svg></a>'
|
||
'<button class="burger" type="button" aria-label="Menu" aria-expanded="false" data-burger>'
|
||
'<span></span><span></span><span></span></button>'
|
||
'</div></header>'
|
||
f'<div class="menu-wrap" data-menu hidden><nav class="menu">{_nav_links(active)}'
|
||
'<span class="menu-soon">Entertainment <em>soon</em></span></nav></div>'
|
||
)
|
||
|
||
|
||
def _back_link_html(label: str = "Back") -> str:
|
||
return ('<button class="hb-back" type="button" data-back aria-label="Go back">'
|
||
'<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" '
|
||
'stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
|
||
f'<path d="M15 18l-6-6 6-6"/></svg>{label}</button>')
|
||
|
||
|
||
# Ported verbatim from HubBar.svelte's <style> (+ HubShell's .back), scoped to the same
|
||
# class names so the bar looks pixel-identical to the SPA regardless of page palette.
|
||
_TOP_BAR_CSS = """
|
||
/* reserve the scrollbar gutter so the bar doesn't jump left-right between pages */
|
||
html { scrollbar-gutter: stable; }
|
||
@font-face { font-family:'Hanken Grotesk'; src:url('/fonts/hanken-var.woff2') format('woff2'); font-weight:400 700; font-style:normal; font-display:swap; }
|
||
header.bar { display:flex; align-items:center; justify-content:space-between; max-width:1180px; width:100%; margin:0 auto; box-sizing:border-box; padding:26px clamp(18px,5vw,44px) 0; font-family:'Hanken Grotesk',ui-sans-serif,system-ui,sans-serif; background:none; border:none; }
|
||
header.bar .brand { display:block; line-height:0; }
|
||
header.bar .brand img { height:48px; width:auto; display:block; }
|
||
.bar-end { display:flex; align-items:center; gap:clamp(16px,2.4vw,32px); }
|
||
.nav { display:flex; align-items:center; gap:clamp(16px,2.4vw,32px); font-size:16.5px; font-weight:500; }
|
||
.nav a { color:#6b6256; text-decoration:none; }
|
||
.nav a.on { color:#23201b; }
|
||
.nav a:hover { color:#0083ad; }
|
||
.nav-soon { color:#b3a890; }
|
||
.acct { width:32px; height:32px; border-radius:50%; border:1.5px solid #e6c9a0; background:#FCEFD7; display:flex; align-items:center; justify-content:center; flex:none; text-decoration:none; }
|
||
.acct.hasimg { background:none; overflow:hidden; padding:0; }
|
||
.acct:hover { background:#fbe6c4; }
|
||
.burger { display:none; flex-direction:column; align-items:center; justify-content:center; gap:4px; width:40px; height:40px; border-radius:11px; border:1.5px solid #e6c9a0; background:#FCEFD7; cursor:pointer; padding:0; flex:none; }
|
||
.burger:hover { background:#fbe6c4; }
|
||
.burger span { width:18px; height:2px; border-radius:2px; background:#7a6a52; transition:transform .2s ease, opacity .15s ease; }
|
||
.burger.open span:nth-child(1) { transform:translateY(6px) rotate(45deg); }
|
||
.burger.open span:nth-child(2) { opacity:0; }
|
||
.burger.open span:nth-child(3) { transform:translateY(-6px) rotate(-45deg); }
|
||
.menu-wrap { max-width:1180px; width:100%; margin:10px auto 0; box-sizing:border-box; padding:0 clamp(18px,5vw,44px); font-family:'Hanken Grotesk',ui-sans-serif,system-ui,sans-serif; }
|
||
.menu { display:flex; flex-direction:column; background:#fff; border:1px solid #f2e7d3; border-radius:14px; overflow:hidden; box-shadow:0 14px 34px -20px rgba(60,50,30,.4); }
|
||
.menu a, .menu .menu-soon { padding:14px 18px; font-size:16px; font-weight:500; text-decoration:none; color:#6b6256; border-top:1px solid #f3ece0; }
|
||
.menu a:first-child { border-top:none; }
|
||
.menu a.on { color:#23201b; }
|
||
.menu a:hover { background:#FFF9EF; color:#0083ad; }
|
||
.menu-soon { display:flex; align-items:center; justify-content:space-between; color:#b3a890; }
|
||
.menu-soon em { font-style:normal; font-size:10px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:#c3b69c; }
|
||
.hb-back { display:inline-flex; align-items:center; gap:6px; margin:0 0 clamp(14px,3vw,24px); background:none; border:none; cursor:pointer; padding:6px 10px 6px 0; font-family:'Hanken Grotesk',ui-sans-serif,system-ui,sans-serif; font-size:14px; font-weight:600; color:#6b6256; transition:color .15s ease; }
|
||
.hb-back:hover { color:#0083ad; }
|
||
.hb-back svg { transition:transform .15s ease; }
|
||
.hb-back:hover svg { transform:translateX(-2px); }
|
||
@media (max-width:720px) { .nav { display:none; } .burger { display:flex; } }
|
||
@media (min-width:721px) { .menu-wrap { display:none !important; } }
|
||
"""
|
||
|
||
# Burger toggle + signed-in avatar (read from the SPA's localStorage cache, same as HubBar).
|
||
# Parity with HubBar: Escape closes the menu, and crossing back to desktop width resets it.
|
||
_TOP_BAR_JS = """<script>
|
||
(function(){
|
||
var b=document.querySelector('[data-burger]'), m=document.querySelector('[data-menu]');
|
||
function close(){ if(m) m.setAttribute('hidden',''); if(b){ b.classList.remove('open'); b.setAttribute('aria-expanded','false'); } }
|
||
if(b&&m){ b.addEventListener('click',function(){
|
||
if(m.hasAttribute('hidden')){ m.removeAttribute('hidden'); b.classList.add('open'); b.setAttribute('aria-expanded','true'); }
|
||
else { close(); }
|
||
}); }
|
||
document.addEventListener('keydown',function(e){ if(e.key==='Escape') close(); });
|
||
if(window.matchMedia){ var mq=window.matchMedia('(min-width:721px)'); if(mq.addEventListener) mq.addEventListener('change',function(e){ if(e.matches) close(); }); }
|
||
try{
|
||
var u=JSON.parse(localStorage.getItem('goodnews:auth_user')||'null');
|
||
if(u&&u.avatar_url){
|
||
var a=document.querySelector('.acct');
|
||
if(a){ var img=document.createElement('img'); img.src=u.avatar_url; img.alt=''; img.referrerPolicy='no-referrer';
|
||
img.style.cssText='width:32px;height:32px;border-radius:999px;object-fit:cover';
|
||
a.classList.add('hasimg'); a.textContent=''; a.appendChild(img); }
|
||
}
|
||
}catch(e){}
|
||
})();
|
||
</script>"""
|
||
|
||
# Single-history Back (mirrors HubShell): go back ONLY when we arrived from our own origin,
|
||
# else go home — never bounce the reader off to an external referrer.
|
||
_BACK_JS = """<script>
|
||
(function(){
|
||
var b=document.querySelector('[data-back]'); if(!b) return;
|
||
b.addEventListener('click',function(){
|
||
var same=false; try{ same=!!document.referrer && new URL(document.referrer).origin===location.origin; }catch(e){}
|
||
if(same && history.length>1){ history.back(); } else { location.href='/'; }
|
||
});
|
||
})();
|
||
</script>"""
|
||
|
||
|
||
def render_share_page(article: dict, base_url: str, summary: str | None = None,
|
||
explanation: dict | None = None) -> str:
|
||
aid = article["id"]
|
||
title = (article.get("title") or "upbeatBytes").strip()
|
||
why = (article.get("reason_text") or article.get("description")
|
||
or "A calm, constructive story worth your attention.").strip()
|
||
source = (article.get("source_name") or "the source").strip()
|
||
source_id = article.get("source_id")
|
||
# Link the source name into the app's publication feed for that source.
|
||
source_html = (
|
||
f'<a class="src srclink" href="/news?source={source_id}">{escape(source)}</a>'
|
||
if source_id else f'<div class="src">{escape(source)}</div>'
|
||
)
|
||
src_url = article.get("canonical_url") or base_url
|
||
image = article.get("image_url")
|
||
page_url = f"{base_url}/a/{aid}"
|
||
# With an image: a large-image card. Without: a clean text unfurl (title +
|
||
# why + brand) — tidy, not a broken/muddy preview. (A branded fallback PNG
|
||
# can replace this later.)
|
||
twitter_card = "summary_large_image" if image else "summary"
|
||
|
||
meta = "\n".join(filter(None, [
|
||
_tag("og:site_name", "upbeatBytes"),
|
||
_tag("og:type", "article"),
|
||
_tag("og:title", title),
|
||
_tag("og:description", why),
|
||
_tag("og:url", page_url),
|
||
_tag("og:image", image),
|
||
_tag("twitter:card", twitter_card, attr="name"),
|
||
_tag("twitter:title", title, attr="name"),
|
||
_tag("twitter:description", why, attr="name"),
|
||
_tag("twitter:image", image, attr="name"),
|
||
]))
|
||
|
||
# The visible image is served from our cached/downscaled copy (not a hotlink), so a
|
||
# flaky source CDN can't blank it. og:image/twitter:image above stay the source URL
|
||
# so social crawlers fetch the canonical image directly.
|
||
# Served from our cache (/api/img/<id>); if it isn't cached yet / fails, drop the
|
||
# element so the page degrades to the clean text unfurl rather than a broken icon.
|
||
media = (
|
||
f'<img class="media" src="/api/img/{aid}" alt="" referrerpolicy="no-referrer" '
|
||
f'onerror="this.remove()">'
|
||
if image else ""
|
||
)
|
||
|
||
raw_tags = (article.get("tags") or "")
|
||
chips = "".join(
|
||
f'<span class="chip">{escape(t.replace("-", " "))}</span>'
|
||
for t in raw_tags.split(",") if t
|
||
)
|
||
groupings = f'<div class="chips">{chips}</div>' if chips else ""
|
||
if summary:
|
||
summary_block = f'<p class="summary" id="summary">{escape(summary)}</p>'
|
||
else:
|
||
pending = (
|
||
f"✦ We’re putting together a quick summary — in the meantime, "
|
||
f"read the full story at {escape(source)} below."
|
||
)
|
||
summary_block = f'<p class="summary pending" id="summary">{pending}</p>'
|
||
|
||
# The structured "Why it belongs" editorial section — only when the LLM gave a
|
||
# clean three-part read; otherwise the single reason line is the calm fallback.
|
||
def _why_row(lbl: str, text: str) -> str:
|
||
return f'<div class="why-row"><span class="lbl">{lbl}</span><p>{escape(text)}</p></div>'
|
||
|
||
if explanation:
|
||
why_block = (
|
||
'<div class="why" id="why">'
|
||
+ _why_row("What happened", explanation["what_happened"])
|
||
+ _why_row("Why it matters", explanation["why_matters"])
|
||
+ _why_row("Why it belongs here", explanation["why_belongs"])
|
||
+ '</div>'
|
||
)
|
||
else:
|
||
why_block = f'<div class="why" id="why"><div class="why-row"><span class="lbl">Why it’s here</span><p>{escape(why)}</p></div></div>'
|
||
|
||
# Quietly poll until BOTH the summary and the structured read are cached, then
|
||
# swap each in (older summaries get topped up with the section on first view).
|
||
poll = ""
|
||
if not summary or not explanation:
|
||
poll = f"""<script>
|
||
(function(){{
|
||
var id={aid}, box=document.getElementById('summary'), n=0;
|
||
function row(l,t){{
|
||
var d=document.createElement('div'); d.className='why-row';
|
||
var s=document.createElement('span'); s.className='lbl'; s.textContent=l;
|
||
var p=document.createElement('p'); p.textContent=t;
|
||
d.appendChild(s); d.appendChild(p); return d;
|
||
}}
|
||
function go(){{
|
||
n++;
|
||
fetch('/api/summary/'+id).then(function(r){{return r.json();}}).then(function(d){{
|
||
var done=true;
|
||
if(d&&d.summary){{ box.textContent=d.summary; box.className='summary'; }} else {{ done=false; }}
|
||
if(d&&d.explanation){{
|
||
var w=document.getElementById('why');
|
||
if(w){{ w.innerHTML=''; w.appendChild(row('What happened',d.explanation.what_happened));
|
||
w.appendChild(row('Why it matters',d.explanation.why_matters));
|
||
w.appendChild(row('Why it belongs here',d.explanation.why_belongs)); }}
|
||
}} else {{ done=false; }}
|
||
if(!done && n<12){{ setTimeout(go,2500); }}
|
||
}}).catch(function(){{ if(n<12) setTimeout(go,3000); }});
|
||
}}
|
||
go();
|
||
}})();
|
||
</script>"""
|
||
|
||
# Fire a privacy-respecting 'source_click' when the reader heads to the source
|
||
# (reuses the SPA's anonymous visitor token from same-origin localStorage).
|
||
src_click = f"""<script>
|
||
(function(){{
|
||
var b=document.querySelector('[data-src-click]'); if(!b) return;
|
||
b.addEventListener('click',function(){{
|
||
try{{
|
||
var v=localStorage.getItem('goodnews:visitor')||'';
|
||
var body=JSON.stringify({{kind:'source_click',article_id:{aid},visitor:v}});
|
||
if(navigator.sendBeacon) navigator.sendBeacon('/api/events',new Blob([body],{{type:'application/json'}}));
|
||
}}catch(e){{}}
|
||
}});
|
||
}})();
|
||
</script>"""
|
||
|
||
return f"""<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>{escape(title)} · upbeatBytes</title>
|
||
<meta name="description" content="{escape(why)}">
|
||
<link rel="canonical" href="{escape(page_url)}">
|
||
<link rel="icon" href="/favicon.svg">
|
||
{meta}
|
||
<style>
|
||
:root {{ --accent:#0083ad; --accent-deep:#006b8e; --ink:#16263a; --muted:#5d6b78;
|
||
--bg:#f7f4ec; --surface:#fffdf8; --line:#e8e3d8; }}
|
||
* {{ box-sizing:border-box; }}
|
||
body {{ margin:0; background:var(--bg); color:var(--ink);
|
||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
||
line-height:1.6; }}
|
||
{_TOP_BAR_CSS}
|
||
.wrap {{ max-width:680px; margin:0 auto; padding:24px 20px 60px; }}
|
||
.card {{ background:var(--surface); border:1px solid var(--line); border-radius:16px;
|
||
overflow:hidden; box-shadow:0 10px 30px rgba(40,38,28,.06); }}
|
||
.media {{ width:100%; height:auto; display:block; max-height:380px; object-fit:cover; }}
|
||
.body {{ padding:24px 26px 28px; }}
|
||
.src {{ color:var(--muted); font-size:.82rem; text-transform:uppercase; letter-spacing:.08em; }}
|
||
a.srclink {{ text-decoration:none; cursor:pointer; border-bottom:1px dotted transparent; }}
|
||
a.srclink:hover {{ color:var(--accent-deep); border-bottom-color:var(--line); }}
|
||
h1 {{ font-family:"Iowan Old Style",Palatino,Georgia,serif; font-weight:600;
|
||
font-size:1.7rem; line-height:1.2; margin:6px 0 14px; }}
|
||
.summary {{ font-size:1.05rem; color:var(--ink); margin:0 0 18px; }}
|
||
.summary.pending {{ color:var(--muted); font-style:italic; }}
|
||
.why {{ color:#3b4754; font-size:.92rem; margin:0 0 22px;
|
||
border-left:2px solid var(--accent); padding-left:14px; }}
|
||
.why-row {{ margin:0 0 12px; }}
|
||
.why-row:last-child {{ margin-bottom:0; }}
|
||
.why-row p {{ margin:0; }}
|
||
.why .lbl {{ display:block; text-transform:uppercase; letter-spacing:.08em; font-size:.7rem;
|
||
color:var(--accent-deep); font-weight:600; margin-bottom:2px; }}
|
||
.chips {{ display:flex; flex-wrap:wrap; gap:7px; margin:0 0 22px; }}
|
||
.chip {{ background:#e0eef3; color:var(--accent-deep); border-radius:999px; padding:3px 11px;
|
||
font-size:.7rem; text-transform:uppercase; letter-spacing:.06em; }}
|
||
.actions {{ display:flex; flex-wrap:wrap; gap:12px; align-items:center; }}
|
||
.primary {{ background:var(--accent); color:#fff; text-decoration:none; font-weight:600;
|
||
padding:12px 20px; border-radius:999px; display:inline-block; }}
|
||
.primary:hover {{ background:var(--accent-deep); }}
|
||
.secondary {{ color:var(--accent-deep); text-decoration:none; font-size:.92rem;
|
||
background:none; border:none; cursor:pointer; font-family:inherit; padding:0; }}
|
||
.secondary:hover {{ text-decoration:underline; }}
|
||
.note {{ color:var(--muted); font-size:.8rem; margin-top:22px; }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
{_top_bar_html()}
|
||
<main class="wrap">
|
||
{_back_link_html()}
|
||
<article class="card">
|
||
{media}
|
||
<div class="body">
|
||
{source_html}
|
||
<h1>{escape(title)}</h1>
|
||
{summary_block}
|
||
{why_block}
|
||
{groupings}
|
||
<div class="actions">
|
||
<a class="primary" href="{escape(src_url)}" target="_blank" rel="noopener" data-src-click>Read the full story at {escape(source)}</a>
|
||
<button class="secondary" type="button" data-share>Copy link</button>
|
||
<a class="secondary" href="/news">Explore upbeatBytes →</a>
|
||
</div>
|
||
<p class="note">upbeatBytes summarizes in its own words and links to the original publisher — it doesn't host the article.</p>
|
||
</div>
|
||
</article>
|
||
</main>
|
||
<script>
|
||
(function(){{
|
||
try{{
|
||
var v=localStorage.getItem('goodnews:visitor');
|
||
if(!v){{v=crypto.randomUUID?crypto.randomUUID():String(Math.random()).slice(2)+Date.now();localStorage.setItem('goodnews:visitor',v);}}
|
||
function beacon(o){{var b=JSON.stringify(o);if(navigator.sendBeacon)navigator.sendBeacon('/api/events',new Blob([b],{{type:'application/json'}}));}}
|
||
beacon({{kind:'summary_viewed',article_id:{aid},visitor:v}});
|
||
// This page is server-rendered (outside the Svelte app), so the SPA's daily
|
||
// visit isn't recorded for a /a/ landing — count it here, once per day per device.
|
||
var t=new Date().toISOString().slice(0,10);
|
||
if(localStorage.getItem('goodnews:visitday')!==t){{localStorage.setItem('goodnews:visitday',t);beacon({{kind:'visit',article_id:0,visitor:v}});}}
|
||
}}catch(e){{}}
|
||
}})();
|
||
</script>
|
||
{src_click}
|
||
<script>
|
||
(function(){{
|
||
var s=document.querySelector('[data-share]'); if(!s) return;
|
||
s.addEventListener('click',function(){{
|
||
var url=location.href;
|
||
if(navigator.share){{ navigator.share({{title:document.title,url:url}}).catch(function(){{}}); }}
|
||
else if(navigator.clipboard){{ navigator.clipboard.writeText(url).then(function(){{ s.textContent='Copied!'; setTimeout(function(){{s.textContent='Copy link';}},1500); }}); }}
|
||
}});
|
||
}})();
|
||
</script>
|
||
{_BACK_JS}
|
||
{_TOP_BAR_JS}
|
||
{poll}
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
def render_digest(items: list[dict], base_url: str, brief_date: str | None) -> str:
|
||
"""A shareable, indexable 'today's good news, summarized' page."""
|
||
lead_img = next((i.get("image_url") for i in items if i.get("image_url")), None)
|
||
intro = "The day's calm, constructive news — summarized in plain English, so you can get the gist and go deeper only if you want."
|
||
page_url = f"{base_url}/today"
|
||
|
||
cards = ""
|
||
for it in items:
|
||
aid = it["id"]
|
||
title = escape((it.get("title") or "").strip())
|
||
source = escape((it.get("source_name") or "the source").strip())
|
||
src_url = escape(it.get("canonical_url") or base_url)
|
||
gist = escape((it.get("summary") or it.get("reason_text") or it.get("description") or "").strip())
|
||
cards += (
|
||
'<article class="item">'
|
||
f'<a class="t" href="/a/{aid}"><h2>{title}</h2></a>'
|
||
f'<div class="src">{source}</div>'
|
||
f'<p class="gist">{gist}</p>'
|
||
f'<div class="links"><a href="/a/{aid}">Read the summary</a>'
|
||
f'<a href="{src_url}" rel="noopener">Full story at {source} →</a></div>'
|
||
"</article>"
|
||
)
|
||
|
||
meta = "\n".join(filter(None, [
|
||
_tag("og:site_name", "upbeatBytes"),
|
||
_tag("og:type", "website"),
|
||
_tag("og:title", "Today's good news, summarized"),
|
||
_tag("og:description", intro),
|
||
_tag("og:url", page_url),
|
||
_tag("og:image", lead_img),
|
||
_tag("twitter:card", "summary_large_image" if lead_img else "summary", attr="name"),
|
||
_tag("twitter:title", "Today's good news, summarized", attr="name"),
|
||
_tag("twitter:description", intro, attr="name"),
|
||
_tag("twitter:image", lead_img, attr="name"),
|
||
]))
|
||
|
||
return f"""<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Today's good news, summarized · upbeatBytes</title>
|
||
<meta name="description" content="{escape(intro)}">
|
||
<link rel="canonical" href="{page_url}">
|
||
<link rel="icon" href="/favicon.svg">
|
||
{meta}
|
||
<style>
|
||
:root {{ --accent:#0083ad; --accent-deep:#006b8e; --ink:#16263a; --muted:#5d6b78;
|
||
--bg:#f7f4ec; --surface:#fffdf8; --line:#e8e3d8; }}
|
||
* {{ box-sizing:border-box; }}
|
||
body {{ margin:0; background:var(--bg); color:var(--ink);
|
||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; line-height:1.6; }}
|
||
{_TOP_BAR_CSS}
|
||
.wrap {{ max-width:720px; margin:0 auto; padding:26px 20px 60px; }}
|
||
h1 {{ font-family:"Iowan Old Style",Palatino,Georgia,serif; font-weight:600; font-size:2rem; margin:0 0 4px; }}
|
||
.lede {{ color:var(--muted); margin:0 0 26px; }}
|
||
.item {{ border-top:1px solid var(--line); padding:20px 0; }}
|
||
.item .t {{ text-decoration:none; color:var(--ink); }}
|
||
.item h2 {{ font-family:"Iowan Old Style",Palatino,Georgia,serif; font-weight:600; font-size:1.3rem; margin:0 0 4px; }}
|
||
.item .t:hover h2 {{ color:var(--accent-deep); }}
|
||
.item .src {{ color:var(--muted); font-size:.78rem; text-transform:uppercase; letter-spacing:.07em; margin-bottom:8px; }}
|
||
.item .gist {{ margin:0 0 10px; }}
|
||
.item .links {{ display:flex; flex-wrap:wrap; gap:16px; font-size:.9rem; }}
|
||
.item .links a {{ color:var(--accent-deep); text-decoration:none; }}
|
||
.item .links a:hover {{ text-decoration:underline; }}
|
||
.more {{ margin-top:28px; }}
|
||
.more a {{ display:inline-block; background:var(--accent); color:#fff; text-decoration:none;
|
||
padding:11px 20px; border-radius:999px; font-weight:600; }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
{_top_bar_html()}
|
||
<main class="wrap">
|
||
<h1>Today's good news</h1>
|
||
<p class="lede">{escape(intro)}{f' · {escape(brief_date)}' if brief_date else ''}</p>
|
||
{cards}
|
||
<p class="more"><a href="/news">Browse more on upbeatBytes →</a></p>
|
||
</main>
|
||
{_TOP_BAR_JS}
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
def render_not_found(base_url: str) -> str:
|
||
return f"""<!doctype html>
|
||
<html lang="en"><head><meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Story not found · upbeatBytes</title><link rel="icon" href="/favicon.svg">
|
||
<style>
|
||
body {{ margin:0; min-height:100vh; display:flex; flex-direction:column; align-items:center;
|
||
justify-content:center; gap:14px; text-align:center; padding:40px;
|
||
background:#f7f4ec; color:#16263a;
|
||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }}
|
||
a {{ color:#006b8e; }}
|
||
</style></head>
|
||
<body>
|
||
<img src="/logo.svg" alt="upbeatBytes" style="height:44px">
|
||
<h1 style="font-family:Georgia,serif;font-weight:600">That story isn't here</h1>
|
||
<p style="color:#5d6b78">It may have moved on — the good news refreshes often.</p>
|
||
<a href="/">← Back to upbeatBytes</a>
|
||
</body></html>"""
|