Files
auto-publisher/sites/_engine/app.py

1058 lines
42 KiB
Python

"""
Living Site Engine — Dynamic, self-learning authority website.
Each vertical gets its own instance with its own database, identity, and content.
"""
import os
import json
import sqlite3
import hashlib
import time
from pathlib import Path
from datetime import datetime, timedelta
from functools import wraps
from flask import Flask, request, jsonify, render_template_string, g, abort, Response
# ─── Config ────────────────────────────────────────────────────────
VERTICAL = os.environ.get("PUBLISHER_VERTICAL", "guides")
DOMAIN = f"{VERTICAL}.thetempleofdoom.com"
DB_PATH = Path(f"/var/lib/publisher/{VERTICAL}.db")
SECRET = os.environ.get("PUBLISHER_SECRET", "auto-publish-2026")
# Per-vertical identity
IDENTITIES = {
"ai": {
"name": "AI Insights",
"tagline": "Exploring machine intelligence, one breakthrough at a time.",
"primary": "#7c3aed",
"accent": "#a78bfa",
"bg": "#0f0720",
"card_bg": "#1a1030",
"font_heading": "'Space Grotesk', sans-serif",
"gradient": "linear-gradient(135deg, #7c3aed, #a78bfa)",
"description": "Deep dives into AI, machine learning, LLMs, and the future of intelligence.",
},
"tech": {
"name": "Tech Frontier",
"tagline": "The software, systems, and stacks shaping tomorrow.",
"primary": "#2563eb",
"accent": "#60a5fa",
"bg": "#0a1628",
"card_bg": "#112240",
"font_heading": "'JetBrains Mono', monospace",
"gradient": "linear-gradient(135deg, #2563eb, #06b6d4)",
"description": "Programming, cloud infrastructure, DevOps, and the tools that power the internet.",
},
"science": {
"name": "Science Decoded",
"tagline": "The universe, explained clearly.",
"primary": "#059669",
"accent": "#34d399",
"bg": "#061a12",
"card_bg": "#0d2b1e",
"font_heading": "'Crimson Text', serif",
"gradient": "linear-gradient(135deg, #059669, #06b6d4)",
"description": "Physics, biology, chemistry, astronomy — the frontiers of human knowledge.",
},
"crypto": {
"name": "Crypto Compass",
"tagline": "Navigate the blockchain wilderness.",
"primary": "#f59e0b",
"accent": "#fbbf24",
"bg": "#1a1400",
"card_bg": "#261f00",
"font_heading": "'DM Mono', monospace",
"gradient": "linear-gradient(135deg, #f59e0b, #ef4444)",
"description": "Bitcoin, Ethereum, DeFi, and the technology rebuilding finance.",
},
"linux": {
"name": "Linux Lab",
"tagline": "Open source. Open mind. Open systems.",
"primary": "#f97316",
"accent": "#fb923c",
"bg": "#1a0e00",
"card_bg": "#261500",
"font_heading": "'Fira Code', monospace",
"gradient": "linear-gradient(135deg, #f97316, #ef4444)",
"description": "Linux, kernel hacking, system administration, and the FOSS ecosystem.",
},
"gaming": {
"name": "Game Layer",
"tagline": "The art, tech, and culture of play.",
"primary": "#ec4899",
"accent": "#f472b6",
"bg": "#1a0010",
"card_bg": "#260018",
"font_heading": "'Press Start 2P', cursive",
"gradient": "linear-gradient(135deg, #ec4899, #8b5cf6)",
"description": "Video games, game development, esports, hardware, and gaming culture.",
},
"diy": {
"name": "Maker Forge",
"tagline": "Build it yourself. Build it better.",
"primary": "#ef4444",
"accent": "#f87171",
"bg": "#1a0808",
"card_bg": "#261010",
"font_heading": "'Oswald', sans-serif",
"gradient": "linear-gradient(135deg, #ef4444, #f97316)",
"description": "3D printing, woodworking, electronics, home automation, and the maker movement.",
},
"guides": {
"name": "Practical Guides",
"tagline": "Learn anything. Step by step.",
"primary": "#0891b2",
"accent": "#22d3ee",
"bg": "#061a1e",
"card_bg": "#0d2b30",
"font_heading": "'Inter', sans-serif",
"gradient": "linear-gradient(135deg, #0891b2, #06b6d4)",
"description": "Clear, practical tutorials and how-to guides for everything that matters.",
},
}
IDENTITY = IDENTITIES.get(VERTICAL, IDENTITIES["guides"])
app = Flask(__name__)
# ─── Database ──────────────────────────────────────────────────────
def get_db():
if "db" not in g:
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
g.db = sqlite3.connect(str(DB_PATH))
g.db.row_factory = sqlite3.Row
g.db.execute("PRAGMA journal_mode=WAL")
g.db.execute("PRAGMA foreign_keys=ON")
return g.db
def init_db():
db = get_db()
db.executescript("""
CREATE TABLE IF NOT EXISTS articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
content_html TEXT NOT NULL,
content_md TEXT NOT NULL DEFAULT '',
excerpt TEXT DEFAULT '',
seo_title TEXT,
seo_description TEXT,
og_image TEXT,
keywords TEXT DEFAULT '[]',
word_count INTEGER DEFAULT 0,
reading_time INTEGER DEFAULT 0,
status TEXT DEFAULT 'published',
published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS pageviews (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL,
article_id INTEGER,
referrer TEXT DEFAULT '',
ip_hash TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS topics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
score REAL DEFAULT 0,
status TEXT DEFAULT 'discovered',
article_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS learning (
id INTEGER PRIMARY KEY AUTOINCREMENT,
metric TEXT NOT NULL,
value TEXT NOT NULL,
recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
title, content_md, excerpt, keywords,
content='articles', content_rowid='id'
);
CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN
INSERT INTO articles_fts(rowid, title, content_md, excerpt, keywords)
VALUES (new.id, new.title, new.content_md, new.excerpt, new.keywords);
END;
CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN
INSERT INTO articles_fts(articles_fts, rowid, title, content_md, excerpt, keywords)
VALUES ('delete', old.id, old.title, old.content_md, old.excerpt, old.keywords);
END;
CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN
INSERT INTO articles_fts(articles_fts, rowid, title, content_md, excerpt, keywords)
VALUES ('delete', old.id, old.title, old.content_md, old.excerpt, old.keywords);
INSERT INTO articles_fts(rowid, title, content_md, excerpt, keywords)
VALUES (new.id, new.title, new.content_md, new.excerpt, new.keywords);
END;
CREATE INDEX IF NOT EXISTS idx_pageviews_path ON pageviews(path);
CREATE INDEX IF NOT EXISTS idx_pageviews_created ON pageviews(created_at);
CREATE INDEX IF NOT EXISTS idx_articles_published ON articles(published_at);
CREATE INDEX IF NOT EXISTS idx_articles_slug ON articles(slug);
""")
@app.teardown_appcontext
def close_db(exception):
db = g.pop("db", None)
if db:
db.close()
# ─── Helpers ───────────────────────────────────────────────────────
def record_view(path, article_id=None):
db = get_db()
ip = request.remote_addr or ""
ip_hash = hashlib.sha256(ip.encode()).hexdigest()[:16]
ref = request.headers.get("Referer", "")[:500]
db.execute(
"INSERT INTO pageviews (path, article_id, referrer, ip_hash) VALUES (?, ?, ?, ?)",
(path, article_id, ref, ip_hash)
)
db.commit()
def get_popular(limit=6):
db = get_db()
rows = db.execute("""
SELECT a.*, COUNT(p.id) as views
FROM articles a
LEFT JOIN pageviews p ON a.id = p.article_id
WHERE a.status = 'published'
GROUP BY a.id
ORDER BY views DESC
LIMIT ?
""", (limit,)).fetchall()
return [dict(r) for r in rows]
def get_related(article_id, vertical=None, limit=4):
db = get_db()
# Simple: same vertical, different article, most recent
rows = db.execute("""
SELECT * FROM articles
WHERE id != ? AND status = 'published'
ORDER BY published_at DESC
LIMIT ?
""", (article_id, limit)).fetchall()
return [dict(r) for r in rows]
def search_articles(query, limit=20):
db = get_db()
rows = db.execute("""
SELECT a.* FROM articles a
JOIN articles_fts fts ON a.id = fts.rowid
WHERE articles_fts MATCH ? AND a.status = 'published'
ORDER BY rank
LIMIT ?
""", (query, limit)).fetchall()
return [dict(r) for r in rows]
def build_rss():
db = get_db()
articles = db.execute(
"SELECT * FROM articles WHERE status='published' ORDER BY published_at DESC LIMIT 20"
).fetchall()
items = ""
for a in articles:
a = dict(a)
items += f"""
<item>
<title>{_xml(a['title'])}</title>
<link>https://{DOMAIN}/articles/{a['slug']}</link>
<guid>https://{DOMAIN}/articles/{a['slug']}</guid>
<description>{_xml(a.get('excerpt', ''))}</description>
<pubDate>{a.get('published_at', '')}</pubDate>
</item>"""
return f"""<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>{IDENTITY['name']}</title>
<link>https://{DOMAIN}</link>
<description>{IDENTITY['description']}</description>
<language>en-us</language>
<lastBuildDate>{datetime.now().isoformat()}</lastBuildDate>
<atom:link href="https://{DOMAIN}/rss.xml" rel="self" type="application/rss+xml"/>
{items}
</channel>
</rss>"""
def build_sitemap_xml():
db = get_db()
articles = db.execute(
"SELECT slug, updated_at FROM articles WHERE status='published' ORDER BY published_at DESC"
).fetchall()
urls = f"""
<url>
<loc>https://{DOMAIN}/</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://{DOMAIN}/search</loc>
<changefreq>weekly</changefreq>
<priority>0.6</priority>
</url>"""
for a in articles:
urls += f"""
<url>
<loc>https://{DOMAIN}/articles/{a['slug']}</loc>
<lastmod>{a['updated_at'][:10]}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>"""
return f"""<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">{urls}
</urlset>"""
def build_json_ld(article=None):
if article:
return {
"@context": "https://schema.org",
"@type": "Article",
"headline": article.get("title", ""),
"description": article.get("excerpt", ""),
"datePublished": article.get("published_at", ""),
"author": {"@type": "Organization", "name": IDENTITY["name"]},
"publisher": {"@type": "Organization", "name": IDENTITY["name"], "logo": {"@type": "ImageObject", "url": f"https://{DOMAIN}/assets/logo.png"}},
}
return {
"@context": "https://schema.org",
"@type": "WebSite",
"name": IDENTITY["name"],
"url": f"https://{DOMAIN}",
"description": IDENTITY["description"],
"potentialAction": {
"@type": "SearchAction",
"target": f"https://{DOMAIN}/search?q={{search_term_string}}",
"query-input": "required name=search_term_string",
},
}
def _xml(s):
return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
# ─── Routes ────────────────────────────────────────────────────────
@app.route("/")
def home():
db = get_db()
articles = db.execute(
"SELECT * FROM articles WHERE status='published' ORDER BY published_at DESC LIMIT 20"
).fetchall()
articles = [dict(a) for a in articles]
popular = get_popular(6)
total = db.execute("SELECT COUNT(*) FROM articles WHERE status='published'").fetchone()[0]
total_views = db.execute("SELECT COUNT(*) FROM pageviews").fetchone()[0]
record_view("/")
return render_template_string(HOME_TEMPLATE, **IDENTITY,
articles=articles, popular=popular, total=total, total_views=total_views,
domain=DOMAIN, vertical=VERTICAL, json_ld=json.dumps(build_json_ld()))
@app.route("/articles/<slug>")
def article(slug):
db = get_db()
row = db.execute("SELECT * FROM articles WHERE slug=? AND status='published'", (slug,)).fetchone()
if not row:
abort(404)
article = dict(row)
related = get_related(article["id"])
record_view(f"/articles/{slug}", article["id"])
return render_template_string(ARTICLE_TEMPLATE, **IDENTITY,
article=article, related=related, domain=DOMAIN, vertical=VERTICAL,
json_ld=json.dumps(build_json_ld(article)))
@app.route("/search")
def search():
q = request.args.get("q", "").strip()
results = []
if q:
results = search_articles(q)
record_view("/search")
return render_template_string(SEARCH_TEMPLATE, **IDENTITY,
query=q, results=results, domain=DOMAIN, vertical=VERTICAL)
@app.route("/rss.xml")
def rss():
return Response(build_rss(), mimetype="application/rss+xml")
@app.route("/sitemap.xml")
def sitemap():
return Response(build_sitemap_xml(), mimetype="application/xml")
# ─── Analytics Collector ──────────────────────────────────────────
@app.route("/a/ping")
def analytics_ping():
"""Invisible tracking pixel."""
path = request.args.get("p", "/")
record_view(path)
# 1x1 transparent GIF
return Response(
b"\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff\x00\x00\x00\x21\xf9\x04\x01\x00\x00\x00\x00\x2c\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02\x44\x01\x00\x3b",
mimetype="image/gif",
headers={"Cache-Control": "no-cache, no-store, must-revalidate"},
)
# ─── Content API (for orchestrator) ────────────────────────────────
@app.route("/api/publish", methods=["POST"])
def api_publish():
"""Receive a new article from the orchestrator."""
auth = request.headers.get("Authorization", "")
if auth != f"Bearer {SECRET}":
return jsonify({"error": "unauthorized"}), 401
data = request.json
if not data:
return jsonify({"error": "no data"}), 400
db = get_db()
slug = data.get("slug", "")
title = data.get("title", "")
content_html = data.get("content_html", data.get("content_md", ""))
content_md = data.get("content_md", "")
excerpt = data.get("excerpt", data.get("seo_description", ""))
seo_title = data.get("seo_title", title)
seo_description = data.get("seo_description", "")
og_image = data.get("og_image", "")
keywords = json.dumps(data.get("keywords", []))
word_count = data.get("word_count", len(content_md.split()))
reading_time = data.get("reading_time", max(1, word_count // 200))
# Upsert
existing = db.execute("SELECT id FROM articles WHERE slug=?", (slug,)).fetchone()
if existing:
db.execute("""
UPDATE articles SET title=?, content_html=?, content_md=?, excerpt=?,
seo_title=?, seo_description=?, og_image=?, keywords=?,
word_count=?, reading_time=?, updated_at=CURRENT_TIMESTAMP
WHERE slug=?
""", (title, content_html, content_md, excerpt, seo_title, seo_description,
og_image, keywords, word_count, reading_time, slug))
else:
db.execute("""
INSERT INTO articles (title, slug, content_html, content_md, excerpt,
seo_title, seo_description, og_image, keywords, word_count, reading_time, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'published')
""", (title, slug, content_html, content_md, excerpt, seo_title, seo_description,
og_image, keywords, word_count, reading_time))
db.commit()
return jsonify({"status": "published", "slug": slug}), 201
@app.route("/api/articles")
def api_articles():
db = get_db()
limit = request.args.get("limit", 50)
rows = db.execute(
"SELECT * FROM articles WHERE status='published' ORDER BY published_at DESC LIMIT ?",
(limit,)
).fetchall()
return jsonify([dict(r) for r in rows])
@app.route("/api/stats")
def api_stats():
db = get_db()
return jsonify({
"total_articles": db.execute("SELECT COUNT(*) FROM articles WHERE status='published'").fetchone()[0],
"total_views": db.execute("SELECT COUNT(*) FROM pageviews").fetchone()[0],
"today_views": db.execute(
"SELECT COUNT(*) FROM pageviews WHERE created_at >= date('now')"
).fetchone()[0],
"popular": get_popular(5),
})
@app.route("/health")
def health():
db = get_db()
count = db.execute("SELECT COUNT(*) FROM articles").fetchone()[0]
return jsonify({
"status": "healthy",
"vertical": VERTICAL,
"articles": count,
"timestamp": datetime.now().isoformat(),
})
# ─── Error Handlers ──────────────────────────────────────────────
@app.errorhandler(404)
def not_found(e):
return render_template_string(NOT_FOUND_TEMPLATE, **IDENTITY, domain=DOMAIN, vertical=VERTICAL), 404
# ─── Templates ─────────────────────────────────────────────────────
HOME_TEMPLATE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ name }} — {{ tagline }}</title>
<meta name="description" content="{{ description }}">
<meta property="og:title" content="{{ name }}">
<meta property="og:description" content="{{ tagline }}">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary_large_image">
<link rel="canonical" href="https://{{ domain }}/">
<link rel="alternate" type="application/rss+xml" title="{{ name }} RSS" href="/rss.xml">
<script type="application/ld+json">{{ json_ld }}</script>
<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&family=Space+Grotesk:wght@400;500;600;700&family=Crimson+Text:wght@400;600;700&family=DM+Mono:wght@400;500&family=Fira+Code:wght@400;500&family=Press+Start+2P&family=Oswald:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: {{ bg }};
--card-bg: {{ card_bg }};
--text: #e2e8f0;
--text-muted: #94a3b8;
--primary: {{ primary }};
--accent: {{ accent }};
--gradient: {{ gradient }};
--border: rgba(255,255,255,0.08);
--font-heading: {{ font_heading }};
--radius: 12px;
--max-width: 1000px;
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{font-size:17px;line-height:1.6;scroll-behavior:smooth}
body{
font-family:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;
background:var(--bg);color:var(--text);
min-height:100vh;
-webkit-font-smoothing:antialiased;
}
/* ── Header ── */
header{
position:sticky;top:0;z-index:100;
background:rgba(15,7,32,0.85);backdrop-filter:blur(20px);
border-bottom:1px solid var(--border);
}
nav{
max-width:var(--max-width);margin:0 auto;
display:flex;justify-content:space-between;align-items:center;
padding:0.75rem 1.5rem;
}
.logo{
font-family:var(--font-heading);font-weight:700;font-size:1.3rem;
background:var(--gradient);-webkit-background-clip:text;
-webkit-text-fill-color:transparent;text-decoration:none;
}
.nav-links{display:flex;gap:1.5rem;align-items:center}
.nav-links a{
color:var(--text-muted);text-decoration:none;font-size:0.85rem;
font-weight:500;transition:color 0.2s;
}
.nav-links a:hover{color:var(--text)}
.search-box{display:flex;gap:0.25rem}
.search-box input{
background:var(--card-bg);border:1px solid var(--border);
color:var(--text);padding:0.4rem 0.75rem;border-radius:6px;
font-size:0.85rem;width:180px;
}
.search-box button{
background:var(--primary);color:white;border:none;
padding:0.4rem 0.75rem;border-radius:6px;cursor:pointer;
font-size:0.85rem;
}
/* ── Hero ── */
.hero{
max-width:var(--max-width);margin:0 auto;padding:4rem 1.5rem 3rem;
text-align:center;
}
.hero h1{
font-family:var(--font-heading);font-size:2.8rem;font-weight:700;
background:var(--gradient);-webkit-background-clip:text;
-webkit-text-fill-color:transparent;line-height:1.15;
margin-bottom:0.75rem;
}
.hero p{
color:var(--text-muted);font-size:1.15rem;max-width:600px;
margin:0 auto 1.5rem;
}
.hero-stats{
display:flex;gap:2rem;justify-content:center;
font-size:0.85rem;color:var(--text-muted);
}
.hero-stats strong{color:var(--accent);font-family:var(--font-heading)}
/* ── Article Grid ── */
.content{max-width:var(--max-width);margin:0 auto;padding:0 1.5rem 4rem}
.section-title{
font-family:var(--font-heading);font-size:1.3rem;margin-bottom:1rem;
color:var(--text);
}
.articles-grid{
display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));
gap:1.25rem;margin-bottom:2.5rem;
}
.card{
background:var(--card-bg);border:1px solid var(--border);
border-radius:var(--radius);padding:1.5rem;
transition:transform 0.2s,border-color 0.2s,box-shadow 0.2s;
text-decoration:none;display:block;color:inherit;
}
.card:hover{
transform:translateY(-2px);
border-color:var(--primary);
box-shadow:0 8px 30px rgba(0,0,0,0.3);
}
.card h3{
font-family:var(--font-heading);font-size:1.1rem;font-weight:600;
margin-bottom:0.35rem;line-height:1.3;color:var(--text);
}
.card .excerpt{color:var(--text-muted);font-size:0.88rem;line-height:1.5;margin-bottom:0.75rem}
.card .meta{
display:flex;gap:1rem;font-size:0.75rem;color:var(--text-muted);
}
.card .views{color:var(--accent);font-weight:600}
/* ── Popular Sidebar ── */
.layout{display:grid;grid-template-columns:1fr 280px;gap:1.5rem;align-items:start}
.sidebar .card{padding:1rem}
.sidebar .card h3{font-size:0.95rem}
/* ── Footer ── */
footer{
border-top:1px solid var(--border);padding:2rem 1.5rem;
text-align:center;color:var(--text-muted);font-size:0.8rem;
display:flex;justify-content:space-between;max-width:var(--max-width);
margin:0 auto;flex-wrap:wrap;gap:1rem;
}
footer a{color:var(--text-muted);text-decoration:none}
footer a:hover{color:var(--accent)}
@media(max-width:768px){
.hero h1{font-size:1.8rem}
.layout{grid-template-columns:1fr}
.sidebar{display:none}
.articles-grid{grid-template-columns:1fr}
.search-box input{width:120px}
.hero-stats{flex-wrap:wrap;gap:0.75rem}
}
</style>
</head>
<body>
<header>
<nav>
<a href="/" class="logo">{{ name }}</a>
<div class="nav-links">
<a href="/">Home</a>
<a href="/search">Search</a>
<a href="/rss.xml">RSS</a>
<form class="search-box" action="/search" method="GET">
<input type="text" name="q" placeholder="Search...">
<button type="submit">→</button>
</form>
</div>
</nav>
</header>
<section class="hero">
<h1>{{ tagline }}</h1>
<p>{{ description }}</p>
<div class="hero-stats">
<span><strong>{{ total }}</strong> articles</span>
<span><strong>{{ total_views }}</strong> readers</span>
<span>Updated <strong>daily</strong></span>
</div>
</section>
<div class="content">
{% if popular %}
<h2 class="section-title">🔥 Trending Now</h2>
<div class="articles-grid">
{% for a in popular[:3] %}
<a href="/articles/{{ a.slug }}" class="card">
<h3>{{ a.title }}</h3>
<p class="excerpt">{{ a.excerpt[:160] }}{% if a.excerpt|length > 160 %}...{% endif %}</p>
<div class="meta">
<span class="views">{{ a.views }} reads</span>
<span>{{ a.reading_time }} min</span>
<span>{{ a.published_at[:10] }}</span>
</div>
</a>
{% endfor %}
</div>
{% endif %}
<h2 class="section-title">📚 Latest Articles</h2>
<div class="layout">
<div class="articles-grid" style="grid-template-columns:1fr">
{% for a in articles %}
<a href="/articles/{{ a.slug }}" class="card">
<h3>{{ a.title }}</h3>
<p class="excerpt">{{ a.excerpt[:200] }}{% if a.excerpt|length > 200 %}...{% endif %}</p>
<div class="meta">
<span>{{ a.reading_time }} min read</span>
<span>{{ a.word_count }} words</span>
<span>{{ a.published_at[:10] }}</span>
</div>
</a>
{% endfor %}
</div>
{% if popular[3:] %}
<aside class="sidebar">
<h3 style="font-family:var(--font-heading);margin-bottom:0.75rem;font-size:1rem">More Popular</h3>
{% for a in popular[3:6] %}
<a href="/articles/{{ a.slug }}" class="card" style="margin-bottom:0.5rem">
<h3>{{ a.title[:60] }}</h3>
<div class="meta"><span class="views">{{ a.views }} reads</span></div>
</a>
{% endfor %}
</aside>
{% endif %}
</div>
</div>
<footer>
<span>© {{ domain }}</span>
<nav>
<a href="/rss.xml">RSS</a>
<a href="/sitemap.xml">Sitemap</a>
<a href="/search">Search</a>
</nav>
</footer>
<img src="/a/ping?p=/" alt="" width="1" height="1" style="display:none">
</body>
</html>"""
ARTICLE_TEMPLATE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ article.seo_title or article.title }}</title>
<meta name="description" content="{{ article.seo_description or article.excerpt }}">
<meta property="og:title" content="{{ article.seo_title or article.title }}">
<meta property="og:description" content="{{ article.seo_description or article.excerpt }}">
<meta property="og:type" content="article">
<meta property="og:image" content="{{ article.og_image or '' }}">
<meta name="twitter:card" content="summary_large_image">
<link rel="canonical" href="https://{{ domain }}/articles/{{ article.slug }}">
<script type="application/ld+json">{{ json_ld }}</script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Space+Grotesk:wght@400;500;600;700&family=Crimson+Text:wght@400;600;700&family=DM+Mono:wght@400;500&family=Fira+Code:wght@400;500&family=Press+Start+2P&family=Oswald:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: {{ bg }};
--card-bg: {{ card_bg }};
--text: #e2e8f0;
--text-muted: #94a3b8;
--primary: {{ primary }};
--accent: {{ accent }};
--gradient: {{ gradient }};
--border: rgba(255,255,255,0.08);
--font-heading: {{ font_heading }};
--radius: 12px;
--max-width: 760px;
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{font-size:18px;line-height:1.75;scroll-behavior:smooth}
body{
font-family:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;
background:var(--bg);color:var(--text);
min-height:100vh;-webkit-font-smoothing:antialiased;
}
header{
position:sticky;top:0;z-index:100;
background:rgba(15,7,32,0.85);backdrop-filter:blur(20px);
border-bottom:1px solid var(--border);
}
nav{
max-width:1000px;margin:0 auto;
display:flex;justify-content:space-between;align-items:center;
padding:0.75rem 1.5rem;
}
.logo{
font-family:var(--font-heading);font-weight:700;font-size:1.1rem;
background:var(--gradient);-webkit-background-clip:text;
-webkit-text-fill-color:transparent;text-decoration:none;
}
.nav-links{display:flex;gap:1rem}
.nav-links a{color:var(--text-muted);text-decoration:none;font-size:0.8rem;font-weight:500}
.nav-links a:hover{color:var(--text)}
main{max-width:var(--max-width);margin:0 auto;padding:3rem 1.5rem}
.article-header{margin-bottom:2.5rem;text-align:center}
.article-header h1{
font-family:var(--font-heading);font-size:2.2rem;font-weight:700;
line-height:1.2;margin-bottom:0.75rem;
}
.article-meta{
display:flex;gap:1rem;justify-content:center;
color:var(--text-muted);font-size:0.85rem;flex-wrap:wrap;
}
.article-content{font-size:1.05rem}
.article-content h2{
font-family:var(--font-heading);font-size:1.5rem;margin:2.5rem 0 0.75rem;
color:var(--accent);
}
.article-content h3{font-size:1.2rem;margin:1.5rem 0 0.5rem}
.article-content p{margin-bottom:1.3rem}
.article-content a{color:var(--accent);text-decoration:underline}
.article-content strong{color:var(--text);font-weight:600}
.article-content ul,.article-content ol{margin:0.5rem 0 1.3rem 1.5rem}
.article-content li{margin-bottom:0.4rem}
.article-content blockquote{
border-left:3px solid var(--primary);padding:0.75rem 1.5rem;
margin:1.5rem 0;background:var(--card-bg);border-radius:0 8px 8px 0;
font-style:italic;color:var(--text-muted);
}
.article-content pre{
background:#0d1117;padding:1.25rem;border-radius:8px;
overflow-x:auto;margin:1.5rem 0;font-family:'JetBrains Mono',monospace;
font-size:0.82rem;line-height:1.6;border:1px solid var(--border);
}
.article-content code{
font-family:'JetBrains Mono',monospace;font-size:0.85em;
background:var(--card-bg);padding:0.15em 0.4em;border-radius:4px;
}
.article-content pre code{background:none;padding:0}
.article-content hr{border:none;border-top:1px solid var(--border);margin:2rem 0}
.article-content img{max-width:100%;border-radius:8px;margin:1rem 0}
.article-footer{margin-top:3rem;padding-top:1.5rem;border-top:1px solid var(--border)}
.tags{display:flex;gap:0.5rem;flex-wrap:wrap;margin-bottom:1rem}
.tag{background:var(--card-bg);color:var(--text-muted);padding:0.2rem 0.6rem;border-radius:20px;font-size:0.75rem}
.related{margin-top:3rem}
.related h2{font-family:var(--font-heading);font-size:1.3rem;margin-bottom:1rem}
.related-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:0.75rem}
.related-card{
background:var(--card-bg);border:1px solid var(--border);border-radius:8px;
padding:1rem;text-decoration:none;color:inherit;transition:border-color 0.2s;
}
.related-card:hover{border-color:var(--primary)}
.related-card h4{font-size:0.9rem;font-weight:600;margin-bottom:0.25rem}
.related-card .meta{font-size:0.7rem;color:var(--text-muted)}
footer{
border-top:1px solid var(--border);padding:2rem 1.5rem;
text-align:center;color:var(--text-muted);font-size:0.8rem;
max-width:1000px;margin:0 auto;
display:flex;justify-content:space-between;
}
footer a{color:var(--text-muted);text-decoration:none}
@media(max-width:600px){
.article-header h1{font-size:1.5rem}
.related-grid{grid-template-columns:1fr}
}
</style>
</head>
<body>
<header>
<nav>
<a href="/" class="logo">{{ name }}</a>
<div class="nav-links">
<a href="/">Home</a>
<a href="/search">Search</a>
<a href="/rss.xml">RSS</a>
</div>
</nav>
</header>
<main>
<article>
<header class="article-header">
<h1>{{ article.title }}</h1>
<div class="article-meta">
<span>{{ article.published_at[:10] }}</span>
<span>{{ article.reading_time }} min read</span>
<span>{{ article.word_count }} words</span>
</div>
</header>
<div class="article-content">
{{ article.content_html|safe }}
</div>
<footer class="article-footer">
{% if article.keywords %}
<div class="tags">
{% for kw in article.keywords|from_json %}
<span class="tag">{{ kw }}</span>
{% endfor %}
</div>
{% endif %}
</footer>
</article>
{% if related %}
<section class="related">
<h2>Continue Reading</h2>
<div class="related-grid">
{% for r in related %}
<a href="/articles/{{ r.slug }}" class="related-card">
<h4>{{ r.title[:60] }}</h4>
<div class="meta">{{ r.reading_time }} min · {{ r.published_at[:10] }}</div>
</a>
{% endfor %}
</div>
</section>
{% endif %}
</main>
<footer>
<span>© {{ domain }}</span>
<nav>
<a href="/rss.xml">RSS</a>
<a href="/sitemap.xml">Sitemap</a>
</nav>
</footer>
<img src="/a/ping?p=/articles/{{ article.slug }}" alt="" width="1" height="1" style="display:none">
</body>
</html>"""
SEARCH_TEMPLATE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Search — {{ name }}</title>
<meta name="description" content="Search {{ name }} for articles and guides.">
<style>
:root {
--bg: {{ bg }};--card-bg: {{ card_bg }};--text: #e2e8f0;
--text-muted: #94a3b8;--primary: {{ primary }};--accent: {{ accent }};
--gradient: {{ gradient }};--border: rgba(255,255,255,0.08);
--font-heading: {{ font_heading }};
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
header{position:sticky;top:0;background:rgba(15,7,32,0.85);backdrop-filter:blur(20px);border-bottom:1px solid var(--border)}
nav{max-width:1000px;margin:0 auto;display:flex;justify-content:space-between;align-items:center;padding:0.75rem 1.5rem}
.logo{font-family:var(--font-heading);font-weight:700;font-size:1.3rem;background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent;text-decoration:none}
main{max-width:700px;margin:0 auto;padding:3rem 1.5rem}
h1{font-family:var(--font-heading);font-size:1.8rem;margin-bottom:1.5rem}
.search-form{display:flex;gap:0.5rem;margin-bottom:2rem}
.search-form input{
flex:1;padding:0.75rem 1rem;background:var(--card-bg);border:1px solid var(--border);
color:var(--text);border-radius:8px;font-size:1rem;
}
.search-form button{
padding:0.75rem 1.5rem;background:var(--primary);color:white;
border:none;border-radius:8px;cursor:pointer;font-size:1rem;font-weight:600;
}
.result{
background:var(--card-bg);border:1px solid var(--border);border-radius:8px;
padding:1.25rem;margin-bottom:0.75rem;text-decoration:none;display:block;color:inherit;
}
.result:hover{border-color:var(--primary)}
.result h3{font-size:1.05rem;margin-bottom:0.25rem}
.result p{color:var(--text-muted);font-size:0.88rem}
footer{border-top:1px solid var(--border);padding:2rem 1.5rem;text-align:center;color:var(--text-muted);font-size:0.8rem}
</style>
</head>
<body>
<header>
<nav>
<a href="/" class="logo">{{ name }}</a>
</nav>
</header>
<main>
<h1>Search {{ name }}</h1>
<form class="search-form" action="/search" method="GET">
<input type="text" name="q" value="{{ query }}" placeholder="Search articles..." autofocus>
<button type="submit">Search</button>
</form>
{% if results %}
<p style="color:var(--text-muted);margin-bottom:1rem">{{ results|length }} results for "{{ query }}"</p>
{% for r in results %}
<a href="/articles/{{ r.slug }}" class="result">
<h3>{{ r.title }}</h3>
<p>{{ r.excerpt[:200] }}</p>
</a>
{% endfor %}
{% elif query %}
<p style="color:var(--text-muted)">No results for "{{ query }}"</p>
{% endif %}
</main>
<footer>© {{ domain }}</footer>
</body>
</html>"""
NOT_FOUND_TEMPLATE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 — {{ name }}</title>
<style>
:root{--bg:{{ bg }};--text:#e2e8f0;--text-muted:#94a3b8;--primary:{{ primary }};--gradient:{{ gradient }};--font-heading:{{ font_heading }}}
body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center}
h1{font-family:var(--font-heading);font-size:4rem;background:var(--gradient);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
p{color:var(--text-muted);margin:1rem 0}
a{color:var(--primary)}
</style>
</head>
<body>
<div>
<h1>404</h1>
<p>This page doesn't exist yet. Maybe it will tomorrow.</p>
<a href="/">← Back to {{ name }}</a>
</div>
</body>
</html>"""
# ─── Jinja filter ──────────────────────────────────────────────────
@app.template_filter("from_json")
def from_json_filter(s):
try:
return json.loads(s)
except (json.JSONDecodeError, TypeError):
return []
# ─── Main ──────────────────────────────────────────────────────────
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("--port", type=int, default=5000)
ap.add_argument("--host", type=str, default="0.0.0.0")
args = ap.parse_args()
with app.app_context():
init_db()
print(f"🚀 {IDENTITY['name']} → http://{args.host}:{args.port}")
app.run(host=args.host, port=args.port, debug=False)