Files
apophenia.news/scripts/lib/renderers.mjs
T
2026-10-07 16:39:57 -07:00

410 lines
16 KiB
JavaScript

import { SITE_URL, TURNSTILE_SITE_KEY } from "./constants.mjs";
import { footer, merch, nav, shellHead } from "./templates.mjs";
import { escapeHtml, escapeXml, fmtDate, renderAuthorInline, toISODate } from "./utils.mjs";
export const renderHome = (articles) => `
${shellHead({
title: "apophenia.news — The news outlet for pattern seekers",
desc: "Signals, anomalies, civilization trajectories, and deep pattern analysis.",
image: "https://direct-img.link/constellation+data+points+minimal+white+background",
url: `${SITE_URL}/`,
type: "website"
})}
${nav}
<main class="shell py-10">
<section class="card p-6 sm:p-10 bg-gradient-to-b from-white to-indigo-50/50">
<p class="tag mb-3"><i data-lucide="sparkles" class="h-3.5 w-3.5"></i>Pattern Intelligence Journalism</p>
<h1 class="text-4xl sm:text-5xl font-bold leading-tight">The news outlet for pattern seekers</h1>
<p class="mt-4 text-zinc-700 max-w-2xl">High-agency analysis at the intersection of AGI, consciousness, geopolitics, and first-contact logic.</p>
</section>
<section class="mt-10">
<div class="flex items-center justify-between mb-4">
<h2 class="text-2xl font-bold">Latest Articles</h2>
<span class="text-sm text-zinc-500">${articles.length} published</span>
</div>
<div class="grid md:grid-cols-2 gap-5">
${articles
.map(
(a) => `
<article class="card overflow-hidden hover:-translate-y-0.5 transition">
<img src="${a.header_image}" alt="${escapeHtml(a.title)}" class="h-48 w-full object-cover" />
<div class="p-5">
<p class="text-xs uppercase tracking-wide text-zinc-500">${fmtDate(a.date)}</p>
<h3 class="mt-2 text-2xl font-semibold leading-tight">
<a href="/${a.slug}/">${escapeHtml(a.title)}</a>
</h3>
<p class="mt-2 text-zinc-700">${escapeHtml(a.description || "")}</p>
<div class="mt-4 flex flex-wrap gap-2">
${(a.tags || []).slice(0, 4).map((t) => `<span class="tag">#${escapeHtml(t)}</span>`).join("")}
</div>
<a href="/${a.slug}/" class="inline-flex items-center gap-1 mt-5 text-sm font-medium">
Read article <i data-lucide="arrow-right" class="h-4 w-4"></i>
</a>
</div>
</article>`
)
.join("")}
</div>
</section>
${merch}
</main>
${footer}
`;
export const renderArticle = (article) => `
${shellHead({
title: `${article.title} — apophenia.news`,
desc: article.description,
image: article.header_image,
url: `${SITE_URL}/${article.slug}/`,
type: "article"
})}
${nav}
<main class="shell py-10">
<article class="card overflow-hidden">
<img src="${article.header_image}" alt="${escapeHtml(article.title)}" class="h-64 w-full object-cover" />
<div class="p-6 sm:p-10">
<div class="flex items-center justify-between gap-4">
<p class="text-xs uppercase tracking-wide text-zinc-500">${fmtDate(article.date)} • ${renderAuthorInline(article.author)}</p>
<button type="button" data-copy-article aria-label="Copy article" class="inline-flex shrink-0 items-center gap-2 rounded-lg border border-zinc-200 bg-white px-2 py-2 text-sm font-medium text-zinc-800 shadow-sm transition hover:bg-zinc-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 sm:px-3">
<i data-lucide="copy" class="h-4 w-4"></i><span aria-live="polite" class="hidden sm:inline">Copy article</span>
</button>
</div>
<h1 class="mt-2 text-4xl sm:text-5xl font-bold leading-tight">${escapeHtml(article.title)}</h1>
<p class="mt-4 text-zinc-700 max-w-3xl">${escapeHtml(article.description || "")}</p>
<div class="mt-5 flex flex-wrap gap-2">
${(article.tags || []).map((t) => `<span class="tag">#${escapeHtml(t)}</span>`).join("")}
</div>
<hr class="my-8 border-zinc-200" />
<div class="article-prose">${article.html}</div>
</div>
<script id="article-markdown" type="application/json">${JSON.stringify(article.markdown).replace(/</g, "\\u003c")}</script>
</article>
${merch}
<div class="mt-8">
<a href="/" class="inline-flex items-center gap-2 text-sm">
<i data-lucide="arrow-left" class="h-4 w-4"></i> Back to Home
</a>
</div>
</main>
${footer}
`;
export const renderNewsletterPage = () => `
${shellHead({
title: "Newsletter — apophenia.news",
desc: "Subscribe to the apophenia.news newsletter. We won't spam you with everything — only the good stuff.",
image: "https://direct-img.link/cosmic+newsletter+signal+envelope+minimal",
url: `${SITE_URL}/newsletter/`,
type: "website"
})}
${nav}
<main class="shell py-10">
<article class="card p-6 sm:p-10 bg-gradient-to-b from-white to-indigo-50/50">
<p class="tag mb-3"><i data-lucide="mail" class="h-3.5 w-3.5"></i>Newsletter</p>
<h1 class="text-4xl sm:text-5xl font-bold leading-tight">Get the signal, skip the noise</h1>
<p class="mt-4 text-zinc-700 max-w-2xl">
The sharpest pattern analysis, delivered straight to your inbox. <strong>We won't spam you with everything — only the good stuff.</strong> No filler, no daily blasts, just the pieces worth your attention.
</p>
<form
class="mt-8 max-w-xl"
x-data="{
email: '',
loading: false,
ok: false,
error: '',
async submit() {
this.error = '';
this.ok = false;
this.loading = true;
const token = () => this.$el.querySelector('[name=cf-turnstile-response]')?.value;
for (let i = 0; i < 50 && !token(); i++) await new Promise((r) => setTimeout(r, 100));
try {
const res = await fetch('https://newsletter.planetrenox.com/api/sub', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pool: 'apophenia', email: this.email, token: token() || '' })
});
const data = await res.json();
if (res.ok && data.ok) {
this.ok = true;
this.email = '';
} else {
this.error = data.error === 'invalid email' ? 'That email doesn\\'t look right.'
: data.error === 'captcha failed' ? 'Couldn\\'t verify you\\'re human. Please try again.'
: 'Something went wrong. Please try again.';
}
} catch (e) {
this.error = 'Network error. Please try again.';
}
window.turnstile?.reset();
this.loading = false;
}
}"
@submit.prevent="submit"
>
<div class="flex flex-col sm:flex-row gap-3">
<input
type="email"
required
placeholder="you@example.com"
x-model="email"
:disabled="loading"
class="flex-1 rounded-xl border border-zinc-300 bg-white px-4 py-3 text-zinc-900 placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-indigo-400"
aria-label="Email address"
/>
<button
type="submit"
:disabled="loading"
class="inline-flex items-center justify-center gap-2 rounded-xl bg-accent px-6 py-3 font-medium text-white transition hover:bg-indigo-700 disabled:opacity-60"
>
<span x-show="!loading">Subscribe</span>
<span x-show="loading" x-cloak>Subscribing…</span>
<i data-lucide="arrow-right" class="h-4 w-4"></i>
</button>
</div>
<div class="cf-turnstile mt-3" data-sitekey="${TURNSTILE_SITE_KEY}" data-appearance="interaction-only" data-size="flexible"></div>
<p x-show="ok" x-cloak class="mt-4 inline-flex items-center gap-2 text-sm font-medium text-emerald-700">
<i data-lucide="check-circle" class="h-4 w-4"></i>
You're in. Welcome to the pattern.
</p>
<p x-show="error" x-cloak class="mt-4 inline-flex items-center gap-2 text-sm font-medium text-rose-700">
<i data-lucide="alert-circle" class="h-4 w-4"></i>
<span x-text="error"></span>
</p>
<p class="mt-4 text-xs text-zinc-500"><a href="/newsletter/unsubscribe" class="underline">Unsubscribe</a> anytime. We never share your email.</p>
</form>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</article>
${merch}
<div class="mt-8">
<a href="/" class="inline-flex items-center gap-2 text-sm">
<i data-lucide="arrow-left" class="h-4 w-4"></i> Back to Home
</a>
</div>
</main>
${footer}
`;
export const renderUnsubscribePage = () => `
${shellHead({
title: "Unsubscribe — apophenia.news",
desc: "Unsubscribe from the apophenia.news newsletter.",
url: `${SITE_URL}/newsletter/unsubscribe`
})}
${nav}
<main class="shell py-10">
<section class="card max-w-md mx-auto p-6 sm:p-8" x-data="{
email: '', done: '', auto: false, loading: false, ok: false, error: '',
init() {
// ?email=a+b@x.com -> URLSearchParams turns '+' into ' ', and emails never contain spaces
const e = new URLSearchParams(location.search).get('email')?.trim().replace(/ /g, '+');
if (e) (this.email = e, this.auto = true, this.submit());
},
async submit() {
if (this.loading) return;
this.loading = true;
this.ok = false;
this.error = '';
try {
const res = await fetch('https://newsletter.planetrenox.com/api/unsub', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pool: 'apophenia', email: this.email.trim() })
});
const data = await res.json();
if (res.ok &amp;&amp; data.ok) {
this.done = data.email;
this.ok = true;
this.email = '';
history.replaceState(null, '', location.pathname);
} else {
this.error = data.error === 'invalid email' ? 'Please enter a valid email address.' : 'Something went wrong. Please try again.';
}
} catch {
this.error = 'Could not connect. Please try again.';
} finally {
this.loading = false;
this.auto = false;
}
}
}">
<div x-show="ok" x-cloak role="status" class="text-center py-2">
<svg class="unsub-check mx-auto h-32 w-32" viewBox="0 0 120 120" aria-hidden="true">
<circle class="uc-wave" cx="60" cy="60" r="44"/>
<g class="uc-burst">${[0, 45, 90, 135, 180, 225, 270, 315].map((a) => `<line x1="60" y1="9" x2="60" y2="2" transform="rotate(${a} 60 60)"/>`).join("")}</g>
<circle class="uc-disc" cx="60" cy="60" r="44"/>
<circle class="uc-ring" cx="60" cy="60" r="44" pathLength="1"/>
<path class="uc-tick" d="M39 61.5l14 14 28-29" pathLength="1"/>
</svg>
<h1 class="uc-text mt-5 text-3xl font-bold">You're unsubscribed</h1>
<p class="uc-text mt-3 text-zinc-600"><b class="text-zinc-900 break-all" x-text="done"></b> won't receive Apophenia emails anymore.</p>
<a href="/newsletter/" class="uc-text mt-6 inline-block text-sm">Changed your mind? Resubscribe</a>
</div>
<div x-show="auto &amp;&amp; !ok" x-cloak class="py-10 text-center text-zinc-600">
<span class="mx-auto mb-4 block h-10 w-10 animate-spin rounded-full border-4 border-emerald-100 border-t-emerald-500"></span>
Unsubscribing <b class="text-zinc-900 break-all" x-text="email"></b>…
</div>
<div x-show="!ok &amp;&amp; !auto">
<h1 class="text-3xl font-bold">Unsubscribe</h1>
<p class="mt-3 text-zinc-600">Enter your email to stop receiving our newsletter.</p>
<form class="mt-6" novalidate @submit.prevent="submit">
<label for="unsubscribe-email" class="block text-sm font-medium">Email address</label>
<input id="unsubscribe-email" name="email" type="email" autocomplete="email" required x-model="email" :disabled="loading"
placeholder="you@example.com" class="mt-2 w-full rounded-xl border border-zinc-300 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-indigo-400" />
<button type="submit" :disabled="loading"
class="mt-3 w-full rounded-xl bg-accent px-4 py-3 font-medium text-white transition hover:bg-indigo-700 disabled:opacity-60"
x-text="loading ? 'Unsubscribing…' : 'Unsubscribe'">Unsubscribe</button>
<p x-show="error" x-cloak role="alert" x-text="error" class="mt-4 text-sm text-rose-700"></p>
</form>
<a href="/newsletter/" class="mt-6 inline-block text-sm">Back to newsletter</a>
</div>
</section>
</main>
${footer}
`;
export const renderWritePage = () => `
${shellHead({
title: "Become a writer for apophenia.news",
desc: "Pitch your pattern analysis. Email your story as a Markdown file for review and publication.",
image: "https://direct-img.link/writer+typing+cosmic+newsroom+editorial",
url: `${SITE_URL}/write/`,
type: "website"
})}
${nav}
<main class="shell py-10">
<article class="card p-6 sm:p-10">
<p class="tag mb-3"><i data-lucide="pen-line" class="h-3.5 w-3.5"></i>Contributor Program</p>
<h1 class="text-4xl sm:text-5xl font-bold leading-tight">Become a writer for apophenia.news</h1>
<p class="mt-4 text-zinc-700 max-w-3xl">
Have a strong pattern-based story, analysis, or investigation? Send it to us.
</p>
<div class="article-prose mt-8">
<h2>How to submit</h2>
<ul>
<li>Write your article in a <strong>.md (Markdown)</strong> file.</li>
<li>Email it to <a href="mailto:planetrenox@pm.me">planetrenox@pm.me</a>.</li>
<li>If approved, your story will be published on apophenia.news.</li>
<li>Your byline can use your real name or an alias.</li>
</ul>
<h2>Frontmatter template (optional)</h2>
<pre><code>---
title: "Your headline"
slug: your-slug
date: 2026-03-01
author: Your Name or Alias
description: "1-2 sentence summary"
header_image: https://direct-img.link/your+image+query
tags:
- your-tag
- another-tag
---</code></pre>
</div>
</article>
</main>
${footer}
`;
export const renderAuthorPage = (author) => `
${shellHead({
title: `${author.name} — apophenia.news`,
desc: author.bio,
image: author.image || "https://direct-img.link/author+profile+minimal+portrait+placeholder",
url: `${SITE_URL}/author/${author.slug}/`,
type: "profile"
})}
${nav}
<main class="shell py-10">
<article class="card p-6 sm:p-10">
<p class="tag mb-3"><i data-lucide="user-round" class="h-3.5 w-3.5"></i>Author</p>
<h1 class="text-4xl sm:text-5xl font-bold leading-tight">${escapeHtml(author.name)}</h1>
<div class="mt-8 grid md:grid-cols-[240px,1fr] gap-8 items-start">
<div class="h-64 w-full rounded-2xl border border-dashed border-zinc-300 bg-zinc-50/70 flex items-center justify-center text-sm text-zinc-500">
Photo coming soon
</div>
<div class="article-prose max-w-none">
<p>${escapeHtml(author.bio)}</p>
<p><strong>Contact:</strong> <a href="mailto:${escapeHtml(author.contact)}">${escapeHtml(author.contact)}</a></p>
</div>
</div>
</article>
</main>
${footer}
`;
export const renderRss = (articles) => {
const lastBuildDate = new Date().toUTCString();
const items = articles
.map((a) => {
const link = `${SITE_URL}/${a.slug}/`;
return `<item>
<title>${escapeXml(a.title || "")}</title>
<link>${escapeXml(link)}</link>
<guid>${escapeXml(link)}</guid>
<pubDate>${new Date(a.date).toUTCString()}</pubDate>
<description>${escapeXml(a.description || "")}</description>
</item>`;
})
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>apophenia.news</title>
<link>${SITE_URL}/</link>
<description>Signals, anomalies, civilization trajectories, and deep pattern analysis.</description>
<language>en-us</language>
<lastBuildDate>${lastBuildDate}</lastBuildDate>
${items}
</channel>
</rss>
`;
};
export const renderSitemap = (articles, authors = []) => {
const now = toISODate(new Date());
const urls = [
{ loc: `${SITE_URL}/`, lastmod: now },
{ loc: `${SITE_URL}/newsletter/`, lastmod: now },
{ loc: `${SITE_URL}/write/`, lastmod: now },
...authors.map((a) => ({ loc: `${SITE_URL}/author/${a.slug}/`, lastmod: now })),
...articles.map((a) => ({ loc: `${SITE_URL}/${a.slug}/`, lastmod: toISODate(a.date || new Date()) }))
];
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls
.map(
(u) => ` <url>
<loc>${escapeXml(u.loc)}</loc>
<lastmod>${u.lastmod}</lastmod>
</url>`
)
.join("\n")}
</urlset>
`;
};
export const renderRobots = () => `User-agent: *
Allow: /
Sitemap: ${SITE_URL}/sitemap.xml
`;