news-digest: separated SOURCE / REDDIT THREAD action chips + archive page

Two requested polish items:

1. Reddit items now show TWO distinct action chips in the footer:

     [↗ SOURCE huggingface.co]  [⌥ REDDIT THREAD]      3h · u/foo

   Previously the external link was a tiny dot-separated text link
   that visually competed with the post metadata. Now: explicit
   labeled buttons, distinct colors (cyan for source, blue for
   thread), with hover states that match the Australis accent
   palette. Non-Reddit items get just the SOURCE chip.

2. Archive page at /archive.html — lists every edition-*.html on
   disk, newest-first, sorted PM-before-AM within a day. Each row
   is the date in editorial serif + an AM/PM chip color-coded
   (yellow morning / cyan evening) + a hover-affordance arrow.
   Header link from the main edition reads "ARCHIVE →".

   Generation: digest.py walks OUTPUT_DIR for the edition-*.html
   filename pattern on every run, sorts, renders archive.html.j2,
   writes atomically. Cheap (~1ms even with hundreds of editions).
   No retention cap — twice-daily for a year is ~700 small files,
   well under any reasonable concern.

CSS additions: .action chip styles (with per-action color variants),
.archive-link in masthead, full .archive-row + .archive-edition
treatment.
This commit is contained in:
vh
2026-04-26 13:48:58 -07:00
parent b3d20f2a1a
commit 28cb96c087
4 changed files with 285 additions and 29 deletions
+48
View File
@@ -353,6 +353,54 @@ def write_output(html: str, generated_at: datetime) -> None:
index_tmp.write_text(html, encoding="utf-8")
index_tmp.rename(index)
log(f"wrote {index} (and archive {archive.name})")
write_archive_index(generated_at)
ARCHIVE_FNAME_RE = re.compile(r"^edition-(\d{4}-\d{2}-\d{2})-(am|pm)\.html$")
def write_archive_index(generated_at: datetime) -> None:
"""Render /output/archive.html — list every edition-*.html in
OUTPUT_DIR, newest-first. Cheap (re-runs every digest fire);
template loads from the same TEMPLATE_DIR."""
editions = []
for p in OUTPUT_DIR.glob("edition-*.html"):
m = ARCHIVE_FNAME_RE.match(p.name)
if not m:
continue
date_str, ed = m.group(1), m.group(2)
try:
date = datetime.strptime(date_str, "%Y-%m-%d").date()
except ValueError:
continue
editions.append({
"filename": p.name,
"date": date,
"edition": "morning" if ed == "am" else "evening",
"edition_short": ed.upper(),
"date_long": date.strftime("%A %B %-d, %Y"),
# Sort key: date descending, then PM before AM (within a day,
# PM is the most recent edition).
"_sort": (date, 1 if ed == "pm" else 0),
})
editions.sort(key=lambda e: e["_sort"], reverse=True)
env = Environment(
loader=FileSystemLoader(str(TEMPLATE_DIR)),
autoescape=select_autoescape(["html"]),
trim_blocks=True,
lstrip_blocks=True,
)
tpl = env.get_template("archive.html.j2")
html = tpl.render(
editions=editions,
generated_at=generated_at,
total=len(editions),
)
out = OUTPUT_DIR / "archive.html"
out_tmp = out.with_suffix(".html.tmp")
out_tmp.write_text(html, encoding="utf-8")
out_tmp.rename(out)
log(f"wrote {out} ({len(editions)} editions indexed)")
# ── main ─────────────────────────────────────────────────────────────
@@ -0,0 +1,83 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Daily Digest · Archive</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header class="masthead" role="banner">
<div class="masthead-inner">
<div class="masthead-brand">
<span class="brand-glyph" aria-hidden="true">◢</span>
<span class="brand-mark">DAILY DIGEST</span>
</div>
<div class="masthead-edition">
<span class="edition-stamp">ARCHIVE</span>
<a class="archive-link" href="index.html">← LATEST</a>
</div>
<div class="masthead-meta">
<div class="meta-row">
<span class="meta-label">EDITIONS ON FILE</span>
<span class="meta-value">{{ total }}</span>
</div>
<div class="meta-row">
<span class="meta-label">CADENCE</span>
<span class="meta-value">2× DAILY · 0800 / 2000</span>
</div>
<div class="meta-row">
<span class="meta-label">RETENTION</span>
<span class="meta-value">UNLIMITED</span>
</div>
</div>
</div>
<div class="aurora-rule" aria-hidden="true"></div>
</header>
<main class="brief">
<section class="desk">
<header class="desk-head">
<span class="desk-num">∞</span>
<h2 class="desk-title">All Editions</h2>
<span class="desk-sub">newest first</span>
</header>
{% if editions %}
<ol class="archive-list">
{% for e in editions %}
<li class="archive-item">
<a class="archive-row" href="{{ e.filename }}">
<span class="archive-date">{{ e.date_long }}</span>
<span class="archive-edition archive-edition-{{ e.edition_short|lower }}">
{{ e.edition_short }}
</span>
<span class="archive-arrow">→</span>
</a>
</li>
{% endfor %}
</ol>
{% else %}
<p class="empty-msg">No editions on file yet. Cron fires twice daily at 0800 / 2000.</p>
{% endif %}
</section>
</main>
<footer class="colophon">
<div class="colophon-inner">
<div class="colophon-block">
<span class="meta-label">INDEXED</span>
<span class="meta-value">{{ generated_at.isoformat(timespec="seconds") }}</span>
</div>
<div class="colophon-block">
<span class="meta-label">STORE</span>
<span class="meta-value">/opt/docker/data/news-digest</span>
</div>
<div class="colophon-block">
<span class="meta-label">FORMAT</span>
<span class="meta-value">edition-YYYY-MM-DD-{am,pm}.html</span>
</div>
</div>
</footer>
</body>
</html>
+32 -21
View File
@@ -17,6 +17,7 @@
<div class="masthead-edition">
<span class="edition-num">№ {{ generated_at.strftime("%j") }}</span>
<span class="edition-stamp">{{ edition|upper }} EDITION</span>
<a class="archive-link" href="archive.html">ARCHIVE →</a>
</div>
<div class="masthead-meta">
<div class="meta-row">
@@ -92,19 +93,24 @@
<p class="item-tldr">{{ it.tldr }}</p>
{% endif %}
<footer class="item-foot">
<a class="item-link" href="{{ it.permalink }}" target="_blank" rel="noopener">
comments
</a>
{% if it.url and it.url != it.permalink %}
<span class="sep">·</span>
<a class="item-link" href="{{ it.url }}" target="_blank" rel="noopener">
{{ it.url|domain }}
<div class="item-actions">
{% if it.url and it.url != it.permalink %}
<a class="action action-source" href="{{ it.url }}" target="_blank" rel="noopener">
<span class="action-arrow">↗</span>
<span class="action-label">SOURCE</span>
<span class="action-domain">{{ it.url|domain }}</span>
</a>
{% endif %}
<a class="action action-thread" href="{{ it.permalink }}" target="_blank" rel="noopener">
<span class="action-arrow">⌥</span>
<span class="action-label">REDDIT THREAD</span>
</a>
{% endif %}
<span class="sep">·</span>
<span class="item-meta">{{ it.posted_at|humanago }} ago</span>
<span class="sep">·</span>
<span class="item-meta">u/{{ it.author }}</span>
</div>
<div class="item-meta-row">
<span class="item-meta">{{ it.posted_at|humanago }} ago</span>
<span class="sep">·</span>
<span class="item-meta">u/{{ it.author }}</span>
</div>
</footer>
</div>
</li>
@@ -150,16 +156,21 @@
{% endif %}
<footer class="item-foot">
{% if it.url %}
<a class="item-link" href="{{ it.url }}" target="_blank" rel="noopener">
{{ it.url|domain }}
</a>
<span class="sep">·</span>
{% endif %}
<span class="item-meta">{{ it.posted_at|humanago }} ago</span>
{% if it.author %}
<span class="sep">·</span>
<span class="item-meta">{{ it.author }}</span>
<div class="item-actions">
<a class="action action-source" href="{{ it.url }}" target="_blank" rel="noopener">
<span class="action-arrow">↗</span>
<span class="action-label">SOURCE</span>
<span class="action-domain">{{ it.url|domain }}</span>
</a>
</div>
{% endif %}
<div class="item-meta-row">
<span class="item-meta">{{ it.posted_at|humanago }} ago</span>
{% if it.author %}
<span class="sep">·</span>
<span class="item-meta">{{ it.author }}</span>
{% endif %}
</div>
</footer>
</div>
</li>
+122 -8
View File
@@ -131,6 +131,14 @@ a:hover { color: var(--accent); }
gap: 18px;
font-family: var(--font-mono);
}
.archive-link {
color: var(--fg-dim);
font-size: 11px;
letter-spacing: .14em;
border-bottom: 1px dotted var(--fg-faint);
padding-bottom: 1px;
}
.archive-link:hover { color: var(--accent); border-color: var(--accent-dim); }
.edition-num {
color: var(--fg-faint);
font-size: 11px;
@@ -400,26 +408,132 @@ a:hover { color: var(--accent); }
.item-foot {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
align-items: center;
gap: 16px;
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: .06em;
color: var(--fg-faint);
}
.item-link {
color: var(--fg-dim);
border-bottom: 1px dotted var(--fg-faint);
padding-bottom: 1px;
/* Action chips — explicit "go here" buttons. Reddit items get TWO of
these when the post links externally: SOURCE (the linked content)
and REDDIT THREAD (the discussion). Non-Reddit items get just SOURCE. */
.item-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.item-link:hover { color: var(--accent); border-color: var(--accent-dim); }
.item-meta { font-variant-numeric: tabular-nums; }
.action {
display: inline-flex;
align-items: baseline;
gap: 6px;
padding: 4px 10px;
border: 1px solid var(--rule-bright);
border-radius: 2px;
font-family: var(--font-mono);
font-size: 10.5px;
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--fg-dim);
transition: border-color 120ms, color 120ms, background 120ms;
}
.action-arrow {
color: var(--accent-dim);
font-size: 11px;
}
.action-label { color: var(--fg); }
.action-domain {
color: var(--fg-dim);
font-weight: 500;
letter-spacing: .04em;
text-transform: lowercase;
}
.action:hover {
border-color: var(--accent);
background: rgba(66, 220, 209, 0.08);
}
.action:hover .action-arrow,
.action:hover .action-label,
.action:hover .action-domain { color: var(--accent); }
.action-source { /* default styling above is "source" */ }
.action-thread .action-arrow { color: var(--aus-bright-blue); }
.action-thread:hover { border-color: var(--aus-bright-blue); background: rgba(164, 196, 255, 0.08); }
.action-thread:hover .action-arrow,
.action-thread:hover .action-label { color: var(--aus-bright-blue); }
.item-meta-row {
display: flex;
align-items: baseline;
gap: 8px;
margin-left: auto; /* pushes timestamp to the right */
}
.item-meta { font-variant-numeric: tabular-nums; color: var(--fg-faint); }
.sep { color: var(--rule-bright); }
/* Visual de-emphasize purely meme/skip items if any slip through. */
.item[data-tag="meme"] { opacity: .58; }
.item[data-tag="other"] { opacity: .85; }
/* -------------------------------------------------------------------------
archive page — list of all editions
------------------------------------------------------------------------- */
.archive-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0;
border: 1px solid var(--rule);
border-radius: 2px;
overflow: hidden;
}
.archive-item + .archive-item { border-top: 1px solid var(--rule); }
.archive-row {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 24px;
padding: 16px 20px;
background: var(--surface);
transition: background 120ms;
}
.archive-row:hover {
background: var(--surface-hi);
color: var(--fg);
}
.archive-date {
font-family: var(--font-display);
font-size: 17px;
font-weight: 500;
letter-spacing: -0.005em;
color: var(--fg);
font-variation-settings: "opsz" 36;
}
.archive-edition {
font-family: var(--font-mono);
font-size: 10.5px;
font-weight: 700;
letter-spacing: .14em;
padding: 3px 9px;
border: 1px solid currentColor;
border-radius: 2px;
}
.archive-edition-am { color: var(--aus-yellow); }
.archive-edition-pm { color: var(--aus-bright-cyan); }
.archive-arrow {
font-family: var(--font-mono);
color: var(--fg-faint);
font-size: 14px;
}
.archive-row:hover .archive-arrow { color: var(--accent); }
/* -------------------------------------------------------------------------
empty state + colophon
------------------------------------------------------------------------- */