Files
kill-the-news/docs/index.html
T
2026-05-23 14:49:57 +02:00

1281 lines
50 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>kill-the-news — Private newsletter feeds on Cloudflare Workers</title>
<meta name="description" content="Convert email newsletters into private RSS feeds using Cloudflare Workers. Self-hosted, free tier, your own domain." />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" sizes="32x32" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0a0a0a;
--surface: #111111;
--border: #222222;
--text: #f0f0f0;
--muted: #888888;
--accent: #f6821f;
--accent-dim: rgba(246,130,31,0.12);
--radius: 10px;
}
html { scroll-behavior: smooth; }
body {
font-family: 'Inter', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
font-size: 16px;
}
a { color: inherit; text-decoration: none; }
/* ── Nav ── */
nav {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 2rem;
height: 60px;
background: rgba(10,10,10,0.85);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
}
.nav-logo {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
font-size: 1rem;
}
.nav-logo svg { color: var(--accent); }
.btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.45rem 1rem;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: opacity 0.15s, background 0.15s;
border: none;
}
.btn-outline {
background: transparent;
border: 1px solid var(--border);
color: var(--text);
}
.btn-outline:hover { background: var(--surface); }
.btn-primary {
background: var(--accent);
color: #fff;
}
.btn-primary:hover { opacity: 0.88; }
/* ── Hero ── */
.hero {
position: relative;
overflow: hidden;
padding: 6rem 2rem 5rem;
text-align: center;
}
.hero-glow {
position: absolute;
inset: 0;
background: radial-gradient(ellipse 80% 60% at 50% -10%, rgba(246,130,31,0.18) 0%, transparent 70%);
pointer-events: none;
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.75rem;
border-radius: 999px;
border: 1px solid rgba(246,130,31,0.35);
background: var(--accent-dim);
color: var(--accent);
font-size: 0.8rem;
font-weight: 500;
margin-bottom: 1.5rem;
}
.hero h1 {
font-size: clamp(2.2rem, 5vw, 3.5rem);
font-weight: 700;
line-height: 1.15;
letter-spacing: -0.02em;
max-width: 720px;
margin: 0 auto 1.25rem;
}
.hero h1 span { color: var(--accent); }
.hero p {
font-size: 1.1rem;
color: var(--muted);
max-width: 520px;
margin: 0 auto 2.5rem;
}
.hero-ctas {
display: flex;
gap: 0.75rem;
justify-content: center;
flex-wrap: wrap;
}
/* ── Section layout ── */
section {
padding: 5rem 2rem;
max-width: 1100px;
margin: 0 auto;
}
.section-label {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 0.75rem;
}
.section-title {
font-size: clamp(1.6rem, 3vw, 2.2rem);
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 0.75rem;
}
.section-sub {
color: var(--muted);
font-size: 1rem;
max-width: 520px;
}
.section-header { margin-bottom: 3rem; }
/* ── FAQ ── */
.faq-list { display: flex; flex-direction: column; gap: 0.75rem; }
.faq-item {
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
transition: border-color 0.2s;
}
.faq-item[open] { border-color: rgba(246,130,31,0.4); }
.faq-item summary {
cursor: pointer;
list-style: none;
padding: 1.1rem 1.35rem;
font-weight: 600;
font-size: 0.95rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.faq-item summary::-webkit-details-marker { display: none; }
.faq-item summary::after { content: "+"; color: var(--accent); font-size: 1.2rem; line-height: 1; }
.faq-item[open] summary::after { content: ""; }
.faq-item p {
margin: 0;
padding: 0 1.35rem 1.2rem;
color: var(--muted);
font-size: 0.9rem;
line-height: 1.65;
}
.faq-item a { color: var(--accent); }
/* ── Features ── */
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
gap: 1.25rem;
}
.feature-card {
padding: 1.5rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
transition: border-color 0.2s;
}
.feature-card:hover { border-color: rgba(246,130,31,0.4); }
.feature-icon {
width: 38px;
height: 38px;
border-radius: 8px;
background: var(--accent-dim);
display: flex;
align-items: center;
justify-content: center;
color: var(--accent);
margin-bottom: 1rem;
}
.feature-card h3 {
font-size: 0.95rem;
font-weight: 600;
margin-bottom: 0.4rem;
}
.feature-card p {
font-size: 0.875rem;
color: var(--muted);
line-height: 1.6;
}
/* ── How it works ── */
#how-it-works { background: var(--surface); max-width: none; padding: 5rem 2rem; }
#how-it-works .inner { max-width: 1100px; margin: 0 auto; }
.steps {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0;
margin-top: 3rem;
position: relative;
}
.step {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 2rem 2rem 2rem 0;
position: relative;
}
.step:not(:last-child)::after {
content: '';
position: absolute;
right: 0;
top: 2.75rem;
width: 1px;
height: calc(100% - 5.5rem);
background: var(--border);
}
.step-num {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--accent);
color: #fff;
font-weight: 700;
font-size: 0.95rem;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
flex-shrink: 0;
}
.step h3 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.4rem;
}
.step p {
font-size: 0.875rem;
color: var(--muted);
line-height: 1.6;
}
/* ── Quick Start ── */
#quick-start {}
.code-block {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.code-block-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
font-size: 0.8rem;
color: var(--muted);
}
.code-block-header span {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
}
.dot-r { background: #ff5f57; }
.dot-y { background: #febc2e; }
.dot-g { background: #28c840; }
.code-block pre {
padding: 1.25rem 1.5rem;
font-family: 'JetBrains Mono', 'Courier New', monospace;
font-size: 0.85rem;
line-height: 1.75;
overflow-x: auto;
}
.code-block pre .prompt { color: var(--muted); user-select: none; }
.code-block pre .cmd { color: var(--text); }
.code-block pre .comment { color: #555; }
.ingestion-options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
margin-top: 2rem;
}
.ingestion-card {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem 1.5rem;
background: var(--surface);
}
.ingestion-card.recommended { border-color: rgba(246,130,31,0.4); }
.ingestion-card .tag {
display: inline-block;
padding: 0.2rem 0.55rem;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.75rem;
}
.tag-recommended { background: var(--accent-dim); color: var(--accent); }
.tag-alt { background: rgba(255,255,255,0.06); color: var(--muted); }
.ingestion-card h3 { font-size: 0.95rem; font-weight: 600; margin-bottom: 0.4rem; }
.ingestion-card p { font-size: 0.85rem; color: var(--muted); line-height: 1.6; }
/* ── Tech stack ── */
.tech-pills {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
margin-top: 2rem;
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.9rem;
border: 1px solid var(--border);
border-radius: 999px;
font-size: 0.85rem;
background: var(--surface);
color: var(--text);
font-weight: 500;
}
/* ── Footer ── */
footer {
border-top: 1px solid var(--border);
padding: 2.5rem 2rem;
text-align: center;
color: var(--muted);
font-size: 0.85rem;
}
footer a { color: var(--muted); border-bottom: 1px solid var(--border); transition: color 0.15s; }
footer a:hover { color: var(--text); }
footer .sep { margin: 0 0.5rem; }
/* ── Demo banner ── */
.demo-banner {
background: linear-gradient(135deg, rgba(246,130,31,0.08) 0%, rgba(246,130,31,0.03) 100%);
border-top: 1px solid rgba(246,130,31,0.2);
border-bottom: 1px solid rgba(246,130,31,0.2);
padding: 3.5rem 2rem;
}
.demo-banner .inner {
max-width: 1100px;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr auto;
gap: 2.5rem;
align-items: center;
}
.demo-banner h2 {
font-size: clamp(1.3rem, 2.5vw, 1.75rem);
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 0.6rem;
}
.demo-banner h2 span { color: var(--accent); }
.demo-banner p {
font-size: 0.95rem;
color: var(--muted);
max-width: 540px;
line-height: 1.65;
}
.demo-creds {
display: inline-flex;
align-items: center;
gap: 1rem;
margin-top: 1rem;
padding: 0.6rem 1rem;
background: rgba(0,0,0,0.3);
border: 1px solid var(--border);
border-radius: 8px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.82rem;
}
.demo-creds .cred-item { color: var(--muted); }
.demo-creds .cred-item strong { color: var(--text); font-weight: 500; }
.demo-creds .cred-sep { color: #333; }
.demo-actions {
display: flex;
flex-direction: column;
gap: 0.65rem;
align-items: flex-end;
flex-shrink: 0;
}
.demo-note {
font-size: 0.75rem;
color: #555;
text-align: center;
}
@media (max-width: 700px) {
.demo-banner .inner { grid-template-columns: 1fr; }
.demo-actions { align-items: flex-start; flex-direction: row; flex-wrap: wrap; }
.demo-note { text-align: left; }
}
/* ── Live stats ── */
.stats-section { padding: 4rem 2rem; }
.stats-inner { max-width: 1100px; margin: 0 auto; text-align: center; }
.stats-live {
display: inline-flex; align-items: center; gap: 0.5rem;
font-size: 0.78rem; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 2rem;
}
.stats-dot {
width: 8px; height: 8px; border-radius: 50%; background: var(--accent);
animation: stats-pulse 2s infinite;
}
@keyframes stats-pulse {
0% { box-shadow: 0 0 0 0 rgba(246,130,31,0.5); }
70% { box-shadow: 0 0 0 8px rgba(246,130,31,0); }
100% { box-shadow: 0 0 0 0 rgba(246,130,31,0); }
}
.stats-grid {
display: grid; grid-template-columns: repeat(2, 1fr);
gap: 2rem; max-width: 600px; margin: 0 auto;
}
.stat-num {
font-size: clamp(2.5rem, 6vw, 4rem); font-weight: 700;
color: var(--accent); letter-spacing: -0.03em;
font-variant-numeric: tabular-nums; line-height: 1;
}
.stat-label { font-size: 0.9rem; color: var(--muted); margin-top: 0.6rem; }
@media (max-width: 600px) { .stats-grid { gap: 1.5rem; } }
/* ── Nav links ── */
nav-links {
display: flex;
align-items: center;
gap: 0.25rem;
}
.nav-link {
padding: 0.35rem 0.75rem;
border-radius: 6px;
font-size: 0.875rem;
color: var(--muted);
transition: color 0.15s, background 0.15s;
}
.nav-link:hover { color: var(--text); background: var(--surface); }
.nav-link-sponsor { color: #db61a2; display: inline-flex; align-items: center; gap: 0.3rem; }
.nav-link-sponsor:hover { color: #e879b8; background: rgba(219,97,162,0.08); }
@media (max-width: 600px) { nav-links { display: none; } }
/* ── Install section ── */
.install-steps {
display: flex;
flex-direction: column;
gap: 1rem;
counter-reset: install-step;
}
.install-step {
display: grid;
grid-template-columns: 48px minmax(0, 1fr);
gap: 1.25rem;
align-items: start;
}
.install-step-num {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--accent-dim);
border: 1px solid rgba(246,130,31,0.35);
color: var(--accent);
font-weight: 700;
font-size: 0.9rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 2px;
}
.install-step-body h3 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.install-step-body p {
font-size: 0.875rem;
color: var(--muted);
margin-bottom: 0.75rem;
line-height: 1.65;
}
.install-step-body p:last-child { margin-bottom: 0; }
.install-step-body .code-block { margin-top: 0.75rem; }
.install-step-body ul {
margin: 0.5rem 0 0.75rem 1.1rem;
font-size: 0.875rem;
color: var(--muted);
line-height: 1.8;
}
.install-step-connector {
width: 1px;
background: var(--border);
height: 1.5rem;
margin-left: 19px;
}
.install-split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-top: 0.75rem;
}
@media (max-width: 700px) { .install-split { grid-template-columns: 1fr; } }
.install-card {
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem 1.5rem;
background: var(--surface);
}
.install-card.highlight { border-color: rgba(246,130,31,0.4); }
.install-card .tag {
display: inline-block;
padding: 0.2rem 0.55rem;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.6rem;
}
.tag-opt { background: rgba(255,255,255,0.06); color: var(--muted); }
.install-card h4 { font-size: 0.9rem; font-weight: 600; margin-bottom: 0.35rem; }
.install-card p { font-size: 0.825rem; color: var(--muted); line-height: 1.6; }
.waf-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
margin-top: 0.75rem;
table-layout: fixed;
}
.waf-table th,
.waf-table td {
text-align: left;
padding: 0.5rem 0.6rem;
vertical-align: top;
}
.waf-table thead th:first-child { width: 38%; }
.waf-table thead th {
border-bottom: 1px solid var(--border);
color: var(--text);
font-weight: 600;
font-size: 0.8rem;
}
.waf-table tbody th {
color: var(--muted);
font-weight: 500;
font-size: 0.8rem;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.waf-table tbody td {
border-bottom: 1px solid rgba(255,255,255,0.04);
color: var(--text);
}
.waf-table code {
font-family: 'JetBrains Mono', monospace;
font-size: 0.8em;
color: var(--accent);
overflow-wrap: anywhere;
}
.waf-table tbody tr:last-child th,
.waf-table tbody tr:last-child td { border-bottom: none; }
@media (max-width: 600px) {
.step:not(:last-child)::after { display: none; }
.step { padding-right: 0; }
section { padding-left: 1.25rem; padding-right: 1.25rem; }
#how-it-works { padding-left: 1.25rem; padding-right: 1.25rem; }
.install-step { grid-template-columns: 34px minmax(0, 1fr); gap: 0.85rem; }
.install-step-num { width: 34px; height: 34px; font-size: 0.8rem; }
.install-step-connector { margin-left: 16px; }
.code-block pre { padding: 1rem; }
}
</style>
</head>
<body>
<!-- Nav -->
<nav>
<div class="nav-logo">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22,6 12,13 2,6"/>
</svg>
kill-the-news
</div>
<nav-links>
<a href="#features" class="nav-link">Features</a>
<a href="#how-it-works" class="nav-link">How it works</a>
<a href="#install" class="nav-link">Install</a>
<a href="#faq" class="nav-link">FAQ</a>
<a href="https://github.com/sponsors/juherr" class="nav-link nav-link-sponsor" target="_blank" rel="noopener">
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 21.593c-.524-.505-3.655-3.536-5.905-5.8C3.39 13.078 2 10.538 2 8a6 6 0 0 1 10-4.472A6 6 0 0 1 22 8c0 2.538-1.39 5.078-4.095 7.793-2.25 2.264-5.381 5.295-5.905 5.8z"/></svg>
Sponsor
</a>
</nav-links>
<a href="https://github.com/juherr/kill-the-news" class="btn btn-outline" target="_blank" rel="noopener">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.44 9.8 8.2 11.38.6.11.82-.26.82-.58v-2.03c-3.34.73-4.04-1.61-4.04-1.61-.54-1.38-1.33-1.75-1.33-1.75-1.09-.74.08-.73.08-.73 1.2.09 1.84 1.24 1.84 1.24 1.07 1.83 2.8 1.3 3.49 1 .11-.78.42-1.3.76-1.6-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.12-.3-.54-1.52.12-3.17 0 0 1.01-.32 3.3 1.23a11.5 11.5 0 0 1 3-.4c1.02.005 2.04.14 3 .4 2.28-1.55 3.29-1.23 3.29-1.23.66 1.65.24 2.87.12 3.17.77.84 1.24 1.91 1.24 3.22 0 4.61-2.81 5.63-5.48 5.92.43.37.81 1.1.81 2.22v3.29c0 .32.22.7.83.58C20.57 21.8 24 17.3 24 12c0-6.63-5.37-12-12-12z"/></svg>
GitHub
</a>
</nav>
<!-- Hero -->
<div class="hero">
<div class="hero-glow"></div>
<div class="badge">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="10"/></svg>
Open Source · MIT · Free Tier
</div>
<h1>Turn email newsletters into <span>private RSS feeds</span></h1>
<p>Self-hosted on Cloudflare Workers. Your data stays in your own account, served from your own domain.</p>
<div class="hero-ctas">
<a href="https://demo.kill-the.news" class="btn btn-primary" target="_blank" rel="noopener">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Try the demo
</a>
<a href="#install" class="btn btn-outline">Self-host ↓</a>
<a href="https://github.com/juherr/kill-the-news" class="btn btn-outline" target="_blank" rel="noopener">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.44 9.8 8.2 11.38.6.11.82-.26.82-.58v-2.03c-3.34.73-4.04-1.61-4.04-1.61-.54-1.38-1.33-1.75-1.33-1.75-1.09-.74.08-.73.08-.73 1.2.09 1.84 1.24 1.84 1.24 1.07 1.83 2.8 1.3 3.49 1 .11-.78.42-1.3.76-1.6-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.12-.3-.54-1.52.12-3.17 0 0 1.01-.32 3.3 1.23a11.5 11.5 0 0 1 3-.4c1.02.005 2.04.14 3 .4 2.28-1.55 3.29-1.23 3.29-1.23.66 1.65.24 2.87.12 3.17.77.84 1.24 1.91 1.24 3.22 0 4.61-2.81 5.63-5.48 5.92.43.37.81 1.1.81 2.22v3.29c0 .32.22.7.83.58C20.57 21.8 24 17.3 24 12c0-6.63-5.37-12-12-12z"/></svg>
GitHub
</a>
</div>
</div>
<!-- Live stats (demo instance) -->
<section id="stats" class="stats-section" hidden>
<div class="stats-inner">
<div class="stats-live"><span class="stats-dot"></span> Live from the demo instance</div>
<div class="stats-grid">
<div class="stat">
<div class="stat-num" data-stat="feeds_created">0</div>
<div class="stat-label">Feeds created</div>
</div>
<div class="stat">
<div class="stat-num" data-stat="emails_received">0</div>
<div class="stat-label">Emails received</div>
</div>
</div>
</div>
</section>
<!-- Demo banner -->
<div class="demo-banner">
<div class="inner">
<div>
<h2>Try it <span>live</span> — no setup required</h2>
<p>
A hosted instance runs at <strong style="color:var(--text)">demo.kill-the.news</strong>, pre-loaded with sample feeds.
Create a feed, grab the RSS URL, and add it to your reader — all without deploying anything.
</p>
<div class="demo-creds">
<span class="cred-item">URL <strong>demo.kill-the.news</strong></span>
<span class="cred-sep">·</span>
<span class="cred-item">Password <strong>password</strong></span>
</div>
</div>
<div class="demo-actions">
<a href="https://demo.kill-the.news" class="btn btn-primary" target="_blank" rel="noopener" style="font-size:0.95rem;padding:0.6rem 1.35rem;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Open demo
</a>
<span class="demo-note">Resets periodically · for testing only</span>
</div>
</div>
</div>
<!-- Features -->
<section id="features">
<div class="section-header">
<div class="section-label">Features</div>
<h2 class="section-title">Everything you need, nothing you don't</h2>
<p class="section-sub">Built on serverless infrastructure — zero servers to maintain, no subscription fees.</p>
</div>
<div class="features-grid">
<div class="feature-card">
<div class="feature-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</div>
<h3>Self-Hosted &amp; Private</h3>
<p>Your emails and feeds live exclusively in your own Cloudflare account. No shared infrastructure, no data mining.</p>
</div>
<div class="feature-card">
<div class="feature-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
</div>
<h3>Free Cloudflare Tier</h3>
<p>Cloudflare Workers, KV, and Email Routing all fall within the generous free tier. Deploy at zero cost.</p>
</div>
<div class="feature-card">
<div class="feature-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
</div>
<h3>Your Own Domain</h3>
<p>Subscribe to newsletters using addresses on your own domain (e.g. <code style="font-family:monospace;font-size:0.8em;color:var(--accent)">apple@yourdomain.com</code>). No lock-in.</p>
</div>
<div class="feature-card">
<div class="feature-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
</div>
<h3>Two Ingestion Methods</h3>
<p>Use Cloudflare Email Routing (no third-party) or ForwardEmail webhooks — whichever fits your setup.</p>
</div>
<div class="feature-card">
<div class="feature-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
</div>
<h3>Attachment Enclosures</h3>
<p>Email attachments are stored in Cloudflare R2 and exposed as RSS enclosures — no extra hosting needed.</p>
</div>
<div class="feature-card">
<div class="feature-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
</div>
<h3>Per-Feed Icons</h3>
<p>Each feed picks up the favicon of its newsletter's sender domain, so feeds are easy to tell apart in your reader and the admin UI.</p>
</div>
<div class="feature-card">
<div class="feature-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/></svg>
</div>
<h3>Auto-Unsubscribe on Delete</h3>
<p>Deleting a feed fires RFC 8058 one-click unsubscribe requests to its newsletters, so the messages stop arriving at the now-dead address.</p>
</div>
<div class="feature-card">
<div class="feature-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
</div>
<h3>External Auth Support</h3>
<p>Optionally delegate admin authentication to Authelia, Authentik, or any reverse proxy that sets <code style="font-family:monospace;font-size:0.8em;color:var(--accent)">Remote-User</code>.</p>
</div>
</div>
</section>
<!-- How it works -->
<div id="how-it-works">
<div class="inner">
<div class="section-header">
<div class="section-label">How it works</div>
<h2 class="section-title">Three steps, done</h2>
<p class="section-sub">From email delivery to your RSS reader in milliseconds, with no moving parts.</p>
</div>
<div class="steps">
<div class="step">
<div class="step-num">1</div>
<h3>Subscribe with your address</h3>
<p>Create a feed in the admin UI and get a unique address like <code style="font-family:monospace;font-size:0.8em;color:var(--accent)">newsletter.42@yourdomain.com</code>. Subscribe to any newsletter with it.</p>
</div>
<div class="step">
<div class="step-num">2</div>
<h3>Worker ingests the email</h3>
<p>When a newsletter arrives, Cloudflare routes it to your Worker. It parses the content and stores it in KV — attachments go to R2.</p>
</div>
<div class="step">
<div class="step-num">3</div>
<h3>Read in your RSS reader</h3>
<p>Your feed is live at <code style="font-family:monospace;font-size:0.8em;color:var(--accent)">/rss/:feedId</code>. Add it to any RSS client and never miss an issue.</p>
</div>
</div>
</div>
</div>
<!-- Quick Start -->
<section id="quick-start">
<div class="section-header">
<div class="section-label">Quick Start</div>
<h2 class="section-title">Up and running in minutes</h2>
<p class="section-sub">A single setup script handles KV namespaces, secrets, and <code style="font-family:monospace;font-size:0.9em">wrangler.toml</code> generation.</p>
</div>
<div class="code-block">
<div class="code-block-header">
<span class="dot-r"></span><span class="dot-y"></span><span class="dot-g"></span>
Terminal
</div>
<pre><span class="comment"># 1. Clone the repo</span>
<span class="prompt">$ </span><span class="cmd">git clone https://github.com/juherr/kill-the-news.git &amp;&amp; cd kill-the-news</span>
<span class="comment"># 2. Log in to Cloudflare</span>
<span class="prompt">$ </span><span class="cmd">npx wrangler login</span>
<span class="comment"># 3. Run the interactive setup (creates KV, sets secrets, writes wrangler.toml)</span>
<span class="prompt">$ </span><span class="cmd">bash setup.sh</span>
<span class="comment"># 4. Deploy to the edge</span>
<span class="prompt">$ </span><span class="cmd">npm run deploy</span></pre>
</div>
<div style="margin-top:2rem;">
<p style="font-size:0.9rem;color:var(--muted);margin-bottom:1rem;">Then choose how emails reach your Worker:</p>
<div class="ingestion-options">
<div class="ingestion-card recommended">
<div class="tag tag-recommended">Recommended</div>
<h3>Cloudflare Email Routing</h3>
<p>Enable Email Routing on your domain and add a catch-all rule that forwards to the Worker. No third-party service required.</p>
</div>
<div class="ingestion-card">
<div class="tag tag-alt">Alternative</div>
<h3>ForwardEmail Webhook</h3>
<p>Point ForwardEmail MX records at your domain. ForwardEmail parses incoming mail and POSTs a JSON payload to <code style="font-family:monospace;font-size:0.8em">/api/inbound</code>.</p>
</div>
</div>
</div>
</section>
<!-- Installation -->
<div style="background:var(--surface);padding:5rem 0 0;border-top:1px solid var(--border);">
<section id="install" style="padding-top:0;padding-bottom:0;">
<div class="section-header">
<div class="section-label">Installation</div>
<h2 class="section-title">Deploy to Cloudflare</h2>
<p class="section-sub">Everything you need to self-host kill-the-news on your own Cloudflare account, step by step.</p>
</div>
<div class="install-steps">
<!-- Step 1 -->
<div class="install-step">
<div class="install-step-num">1</div>
<div class="install-step-body">
<h3>Prerequisites</h3>
<p>You need a free <a href="https://dash.cloudflare.com/sign-up" target="_blank" rel="noopener" style="color:var(--accent);border-bottom:1px solid rgba(246,130,31,0.3)">Cloudflare account</a> with a domain managed by Cloudflare DNS, and Node.js ≥ 18.</p>
<ul>
<li>Cloudflare account (free tier is enough)</li>
<li>A domain with DNS managed by Cloudflare</li>
<li>Node.js ≥ 18 &amp; npm</li>
<li>Git</li>
</ul>
</div>
</div>
<div class="install-step-connector"></div>
<!-- Step 2 -->
<div class="install-step">
<div class="install-step-num">2</div>
<div class="install-step-body">
<h3>Clone &amp; authenticate</h3>
<p>Clone the repo and log in to Cloudflare via Wrangler. This opens a browser window to authorize the CLI.</p>
<div class="code-block">
<div class="code-block-header"><span class="dot-r"></span><span class="dot-y"></span><span class="dot-g"></span> Terminal</div>
<pre><span class="prompt">$ </span><span class="cmd">git clone https://github.com/juherr/kill-the-news.git</span>
<span class="prompt">$ </span><span class="cmd">cd kill-the-news</span>
<span class="prompt">$ </span><span class="cmd">npm install</span>
<span class="prompt">$ </span><span class="cmd">npx wrangler login</span></pre>
</div>
</div>
</div>
<div class="install-step-connector"></div>
<!-- Step 3 -->
<div class="install-step">
<div class="install-step-num">3</div>
<div class="install-step-body">
<h3>Run the setup script</h3>
<p>The interactive setup script creates the KV namespace, sets the admin password secret, and writes <code style="font-family:monospace;font-size:0.85em;color:var(--accent)">wrangler.toml</code> for you.</p>
<div class="code-block">
<div class="code-block-header"><span class="dot-r"></span><span class="dot-y"></span><span class="dot-g"></span> Terminal</div>
<pre><span class="prompt">$ </span><span class="cmd">bash setup.sh</span>
<span class="comment">
# The script will ask for:
# - Your domain (e.g. newsletters.example.com)
# - An admin password (stored as a Wrangler secret)
# - Whether to enable R2 for attachments (optional)</span></pre>
</div>
<p style="margin-top:0.75rem;">This generates <code style="font-family:monospace;font-size:0.85em;color:var(--accent)">wrangler.toml</code> from the example template. Do not commit it — it is gitignored.</p>
</div>
</div>
<div class="install-step-connector"></div>
<!-- Step 4 -->
<div class="install-step">
<div class="install-step-num">4</div>
<div class="install-step-body">
<h3>Deploy the Worker</h3>
<div class="code-block">
<div class="code-block-header"><span class="dot-r"></span><span class="dot-y"></span><span class="dot-g"></span> Terminal</div>
<pre><span class="prompt">$ </span><span class="cmd">npm run deploy</span>
<span class="comment">
# Wrangler compiles the Worker, uploads it to Cloudflare,
# and prints the Worker URL. Your admin UI will be live at:
# https://&lt;your-domain&gt;/admin</span></pre>
</div>
</div>
</div>
<div class="install-step-connector"></div>
<!-- Step 5 -->
<div class="install-step">
<div class="install-step-num">5</div>
<div class="install-step-body">
<h3>Configure email ingestion</h3>
<p>Choose how incoming emails reach your Worker:</p>
<div class="install-split">
<div class="install-card highlight">
<div class="tag tag-recommended">Recommended</div>
<h4>Cloudflare Email Routing</h4>
<p>In the Cloudflare dashboard, go to <strong>Email → Email Routing</strong> and enable it on your domain. Add a catch-all rule with action <em>Send to Worker</em> pointing to your deployed Worker. No third-party service required.</p>
</div>
<div class="install-card">
<div class="tag tag-alt">Alternative</div>
<h4>ForwardEmail Webhook</h4>
<p>Point ForwardEmail MX records at your domain. ForwardEmail parses incoming mail and POSTs a JSON payload to <code style="font-family:monospace;font-size:0.8em">/api/inbound</code>. Useful if you already use ForwardEmail.</p>
</div>
</div>
</div>
</div>
<div class="install-step-connector"></div>
<!-- Step 6 — Optional R2 -->
<div class="install-step">
<div class="install-step-num">6</div>
<div class="install-step-body">
<h3>Optional: enable attachment storage</h3>
<p>To store email attachments and expose them as RSS enclosures, create an R2 bucket and bind it in <code style="font-family:monospace;font-size:0.85em;color:var(--accent)">wrangler.toml</code>:</p>
<div class="code-block">
<div class="code-block-header"><span class="dot-r"></span><span class="dot-y"></span><span class="dot-g"></span> wrangler.toml</div>
<pre><span class="comment">[[r2_buckets]]</span>
<span class="cmd">binding = "ATTACHMENT_BUCKET"
bucket_name = "kill-the-news-attachments"</span></pre>
</div>
<p style="margin-top:0.75rem;">Attachments will be served at <code style="font-family:monospace;font-size:0.85em;color:var(--accent)">/files/:id/:filename</code> and linked as <code style="font-family:monospace;font-size:0.85em;color:var(--accent)">&lt;enclosure&gt;</code> elements in the RSS feed.</p>
</div>
</div>
<div class="install-step-connector"></div>
<!-- Step 7 — WAF -->
<div class="install-step">
<div class="install-step-num">7</div>
<div class="install-step-body">
<h3>Harden with WAF rate limiting</h3>
<p>Protect your endpoints against abuse using Cloudflare WAF custom rate-limiting rules — no code changes required, pure infrastructure.</p>
<div class="install-split" style="margin-top:0.75rem;margin-bottom:1rem;">
<div class="install-card">
<div class="tag tag-recommended" style="background:var(--accent-dim);color:var(--accent);">Dashboard</div>
<h4>Via Cloudflare Dashboard</h4>
<p>Go to <strong>Security → Security rules</strong>, click <strong>Create rule</strong>, choose <strong>Rate limiting rule</strong>, and create one rule per endpoint below.</p>
<p style="margin-top:0.5rem;font-size:0.82em;color:#666;">⚠️ Free tier limitations: only <strong>1 rate limiting rule</strong> allowed; period and block duration capped at <strong>10 seconds</strong>. Prioritise the <code>/api/inbound</code> rule — it's the public-facing attack surface. Upgrade to a paid plan for full coverage.</p>
</div>
<div class="install-card">
<div class="tag tag-opt">Terraform</div>
<h4>Via Terraform</h4>
<p>Use <code style="font-family:monospace;font-size:0.8em">cloudflare_ruleset</code> with <code style="font-family:monospace;font-size:0.8em">phase = "http_ratelimit"</code> — see the snippet below.</p>
</div>
</div>
<p><strong style="color:var(--text)">Recommended limits:</strong></p>
<div class="code-block" style="margin-top:0.5rem;">
<div class="code-block-header"><span class="dot-r"></span><span class="dot-y"></span><span class="dot-g"></span> WAF rules</div>
<table class="waf-table">
<thead>
<tr>
<th scope="col">Setting</th>
<th scope="col"><code>/api/inbound</code></th>
<th scope="col"><code>/admin*</code></th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Condition (URI Path)</th>
<td>wildcard <code>/api/inbound/*</code></td>
<td>wildcard <code>/admin/*</code></td>
</tr>
<tr>
<th scope="row">Limit (recommended)</th>
<td>60 req / min / IP</td>
<td>20 req / min / IP</td>
</tr>
<tr>
<th scope="row">Limit (free tier)</th>
<td>10 req / 10 s / IP</td>
<td>20 req / 10 s / IP</td>
</tr>
<tr>
<th scope="row">Action (recommended)</th>
<td>Block (1 min)</td>
<td>Managed Challenge (5 min)</td>
</tr>
<tr>
<th scope="row">Action (free tier)</th>
<td>Block (10 s)</td>
<td>Managed Challenge (10 s)</td>
</tr>
</tbody>
</table>
</div>
<p style="margin-top:0.75rem;">Terraform equivalent <em style="font-size:0.82em;color:#666;">(supports method filtering and longer periods — requires a paid Cloudflare plan)</em>:</p>
<div class="code-block" style="margin-top:0.5rem;">
<div class="code-block-header"><span class="dot-r"></span><span class="dot-y"></span><span class="dot-g"></span> main.tf</div>
<pre><span class="cmd">resource "cloudflare_ruleset" "rate_limiting" {</span>
<span class="cmd"> zone_id = var.cloudflare_zone_id</span>
<span class="cmd"> name = "kill-the-news rate limiting"</span>
<span class="cmd"> kind = "zone"</span>
<span class="cmd"> phase = "http_ratelimit"</span>
<span class="cmd">
rules {</span>
<span class="cmd"> description = "Rate limit /api/inbound"</span>
<span class="cmd"> expression = "(http.request.uri.path eq \"/api/inbound\" and http.request.method eq \"POST\")"</span>
<span class="cmd"> action = "block"</span>
<span class="cmd"> ratelimit {</span>
<span class="cmd"> characteristics = ["ip.src"]</span>
<span class="cmd"> period = 60</span>
<span class="cmd"> requests_per_period = 60</span>
<span class="cmd"> mitigation_timeout = 60</span>
<span class="cmd"> }</span>
<span class="cmd"> }
</span>
<span class="cmd"> rules {</span>
<span class="cmd"> description = "Rate limit /admin"</span>
<span class="cmd"> expression = "starts_with(http.request.uri.path, \"/admin\")"</span>
<span class="cmd"> action = "managed_challenge"</span>
<span class="cmd"> ratelimit {</span>
<span class="cmd"> characteristics = ["ip.src"]</span>
<span class="cmd"> period = 60</span>
<span class="cmd"> requests_per_period = 20</span>
<span class="cmd"> mitigation_timeout = 300</span>
<span class="cmd"> }</span>
<span class="cmd"> }
}</span></pre>
</div>
<p style="margin-top:0.75rem;font-size:0.8rem;color:#555;">
These rules run at the Cloudflare edge before the Worker is invoked — zero latency impact on normal traffic.
If ForwardEmail's delivery IPs ever trigger the <code style="font-family:monospace;color:var(--muted)">/api/inbound</code> limit, add them as an IP Access Rule with action <em>Allow</em> under Security → WAF → Tools.
</p>
</div>
</div>
</div>
</section>
</div>
<!-- FAQ -->
<section id="faq">
<div class="section-header">
<div class="section-label">FAQ</div>
<h2 class="section-title">Questions &amp; answers</h2>
<p class="section-sub">The practical stuff — subscribing, privacy, troubleshooting, and how kill-the-news differs.</p>
</div>
<div class="faq-list">
<details class="faq-item">
<summary>How does kill-the-news work?</summary>
<p>Create a feed in the admin UI and you get a unique address on your domain (e.g. <code style="font-family:monospace;color:var(--accent)">newsletter.42@yourdomain.com</code>) plus an RSS and an Atom feed. Any email sent to that address is turned into entries in those feeds.</p>
</details>
<details class="faq-item">
<summary>How do I confirm a newsletter subscription?</summary>
<p>Confirmation emails arrive as feed entries — open the entry in your reader and click the confirmation link. If a publisher requires a reply, subscribe with your normal inbox instead and set up a filter that auto-forwards its mail to your feed address.</p>
</details>
<details class="faq-item">
<summary>Are my feeds private?</summary>
<p>Yes. Each feed URL carries an unguessable ID, it is served from your own domain on your own Cloudflare account, and the admin UI is password-protected. Treat the feed URL like a password — anyone who has it can read your newsletters.</p>
</details>
<details class="faq-item">
<summary>Why are old entries disappearing?</summary>
<p>Feeds honor an optional size and time-to-live cap so RSS readers stay happy — some readers choke on feeds that grow too large. When a limit is reached, the oldest entries (and their R2 attachments) are purged automatically.</p>
</details>
<details class="faq-item">
<summary>Can I share a feed with someone?</summary>
<p>Don't. Anyone with the URL can read your newsletters and even unsubscribe you. Share the project instead, so others can self-host and create their own feeds.</p>
</details>
<details class="faq-item">
<summary>Why isn't my feed updating?</summary>
<p>Send a test email to the feed address. If it shows up within a minute, the delay is on the newsletter publisher's side, not kill-the-news. Readers that support WebSub get near-instant push updates instead of waiting for the next poll.</p>
</details>
<details class="faq-item">
<summary>How is this different from kill-the-newsletter.com?</summary>
<p>kill-the-news is self-hosted on your own Cloudflare account: your data, your domain, RSS <em>and</em> Atom output, attachments served as enclosures, WebSub push updates — all running on the free tier.</p>
</details>
<details class="faq-item">
<summary>How much does it cost?</summary>
<p>It runs on Cloudflare's free tier (Workers + KV + R2) plus the cost of your domain. With Cloudflare Email Routing, no third-party service is required at all.</p>
</details>
<details class="faq-item">
<summary>How do I delete a feed?</summary>
<p>From the password-protected admin UI — open the Feeds tab and delete it there. Its entries and attachments are removed along with it.</p>
</details>
</div>
</section>
<!-- Tech Stack -->
<section id="tech-stack" style="padding-top:0;">
<div class="section-header">
<div class="section-label">Tech Stack</div>
<h2 class="section-title">Built on reliable primitives</h2>
<p class="section-sub">Minimal dependencies, maximum portability — runs entirely on Cloudflare's global network.</p>
</div>
<div class="tech-pills">
<span class="pill">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style="color:var(--accent)"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
Cloudflare Workers
</span>
<span class="pill">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--accent)"><rect x="2" y="2" width="9" height="9"/><rect x="13" y="2" width="9" height="9"/><rect x="13" y="13" width="9" height="9"/><rect x="2" y="13" width="9" height="9"/></svg>
Hono
</span>
<span class="pill">KV Storage</span>
<span class="pill">R2 Object Storage</span>
<span class="pill">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style="color:#3178c6"><path d="M0 12v12h24V0H0zm19.341-.956c.61.152 1.074.423 1.501.865.221.236.549.666.575.769.008.03-1.036.73-1.668 1.123-.023.015-.115-.084-.217-.236-.31-.45-.633-.644-1.128-.678-.728-.05-1.196.331-1.192.967a.88.88 0 0 0 .102.45c.16.331.458.53 1.39.933 1.719.74 2.454 1.227 2.911 1.92.51.773.625 2.008.278 2.926-.38.998-1.325 1.676-2.655 1.9-.411.073-1.386.062-1.828-.018-.964-.172-1.878-.648-2.442-1.273-.221-.243-.652-.88-.625-.925.011-.016.11-.077.22-.141.108-.061.511-.294.892-.515l.69-.4.145.214c.202.308.643.731.91.872.766.404 1.817.347 2.335-.118a.883.883 0 0 0 .313-.72c0-.278-.035-.4-.18-.61-.186-.266-.567-.49-1.649-.96-1.238-.533-1.771-.864-2.259-1.39a3.165 3.165 0 0 1-.659-1.2c-.091-.339-.114-1.189-.042-1.531.255-1.197 1.158-2.03 2.461-2.278.423-.08 1.406-.05 1.821.053z"/></svg>
TypeScript
</span>
<span class="pill">RSS 2.0</span>
<span class="pill">Atom</span>
<span class="pill">postal-mime</span>
<span class="pill">Zod</span>
<span class="pill">Vitest</span>
</div>
</section>
<!-- Sponsor -->
<div style="border-top:1px solid var(--border);padding:3rem 2rem;text-align:center;">
<p style="font-size:0.85rem;color:var(--muted);margin-bottom:1rem;">
kill-the-news is free and open source. If it saves you time, consider supporting its development.
</p>
<a href="https://github.com/sponsors/juherr" class="btn" target="_blank" rel="noopener"
style="background:rgba(219,97,162,0.12);border:1px solid rgba(219,97,162,0.35);color:#e879b8;font-size:0.875rem;padding:0.5rem 1.25rem;">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 21.593c-.524-.505-3.655-3.536-5.905-5.8C3.39 13.078 2 10.538 2 8a6 6 0 0 1 10-4.472A6 6 0 0 1 22 8c0 2.538-1.39 5.078-4.095 7.793-2.25 2.264-5.381 5.295-5.905 5.8z"/></svg>
Sponsor on GitHub
</a>
</div>
<!-- Footer -->
<footer>
<p>
<a href="https://github.com/juherr/kill-the-news" target="_blank" rel="noopener">kill-the-news</a>
<span class="sep">·</span>
MIT License
<span class="sep">·</span>
Built on <a href="https://workers.cloudflare.com/" target="_blank" rel="noopener">Cloudflare Workers</a>
<span class="sep">·</span>
Inspired by <a href="https://github.com/leafac/kill-the-newsletter" target="_blank" rel="noopener">kill-the-newsletter</a>
<span class="sep">·</span>
<a href="https://github.com/sponsors/juherr" target="_blank" rel="noopener">♥ Sponsor</a>
</p>
</footer>
<script>
(async function () {
const section = document.getElementById('stats');
let data;
try {
const res = await fetch('https://demo.kill-the.news/api/stats', { cache: 'no-store' });
if (!res.ok) return; // section stays hidden
data = await res.json();
} catch { return; }
const nums = section.querySelectorAll('.stat-num');
nums.forEach(el => { el.dataset.value = data[el.dataset.stat] ?? 0; });
section.hidden = false;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const fmt = n => n.toLocaleString('en-US');
const ease = t => 1 - Math.pow(1 - t, 3);
function animate(el) {
const target = Number(el.dataset.value) || 0;
if (reduce || target === 0) { el.textContent = fmt(target); return; }
const dur = 1400, start = performance.now();
(function step(now) {
const t = Math.min((now - start) / dur, 1);
el.textContent = fmt(Math.round(ease(t) * target));
if (t < 1) requestAnimationFrame(step);
})(performance.now());
}
const io = new IntersectionObserver((entries) => {
entries.forEach(e => { if (e.isIntersecting) { animate(e.target); io.unobserve(e.target); } });
}, { threshold: 0.4 });
nums.forEach(el => io.observe(el));
})();
</script>
</body>
</html>