chore: sync upstream — performance, webmentions v2, OG v3

- _data: switch to cachedFetch wrapper (10s timeout + 4h watch cache)
- js/webmentions.js: owner reply threading, platform provenance badges, DOM dedup, Micropub reply support
- js/comments.js: owner detection, reply system, Alpine.store integration
- _includes/components/webmentions.njk: data-wm-* attrs, provenance badge slots, reply buttons
- _includes/components/comments.njk: owner-aware comment form, threaded replies
- widgets/toc.njk: Alpine.js tocScanner upgrade (replaces is-land/inline-JS)
- lib/og.js + og-cli.js: OG card v3 (light theme, avatar, batched spawn, DESIGN_VERSION=3)
- eleventy.config.js: hasOgImage cache, memoized date filters, batched OG/unfurl, post-build GC, YouTube check opt
- base.njk: Inter font preloads + toc-scanner.js script
- critical.css: font-face declarations (font-display:optional)
- tailwind.css: font-display swap→optional
- tailwind.config.js: prose link colors -700→-600
- Color design system: accent-700/300 → accent-600/400 across components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
svemagie
2026-03-15 23:56:56 +01:00
parent f2b05746d7
commit a166af2306
40 changed files with 1208 additions and 430 deletions

View File

@@ -4,7 +4,7 @@
* Used for conditional navigation — the blogroll page itself loads data client-side.
*/
import EleventyFetch from "@11ty/eleventy-fetch";
import { cachedFetch } from "../lib/data-fetch.js";
const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
@@ -12,7 +12,7 @@ export default async function () {
try {
const url = `${INDIEKIT_URL}/blogrollapi/api/status`;
console.log(`[blogrollStatus] Checking API: ${url}`);
const data = await EleventyFetch(url, {
const data = await cachedFetch(url, {
duration: "15m",
type: "json",
});

View File

@@ -1,8 +1,8 @@
import EleventyFetch from "@11ty/eleventy-fetch";
import { cachedFetch } from "../lib/data-fetch.js";
export default async function () {
try {
const data = await EleventyFetch(
const data = await cachedFetch(
"http://127.0.0.1:8080/conversations/api/mentions?per-page=10000",
{ duration: "15m", type: "json" }
);

View File

@@ -3,7 +3,7 @@
* Fetches public repositories from GitHub API
*/
import EleventyFetch from "@11ty/eleventy-fetch";
import { cachedFetch } from "../lib/data-fetch.js";
export default async function () {
const username = process.env.GITHUB_USERNAME || "";
@@ -12,7 +12,7 @@ export default async function () {
// Fetch public repos, sorted by updated date
const url = `https://api.github.com/users/${username}/repos?sort=updated&per_page=10&type=owner`;
const repos = await EleventyFetch(url, {
const repos = await cachedFetch(url, {
duration: "1h", // Cache for 1 hour
type: "json",
fetchOptions: {

View File

@@ -3,7 +3,7 @@
* Fetches from Indiekit's endpoint-rss public API
*/
import EleventyFetch from "@11ty/eleventy-fetch";
import { cachedFetch } from "../lib/data-fetch.js";
const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
@@ -14,7 +14,7 @@ async function fetchFromIndiekit(endpoint) {
try {
const url = `${INDIEKIT_URL}/rssapi/api/${endpoint}`;
console.log(`[newsActivity] Fetching from Indiekit: ${url}`);
const data = await EleventyFetch(url, {
const data = await cachedFetch(url, {
duration: "15m",
type: "json",
});

View File

@@ -4,7 +4,7 @@
* Used for conditional navigation — the podroll page itself loads data client-side.
*/
import EleventyFetch from "@11ty/eleventy-fetch";
import { cachedFetch } from "../lib/data-fetch.js";
const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
@@ -12,7 +12,7 @@ export default async function () {
try {
const url = `${INDIEKIT_URL}/podrollapi/api/status`;
console.log(`[podrollStatus] Checking API: ${url}`);
const data = await EleventyFetch(url, {
const data = await cachedFetch(url, {
duration: "15m",
type: "json",
});

View File

@@ -3,7 +3,7 @@
* Fetches the 5 most recent comments at build time for the sidebar widget.
*/
import EleventyFetch from "@11ty/eleventy-fetch";
import { cachedFetch } from "../lib/data-fetch.js";
const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
@@ -11,7 +11,7 @@ export default async function () {
try {
const url = `${INDIEKIT_URL}/comments/api/comments?limit=5`;
console.log(`[recentComments] Fetching: ${url}`);
const data = await EleventyFetch(url, {
const data = await cachedFetch(url, {
duration: "15m",
type: "json",
});

View File

@@ -4,7 +4,7 @@
* Supports single or multiple channels
*/
import EleventyFetch from "@11ty/eleventy-fetch";
import { cachedFetch } from "../lib/data-fetch.js";
const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
@@ -15,7 +15,7 @@ async function fetchFromIndiekit(endpoint) {
try {
const url = `${INDIEKIT_URL}/youtubeapi/api/${endpoint}`;
console.log(`[youtubeChannel] Fetching from Indiekit: ${url}`);
const data = await EleventyFetch(url, {
const data = await cachedFetch(url, {
duration: "5m",
type: "json",
});

View File

@@ -30,7 +30,7 @@
</div>
{# Sign-in form (shown when not authenticated) #}
<div x-show="!user" x-cloak>
<div x-show="!user && !isOwner" x-cloak>
<p class="text-sm text-surface-600 dark:text-surface-400 mb-3">Sign in with your website to comment:</p>
<form x-on:submit.prevent="startAuth()" class="flex gap-2 items-end flex-wrap">
<div class="flex-1 min-w-[200px]">
@@ -46,12 +46,13 @@
</form>
</div>
{# Comment form (shown when authenticated) #}
<div x-show="user" x-cloak>
{# Comment form (shown when authenticated via IndieAuth OR as site owner) #}
<div x-show="user || isOwner" x-cloak>
<div class="flex items-center gap-2 mb-3 text-sm text-surface-600 dark:text-surface-400">
<span>Signed in as</span>
<a x-bind:href="user?.url" class="font-medium hover:underline" x-text="user?.name || user?.url" target="_blank" rel="noopener"></a>
<button x-on:click="signOut()" class="text-xs underline hover:no-underline">Sign out</button>
<a x-bind:href="user?.url || ownerProfile?.url" class="font-medium hover:underline"
x-text="user?.name || ownerProfile?.name || user?.url || 'Owner'" target="_blank" rel="noopener"></a>
<button x-show="user" x-on:click="signOut()" class="text-xs underline hover:no-underline">Sign out</button>
</div>
<form x-on:submit.prevent="submitComment()">
@@ -76,28 +77,83 @@
<p class="text-sm text-surface-600 dark:text-surface-400">Loading comments...</p>
</template>
<template x-for="comment in comments" x-bind:key="comment.published">
<div class="p-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm">
<div class="flex items-start gap-3">
<template x-if="comment.author?.photo">
<img x-bind:src="comment.author.photo" x-bind:alt="comment.author.name"
class="w-8 h-8 rounded-full flex-shrink-0" loading="lazy">
</template>
<template x-if="!comment.author?.photo">
<div class="w-8 h-8 rounded-full bg-surface-200 dark:bg-surface-700 flex-shrink-0 flex items-center justify-center text-xs font-bold"
x-text="(comment.author?.name || '?')[0].toUpperCase()">
<template x-for="comment in comments.filter(c => !c.parent_id)" x-bind:key="comment._id || comment.published">
<div>
{# Parent comment #}
<div class="p-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm">
<div class="flex items-start gap-3">
<template x-if="comment.author?.photo">
<img x-bind:src="comment.author.photo" x-bind:alt="comment.author.name"
class="w-8 h-8 rounded-full flex-shrink-0" loading="lazy">
</template>
<template x-if="!comment.author?.photo">
<div class="w-8 h-8 rounded-full bg-surface-200 dark:bg-surface-700 flex-shrink-0 flex items-center justify-center text-xs font-bold"
x-text="(comment.author?.name || '?')[0].toUpperCase()">
</div>
</template>
<div class="flex-1">
<div class="flex items-center gap-2 flex-wrap">
<a x-bind:href="comment.author?.url" class="font-medium text-sm hover:underline" target="_blank" rel="noopener"
x-text="comment.author?.name || comment.author?.url"></a>
<span x-show="comment.is_owner || (ownerProfile && comment.author?.url === ownerProfile.url)"
class="inline-flex items-center px-1.5 py-0.5 text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded-full">
Author
</span>
<time class="text-xs text-surface-600 dark:text-surface-400 font-mono" x-bind:datetime="comment.published"
x-text="new Date(comment.published).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })"></time>
</div>
<div class="mt-1 text-sm prose dark:prose-invert" x-html="comment.content?.html || comment.content?.text"></div>
<div class="mt-2" x-show="isOwner && !(comment.is_owner || (ownerProfile && comment.author?.url === ownerProfile.url))">
<button class="text-xs text-primary-600 dark:text-primary-400 hover:underline"
@click="startReply(comment._id, 'comment', null, null)">
Reply
</button>
</div>
</div>
</template>
<div class="flex-1">
<div class="flex items-center gap-2">
<a x-bind:href="comment.author?.url" class="font-medium text-sm hover:underline" target="_blank" rel="noopener"
x-text="comment.author?.name || comment.author?.url"></a>
<time class="text-xs text-surface-600 dark:text-surface-400 font-mono" x-bind:datetime="comment.published"
x-text="new Date(comment.published).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })"></time>
</div>
<div class="mt-1 text-sm prose dark:prose-invert" x-html="comment.content?.html || comment.content?.text"></div>
</div>
</div>
{# Inline reply form #}
<div x-show="replyingTo && replyingTo.commentId === comment._id && replyingTo.platform === 'comment'"
class="ml-8 mt-2 p-3 bg-surface-100 dark:bg-surface-900 rounded-lg border-l-2 border-primary-400" x-cloak>
<textarea x-model="replyText" rows="3" placeholder="Write your reply..."
class="w-full px-3 py-2 border rounded-lg text-sm dark:bg-surface-800 dark:border-surface-700 dark:text-surface-100"></textarea>
<div class="flex gap-2 mt-2">
<button class="button text-sm" @click="submitReply()" x-bind:disabled="replySubmitting">
<span x-show="!replySubmitting">Send Reply</span>
<span x-show="replySubmitting" x-cloak>Sending...</span>
</button>
<button class="text-xs text-surface-500 hover:underline" @click="cancelReply()">Cancel</button>
</div>
</div>
{# Threaded child replies #}
<template x-for="reply in comments.filter(c => c.parent_id === comment._id)" x-bind:key="reply._id || reply.published">
<div class="ml-8 mt-2 p-3 bg-surface-100 dark:bg-surface-900 rounded-lg border-l-2 border-amber-400 dark:border-amber-600">
<div class="flex items-start gap-2">
<template x-if="reply.author?.photo">
<img x-bind:src="reply.author.photo" x-bind:alt="reply.author.name"
class="w-6 h-6 rounded-full flex-shrink-0" loading="lazy">
</template>
<template x-if="!reply.author?.photo">
<div class="w-6 h-6 rounded-full bg-amber-100 dark:bg-amber-900 flex-shrink-0 flex items-center justify-center text-xs font-bold"
x-text="(reply.author?.name || '?')[0].toUpperCase()">
</div>
</template>
<div class="flex-1">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-medium text-sm" x-text="reply.author?.name || 'Owner'"></span>
<span class="inline-flex items-center px-1.5 py-0.5 text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded-full">
Author
</span>
<time class="text-xs text-surface-600 dark:text-surface-400 font-mono" x-bind:datetime="reply.published"
x-text="new Date(reply.published).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })"></time>
</div>
<div class="mt-1 text-sm prose dark:prose-invert" x-html="reply.content?.html || reply.content?.text"></div>
</div>
</div>
</div>
</template>
</div>
</template>

View File

@@ -37,7 +37,7 @@
{{ authorName }}
</h1>
{% if authorTitle %}
<p class="text-lg sm:text-xl text-accent-700 dark:text-accent-300 mb-3 sm:mb-4">
<p class="text-lg sm:text-xl text-accent-600 dark:text-accent-400 mb-3 sm:mb-4">
{{ authorTitle }}
</p>
{% endif %}
@@ -48,7 +48,7 @@
{% endif %}
{% if authorDescription %}
<details class="mb-4 sm:mb-6">
<summary class="text-sm font-medium text-accent-700 dark:text-accent-300 cursor-pointer hover:underline list-none">
<summary class="text-sm font-medium text-accent-600 dark:text-accent-400 cursor-pointer hover:underline list-none">
More about me &darr;
</summary>
<p class="text-base sm:text-lg text-surface-700 dark:text-surface-300 mt-3">
@@ -82,13 +82,13 @@
<span>{{ cvOrg }}</span>
{% endif %}
{% if cvUrl %}
<span><a href="{{ cvUrl }}" class="text-accent-700 dark:text-accent-300 hover:underline" target="_blank" rel="noopener">{{ cvUrl | replace("https://", "") | replace("http://", "") }}</a></span>
<span><a href="{{ cvUrl }}" class="text-accent-600 dark:text-accent-400 hover:underline" target="_blank" rel="noopener">{{ cvUrl | replace("https://", "") | replace("http://", "") }}</a></span>
{% endif %}
{% if cvEmail %}
<span><a href="mailto:{{ cvEmail }}" class="text-accent-700 dark:text-accent-300 hover:underline">{{ cvEmail }}</a></span>
<span><a href="mailto:{{ cvEmail }}" class="text-accent-600 dark:text-accent-400 hover:underline">{{ cvEmail }}</a></span>
{% endif %}
{% if cvKeyUrl %}
<span><a href="{{ cvKeyUrl }}" class="text-accent-700 dark:text-accent-300 hover:underline" target="_blank" rel="noopener">PGP Key</a></span>
<span><a href="{{ cvKeyUrl }}" class="text-accent-600 dark:text-accent-400 hover:underline" target="_blank" rel="noopener">PGP Key</a></span>
{% endif %}
</div>
{% endif %}

View File

@@ -5,20 +5,20 @@
Include in sidebar widgets, author cards, etc.
#}
{% set id = homepageConfig.identity if (homepageConfig and homepageConfig.identity) else {} %}
{% set authorName = id.name if (id.name is defined) else site.author.name %}
{% set authorAvatar = id.avatar if (id.avatar is defined) else site.author.avatar %}
{% set authorTitle = id.title if (id.title is defined) else site.author.title %}
{% set authorBio = id.bio if (id.bio is defined) else site.author.bio %}
{% set authorUrl = id.url if (id.url is defined and id.url) else site.author.url %}
{% set authorPronoun = id.pronoun if (id.pronoun is defined) else site.author.pronoun %}
{% set authorLocality = id.locality if (id.locality is defined) else site.author.locality %}
{% set authorCountry = id.country if (id.country is defined) else site.author.country %}
{% set authorLocation = id.location if (id.location is defined) else site.author.location %}
{% set authorOrg = id.org if (id.org is defined) else site.author.org %}
{% set authorEmail = id.email if (id.email is defined) else site.author.email %}
{% set authorKeyUrl = id.keyUrl if (id.keyUrl is defined) else site.author.keyUrl %}
{% set authorCategories = id.categories if (id.categories is defined) else site.author.categories %}
{% set socialLinks = id.social if (id.social is defined) else site.social %}
{% set authorName = id.name or site.author.name %}
{% set authorAvatar = id.avatar or site.author.avatar %}
{% set authorTitle = id.title or site.author.title %}
{% set authorBio = id.bio or site.author.bio %}
{% set authorUrl = id.url or site.author.url %}
{% set authorPronoun = id.pronoun or site.author.pronoun %}
{% set authorLocality = id.locality or site.author.locality %}
{% set authorCountry = id.country or site.author.country %}
{% set authorLocation = site.author.location %}
{% set authorOrg = id.org or site.author.org %}
{% set authorEmail = id.email or site.author.email %}
{% set authorKeyUrl = id.keyUrl or site.author.keyUrl %}
{% set authorCategories = id.categories if (id.categories and id.categories.length) else site.author.categories %}
{% set socialLinks = id.social if (id.social and id.social.length) else site.social %}
<div class="h-card p-author" itemscope itemtype="http://schema.org/Person">
{# Hidden u-photo for reliable microformat parsing (some parsers struggle with img inside links) #}
@@ -37,17 +37,15 @@
>
</a>
<div>
<a href="{{ authorUrl }}" class="u-url p-name font-bold text-lg block hover:text-accent-700 dark:hover:text-accent-300" itemprop="name">
<a href="{{ authorUrl }}" class="u-url p-name font-bold text-lg block hover:text-accent-600 dark:hover:text-accent-400" itemprop="name">
{{ authorName }}
</a>
{% if authorPronoun %}
<span class="p-pronoun text-xs text-surface-600 dark:text-surface-400">({{ authorPronoun }})</span>
{% endif %}
{% if authorTitle %}
<p class="p-job-title text-sm text-surface-600 dark:text-surface-400" itemprop="jobTitle">{{ authorTitle }}</p>
{% endif %}
{# Structured address #}
<p class="p-adr h-adr text-sm text-surface-700 dark:text-surface-300" itemprop="address" itemscope itemtype="http://schema.org/PostalAddress">
<p class="p-adr h-adr text-sm text-surface-600 dark:text-surface-400" itemprop="address" itemscope itemtype="http://schema.org/PostalAddress">
{% if authorLocality %}
<span class="p-locality" itemprop="addressLocality">{{ authorLocality }}</span>{% if authorCountry %}, {% endif %}
{% endif %}
@@ -63,9 +61,7 @@
</div>
{# Bio #}
{% if authorBio %}
<p class="p-note mt-3 text-sm text-surface-700 dark:text-surface-300" itemprop="description">{{ authorBio }}</p>
{% endif %}
{# Organization #}
{% if authorOrg %}
@@ -78,12 +74,12 @@
<div class="mt-2 flex flex-wrap gap-3 text-sm">
{% if authorEmail %}
{# Display text obfuscated to deter spam harvesters; href kept plain for browser compatibility #}
<a href="mailto:{{ authorEmail }}" class="u-email text-accent-700 dark:text-accent-300 hover:underline" itemprop="email" aria-label="Email {{ authorEmail }}">
<a href="mailto:{{ authorEmail }}" class="u-email text-accent-600 dark:text-accent-400 hover:underline" itemprop="email" aria-label="Email {{ authorEmail }}">
✉️ {{ authorEmail | obfuscateEmail | safe }}
</a>
{% endif %}
{% if authorKeyUrl %}
<a href="{{ authorKeyUrl }}" class="u-key text-surface-700 dark:text-surface-300 hover:underline" rel="pgpkey">
<a href="{{ authorKeyUrl }}" class="u-key text-surface-600 dark:text-surface-400 hover:underline" rel="pgpkey">
🔐 PGP Key
</a>
{% endif %}
@@ -106,7 +102,7 @@
<a
href="{{ link.url }}"
rel="{{ link.rel }} noopener"
class="u-url text-surface-600 dark:text-surface-400 hover:text-accent-700 dark:hover:text-accent-300 transition-colors"
class="u-url text-surface-600 dark:text-surface-400 hover:text-accent-600 dark:hover:text-accent-400 transition-colors"
aria-label="{{ link.name }} (opens in new tab)"
target="_blank">
{{ socialIcon(link.icon, "w-5 h-5") }}

View File

@@ -39,8 +39,8 @@
</span>
{% else %}
<div class="p-4 sm:p-5">
<span class="text-[10px] sm:text-xs font-semibold uppercase tracking-wide text-surface-700 dark:text-surface-300 block mb-2">&larr; Previous</span>
<span class="text-sm sm:text-base font-medium text-surface-900 dark:text-surface-100 group-hover:text-accent-700 dark:group-hover:text-accent-300 line-clamp-2 transition-colors">
<span class="text-[10px] sm:text-xs font-semibold uppercase tracking-wide text-surface-600 dark:text-surface-400 block mb-2">&larr; Previous</span>
<span class="text-sm sm:text-base font-medium text-surface-900 dark:text-surface-100 group-hover:text-accent-600 dark:group-hover:text-accent-400 line-clamp-2 transition-colors">
{{ _prevTitle }}
</span>
<time class="text-xs text-surface-600 dark:text-surface-400 mt-1 block font-mono" datetime="{{ _prevPost.date | isoDate }}">{{ _prevPost.date | dateDisplay }}</time>
@@ -85,8 +85,8 @@
</span>
{% else %}
<div class="p-4 sm:p-5 text-right">
<span class="text-[10px] sm:text-xs font-semibold uppercase tracking-wide text-surface-700 dark:text-surface-300 block mb-2">Next &rarr;</span>
<span class="text-sm sm:text-base font-medium text-surface-900 dark:text-surface-100 group-hover:text-accent-700 dark:group-hover:text-accent-300 line-clamp-2 transition-colors">
<span class="text-[10px] sm:text-xs font-semibold uppercase tracking-wide text-surface-600 dark:text-surface-400 block mb-2">Next &rarr;</span>
<span class="text-sm sm:text-base font-medium text-surface-900 dark:text-surface-100 group-hover:text-accent-600 dark:group-hover:text-accent-400 line-clamp-2 transition-colors">
{{ _nextTitle }}
</span>
<time class="text-xs text-surface-600 dark:text-surface-400 mt-1 block font-mono" datetime="{{ _nextPost.date | isoDate }}">{{ _nextPost.date | dateDisplay }}</time>

View File

@@ -68,7 +68,7 @@
{{ post.templateContent | safe }}
</div>
{% endif %}
<a class="u-url text-xs text-accent-700 dark:text-accent-300 hover:underline mt-2 inline-block" href="{{ post.url }}" aria-label="Permalink: {{ post.data.title or (post.date | dateDisplay) }}">Permalink</a>
<a class="u-url text-xs text-accent-600 dark:text-accent-400 hover:underline mt-2 inline-block" href="{{ post.url }}" aria-label="Permalink: {{ post.data.title or (post.date | dateDisplay) }}">Permalink</a>
</div>
</div>
@@ -89,7 +89,7 @@
</div>
{% if post.data.title %}
<h3 class="p-name font-semibold mt-1">
<a class="text-surface-900 dark:text-surface-100 hover:text-accent-700 dark:hover:text-accent-300 hover:underline" href="{{ post.url }}">{{ post.data.title }}</a>
<a class="text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400 hover:underline" href="{{ post.url }}">{{ post.data.title }}</a>
</h3>
{% endif %}
{{ bookmarkedUrl | unfurlCard | safe }}
@@ -101,7 +101,7 @@
{{ post.templateContent | safe }}
</div>
{% endif %}
<a class="u-url text-xs text-accent-700 dark:text-accent-300 hover:underline mt-2 inline-block" href="{{ post.url }}" aria-label="Permalink: {{ post.data.title or (post.date | dateDisplay) }}">Permalink</a>
<a class="u-url text-xs text-accent-600 dark:text-accent-400 hover:underline mt-2 inline-block" href="{{ post.url }}" aria-label="Permalink: {{ post.data.title or (post.date | dateDisplay) }}">Permalink</a>
</div>
</div>
@@ -129,7 +129,7 @@
{{ post.templateContent | safe }}
</div>
{% endif %}
<a class="u-url text-xs text-accent-700 dark:text-accent-300 hover:underline mt-2 inline-block" href="{{ post.url }}" aria-label="Permalink: {{ post.data.title or (post.date | dateDisplay) }}">Permalink</a>
<a class="u-url text-xs text-accent-600 dark:text-accent-400 hover:underline mt-2 inline-block" href="{{ post.url }}" aria-label="Permalink: {{ post.data.title or (post.date | dateDisplay) }}">Permalink</a>
</div>
</div>
@@ -157,7 +157,7 @@
{{ post.templateContent | safe }}
</div>
{% endif %}
<a class="u-url text-xs text-accent-700 dark:text-accent-300 hover:underline mt-2 inline-block" href="{{ post.url }}" aria-label="Permalink: {{ post.data.title or (post.date | dateDisplay) }}">Permalink</a>
<a class="u-url text-xs text-accent-600 dark:text-accent-400 hover:underline mt-2 inline-block" href="{{ post.url }}" aria-label="Permalink: {{ post.data.title or (post.date | dateDisplay) }}">Permalink</a>
</div>
</div>
@@ -193,14 +193,14 @@
{{ post.templateContent | safe }}
</div>
{% endif %}
<a class="u-url text-xs text-accent-700 dark:text-accent-300 hover:underline mt-2 inline-block" href="{{ post.url }}" aria-label="Permalink: {{ post.data.title or (post.date | dateDisplay) }}">Permalink</a>
<a class="u-url text-xs text-accent-600 dark:text-accent-400 hover:underline mt-2 inline-block" href="{{ post.url }}" aria-label="Permalink: {{ post.data.title or (post.date | dateDisplay) }}">Permalink</a>
</div>
</div>
{% elif post.data.title %}
{# ── Article/Page card ── #}
<h3 class="p-name font-semibold mb-1">
<a href="{{ post.url }}" class="u-url text-surface-900 dark:text-surface-100 hover:text-accent-700 dark:hover:text-accent-300 hover:underline">
<a href="{{ post.url }}" class="u-url text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400 hover:underline">
{{ post.data.title }}
</a>
</h3>
@@ -239,7 +239,7 @@
{{ post.templateContent | safe }}
</div>
{% endif %}
<a href="{{ post.url }}" class="text-xs text-accent-700 dark:text-accent-300 hover:underline mt-2 inline-block" aria-label="Permalink: {{ post.data.title or (post.date | dateDisplay) }}">
<a href="{{ post.url }}" class="text-xs text-accent-600 dark:text-accent-400 hover:underline mt-2 inline-block" aria-label="Permalink: {{ post.data.title or (post.date | dateDisplay) }}">
Permalink
</a>
{% endif %}
@@ -250,7 +250,7 @@
{% if collections.featuredPosts.length > maxItems %}
<div class="mt-4 text-center">
<a href="/featured/" class="inline-flex items-center gap-1 text-sm text-accent-700 dark:text-accent-300 hover:underline font-medium">
<a href="/featured/" class="inline-flex items-center gap-1 text-sm text-accent-600 dark:text-accent-400 hover:underline font-medium">
View all {{ collections.featuredPosts.length }} featured posts &rarr;
</a>
</div>

View File

@@ -14,7 +14,7 @@
{% set graphOptions = { limit: sectionConfig.limit } %}
{% endif %}
{% postGraph collections.posts, graphOptions %}
<a href="/graph/" class="text-sm text-accent-700 dark:text-accent-300 hover:underline mt-4 inline-flex items-center gap-1">
<a href="/graph/" class="text-sm text-accent-600 dark:text-accent-400 hover:underline mt-4 inline-flex items-center gap-1">
View full history
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>

View File

@@ -30,7 +30,7 @@
<is-land on:visible>
<ul class="facepile" role="list">
{% for like in likes %}
<li class="inline">
<li class="inline" data-wm-source="{{ like['wm-source'] if like['wm-source'] else '' }}" data-author-url="{{ like.author.url }}">
<a href="{{ like.author.url }}"
class="facepile-avatar"
aria-label="{{ like.author.name }} (opens in new tab)"
@@ -60,7 +60,7 @@
<is-land on:visible>
<ul class="facepile" role="list">
{% for repost in reposts %}
<li class="inline">
<li class="inline" data-wm-source="{{ repost['wm-source'] if repost['wm-source'] else '' }}" data-author-url="{{ repost.author.url }}">
<a href="{{ repost.author.url }}"
class="facepile-avatar"
aria-label="{{ repost.author.name }} (opens in new tab)"
@@ -90,7 +90,7 @@
<is-land on:visible>
<ul class="facepile" role="list">
{% for bookmark in bookmarks %}
<li class="inline">
<li class="inline" data-wm-source="{{ bookmark['wm-source'] if bookmark['wm-source'] else '' }}" data-author-url="{{ bookmark.author.url }}">
<a href="{{ bookmark.author.url }}"
class="facepile-avatar"
aria-label="{{ bookmark.author.name }} (opens in new tab)"
@@ -119,7 +119,10 @@
</h3>
<ul class="space-y-4">
{% for reply in replies %}
<li class="p-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm">
<li class="p-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm"
data-wm-url="{{ reply.url }}"
data-wm-source="{{ reply['wm-source'] if reply['wm-source'] else '' }}"
data-author-url="{{ reply.author.url }}">
<div class="flex gap-3">
<a href="{{ reply.author.url }}" target="_blank" rel="noopener">
<img
@@ -130,13 +133,14 @@
>
</a>
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-2 mb-1">
<div class="flex items-center gap-2 mb-1 flex-wrap">
<a href="{{ reply.author.url }}"
class="font-semibold text-surface-900 dark:text-surface-100 hover:underline"
target="_blank"
rel="noopener">
{{ reply.author.name }}
</a>
<span class="wm-provenance-badge" data-detect="true"></span>
<a href="{{ reply.url }}"
class="text-xs text-surface-600 dark:text-surface-400 hover:underline"
target="_blank"
@@ -149,8 +153,14 @@
<div class="text-surface-700 dark:text-surface-300 prose dark:prose-invert prose-sm max-w-none">
{{ reply.content.html | safe if reply.content.html else reply.content.text }}
</div>
<button class="wm-reply-btn hidden text-xs text-primary-600 dark:text-primary-400 hover:underline mt-2"
data-reply-url="{{ reply.url }}"
data-platform="">
Reply
</button>
</div>
</div>
<div class="wm-owner-reply-slot ml-13 mt-2"></div>
</li>
{% endfor %}
</ul>
@@ -168,7 +178,7 @@
{% for mention in otherMentions %}
<li>
<a href="{{ mention.url }}"
class="text-accent-700 dark:text-accent-300 hover:underline"
class="text-accent-600 dark:text-accent-400 hover:underline"
target="_blank"
rel="noopener">
{{ mention.author.name }} mentioned this on <time class="font-mono" datetime="{{ mention.published }}">{{ mention.published | date("MMM d, yyyy") }}</time>
@@ -182,8 +192,8 @@
{% endif %}
{# Webmention send form — collapsed by default #}
<details class="mt-8">
<summary class="text-sm font-semibold text-surface-700 dark:text-surface-300 cursor-pointer hover:text-surface-800 dark:hover:text-surface-200 transition-colors list-none [&::-webkit-details-marker]:hidden flex items-center gap-1.5">
<details class="webmention-form mt-8">
<summary class="text-sm font-semibold text-surface-600 dark:text-surface-400 cursor-pointer hover:text-surface-700 dark:hover:text-surface-300 transition-colors list-none [&::-webkit-details-marker]:hidden flex items-center gap-1.5">
<svg class="w-3.5 h-3.5 transition-transform [[open]>&]:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
@@ -206,7 +216,7 @@
>
<button
type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-accent-700 hover:bg-accent-800 rounded transition-colors">
class="px-4 py-2 text-sm font-medium text-white bg-accent-600 hover:bg-accent-700 rounded transition-colors">
Send
</button>
</form>

View File

@@ -18,11 +18,11 @@
<div class="text-[10px] text-surface-600 dark:text-surface-400">Total</div>
</div>
<div class="text-center p-2 rounded-lg bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700">
<div class="text-lg font-bold text-amber-700 dark:text-amber-300">{{ stats.aiCount }}</div>
<div class="text-lg font-bold text-amber-600 dark:text-amber-400">{{ stats.aiCount }}</div>
<div class="text-[10px] text-surface-600 dark:text-surface-400">AI-involved</div>
</div>
<div class="text-center p-2 rounded-lg bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700">
<div class="text-lg font-bold text-emerald-700 dark:text-emerald-300">{{ stats.total - stats.aiCount }}</div>
<div class="text-lg font-bold text-emerald-600 dark:text-emerald-400">{{ stats.total - stats.aiCount }}</div>
<div class="text-[10px] text-surface-600 dark:text-surface-400">Human-only</div>
</div>
<div class="text-center p-2 rounded-lg bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700">
@@ -53,7 +53,7 @@
{% postGraph aiPostsList, { prefix: "ai-widget", limit: 1, noLabels: true, boxColorDark: "#44403c", highlightColorLight: "#d97706", highlightColorDark: "#fbbf24" } %}
{% endif %}
<a href="/ai/" class="text-sm text-amber-700 dark:text-amber-300 hover:underline flex items-center gap-1 mt-3">
<a href="/ai/" class="text-sm text-amber-600 dark:text-amber-400 hover:underline flex items-center gap-1 mt-3">
View full AI report
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
</a>

View File

@@ -2,10 +2,10 @@
<is-land on:visible>
<div class="widget" x-data="blogrollWidget()" x-init="init()">
<h3 class="widget-title flex items-center gap-2">
<svg class="w-5 h-5 text-accent-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-5 h-5 text-accent-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"/>
</svg>
<a href="/blogroll/" class="hover:text-accent-700 dark:hover:text-accent-300">Blogroll</a>
<a href="/blogroll/" class="hover:text-accent-600 dark:hover:text-accent-400">Blogroll</a>
</h3>
{# Source tabs - only shown when multiple sources exist #}
@@ -18,7 +18,7 @@
aria-controls="blogroll-panel"
@click="activeTab = tab.key"
:class="activeTab === tab.key
? 'border-b-2 border-accent-600 text-accent-700 dark:text-accent-300 dark:border-accent-400'
? 'border-b-2 border-accent-600 text-accent-600 dark:text-accent-400 dark:border-accent-400'
: 'text-surface-600 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-300'"
class="px-2 py-1 text-xs font-medium transition-colors -mb-px"
x-text="tab.label + ' (' + tab.count + ')'"
@@ -31,7 +31,7 @@
<li>
<a
:href="blog.siteUrl || blog.feedUrl"
class="flex items-center gap-2 text-sm text-surface-700 dark:text-surface-300 hover:text-accent-700 dark:hover:text-accent-300 hover:underline transition-colors"
class="flex items-center gap-2 text-sm text-surface-700 dark:text-surface-300 hover:text-accent-600 dark:hover:text-accent-400 hover:underline transition-colors"
target="_blank"
rel="noopener"
>
@@ -48,7 +48,7 @@
No blogs loaded yet.
</div>
<a x-show="allBlogs.length > 0" href="/blogroll/" class="text-sm text-accent-700 dark:text-accent-300 hover:underline mt-3 inline-flex items-center gap-1">
<a x-show="allBlogs.length > 0" href="/blogroll/" class="text-sm text-accent-600 dark:text-accent-400 hover:underline mt-3 inline-flex items-center gap-1">
View all <span x-text="allBlogs.length"></span> blogs
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
</a>

View File

@@ -15,7 +15,7 @@
{% set _bookmarkedUrl = _prevPost.data.bookmarkOf or _prevPost.data.bookmark_of %}
{% set _repostedUrl = _prevPost.data.repostOf or _prevPost.data.repost_of %}
{% set _replyToUrl = _prevPost.data.inReplyTo or _prevPost.data.in_reply_to %}
<a href="{{ _prevPost.url }}" class="text-sm text-accent-700 dark:text-accent-300 hover:underline line-clamp-2 flex items-center gap-1.5">
<a href="{{ _prevPost.url }}" class="text-sm text-accent-600 dark:text-accent-400 hover:underline line-clamp-2 flex items-center gap-1.5">
{% if _likedUrl %}
<svg class="w-3.5 h-3.5 text-red-500 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>
Liked {{ _likedUrl | replace("https://", "") | truncate(35) }}
@@ -41,7 +41,7 @@
{% set _bookmarkedUrl = _nextPost.data.bookmarkOf or _nextPost.data.bookmark_of %}
{% set _repostedUrl = _nextPost.data.repostOf or _nextPost.data.repost_of %}
{% set _replyToUrl = _nextPost.data.inReplyTo or _nextPost.data.in_reply_to %}
<a href="{{ _nextPost.url }}" class="text-sm text-accent-700 dark:text-accent-300 hover:underline line-clamp-2 flex items-center gap-1.5">
<a href="{{ _nextPost.url }}" class="text-sm text-accent-600 dark:text-accent-400 hover:underline line-clamp-2 flex items-center gap-1.5">
{% if _likedUrl %}
<svg class="w-3.5 h-3.5 text-red-500 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>
Liked {{ _likedUrl | replace("https://", "") | truncate(35) }}

View File

@@ -15,7 +15,7 @@
<span class="font-medium">{{ comment.author.name or "Anonymous" }}</span>
<p class="text-surface-600 dark:text-surface-400 line-clamp-2">{{ comment.content.text | truncate(80) }}</p>
{% if comment["comment-target"] %}
<a href="{{ comment['comment-target'] }}" class="text-xs text-accent-700 dark:text-accent-300 hover:underline">View post</a>
<a href="{{ comment['comment-target'] }}" class="text-xs text-accent-600 dark:text-accent-400 hover:underline">View post</a>
{% endif %}
</div>
</div>

View File

@@ -1,59 +1,27 @@
{# Table of Contents Widget (for articles with headings) #}
<is-land on:visible>
<div class="widget">
<h3 class="widget-title">Contents</h3>
<nav class="toc" aria-label="Table of contents">
{% if toc and toc.length %}
<ul class="space-y-1 text-sm">
{% for item in toc %}
<li class="{% if item.level == 3 %}ml-3{% elif item.level == 4 %}ml-6{% elif item.level == 5 %}ml-9{% elif item.level == 6 %}ml-12{% endif %}">
<a href="#{{ item.slug }}" class="text-surface-600 dark:text-surface-400 hover:text-accent-700 dark:hover:text-accent-300 hover:underline transition-colors">
{{ item.text }}
</a>
</li>
{% endfor %}
</ul>
{% else %}
<ul class="space-y-1 text-sm" data-toc-fallback-list></ul>
<script>
(() => {
const script = document.currentScript;
if (!script) return;
const widget = script.closest(".widget");
const fallbackList = widget ? widget.querySelector("[data-toc-fallback-list]") : null;
if (!widget || !fallbackList) return;
const shell = widget.closest(".widget-collapsible");
const contentRoot = document.querySelector("article .e-content");
if (!contentRoot) {
if (shell) shell.style.display = "none";
return;
}
const headings = Array.from(contentRoot.querySelectorAll("h2[id], h3[id], h4[id], h5[id], h6[id]"));
if (!headings.length) {
if (shell) shell.style.display = "none";
return;
}
for (const heading of headings) {
const level = Number.parseInt(heading.tagName.slice(1), 10);
const item = document.createElement("li");
if (Number.isFinite(level) && level > 2) {
item.style.marginLeft = `${(level - 2) * 0.75}rem`;
}
const link = document.createElement("a");
link.href = `#${heading.id}`;
link.className = "text-surface-600 dark:text-surface-400 hover:text-accent-700 dark:hover:text-accent-300 hover:underline transition-colors";
link.textContent = heading.textContent ? heading.textContent.trim() : heading.id;
item.appendChild(link);
fallbackList.appendChild(item);
}
})();
</script>
{% endif %}
</nav>
{# Table of Contents Widget — client-side Alpine.js heading scanner with scroll spy #}
{# Only renders on Articles/Notes with 3+ headings (h2-h4) #}
{% if title or (not (bookmarkOf or bookmark_of or likeOf or like_of or repostOf or repost_of)) %}
<div x-data="tocScanner" x-show="items.length > 0" x-cloak>
<div class="widget">
<h3 class="widget-title">Contents</h3>
<nav class="toc" aria-label="Table of contents">
<ul class="space-y-1 text-sm">
<template x-for="item in items" :key="item.id">
<li :class="{
'ml-3': item.level === 3,
'ml-6': item.level === 4
}">
<a :href="'#' + item.id"
x-text="item.text"
class="transition-colors hover:underline"
:class="item.active
? 'text-accent-600 dark:text-accent-400 font-medium'
: 'text-surface-600 dark:text-surface-400 hover:text-accent-600 dark:hover:text-accent-400'"
></a>
</li>
</template>
</ul>
</nav>
</div>
</div>
</is-land>
{% endif %}

View File

@@ -14,14 +14,14 @@
<div class="flex border-b border-surface-200 dark:border-surface-700 mb-3">
<button
@click="tab = 'inbound'"
:class="tab === 'inbound' ? 'border-accent-500 text-accent-700 dark:text-accent-300' : 'border-transparent text-surface-600 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-300'"
:class="tab === 'inbound' ? 'border-accent-500 text-accent-600 dark:text-accent-400' : 'border-transparent text-surface-600 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-300'"
class="px-2 py-1.5 text-xs font-medium border-b-2 -mb-px transition-colors">
Received
<span x-show="mentions.length" x-text="mentions.length" class="ml-0.5 text-xs opacity-75"></span>
</button>
<button
@click="tab = 'outbound'"
:class="tab === 'outbound' ? 'border-accent-500 text-accent-700 dark:text-accent-300' : 'border-transparent text-surface-600 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-300'"
:class="tab === 'outbound' ? 'border-accent-500 text-accent-600 dark:text-accent-400' : 'border-transparent text-surface-600 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-300'"
class="px-2 py-1.5 text-xs font-medium border-b-2 -mb-px transition-colors">
Sent
</button>
@@ -66,7 +66,7 @@
{# Link to full interactions page #}
<div x-show="mentions.length > 0" class="mt-2 pt-2 border-t border-surface-200 dark:border-surface-700">
<a href="/interactions/" class="text-xs text-accent-700 dark:text-accent-300 hover:underline">
<a href="/interactions/" class="text-xs text-accent-600 dark:text-accent-400 hover:underline">
View all &rarr;
</a>
</div>
@@ -111,7 +111,7 @@
{% endfor %}
</div>
<div class="mt-2 pt-2 border-t border-surface-200 dark:border-surface-700">
<a href="/interactions/" class="text-xs text-accent-700 dark:text-accent-300 hover:underline">
<a href="/interactions/" class="text-xs text-accent-600 dark:text-accent-400 hover:underline">
View all &rarr;
</a>
</div>
@@ -150,19 +150,7 @@ function webmentionsWidget() {
merged.push(item);
}
// Use same self-Bluesky filter as post-interactions/interactions.njk
const isSelfBsky = (item) => {
const u = (item.url || '').toLowerCase();
const a = ((item.author && item.author.url) || '').toLowerCase();
return u.includes('did:plc:g4utqyolpyb5zpwwodmm3hht') ||
u.includes('bsky.app/profile/svemagie.bsky.social') ||
a.includes('did:plc:g4utqyolpyb5zpwwodmm3hht') ||
a.includes('bsky.app/profile/svemagie.bsky.social');
};
const filtered = merged.filter((item) => !isSelfBsky(item));
this.mentions = filtered.sort((a, b) => {
this.mentions = merged.sort((a, b) => {
return new Date(b.published || b['wm-received'] || 0) - new Date(a.published || a['wm-received'] || 0);
});
} catch (e) {

View File

@@ -54,6 +54,11 @@
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
{# Preload critical fonts — starts download before CSS is parsed #}
<link rel="preload" href="/fonts/inter-latin-400-normal.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/fonts/inter-latin-600-normal.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/fonts/inter-latin-700-normal.woff2" as="font" type="font/woff2" crossorigin>
{# Critical CSS — inlined for fast first paint #}
<style>{{ "css/critical.css" | inlineFile | safe }}</style>
{# Defer full stylesheet — loads after first paint #}
@@ -71,6 +76,7 @@
<noscript><link rel="stylesheet" href="/css/lite-yt-embed.css?v={{ '/css/lite-yt-embed.css' | hash }}"></noscript>
<script src="/js/vendor/lite-yt-embed.js?v={{ '/js/vendor/lite-yt-embed.js' | hash }}" defer></script>
{# Alpine.js components — MUST load before Alpine core (Alpine.data() registration via alpine:init) #}
<script src="/js/toc-scanner.js?v={{ '/js/toc-scanner.js' | hash }}" defer></script>
<script src="/js/comments.js?v={{ '/js/comments.js' | hash }}" defer></script>
<script src="/js/fediverse-interact.js?v={{ '/js/fediverse-interact.js' | hash }}" defer></script>
<script src="/js/lightbox.js?v={{ '/js/lightbox.js' | hash }}" defer></script>

View File

@@ -17,7 +17,7 @@ eleventyExcludeFromCollections: true
{{ site.author.name }}
</h1>
{% if site.author.title %}
<p class="p-job-title text-xl text-accent-700 dark:text-accent-300 mb-2">
<p class="p-job-title text-xl text-accent-600 dark:text-accent-400 mb-2">
{{ site.author.title }}
</p>
{% endif %}

View File

@@ -18,7 +18,7 @@ withSidebar: false
<template x-for="tab in tabs" :key="tab.key">
<button
@click="activeTab = tab.key"
:class="activeTab === tab.key ? 'border-b-2 border-accent-500 text-accent-700 dark:text-accent-300' : 'text-surface-600 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-300'"
:class="activeTab === tab.key ? 'border-b-2 border-accent-500 text-accent-600 dark:text-accent-400' : 'text-surface-600 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-300'"
:aria-selected="(activeTab === tab.key).toString()"
role="tab"
class="flex items-center gap-1.5 px-3 py-2 text-sm font-medium transition-colors -mb-px whitespace-nowrap flex-shrink-0"
@@ -53,7 +53,7 @@ withSidebar: false
<li class="bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg p-4 shadow-sm">
<div class="flex items-start gap-3">
<a :href="commit.url" target="_blank" rel="noopener"
class="font-mono text-xs bg-surface-100 dark:bg-surface-800 px-1.5 py-0.5 rounded text-accent-700 dark:text-accent-300 hover:underline flex-shrink-0 mt-0.5"
class="font-mono text-xs bg-surface-100 dark:bg-surface-800 px-1.5 py-0.5 rounded text-accent-600 dark:text-accent-400 hover:underline flex-shrink-0 mt-0.5"
x-text="commit.sha"></a>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-surface-900 dark:text-surface-100 break-words" x-text="commit.title"></p>
@@ -64,7 +64,7 @@ withSidebar: false
x-text="categoryLabels[commit.category] || commit.category"
></span>
<a :href="commit.repoUrl" target="_blank" rel="noopener"
class="text-xs px-2 py-0.5 rounded-full bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-400 hover:text-accent-700 dark:hover:text-accent-300"
class="text-xs px-2 py-0.5 rounded-full bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-400 hover:text-accent-600 dark:hover:text-accent-400"
x-text="commit.repoName"></a>
<span class="text-xs text-surface-600 dark:text-surface-400 font-mono" x-text="formatDate(commit.date)"></span>
<span class="text-xs text-surface-600 dark:text-surface-400" x-text="'by ' + commit.author"></span>
@@ -118,32 +118,34 @@ function changelogApp() {
tabs: [
{ key: 'all', label: 'All' },
{ key: 'features', label: 'Features' },
{ key: 'fixes', label: 'Fixes' },
{ key: 'performance', label: 'Performance' },
{ key: 'accessibility', label: 'Accessibility' },
{ key: 'documentation', label: 'Docs' },
{ key: 'style', label: 'Style' },
{ key: 'other', label: 'Other' },
{ key: 'core', label: 'Core' },
{ key: 'deployment', label: 'Deployment' },
{ key: 'theme', label: 'Theme' },
{ key: 'endpoints', label: 'Endpoints' },
{ key: 'syndicators', label: 'Syndicators' },
{ key: 'post-types', label: 'Post Types' },
{ key: 'presets', label: 'Presets' },
],
categoryLabels: {
features: 'Features',
fixes: 'Fixes',
performance: 'Performance',
accessibility: 'Accessibility',
documentation: 'Docs',
style: 'Style',
core: 'Core',
deployment: 'Deployment',
theme: 'Theme',
endpoints: 'Endpoint',
syndicators: 'Syndicator',
'post-types': 'Post Type',
presets: 'Preset',
other: 'Other',
},
categoryColors: {
features: 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300',
fixes: 'bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-300',
performance: 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300',
accessibility: 'bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-300',
documentation: 'bg-yellow-100 dark:bg-yellow-900 text-yellow-700 dark:text-yellow-300',
style: 'bg-pink-100 dark:bg-pink-900 text-pink-700 dark:text-pink-300',
core: 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300',
deployment: 'bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-300',
theme: 'bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-300',
endpoints: 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300',
syndicators: 'bg-teal-100 dark:bg-teal-900 text-teal-700 dark:text-teal-300',
'post-types': 'bg-pink-100 dark:bg-pink-900 text-pink-700 dark:text-pink-300',
presets: 'bg-yellow-100 dark:bg-yellow-900 text-yellow-700 dark:text-yellow-300',
other: 'bg-surface-100 dark:bg-surface-800 text-surface-700 dark:text-surface-300',
},
@@ -156,37 +158,11 @@ function changelogApp() {
await this.fetchChangelog(30);
},
async fetchJson(paths) {
for (const path of paths) {
try {
const response = await fetch(path);
if (response.ok) {
return {
ok: true,
data: await response.json(),
};
}
} catch {
// Try next candidate path.
}
}
return {
ok: false,
data: null,
};
},
async fetchChangelog(days) {
try {
const result = await this.fetchJson([
'/github/api/changelog?days=' + days,
'/githubapi/api/changelog?days=' + days,
]);
if (!result.ok) throw new Error('Failed to fetch');
const data = result.data || {};
const response = await fetch('/githubapi/api/changelog?days=' + days);
if (!response.ok) throw new Error('Failed to fetch');
const data = await response.json();
this.commits = data.commits || [];
this.categories = data.categories || {};
this.currentDays = data.days;

View File

@@ -48,6 +48,12 @@ main.container{padding-top:1.5rem;padding-bottom:1.5rem}
/* Reserve sidebar space on desktop to prevent CLS when Alpine.js hydrates collapsible widgets */
@media(min-width:1024px){.sidebar{min-height:600px}}
/* Font faces — in critical CSS so fonts begin downloading immediately.
font-display:optional prevents FOUT/CLS: font either loads in time or fallback is kept. */
@font-face{font-family:'Inter';font-style:normal;font-display:optional;font-weight:400;src:url(/fonts/inter-latin-400-normal.woff2) format('woff2')}
@font-face{font-family:'Inter';font-style:normal;font-display:optional;font-weight:600;src:url(/fonts/inter-latin-600-normal.woff2) format('woff2')}
@font-face{font-family:'Inter';font-style:normal;font-display:optional;font-weight:700;src:url(/fonts/inter-latin-700-normal.woff2) format('woff2')}
/* Basic typography — prevent FOUT */
h1,h2,h3,h4{margin:0;line-height:1.25}
a{color:#b45309}

View File

@@ -2,7 +2,7 @@
@font-face {
font-family: 'Inter';
font-style: normal;
font-display: swap;
font-display: optional;
font-weight: 400;
src: url(/fonts/inter-latin-ext-400-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
@@ -10,7 +10,7 @@
@font-face {
font-family: 'Inter';
font-style: normal;
font-display: swap;
font-display: optional;
font-weight: 400;
src: url(/fonts/inter-latin-400-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
@@ -18,7 +18,7 @@
@font-face {
font-family: 'Inter';
font-style: normal;
font-display: swap;
font-display: optional;
font-weight: 500;
src: url(/fonts/inter-latin-ext-500-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
@@ -26,7 +26,7 @@
@font-face {
font-family: 'Inter';
font-style: normal;
font-display: swap;
font-display: optional;
font-weight: 500;
src: url(/fonts/inter-latin-500-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
@@ -34,7 +34,7 @@
@font-face {
font-family: 'Inter';
font-style: normal;
font-display: swap;
font-display: optional;
font-weight: 600;
src: url(/fonts/inter-latin-ext-600-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
@@ -42,7 +42,7 @@
@font-face {
font-family: 'Inter';
font-style: normal;
font-display: swap;
font-display: optional;
font-weight: 600;
src: url(/fonts/inter-latin-600-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
@@ -50,7 +50,7 @@
@font-face {
font-family: 'Inter';
font-style: normal;
font-display: swap;
font-display: optional;
font-weight: 700;
src: url(/fonts/inter-latin-ext-700-normal.woff2) format('woff2');
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
@@ -58,7 +58,7 @@
@font-face {
font-family: 'Inter';
font-style: normal;
font-display: swap;
font-display: optional;
font-weight: 700;
src: url(/fonts/inter-latin-700-normal.woff2) format('woff2');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;

10
cv.njk
View File

@@ -51,7 +51,7 @@ pagefindIgnore: true
{{ authorName }}
</h1>
{% if authorTitle %}
<p class="text-lg sm:text-xl text-accent-700 dark:text-accent-300 mb-3 sm:mb-4">
<p class="text-lg sm:text-xl text-accent-600 dark:text-accent-400 mb-3 sm:mb-4">
{{ authorTitle }}
</p>
{% endif %}
@@ -86,13 +86,13 @@ pagefindIgnore: true
<span>{{ cvOrg }}</span>
{% endif %}
{% if cvUrl %}
<span><a href="{{ cvUrl }}" class="text-accent-700 dark:text-accent-300 hover:underline" target="_blank" rel="noopener">{{ cvUrl | replace("https://", "") | replace("http://", "") }}</a></span>
<span><a href="{{ cvUrl }}" class="text-accent-600 dark:text-accent-400 hover:underline" target="_blank" rel="noopener">{{ cvUrl | replace("https://", "") | replace("http://", "") }}</a></span>
{% endif %}
{% if cvEmail %}
<span><a href="mailto:{{ cvEmail }}" class="text-accent-700 dark:text-accent-300 hover:underline">{{ cvEmail }}</a></span>
<span><a href="mailto:{{ cvEmail }}" class="text-accent-600 dark:text-accent-400 hover:underline">{{ cvEmail }}</a></span>
{% endif %}
{% if cvKeyUrl %}
<span><a href="{{ cvKeyUrl }}" class="text-accent-700 dark:text-accent-300 hover:underline" target="_blank" rel="noopener">PGP Key</a></span>
<span><a href="{{ cvKeyUrl }}" class="text-accent-600 dark:text-accent-400 hover:underline" target="_blank" rel="noopener">PGP Key</a></span>
{% endif %}
</div>
{% endif %}
@@ -139,7 +139,7 @@ pagefindIgnore: true
<h1 class="text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4">CV</h1>
<p class="text-surface-600 dark:text-surface-400">
No CV data available yet. Add your experience, projects, and skills via the
<a href="/dashboard" class="text-accent-700 dark:text-accent-300 hover:underline">admin dashboard</a>.
<a href="/dashboard" class="text-accent-600 dark:text-accent-400 hover:underline">admin dashboard</a>.
</p>
</div>

View File

@@ -24,7 +24,7 @@ permalink: "digest/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumb
{% for d in paginatedDigests %}
<li class="p-4 bg-surface-50 dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg hover:border-amber-400 dark:hover:border-amber-600 transition-colors shadow-sm">
<a href="/digest/{{ d.slug }}/" class="block">
<h2 class="font-semibold text-surface-900 dark:text-surface-100 hover:text-accent-700 dark:hover:text-accent-300">
<h2 class="font-semibold text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">
{{ d.label }}
</h2>
<p class="text-sm text-surface-600 dark:text-surface-400 mt-1">

View File

@@ -49,7 +49,7 @@ permalink: "digest/{{ digest.slug }}/"
<div class="flex items-start gap-2">
<span class="text-red-500 flex-shrink-0">&#x2764;</span>
<div>
<a href="{{ targetUrl }}" class="text-accent-700 dark:text-accent-300 hover:underline break-all">{{ targetUrl }}</a>
<a href="{{ targetUrl }}" class="text-accent-600 dark:text-accent-400 hover:underline break-all">{{ targetUrl }}</a>
<div class="text-sm text-surface-600 dark:text-surface-400 mt-1">
<time class="dt-published font-mono text-sm" datetime="{{ post.date | isoDate }}">{{ post.date | dateDisplay }}</time>
&middot; <a href="{{ post.url }}" class="hover:underline" aria-label="Permalink: {{ post.data.title or (post.date | dateDisplay) }}">Permalink</a>
@@ -63,9 +63,9 @@ permalink: "digest/{{ digest.slug }}/"
<span class="text-amber-500 flex-shrink-0">&#x1F516;</span>
<div>
{% if post.data.title %}
<a href="{{ post.url }}" class="font-medium text-surface-900 dark:text-surface-100 hover:text-accent-700 dark:hover:text-accent-300">{{ post.data.title }}</a>
<a href="{{ post.url }}" class="font-medium text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">{{ post.data.title }}</a>
{% else %}
<a href="{{ targetUrl }}" class="text-accent-700 dark:text-accent-300 hover:underline break-all">{{ targetUrl }}</a>
<a href="{{ targetUrl }}" class="text-accent-600 dark:text-accent-400 hover:underline break-all">{{ targetUrl }}</a>
{% endif %}
<div class="text-sm text-surface-600 dark:text-surface-400 mt-1">
<time class="dt-published font-mono text-sm" datetime="{{ post.date | isoDate }}">{{ post.date | dateDisplay }}</time>
@@ -79,7 +79,7 @@ permalink: "digest/{{ digest.slug }}/"
<div class="flex items-start gap-2">
<span class="text-green-500 flex-shrink-0">&#x1F501;</span>
<div>
<a href="{{ targetUrl }}" class="text-accent-700 dark:text-accent-300 hover:underline break-all">{{ targetUrl }}</a>
<a href="{{ targetUrl }}" class="text-accent-600 dark:text-accent-400 hover:underline break-all">{{ targetUrl }}</a>
<div class="text-sm text-surface-600 dark:text-surface-400 mt-1">
<time class="dt-published font-mono text-sm" datetime="{{ post.date | isoDate }}">{{ post.date | dateDisplay }}</time>
&middot; <a href="{{ post.url }}" class="hover:underline" aria-label="Permalink: {{ post.data.title or (post.date | dateDisplay) }}">Permalink</a>
@@ -99,7 +99,7 @@ permalink: "digest/{{ digest.slug }}/"
</a>
{% endif %}
{% if post.data.title %}
<a href="{{ post.url }}" class="font-medium text-surface-900 dark:text-surface-100 hover:text-accent-700 dark:hover:text-accent-300">{{ post.data.title }}</a>
<a href="{{ post.url }}" class="font-medium text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">{{ post.data.title }}</a>
{% elif post.templateContent %}
<p class="text-surface-700 dark:text-surface-300 text-sm">{{ post.templateContent | striptags | truncate(120) }}</p>
{% endif %}
@@ -111,7 +111,7 @@ permalink: "digest/{{ digest.slug }}/"
{% elif typeInfo.key == "articles" %}
<div>
<a href="{{ post.url }}" class="font-medium text-surface-900 dark:text-surface-100 hover:text-accent-700 dark:hover:text-accent-300">
<a href="{{ post.url }}" class="font-medium text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">
{{ post.data.title | default("Untitled") }}
</a>
{% if post.templateContent %}

View File

@@ -301,7 +301,12 @@ export default function (eleventyConfig) {
// Custom transform to convert YouTube links to lite-youtube embeds
// Catches bare YouTube links in Markdown that the embed plugin misses
eleventyConfig.addTransform("youtube-link-to-embed", function (content, outputPath) {
if (!outputPath || !outputPath.endsWith(".html")) {
if (typeof outputPath !== "string" || !outputPath.endsWith(".html")) {
return content;
}
// Single substring check — "youtu" covers both youtube.com/watch and youtu.be/
// Avoids scanning large HTML twice (was two includes() calls on 15-50KB per page)
if (!content.includes("youtu")) {
return content;
}
// Match <a> tags where href contains youtube.com/watch or youtu.be
@@ -370,6 +375,21 @@ export default function (eleventyConfig) {
return content;
});
// Cache: directory listing built once per build instead of existsSync calls per page
let _ogFileSet = null;
eleventyConfig.on("eleventy.before", () => { _ogFileSet = null; });
function hasOgImage(ogSlug) {
if (!_ogFileSet) {
const ogDir = resolve(__dirname, ".cache", "og");
try {
_ogFileSet = new Set(readdirSync(ogDir));
} catch {
_ogFileSet = new Set();
}
}
return _ogFileSet.has(`${ogSlug}.png`);
}
// Fix OG image meta tags post-rendering — bypasses Eleventy 3.x race condition (#3183).
// page.url is unreliable during parallel rendering, but outputPath IS correct
// since files are written to the correct location. Derives the OG slug from
@@ -388,7 +408,7 @@ export default function (eleventyConfig) {
const pageUrlPath = `/${type}/${year}/${month}/${day}/${slug}/`;
const correctFullUrl = `${siteUrl}${pageUrlPath}`;
const ogSlug = `${year}-${month}-${day}-${slug}`;
const hasOg = existsSync(resolve(__dirname, ".cache", "og", `${ogSlug}.png`));
const hasOg = hasOgImage(ogSlug);
const ogImageUrl = hasOg
? `${siteUrl}/og/${ogSlug}.png`
: `${siteUrl}/images/og-default.png`;
@@ -518,21 +538,34 @@ export default function (eleventyConfig) {
key: "children",
});
// Date formatting filter
// Date formatting filter — memoized (same dates repeat across pages in sidebars/pagination)
const _dateDisplayCache = new Map();
eleventyConfig.on("eleventy.before", () => { _dateDisplayCache.clear(); });
eleventyConfig.addFilter("dateDisplay", (dateObj) => {
if (!dateObj) return "";
const date = new Date(dateObj);
return date.toLocaleDateString("en-GB", {
const key = dateObj instanceof Date ? dateObj.getTime() : dateObj;
const cached = _dateDisplayCache.get(key);
if (cached !== undefined) return cached;
const result = new Date(dateObj).toLocaleDateString("en-GB", {
year: "numeric",
month: "long",
day: "numeric",
});
_dateDisplayCache.set(key, result);
return result;
});
// ISO date filter
// ISO date filter — memoized
const _isoDateCache = new Map();
eleventyConfig.on("eleventy.before", () => { _isoDateCache.clear(); });
eleventyConfig.addFilter("isoDate", (dateObj) => {
if (!dateObj) return "";
return new Date(dateObj).toISOString();
const key = dateObj instanceof Date ? dateObj.getTime() : dateObj;
const cached = _isoDateCache.get(key);
if (cached !== undefined) return cached;
const result = new Date(dateObj).toISOString();
_isoDateCache.set(key, result);
return result;
});
// Digest-to-HTML filter for RSS feed descriptions
@@ -1306,24 +1339,42 @@ export default function (eleventyConfig) {
return digests;
});
// Generate OpenGraph images for posts without photos
// Runs on every build (including watcher rebuilds) — manifest caching makes it fast
// for incremental: only new posts without an OG image get generated (~200ms each)
// Generate OpenGraph images for posts without photos.
// Uses batch spawning: each invocation generates up to BATCH_SIZE images then exits,
// fully releasing WASM native memory (Satori Yoga + Resvg Rust) between batches.
// Exit code 2 = batch complete, more work remains → re-spawn.
// Manifest caching makes incremental builds fast (only new posts get generated).
eleventyConfig.on("eleventy.before", () => {
const contentDir = resolve(__dirname, "content");
const cacheDir = resolve(__dirname, ".cache");
const siteName = process.env.SITE_NAME || "My IndieWeb Blog";
const BATCH_SIZE = 100;
try {
execFileSync(process.execPath, [
"--max-old-space-size=768",
resolve(__dirname, "lib", "og-cli.js"),
contentDir,
cacheDir,
siteName,
], {
stdio: "inherit",
env: { ...process.env, NODE_OPTIONS: "" },
});
// eslint-disable-next-line no-constant-condition
while (true) {
try {
execFileSync(process.execPath, [
"--max-old-space-size=512",
"--expose-gc",
resolve(__dirname, "lib", "og-cli.js"),
contentDir,
cacheDir,
siteName,
String(BATCH_SIZE),
], {
stdio: "inherit",
env: { ...process.env, NODE_OPTIONS: "" },
});
// Exit code 0 = all done
break;
} catch (err) {
if (err.status === 2) {
// Exit code 2 = batch complete, more images remain
continue;
}
throw err;
}
}
// Sync new OG images to output directory.
// During incremental builds, .cache/og is in watchIgnores so Eleventy's
@@ -1377,8 +1428,26 @@ export default function (eleventyConfig) {
walk(contentDir);
if (urls.size === 0) return;
console.log(`[unfurl] Pre-fetching ${urls.size} interaction URLs...`);
await Promise.all([...urls].map((url) => prefetchUrl(url)));
// Free parsed markdown content before starting network-heavy prefetch
if (typeof global.gc === "function") global.gc();
const urlArray = [...urls];
const UNFURL_BATCH = 50;
const totalBatches = Math.ceil(urlArray.length / UNFURL_BATCH);
console.log(`[unfurl] Pre-fetching ${urlArray.length} interaction URLs (${totalBatches} batches of ${UNFURL_BATCH})...`);
let fetched = 0;
for (let i = 0; i < urlArray.length; i += UNFURL_BATCH) {
const batch = urlArray.slice(i, i + UNFURL_BATCH);
const batchNum = Math.floor(i / UNFURL_BATCH) + 1;
await Promise.all(batch.map((url) => prefetchUrl(url)));
fetched += batch.length;
if (typeof global.gc === "function") global.gc();
if (batchNum === 1 || batchNum % 5 === 0 || batchNum === totalBatches) {
const rss = (process.memoryUsage.rss() / 1024 / 1024).toFixed(0);
console.log(`[unfurl] Batch ${batchNum}/${totalBatches} (${fetched}/${urlArray.length}) | RSS: ${rss} MB`);
}
}
console.log(`[unfurl] Pre-fetch complete.`);
});
@@ -1561,6 +1630,19 @@ export default function (eleventyConfig) {
console.error(`[websub] Hub notification failed for ${feedUrl}:`, err.message);
}
}
// Force garbage collection after post-build work completes.
// V8 doesn't return freed heap pages to the OS without GC pressure.
// In watch mode the watcher sits idle after its initial full build,
// so without this, ~2 GB of build-time allocations stay resident.
// Requires --expose-gc in NODE_OPTIONS (set in start.sh for the watcher).
if (typeof global.gc === "function") {
const before = process.memoryUsage();
global.gc();
const after = process.memoryUsage();
const freed = ((before.heapUsed - after.heapUsed) / 1024 / 1024).toFixed(0);
console.log(`[gc] Post-build GC freed ${freed} MB (heap: ${(after.heapUsed / 1024 / 1024).toFixed(0)} MB)`);
}
});
return {

View File

@@ -121,7 +121,7 @@ permalink: "featured/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNu
{% if post.templateContent %}
<p class="p-summary text-surface-700 dark:text-surface-300 mt-3">{{ post.templateContent | striptags | truncate(250) }}</p>
{% endif %}
<a href="{{ post.url }}" class="text-sm text-accent-700 dark:text-accent-300 hover:underline mt-3 inline-block">Read more &rarr;</a>
<a href="{{ post.url }}" class="text-sm text-accent-600 dark:text-accent-400 hover:underline mt-3 inline-block">Read more &rarr;</a>
{% else %}
{# ── Note ── #}
@@ -137,7 +137,7 @@ permalink: "featured/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNu
<div class="e-content prose dark:prose-invert prose-sm mt-3 max-w-none">{{ post.templateContent | safe }}</div>
{% endif %}
<div class="post-footer mt-3">
<a href="{{ post.url }}" class="text-sm text-accent-700 dark:text-accent-300 hover:underline" aria-label="Permalink: {{ post.data.title or ('Post from ' + (post.date | dateDisplay)) }}">Permalink</a>
<a href="{{ post.url }}" class="text-sm text-accent-600 dark:text-accent-400 hover:underline" aria-label="Permalink: {{ post.data.title or ('Post from ' + (post.date | dateDisplay)) }}">Permalink</a>
</div>
{% endif %}

BIN
images/rick.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -1,12 +1,19 @@
/**
* Client-side comments component (Alpine.js)
* Handles IndieAuth flow, comment submission, and display
* Handles IndieAuth flow, comment submission, display, and owner detection
*
* Registered via Alpine.data() so the component is available
* regardless of script loading order.
*/
document.addEventListener("alpine:init", () => {
// Global owner state store — shared across components
Alpine.store("owner", {
isOwner: false,
profile: null,
syndicationTargets: {},
});
Alpine.data("commentsSection", (targetUrl) => ({
targetUrl,
user: null,
@@ -20,10 +27,21 @@ document.addEventListener("alpine:init", () => {
statusType: "info",
maxLength: 2000,
showForm: false,
isOwner: false,
ownerProfile: null,
syndicationTargets: {},
replyingTo: null,
replyText: "",
replySubmitting: false,
async init() {
await this.checkSession();
await this.checkOwner();
await this.loadComments();
if (this.isOwner) {
// Notify webmentions.js that owner is detected (for reply buttons)
document.dispatchEvent(new CustomEvent("owner:detected"));
}
this.handleAuthReturn();
},
@@ -41,6 +59,124 @@ document.addEventListener("alpine:init", () => {
}
},
async checkOwner() {
try {
const res = await fetch("/comments/api/is-owner", {
credentials: "include",
});
if (res.ok) {
const data = await res.json();
if (data.isOwner) {
this.isOwner = true;
this.ownerProfile = {
name: data.name,
url: data.url,
photo: data.photo,
};
this.syndicationTargets = data.syndicationTargets || {};
// Also update global store for webmentions component
Alpine.store("owner").isOwner = true;
Alpine.store("owner").profile = this.ownerProfile;
Alpine.store("owner").syndicationTargets = this.syndicationTargets;
// Note: owner:detected event is dispatched from init() after
// this completes, so the Alpine store is populated before the event fires
}
}
} catch {
// Not owner
}
},
startReply(commentId, platform, replyUrl, syndicateTo) {
this.replyingTo = { commentId, platform, replyUrl, syndicateTo };
this.replyText = "";
},
cancelReply() {
this.replyingTo = null;
this.replyText = "";
},
async submitReply() {
if (!this.replyText.trim() || !this.replyingTo) return;
this.replySubmitting = true;
try {
if (this.replyingTo.platform === "comment") {
// Native comment reply — POST to comments API
const res = await fetch("/comments/api/reply", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
parent_id: this.replyingTo.commentId,
content: this.replyText,
target: this.targetUrl,
}),
});
if (res.ok) {
const data = await res.json();
if (data.comment) {
this.comments.push(data.comment);
}
this.showStatus("Reply posted!", "success");
} else {
const data = await res.json();
this.showStatus(data.error || "Failed to reply", "error");
}
} else {
// Micropub reply — POST to /micropub
const micropubBody = {
type: ["h-entry"],
properties: {
content: [this.replyText],
"in-reply-to": [this.replyingTo.replyUrl],
},
};
// Only add syndication target for the matching platform
if (this.replyingTo.syndicateTo) {
micropubBody.properties["mp-syndicate-to"] = [
this.replyingTo.syndicateTo,
];
} else {
// IndieWeb webmention — no syndication, empty array
micropubBody.properties["mp-syndicate-to"] = [];
}
const res = await fetch("/micropub", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
credentials: "include",
body: JSON.stringify(micropubBody),
});
if (res.ok || res.status === 201 || res.status === 202) {
this.showStatus("Reply posted and syndicated!", "success");
} else {
const data = await res.json().catch(() => ({}));
this.showStatus(
data.error_description || data.error || "Failed to post reply",
"error",
);
}
}
this.replyingTo = null;
this.replyText = "";
} catch (error) {
this.showStatus("Error posting reply: " + error.message, "error");
} finally {
this.replySubmitting = false;
}
},
handleAuthReturn() {
const params = new URLSearchParams(window.location.search);
const authError = params.get("auth_error");
@@ -62,6 +198,10 @@ document.addEventListener("alpine:init", () => {
if (res.ok) {
const data = await res.json();
this.comments = data.children || [];
// Auto-expand if comments exist
if (this.comments.length > 0) {
this.showForm = true;
}
}
} catch (e) {
console.error("[Comments] Load error:", e);

View File

@@ -55,60 +55,158 @@
function processWebmentions(allChildren) {
if (!allChildren || !allChildren.length) return;
// Separate owner replies (threaded under parent) from regular interactions
var ownerReplies = allChildren.filter(function(wm) { return wm.is_owner && wm.parent_url; });
var regularItems = allChildren.filter(function(wm) { return !wm.is_owner; });
let mentionsToShow;
if (hasBuildTimeSection) {
// Build-time section exists - only show NEW webmentions to avoid duplicates.
// Both webmention.io and conversations items are included at build time,
// so filter all by timestamp (only show items received after the build).
mentionsToShow = allChildren.filter((wm) => {
const wmTime = new Date(wm['wm-received']).getTime();
return wmTime > buildTime;
// Build-time section exists — deduplicate against what's actually rendered
// in the DOM rather than using timestamps (which miss webmentions that the
// build-time cache didn't include but that the API returns).
// Collect author URLs already shown in facepiles (likes, reposts, bookmarks)
var renderedAvatars = new Set();
document.querySelectorAll('.webmention-likes li[data-author-url], .webmention-reposts li[data-author-url], .webmention-bookmarks li[data-author-url]').forEach(function(li) {
var authorUrl = li.dataset.authorUrl;
// Determine the type from the parent section class
var parent = li.closest('[class*="webmention-"]');
var type = 'like-of';
if (parent) {
if (parent.classList.contains('webmention-reposts')) type = 'repost-of';
if (parent.classList.contains('webmention-bookmarks')) type = 'bookmark-of';
}
if (authorUrl) renderedAvatars.add(authorUrl + '::' + type);
});
// Collect reply URLs already shown in reply cards
var renderedReplies = new Set();
document.querySelectorAll('.webmention-replies li[data-wm-url]').forEach(function(li) {
if (li.dataset.wmUrl) renderedReplies.add(li.dataset.wmUrl);
});
mentionsToShow = regularItems.filter(function(wm) {
var prop = wm['wm-property'] || 'mention-of';
if (prop === 'in-reply-to') {
// Skip replies whose source URL is already rendered
return !renderedReplies.has(wm.url);
}
// Skip likes/reposts/bookmarks whose author is already in a facepile
var authorUrl = (wm.author && wm.author.url) || '';
if (authorUrl && renderedAvatars.has(authorUrl + '::' + prop)) return false;
return true;
});
} else {
// No build-time section - show ALL webmentions from API
mentionsToShow = allChildren;
// No build-time section - show ALL regular webmentions from API
mentionsToShow = regularItems;
}
if (!mentionsToShow.length) return;
if (mentionsToShow.length) {
// Group by type
const likes = mentionsToShow.filter((m) => m['wm-property'] === 'like-of');
const reposts = mentionsToShow.filter((m) => m['wm-property'] === 'repost-of');
const replies = mentionsToShow.filter((m) => m['wm-property'] === 'in-reply-to');
const mentions = mentionsToShow.filter((m) => m['wm-property'] === 'mention-of');
// Group by type
const likes = mentionsToShow.filter((m) => m['wm-property'] === 'like-of');
const reposts = mentionsToShow.filter((m) => m['wm-property'] === 'repost-of');
const replies = mentionsToShow.filter((m) => m['wm-property'] === 'in-reply-to');
const mentions = mentionsToShow.filter((m) => m['wm-property'] === 'mention-of');
if (likes.length) {
appendAvatars('.webmention-likes .facepile, .webmention-likes .avatar-row', likes, 'likes');
updateCount('.webmention-likes h3', likes.length, 'Like');
}
// Append new likes
if (likes.length) {
appendAvatars('.webmention-likes .facepile, .webmention-likes .avatar-row', likes, 'likes');
updateCount('.webmention-likes h3', likes.length, 'Like');
if (reposts.length) {
appendAvatars('.webmention-reposts .facepile, .webmention-reposts .avatar-row', reposts, 'reposts');
updateCount('.webmention-reposts h3', reposts.length, 'Repost');
}
if (replies.length) {
appendReplies('.webmention-replies ul', replies);
updateCount('.webmention-replies h3', replies.length, 'Repl', 'ies', 'y');
}
if (mentions.length) {
appendMentions('.webmention-mentions ul', mentions);
updateCount('.webmention-mentions h3', mentions.length, 'Mention');
}
// Update total count in main header
updateTotalCount(mentionsToShow.length);
}
// Append new reposts
if (reposts.length) {
appendAvatars('.webmention-reposts .facepile, .webmention-reposts .avatar-row', reposts, 'reposts');
updateCount('.webmention-reposts h3', reposts.length, 'Repost');
}
// Thread owner replies under their parent interaction cards
threadOwnerReplies(ownerReplies);
}
// Append new replies
if (replies.length) {
appendReplies('.webmention-replies ul', replies);
updateCount('.webmention-replies h3', replies.length, 'Repl', 'ies', 'y');
}
function threadOwnerReplies(ownerReplies) {
if (!ownerReplies || !ownerReplies.length) return;
// Append new mentions
if (mentions.length) {
appendMentions('.webmention-mentions ul', mentions);
updateCount('.webmention-mentions h3', mentions.length, 'Mention');
}
ownerReplies.forEach(function(reply) {
var parentUrl = reply.parent_url;
if (!parentUrl) return;
// Update total count in main header
updateTotalCount(mentionsToShow.length);
// Find the interaction card whose URL matches the parent
var matchingLi = document.querySelector('.webmention-replies li[data-wm-url="' + CSS.escape(parentUrl) + '"]');
if (!matchingLi) return;
var slot = matchingLi.querySelector('.wm-owner-reply-slot');
if (!slot) return;
// Skip if already rendered (dedup by reply URL)
if (slot.querySelector('[data-reply-url="' + CSS.escape(reply.url) + '"]')) return;
var replyCard = document.createElement('div');
replyCard.className = 'p-3 bg-surface-100 dark:bg-surface-900 rounded-lg border-l-2 border-amber-400 dark:border-amber-600';
replyCard.dataset.replyUrl = reply.url || '';
var innerDiv = document.createElement('div');
innerDiv.className = 'flex items-start gap-2';
var avatar = document.createElement('div');
avatar.className = 'w-6 h-6 rounded-full bg-amber-100 dark:bg-amber-900 flex-shrink-0 flex items-center justify-center text-xs font-bold';
avatar.textContent = (reply.author && reply.author.name ? reply.author.name[0] : 'O').toUpperCase();
var contentArea = document.createElement('div');
contentArea.className = 'flex-1';
var headerRow = document.createElement('div');
headerRow.className = 'flex items-center gap-2 flex-wrap';
var nameSpan = document.createElement('span');
nameSpan.className = 'font-medium text-sm';
nameSpan.textContent = (reply.author && reply.author.name) || 'Owner';
var authorBadge = document.createElement('span');
authorBadge.className = 'inline-flex items-center px-1.5 py-0.5 text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded-full';
authorBadge.textContent = 'Author';
var timeEl = document.createElement('time');
timeEl.className = 'text-xs text-surface-600 dark:text-surface-400 font-mono';
timeEl.dateTime = reply.published || '';
timeEl.textContent = formatDate(reply.published);
headerRow.appendChild(nameSpan);
headerRow.appendChild(authorBadge);
headerRow.appendChild(timeEl);
var textDiv = document.createElement('div');
textDiv.className = 'mt-1 text-sm prose dark:prose-invert';
textDiv.textContent = (reply.content && reply.content.text) || '';
contentArea.appendChild(headerRow);
contentArea.appendChild(textDiv);
innerDiv.appendChild(avatar);
innerDiv.appendChild(contentArea);
replyCard.appendChild(innerDiv);
slot.appendChild(replyCard);
});
}
// Try cached data first (renders instantly on refresh)
const cached = getCachedData();
if (cached) {
processWebmentions(cached);
// Enrich build-time badges from cached conversations data
enrichBuildTimeBadges(cached.filter(function(c) { return c.platform; }));
}
// Conversations API URLs (dual-fetch for enriched data)
@@ -170,11 +268,54 @@
if (!cached) {
processWebmentions(allChildren);
}
// Enrich build-time reply badges with conversations API platform data
// Build-time cards have badges from URL heuristics (often wrong for AP servers).
// Conversations items have NodeInfo-resolved platform names — use them to upgrade.
enrichBuildTimeBadges(convItems);
})
.catch((err) => {
console.debug('[Webmentions] Error fetching:', err.message);
});
function enrichBuildTimeBadges(convItems) {
if (!convItems || !convItems.length) return;
convItems.forEach(function(item) {
if (!item.platform) return;
// Find matching build-time reply card: try URL match, then author URL match
var li = null;
if (item.url) {
li = document.querySelector('.webmention-replies li[data-wm-url="' + CSS.escape(item.url) + '"]');
}
if (!li && item.author && item.author.url) {
li = document.querySelector('.webmention-replies li[data-author-url="' + CSS.escape(item.author.url) + '"]');
}
if (!li) return;
// Skip cards we rendered client-side (they already have correct badges)
if (li.dataset.new === 'true') return;
var newPlatform = detectPlatform(item);
var currentPlatform = li.dataset.platform;
if (newPlatform === currentPlatform) return;
// Update the badge
li.dataset.platform = newPlatform;
var oldBadge = li.querySelector('.wm-provenance-badge');
if (oldBadge) {
oldBadge.replaceWith(createProvenanceBadge(newPlatform));
}
// Update reply button platform
var replyBtn = li.querySelector('.wm-reply-btn');
if (replyBtn) {
replyBtn.dataset.platform = newPlatform;
}
});
}
function appendAvatars(selector, items, type) {
let row = document.querySelector(selector);
@@ -247,6 +388,8 @@
const li = document.createElement('li');
li.className = 'p-4 bg-surface-100 dark:bg-surface-800 rounded-lg ring-2 ring-accent-500';
li.dataset.new = 'true';
li.dataset.platform = detectPlatform(item);
li.dataset.wmUrl = item.url || '';
// Build reply card using DOM methods
const wrapper = document.createElement('div');
@@ -292,10 +435,13 @@
dateLink.appendChild(timeEl);
const newBadge = document.createElement('span');
newBadge.className = 'text-xs text-accent-700 dark:text-accent-300 font-medium';
newBadge.className = 'text-xs text-accent-600 dark:text-accent-400 font-medium';
newBadge.textContent = 'NEW';
headerDiv.appendChild(authorLink);
// Add provenance badge
var platform = detectPlatform(item);
headerDiv.appendChild(createProvenanceBadge(platform));
headerDiv.appendChild(dateLink);
headerDiv.appendChild(newBadge);
@@ -304,16 +450,36 @@
replyDiv.className = 'text-surface-700 dark:text-surface-300 prose dark:prose-invert prose-sm max-w-none';
replyDiv.textContent = content.text || '';
// Reply button (hidden by default, shown for owner)
const replyBtn = document.createElement('button');
replyBtn.className = 'wm-reply-btn hidden text-xs text-primary-600 dark:text-primary-400 hover:underline mt-2';
replyBtn.dataset.replyUrl = item.url || '';
replyBtn.dataset.platform = platform;
replyBtn.textContent = 'Reply';
contentDiv.appendChild(headerDiv);
contentDiv.appendChild(replyDiv);
contentDiv.appendChild(replyBtn);
wrapper.appendChild(avatarLink);
wrapper.appendChild(contentDiv);
li.appendChild(wrapper);
// Owner reply slot for threaded replies
const ownerReplySlot = document.createElement('div');
ownerReplySlot.className = 'wm-owner-reply-slot ml-13 mt-2';
li.appendChild(ownerReplySlot);
// Also set data attributes for build-time parity
li.dataset.wmSource = item['wm-source'] || '';
li.dataset.authorUrl = author.url || '';
// Prepend to show newest first
list.insertBefore(li, list.firstChild);
});
// Wire up new reply buttons if owner is already detected
wireReplyButtons();
}
function appendMentions(selector, items) {
@@ -343,13 +509,13 @@
const link = document.createElement('a');
link.href = item.url || '#';
link.className = 'text-accent-700 dark:text-accent-300 hover:underline';
link.className = 'text-accent-600 dark:text-accent-400 hover:underline';
link.target = '_blank';
link.rel = 'noopener';
link.textContent = `${author.name || 'Someone'} mentioned this on ${formatDate(published)}`;
const badge = document.createElement('span');
badge.className = 'text-xs text-accent-700 dark:text-accent-300 font-medium ml-1';
badge.className = 'text-xs text-accent-600 dark:text-accent-400 font-medium ml-1';
badge.textContent = 'NEW';
li.appendChild(link);
@@ -471,4 +637,236 @@
year: 'numeric',
});
}
function detectPlatform(item) {
// Conversations API provides a resolved platform field via NodeInfo
if (item.platform) {
var p = item.platform.toLowerCase();
if (p === 'mastodon') return 'mastodon';
if (p === 'bluesky') return 'bluesky';
if (p === 'webmention') return 'webmention';
// All other fediverse software (pleroma, misskey, gotosocial, fedify, etc.)
return 'activitypub';
}
// Fallback: URL heuristics for webmention.io data and build-time cards
var source = item['wm-source'] || '';
var authorUrl = (item.author && item.author.url) || '';
if (source.includes('brid.gy/') && source.includes('/mastodon/')) return 'mastodon';
if (source.includes('brid.gy/') && source.includes('/bluesky/')) return 'bluesky';
if (source.includes('fed.brid.gy')) return 'activitypub';
if (authorUrl.includes('bsky.app')) return 'bluesky';
return 'webmention';
}
function createSvgIcon(viewBox, fillAttr, paths, strokeAttrs) {
var NS = 'http://www.w3.org/2000/svg';
var svg = document.createElementNS(NS, 'svg');
svg.setAttribute('class', 'w-3 h-3');
svg.setAttribute('viewBox', viewBox);
svg.setAttribute('fill', fillAttr || 'currentColor');
if (strokeAttrs) {
svg.setAttribute('stroke', strokeAttrs.stroke || 'currentColor');
svg.setAttribute('stroke-width', strokeAttrs.strokeWidth || '2');
svg.setAttribute('stroke-linecap', strokeAttrs.strokeLinecap || 'round');
svg.setAttribute('stroke-linejoin', strokeAttrs.strokeLinejoin || 'round');
}
paths.forEach(function(p) {
var el = document.createElementNS(NS, p.tag || 'path');
var attrs = p.attrs || {};
Object.keys(attrs).forEach(function(attr) {
el.setAttribute(attr, attrs[attr]);
});
svg.appendChild(el);
});
return svg;
}
function createProvenanceBadge(platform) {
var span = document.createElement('span');
var svg;
if (platform === 'mastodon') {
span.className = 'wm-provenance-badge inline-flex items-center gap-1 px-1.5 py-0.5 text-xs bg-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 rounded-full';
span.title = 'Mastodon';
svg = createSvgIcon('0 0 24 24', 'currentColor', [
{ tag: 'path', attrs: { d: 'M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.668 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z' } }
]);
} else if (platform === 'bluesky') {
span.className = 'wm-provenance-badge inline-flex items-center gap-1 px-1.5 py-0.5 text-xs bg-sky-100 dark:bg-sky-900/30 text-sky-600 dark:text-sky-400 rounded-full';
span.title = 'Bluesky';
svg = createSvgIcon('0 0 568 501', 'currentColor', [
{ tag: 'path', attrs: { d: 'M123.121 33.664C188.241 82.553 258.281 181.68 284 234.873c25.719-53.192 95.759-152.32 160.879-201.21C491.866-1.611 568-28.906 568 57.947c0 17.346-9.945 145.713-15.778 166.555-20.275 72.453-94.155 90.933-159.875 79.748C507.222 323.8 536.444 388.56 473.333 453.32c-119.86 122.992-172.272-30.859-185.702-70.281-2.462-7.227-3.614-10.608-3.631-7.733-.017-2.875-1.169.506-3.631 7.733-13.43 39.422-65.842 193.273-185.702 70.281-63.111-64.76-33.89-129.52 80.986-149.071-65.72 11.185-139.6-7.295-159.875-79.748C9.945 203.659 0 75.291 0 57.946 0-28.906 76.135-1.612 123.121 33.664Z' } }
]);
} else if (platform === 'activitypub') {
span.className = 'wm-provenance-badge inline-flex items-center gap-1 px-1.5 py-0.5 text-xs bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 rounded-full';
span.title = 'Fediverse (ActivityPub)';
svg = createSvgIcon('0 0 24 24', 'currentColor', [
{ tag: 'path', attrs: { d: 'M13.09 4.43L24 10.73v2.51L13.09 19.58v-2.51L21.83 12 13.09 6.98v-2.55zM13.09 9.49L17.44 12l-4.35 2.51V9.49z' } },
{ tag: 'path', attrs: { d: 'M10.91 4.43L0 10.73v2.51l8.74-5.03v10.09l2.18 1.28V4.43zM6.56 12L2.18 14.51l4.35 2.51V12z' } }
]);
} else {
span.className = 'wm-provenance-badge inline-flex items-center gap-1 px-1.5 py-0.5 text-xs bg-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400 rounded-full';
span.title = 'IndieWeb';
svg = createSvgIcon('0 0 24 24', 'none', [
{ tag: 'circle', attrs: { cx: '12', cy: '12', r: '10' } },
{ tag: 'line', attrs: { x1: '2', y1: '12', x2: '22', y2: '12' } },
{ tag: 'path', attrs: { d: 'M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z' } }
], { stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round', strokeLinejoin: 'round' });
}
span.appendChild(svg);
return span;
}
// Populate provenance badges on build-time reply cards
document.querySelectorAll('.webmention-replies li[data-wm-url]').forEach(function(li) {
var source = li.dataset.wmSource || '';
var authorUrl = li.dataset.authorUrl || '';
var platform = detectPlatform({ 'wm-source': source, author: { url: authorUrl } });
li.dataset.platform = platform;
var badgeSlot = li.querySelector('.wm-provenance-badge');
if (badgeSlot) {
badgeSlot.replaceWith(createProvenanceBadge(platform));
}
// Set platform on reply button
var replyBtn = li.querySelector('.wm-reply-btn');
if (replyBtn) {
replyBtn.dataset.platform = platform;
}
});
// Wire reply buttons: unhide and attach click handlers for unwired buttons
// Called from owner:detected handler AND after dynamic replies are appended
// Close any open inline reply form
function closeActiveReplyForm() {
var existing = document.querySelector('.wm-inline-reply-form');
if (existing) existing.remove();
}
// Submit a Micropub reply
function submitMicropubReply(replyUrl, platform, syndicateTo, textarea, statusEl, submitBtn) {
var content = textarea.value.trim();
if (!content) return;
submitBtn.disabled = true;
submitBtn.textContent = 'Sending...';
statusEl.textContent = '';
var body = {
type: ['h-entry'],
properties: {
content: [content],
'in-reply-to': [replyUrl]
}
};
if (syndicateTo) {
body.properties['mp-syndicate-to'] = [syndicateTo];
}
fetch('/micropub', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
credentials: 'include',
body: JSON.stringify(body)
}).then(function(res) {
if (res.ok || res.status === 201 || res.status === 202) {
statusEl.className = 'text-xs text-green-600 dark:text-green-400 mt-1';
statusEl.textContent = 'Reply posted' + (syndicateTo ? ' and syndicated!' : '!');
textarea.value = '';
setTimeout(closeActiveReplyForm, 2000);
} else {
return res.json().catch(function() { return {}; }).then(function(data) {
statusEl.className = 'text-xs text-red-600 dark:text-red-400 mt-1';
statusEl.textContent = data.error_description || data.error || 'Failed to post reply';
submitBtn.disabled = false;
submitBtn.textContent = 'Send Reply';
});
}
}).catch(function(err) {
statusEl.className = 'text-xs text-red-600 dark:text-red-400 mt-1';
statusEl.textContent = 'Error: ' + err.message;
submitBtn.disabled = false;
submitBtn.textContent = 'Send Reply';
});
}
function wireReplyButtons() {
var ownerStore = Alpine.store && Alpine.store('owner');
if (!ownerStore || !ownerStore.isOwner) return;
document.querySelectorAll('.wm-reply-btn').forEach(function(btn) {
if (btn.dataset.wired) return; // already wired
btn.dataset.wired = 'true';
btn.classList.remove('hidden');
btn.addEventListener('click', function() {
var replyUrl = btn.dataset.replyUrl;
var platform = btn.dataset.platform || 'webmention';
var syndicateTo = null;
if (platform === 'bluesky') syndicateTo = ownerStore.syndicationTargets.bluesky || null;
if (platform === 'mastodon') syndicateTo = ownerStore.syndicationTargets.mastodon || null;
// Close any existing reply form
closeActiveReplyForm();
// Find the owner-reply-slot next to this webmention card
var li = btn.closest('li') || btn.closest('.webmention-reply');
var slot = li ? li.querySelector('.wm-owner-reply-slot') : null;
if (!slot) {
// Fallback: insert after the button's parent
slot = document.createElement('div');
btn.parentElement.after(slot);
}
// Build inline reply form
var form = document.createElement('div');
form.className = 'wm-inline-reply-form mt-2 p-3 bg-surface-100 dark:bg-surface-900 rounded-lg border-l-2 border-primary-400';
var label = document.createElement('div');
label.className = 'text-xs text-surface-500 dark:text-surface-400 mb-1';
label.textContent = 'Replying via ' + platform + (syndicateTo ? ' (will syndicate)' : '');
var textarea = document.createElement('textarea');
textarea.rows = 3;
textarea.placeholder = 'Write your reply...';
textarea.className = 'w-full px-3 py-2 border rounded-lg text-sm dark:bg-surface-800 dark:border-surface-700 dark:text-surface-100';
var actions = document.createElement('div');
actions.className = 'flex items-center gap-2 mt-2';
var submitBtn = document.createElement('button');
submitBtn.className = 'button text-sm';
submitBtn.textContent = 'Send Reply';
var cancelBtn = document.createElement('button');
cancelBtn.className = 'text-xs text-surface-500 hover:underline';
cancelBtn.textContent = 'Cancel';
var statusEl = document.createElement('div');
statusEl.className = 'text-xs mt-1';
submitBtn.addEventListener('click', function() {
submitMicropubReply(replyUrl, platform, syndicateTo, textarea, statusEl, submitBtn);
});
cancelBtn.addEventListener('click', closeActiveReplyForm);
actions.appendChild(submitBtn);
actions.appendChild(cancelBtn);
form.appendChild(label);
form.appendChild(textarea);
form.appendChild(actions);
form.appendChild(statusEl);
slot.appendChild(form);
textarea.focus();
});
});
}
// Show reply buttons when owner is detected
// Listen for custom event dispatched by comments.js after async owner check
document.addEventListener('owner:detected', function() {
wireReplyButtons();
});
})();

View File

@@ -4,16 +4,27 @@
* CLI entry point for OG image generation.
* Runs as a separate process to isolate memory from Eleventy.
*
* Usage: node lib/og-cli.js <contentDir> <cacheDir> <siteName>
* Usage: node lib/og-cli.js <contentDir> <cacheDir> <siteName> [batchSize]
*
* batchSize: Max images to generate per invocation (0 = unlimited).
* When set, exits after generating that many images so the caller
* can re-spawn (releasing all WASM native memory between batches).
* Exit code 2 = batch complete, more work remains.
*/
import { generateOgImages } from "./og.js";
const [contentDir, cacheDir, siteName] = process.argv.slice(2);
const [contentDir, cacheDir, siteName, batchSizeStr] = process.argv.slice(2);
if (!contentDir || !cacheDir || !siteName) {
console.error("[og] Usage: node og-cli.js <contentDir> <cacheDir> <siteName>");
console.error("[og] Usage: node og-cli.js <contentDir> <cacheDir> <siteName> [batchSize]");
process.exit(1);
}
await generateOgImages(contentDir, cacheDir, siteName);
const batchSize = parseInt(batchSizeStr, 10) || 0;
const result = await generateOgImages(contentDir, cacheDir, siteName, batchSize);
// Exit code 2 signals "batch complete, more images remain"
if (result?.hasMore) {
process.exit(2);
}

273
lib/og.js
View File

@@ -2,6 +2,9 @@
* OpenGraph image generation for posts without photos.
* Uses Satori (layout → SVG) + @resvg/resvg-js (SVG → PNG).
* Generated images are cached in .cache/og/ and passthrough-copied to output.
*
* Card design inspired by GitHub's OG images: light background, clean
* typography hierarchy, avatar, metadata row, and accent color bar.
*/
import satori from "satori";
@@ -22,14 +25,19 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const WIDTH = 1200;
const HEIGHT = 630;
// Card design version — bump to force full regeneration
const DESIGN_VERSION = 3;
const COLORS = {
bg: "#09090b",
title: "#f4f4f5",
date: "#a1a1aa",
siteName: "#71717a",
bg: "#ffffff",
title: "#24292f",
description: "#57606a",
meta: "#57606a",
accent: "#3b82f6",
badge: "#2563eb",
badgeText: "#ffffff",
badge: "#ddf4ff",
badgeText: "#0969da",
border: "#d8dee4",
bar: "#3b82f6",
};
const POST_TYPE_MAP = {
@@ -48,6 +56,18 @@ const POST_TYPE_MAP = {
events: "Event",
};
let avatarDataUri = null;
function loadAvatar() {
if (avatarDataUri) return avatarDataUri;
const avatarPath = resolve(__dirname, "..", "images", "rick.jpg");
if (existsSync(avatarPath)) {
const buf = readFileSync(avatarPath);
avatarDataUri = `data:image/jpeg;base64,${buf.toString("base64")}`;
}
return avatarDataUri;
}
function loadFonts() {
const fontsDir = resolve(
__dirname,
@@ -73,9 +93,9 @@ function loadFonts() {
];
}
function computeHash(title, date, postType, siteName) {
function computeHash(title, description, date, postType, siteName) {
return createHash("md5")
.update(`${title}|${date}|${postType}|${siteName}`)
.update(`v${DESIGN_VERSION}|${title}|${description}|${date}|${postType}|${siteName}`)
.digest("hex")
.slice(0, 12);
}
@@ -97,7 +117,7 @@ function formatDate(dateStr) {
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
month: "short",
day: "numeric",
});
} catch {
@@ -107,77 +127,147 @@ function formatDate(dateStr) {
/**
* Use the full filename (with date prefix) as the OG image slug.
* This matches the URL path segment directly, avoiding Eleventy's page.fileSlug
* race condition in Nunjucks parallel rendering.
*/
function toOgSlug(filename) {
return filename;
}
function truncateTitle(title, max = 120) {
if (!title || title.length <= max) return title || "Untitled";
return title.slice(0, max).trim() + "\u2026";
/**
* Sanitize text for Satori rendering — strip characters that cause NO GLYPH.
*/
function sanitize(text) {
if (!text) return "";
return text.replace(/[^\x20-\x7E\u00A0-\u024F\u2010-\u2027\u2030-\u205E]/g, "").trim();
}
function extractBodyText(raw) {
const body = raw
/**
* Strip markdown formatting from raw content, returning plain text lines.
*/
function stripMarkdown(raw) {
return raw
// Strip frontmatter
.replace(/^---[\s\S]*?---\s*/, "")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/[#*_~`>]/g, "")
// Strip images
.replace(/!\[[^\]]*\]\([^)]+\)/g, "")
.replace(/\n+/g, " ")
.trim();
if (!body) return "Untitled";
return body.length > 120 ? body.slice(0, 120).trim() + "\u2026" : body;
// Strip markdown tables (lines with pipes)
.replace(/^\|.*\|$/gm, "")
// Strip table separator rows
.replace(/^\s*[-|: ]+$/gm, "")
// Strip heading anchors {#id}
.replace(/\{#[^}]+\}/g, "")
// Strip HTML tags
.replace(/<[^>]+>/g, "")
// Strip markdown links, keep text
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
// Strip heading markers
.replace(/^#{1,6}\s+/gm, "")
// Strip bold, italic, strikethrough, code, blockquote markers
.replace(/[*_~`>]/g, "")
// Strip list bullets and numbered lists
.replace(/^\s*[-*+]\s+/gm, "")
.replace(/^\s*\d+\.\s+/gm, "")
// Strip horizontal rules
.replace(/^-{3,}$/gm, "");
}
function buildCard(title, dateStr, postType, siteName) {
/**
* Extract the first paragraph from raw markdown content.
* Returns only the first meaningful block of text, ignoring headings,
* tables, lists, and other structural elements.
*/
function extractFirstParagraph(raw) {
const stripped = stripMarkdown(raw);
// Split into lines, find first non-empty line(s) that form a paragraph
const lines = stripped.split("\n");
const paragraphLines = [];
let started = false;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
// Empty line: if we've started collecting, the paragraph is done
if (started) break;
continue;
}
started = true;
paragraphLines.push(trimmed);
}
const text = paragraphLines.join(" ").replace(/\s+/g, " ").trim();
if (!text) return "";
const safe = sanitize(text);
return safe || text;
}
function truncate(text, max) {
if (!text || text.length <= max) return text || "";
return text.slice(0, max).trim() + "\u2026";
}
function buildCard(title, description, dateStr, postType, siteName) {
const avatar = loadAvatar();
const formattedDate = formatDate(dateStr);
return {
type: "div",
props: {
style: {
display: "flex",
flexDirection: "column",
width: `${WIDTH}px`,
height: `${HEIGHT}px`,
backgroundColor: COLORS.bg,
},
children: [
// Top accent bar
{
type: "div",
props: {
style: {
width: "6px",
height: "100%",
backgroundColor: COLORS.accent,
width: "100%",
height: "6px",
backgroundColor: COLORS.bar,
flexShrink: 0,
},
},
},
// Main content — vertically centered
{
type: "div",
props: {
style: {
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: "60px",
flex: 1,
overflow: "hidden",
padding: "0 64px",
alignItems: "center",
},
children: [
// Left: text content
{
type: "div",
props: {
style: {
display: "flex",
flexDirection: "column",
gap: "24px",
flex: 1,
gap: "16px",
overflow: "hidden",
paddingRight: avatar ? "48px" : "0",
},
children: [
// Post type badge + date inline
{
type: "div",
props: {
style: { display: "flex" },
style: {
display: "flex",
alignItems: "center",
gap: "12px",
color: COLORS.meta,
fontSize: "18px",
fontWeight: 400,
fontFamily: "Inter",
},
children: [
{
type: "span",
@@ -185,10 +275,10 @@ function buildCard(title, dateStr, postType, siteName) {
style: {
backgroundColor: COLORS.badge,
color: COLORS.badgeText,
fontSize: "16px",
fontSize: "14px",
fontWeight: 700,
fontFamily: "Inter",
padding: "6px 16px",
padding: "4px 12px",
borderRadius: "999px",
textTransform: "uppercase",
letterSpacing: "0.05em",
@@ -196,9 +286,13 @@ function buildCard(title, dateStr, postType, siteName) {
children: postType,
},
},
],
formattedDate
? { type: "span", props: { children: formattedDate } }
: null,
].filter(Boolean),
},
},
// Title
{
type: "div",
props: {
@@ -210,32 +304,75 @@ function buildCard(title, dateStr, postType, siteName) {
lineHeight: 1.2,
overflow: "hidden",
},
children: truncateTitle(title),
children: truncate(title, 120),
},
},
dateStr
// Description (if available)
description
? {
type: "div",
props: {
style: {
color: COLORS.date,
fontSize: "24px",
color: COLORS.description,
fontSize: "22px",
fontWeight: 400,
fontFamily: "Inter",
lineHeight: 1.4,
overflow: "hidden",
},
children: formatDate(dateStr),
children: truncate(description, 160),
},
}
: null,
].filter(Boolean),
},
},
// Right: avatar
avatar
? {
type: "div",
props: {
style: {
display: "flex",
flexShrink: 0,
},
children: [
{
type: "img",
props: {
src: avatar,
width: 128,
height: 128,
style: {
borderRadius: "16px",
border: `2px solid ${COLORS.border}`,
},
},
},
],
},
}
: null,
].filter(Boolean),
},
},
// Footer: site name
{
type: "div",
props: {
style: {
display: "flex",
justifyContent: "flex-end",
alignItems: "center",
padding: "0 64px 32px 64px",
},
children: [
{
type: "div",
props: {
style: {
color: COLORS.siteName,
fontSize: "20px",
color: "#8b949e",
fontSize: "18px",
fontWeight: 400,
fontFamily: "Inter",
},
@@ -278,8 +415,10 @@ function scanContentFiles(contentDir) {
* @param {string} contentDir - Path to content/ directory
* @param {string} cacheDir - Path to .cache/ directory
* @param {string} siteName - Site name for the card
* @param {number} batchSize - Max images to generate (0 = unlimited)
* @returns {{ hasMore: boolean }} Whether more images need generation
*/
export async function generateOgImages(contentDir, cacheDir, siteName) {
export async function generateOgImages(contentDir, cacheDir, siteName, batchSize = 0) {
const ogDir = join(cacheDir, "og");
mkdirSync(ogDir, { recursive: true });
@@ -296,8 +435,13 @@ export async function generateOgImages(contentDir, cacheDir, siteName) {
let generated = 0;
let skipped = 0;
const newManifest = {};
// Seed with existing manifest so unscanned entries survive batch writes
const newManifest = { ...manifest };
const SAVE_INTERVAL = 10;
// GC every 5 images to keep WASM native memory bounded.
const GC_INTERVAL = 5;
const hasGC = typeof global.gc === "function";
let peakRss = 0;
for (const filePath of mdFiles) {
const raw = readFileSync(filePath, "utf8");
@@ -309,10 +453,18 @@ export async function generateOgImages(contentDir, cacheDir, siteName) {
}
const slug = toOgSlug(basename(filePath, ".md"));
const title = fm.title || fm.name || extractBodyText(raw);
const date = fm.published || fm.date || "";
const postType = detectPostType(filePath);
const hash = computeHash(title, date, postType, siteName);
const date = fm.published || fm.date || "";
// Title: use frontmatter title/name, or first paragraph of body
const fmTitle = fm.title || fm.name || "";
const bodyText = extractFirstParagraph(raw);
const title = fmTitle || bodyText || "Untitled";
// Description: only show if we have a frontmatter title (so body adds context)
const description = fmTitle ? bodyText : "";
const hash = computeHash(title, description, date, postType, siteName);
if (manifest[slug]?.hash === hash && existsSync(join(ogDir, `${slug}.png`))) {
newManifest[slug] = manifest[slug];
@@ -320,7 +472,7 @@ export async function generateOgImages(contentDir, cacheDir, siteName) {
continue;
}
const card = buildCard(title, date, postType, siteName);
const card = buildCard(title, description, date, postType, siteName);
const svg = await satori(card, { width: WIDTH, height: HEIGHT, fonts });
const resvg = new Resvg(svg, {
fitTo: { mode: "width", value: WIDTH },
@@ -331,14 +483,35 @@ export async function generateOgImages(contentDir, cacheDir, siteName) {
newManifest[slug] = { title: slug, hash };
generated++;
// Save manifest periodically to preserve progress
// Save manifest periodically to preserve progress on OOM kill
if (generated % SAVE_INTERVAL === 0) {
writeFileSync(manifestPath, JSON.stringify(newManifest, null, 2));
}
// Force GC to reclaim Satori/Resvg WASM native memory.
if (hasGC && generated % GC_INTERVAL === 0) {
global.gc();
const rss = process.memoryUsage().rss;
if (rss > peakRss) peakRss = rss;
}
// Batch limit: stop after N images so the caller can re-spawn
if (batchSize > 0 && generated >= batchSize) {
break;
}
}
const hasMore = batchSize > 0 && generated >= batchSize;
if (hasGC) global.gc();
writeFileSync(manifestPath, JSON.stringify(newManifest, null, 2));
const mem = process.memoryUsage();
if (mem.rss > peakRss) peakRss = mem.rss;
console.log(
`[og] Generated ${generated} images, skipped ${skipped} (cached or have photos)`,
`[og] Generated ${generated} images, skipped ${skipped} (cached or have photos)` +
(hasMore ? ` [batch, more remain]` : ``) +
` | RSS: ${(mem.rss / 1024 / 1024).toFixed(0)} MB, peak: ${(peakRss / 1024 / 1024).toFixed(0)} MB, heap: ${(mem.heapUsed / 1024 / 1024).toFixed(0)} MB`,
);
return { hasMore };
}

View File

@@ -12,7 +12,7 @@ withSidebar: true
</p>
<p class="text-xs text-surface-600 dark:text-surface-400 mt-2" x-show="lastUpdated">
Last updated: <span class="font-mono" x-text="formatDate(lastUpdated, 'full')"></span>
<button @click="refresh()" class="ml-2 text-accent-700 hover:text-accent-700 dark:text-accent-300 rounded" :disabled="loading" aria-label="Refresh news">
<button @click="refresh()" class="ml-2 text-accent-600 hover:text-accent-700 dark:text-accent-400 rounded" :disabled="loading" aria-label="Refresh news">
<svg class="w-3 h-3 inline" :class="{ 'animate-spin': loading }" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
@@ -116,7 +116,7 @@ withSidebar: true
</svg>
Syncing...
</div>
<div x-show="loading && items.length > 0" class="flex items-center gap-2 text-accent-700 dark:text-accent-300 ml-auto">
<div x-show="loading && items.length > 0" class="flex items-center gap-2 text-accent-600 dark:text-accent-400 ml-auto">
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
@@ -142,10 +142,10 @@ withSidebar: true
<h2 class="font-semibold text-surface-900 dark:text-surface-100 mb-1">
<a
:href="item.link"
class="hover:text-accent-700 dark:hover:text-accent-300"
class="hover:text-accent-600 dark:hover:text-accent-400"
target="_blank"
rel="noopener"
x-text="item.title || item.link"
x-text="item.title"
></a>
</h2>
<p x-show="item.description" class="text-sm text-surface-600 dark:text-surface-400 line-clamp-2 mb-2" x-text="item.description"></p>
@@ -162,7 +162,7 @@ withSidebar: true
<time class="font-mono text-sm" :datetime="item.pubDate" x-text="formatDate(item.pubDate)"></time>
<span class="hidden sm:inline" x-show="item.categories?.length">
<template x-for="cat in item.categories.slice(0, 3)" :key="cat">
<span class="text-accent-700 dark:text-accent-300" x-text="'#' + cat"></span>
<span class="text-accent-600 dark:text-accent-400" x-text="'#' + cat"></span>
</template>
</span>
<button
@@ -208,10 +208,10 @@ withSidebar: true
<h2 class="font-semibold text-surface-900 dark:text-surface-100 mb-2 line-clamp-2">
<a
:href="item.link"
class="hover:text-accent-700 dark:hover:text-accent-300"
class="hover:text-accent-600 dark:hover:text-accent-400"
target="_blank"
rel="noopener"
x-text="item.title || item.link"
x-text="item.title"
></a>
</h2>
<p x-show="item.description" class="text-sm text-surface-600 dark:text-surface-400 line-clamp-3 mb-3" x-text="item.description"></p>
@@ -275,10 +275,10 @@ withSidebar: true
<h2 class="text-xl sm:text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4">
<a
:href="item.link"
class="hover:text-accent-700 dark:hover:text-accent-300"
class="hover:text-accent-600 dark:hover:text-accent-400"
target="_blank"
rel="noopener"
x-text="item.title || item.link"
x-text="item.title"
></a>
</h2>
@@ -319,7 +319,7 @@ withSidebar: true
</button>
<div x-show="item.categories?.length" class="flex flex-wrap gap-2">
<template x-for="cat in item.categories" :key="cat">
<span class="px-2 py-1 text-xs bg-accent-100 dark:bg-accent-900/30 text-accent-700 dark:text-accent-300 rounded-full" x-text="cat"></span>
<span class="px-2 py-1 text-xs bg-accent-100 dark:bg-accent-900/30 text-accent-700 dark:text-accent-400 rounded-full" x-text="cat"></span>
</template>
</div>
</div>

View File

@@ -14,7 +14,7 @@ pagefindIgnore: true
<noscript>
<div class="p-6 bg-surface-100 dark:bg-surface-800 rounded-lg mt-4">
<p class="text-surface-700 dark:text-surface-300">Search requires JavaScript to be enabled. Please enable JavaScript in your browser settings to use the search feature.</p>
<p class="text-surface-600 dark:text-surface-400 text-sm mt-2">Alternatively, you can browse content via the <a href="/blog/" class="text-accent-700 dark:text-accent-300 hover:underline">blog archive</a> or <a href="/categories/" class="text-accent-700 dark:text-accent-300 hover:underline">categories</a>.</p>
<p class="text-surface-600 dark:text-surface-400 text-sm mt-2">Alternatively, you can browse content via the <a href="/blog/" class="text-accent-600 dark:text-accent-400 hover:underline">blog archive</a> or <a href="/categories/" class="text-accent-600 dark:text-accent-400 hover:underline">categories</a>.</p>
</div>
</noscript>

View File

@@ -10,7 +10,7 @@ eleventyImport:
<div class="h-feed">
<h1 class="text-2xl sm:text-3xl font-bold text-surface-900 dark:text-surface-100 mb-2">Slash Pages</h1>
<p class="text-surface-600 dark:text-surface-400 mb-6 sm:mb-8">
Root-level pages on this site. Inspired by <a href="https://slashpages.net" class="text-accent-700 dark:text-accent-300 hover:underline" target="_blank" rel="noopener">slashpages.net</a>.
Root-level pages on this site. Inspired by <a href="https://slashpages.net" class="text-accent-600 dark:text-accent-400 hover:underline" target="_blank" rel="noopener">slashpages.net</a>.
</p>
{# Dynamic pages (created via Indiekit) #}
@@ -22,7 +22,7 @@ eleventyImport:
<li class="h-entry post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="{{ page.url }}" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-700 dark:hover:text-accent-300">
<a href="{{ page.url }}" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">
/{{ page.fileSlug }}
</a>
</h3>
@@ -72,7 +72,7 @@ eleventyImport:
<li class="h-entry post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/blogroll/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-700 dark:hover:text-accent-300">/blogroll</a>
<a href="/blogroll/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">/blogroll</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">Sites I follow</p>
@@ -82,7 +82,7 @@ eleventyImport:
<li class="h-entry post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/funkwhale/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-700 dark:hover:text-accent-300">/funkwhale</a>
<a href="/funkwhale/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">/funkwhale</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">Funkwhale activity</p>
@@ -92,7 +92,7 @@ eleventyImport:
<li class="h-entry post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/github/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-700 dark:hover:text-accent-300">/github</a>
<a href="/github/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">/github</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">GitHub activity</p>
@@ -102,7 +102,7 @@ eleventyImport:
<li class="h-entry post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/listening/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-700 dark:hover:text-accent-300">/listening</a>
<a href="/listening/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">/listening</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">Last.fm scrobbles</p>
@@ -112,7 +112,7 @@ eleventyImport:
<li class="h-entry post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/news/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-700 dark:hover:text-accent-300">/news</a>
<a href="/news/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">/news</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">RSS feed aggregator</p>
@@ -122,7 +122,7 @@ eleventyImport:
<li class="h-entry post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/podroll/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-700 dark:hover:text-accent-300">/podroll</a>
<a href="/podroll/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">/podroll</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">Podcasts I listen to</p>
@@ -132,7 +132,7 @@ eleventyImport:
<li class="h-entry post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/youtube/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-700 dark:hover:text-accent-300">/youtube</a>
<a href="/youtube/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">/youtube</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">YouTube channel</p>
@@ -152,7 +152,7 @@ eleventyImport:
<li class="h-entry post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/blog/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-700 dark:hover:text-accent-300">/blog</a>
<a href="/blog/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">/blog</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">All posts chronologically</p>
@@ -160,23 +160,7 @@ eleventyImport:
<li class="h-entry post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/garden/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-700 dark:hover:text-accent-300">/garden</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">A Digital Garden</p>
</li>
<li class="h-entry post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/til/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-700 dark:hover:text-accent-300">/til</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">Today I Learned — small things learnt day to day</p>
</li>
<li class="h-entry post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/cv/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-700 dark:hover:text-accent-300">/cv</a>
<a href="/cv/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">/cv</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">Curriculum vitae</p>
@@ -184,7 +168,7 @@ eleventyImport:
<li class="h-entry post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/changelog/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-700 dark:hover:text-accent-300">/changelog</a>
<a href="/changelog/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">/changelog</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">Site changes and updates</p>
@@ -192,7 +176,7 @@ eleventyImport:
<li class="h-entry post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/digest/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-700 dark:hover:text-accent-300">/digest</a>
<a href="/digest/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">/digest</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">Content digest</p>
@@ -200,7 +184,7 @@ eleventyImport:
<li class="h-entry post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/featured/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-700 dark:hover:text-accent-300">/featured</a>
<a href="/featured/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">/featured</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">Featured posts</p>
@@ -208,7 +192,7 @@ eleventyImport:
<li class="h-entry post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/graph/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-700 dark:hover:text-accent-300">/graph</a>
<a href="/graph/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">/graph</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">Content graph visualization</p>
@@ -216,7 +200,7 @@ eleventyImport:
<li class="h-entry post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/interactions/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-700 dark:hover:text-accent-300">/interactions</a>
<a href="/interactions/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">/interactions</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">Social interactions (likes, reposts, replies)</p>
@@ -224,23 +208,7 @@ eleventyImport:
<li class="h-entry post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/where/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-700 dark:hover:text-accent-300">/where</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">Location check-ins</p>
</li>
<li class="h-entry post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/been/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-700 dark:hover:text-accent-300">/been</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">Past check-ins</p>
</li>
<li class="h-entry post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/readlater/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-700 dark:hover:text-accent-300">/readlater</a>
<a href="/readlater/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">/readlater</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">Read later queue</p>
@@ -248,7 +216,7 @@ eleventyImport:
<li class="h-entry post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/search/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-700 dark:hover:text-accent-300">/search</a>
<a href="/search/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">/search</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">Full-text search</p>
@@ -256,7 +224,7 @@ eleventyImport:
<li class="h-entry post-card">
<div class="post-header">
<h3 class="text-xl font-semibold">
<a href="/github/starred/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-700 dark:hover:text-accent-300">/github/starred</a>
<a href="/github/starred/" class="p-name u-url text-surface-900 dark:text-surface-100 hover:text-accent-600 dark:hover:text-accent-400">/github/starred</a>
</h3>
</div>
<p class="text-surface-600 dark:text-surface-400 mt-2">Starred GitHub repositories</p>
@@ -268,7 +236,7 @@ eleventyImport:
<div class="mt-8 p-4 bg-surface-100 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm">
<h2 class="text-lg font-semibold text-surface-800 dark:text-surface-200 mb-2">Want more slash pages?</h2>
<p class="text-surface-600 dark:text-surface-400 text-sm">
Check out <a href="https://slashpages.net" class="text-accent-700 dark:text-accent-300 hover:underline" target="_blank" rel="noopener">slashpages.net</a>
Check out <a href="https://slashpages.net" class="text-accent-600 dark:text-accent-400 hover:underline" target="_blank" rel="noopener">slashpages.net</a>
for inspiration on pages like <code>/now</code>, <code>/uses</code>, <code>/colophon</code>, <code>/blogroll</code>, and more.
</p>
</div>

View File

@@ -70,13 +70,13 @@ export default {
typography: (theme) => ({
DEFAULT: {
css: {
"--tw-prose-links": theme("colors.accent.700"),
"--tw-prose-links": theme("colors.accent.600"),
maxWidth: "none",
},
},
invert: {
css: {
"--tw-prose-links": theme("colors.accent.300"),
"--tw-prose-links": theme("colors.accent.400"),
},
},
}),

View File

@@ -48,7 +48,7 @@ pagefindIgnore: true
{% if allMentions.length > 0 or legacyUrls.length > 0 %}
<tr class="hover:bg-surface-50 dark:hover:bg-surface-800/50">
<td class="p-2">
<a href="{{ post.url }}" class="text-accent-700 dark:text-accent-300 hover:underline">
<a href="{{ post.url }}" class="text-accent-600 dark:text-accent-400 hover:underline">
{{ post.url }}
</a>
</td>