feat: add zachleat.com-inspired theme enhancements

- Add time-difference web component for relative dates
- Add @zachleat/table-saw for responsive tables
- Add webmention facepile styling with bookmarks support
- Add OG image thumbnails to post navigation
- Add @11ty/is-land for lazy widget hydration
- Wrap sidebar widgets in is-land for deferred loading
- Lazy-load webmention avatars with is-land
- Add @zachleat/filter-container for blog archive filtering
- Add posting frequency sparkline to blog header
- Inline critical CSS and defer full stylesheet loading
This commit is contained in:
Ricardo
2026-02-18 11:16:33 +01:00
parent e5b0fd7dc6
commit c3eb04570c
16 changed files with 368 additions and 65 deletions

View File

@@ -12,22 +12,28 @@
{% 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-primary-600 dark:text-primary-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) }}
{% elif _bookmarkedUrl %}
<svg class="w-3.5 h-3.5 text-amber-500 flex-shrink-0" 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>
{{ _prevPost.data.title or ("Bookmarked " + (_bookmarkedUrl | replace("https://", "") | truncate(30))) }}
{% elif _repostedUrl %}
<svg class="w-3.5 h-3.5 text-green-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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>
Reposted {{ _repostedUrl | replace("https://", "") | truncate(35) }}
{% elif _replyToUrl %}
<svg class="w-3.5 h-3.5 text-primary-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/></svg>
Reply to {{ _replyToUrl | replace("https://", "") | truncate(35) }}
{% else %}
{{ _prevPost.data.title or _prevPost.data.name or (_prevPost.templateContent | striptags | truncate(50)) or "Note" }}
{% set _prevHasOg = _prevPost.fileSlug | hasOgImage %}
<a href="{{ _prevPost.url }}" class="group flex items-start gap-3 text-sm text-primary-600 dark:text-primary-400 hover:underline">
{% if _prevHasOg %}
<img src="/og/{{ _prevPost.fileSlug }}.png" alt="" class="w-20 h-[42px] object-cover rounded flex-shrink-0 opacity-80 group-hover:opacity-100 transition-opacity" loading="lazy" eleventy:ignore>
{% endif %}
<span class="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) }}
{% elif _bookmarkedUrl %}
<svg class="w-3.5 h-3.5 text-amber-500 flex-shrink-0" 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>
{{ _prevPost.data.title or ("Bookmarked " + (_bookmarkedUrl | replace("https://", "") | truncate(30))) }}
{% elif _repostedUrl %}
<svg class="w-3.5 h-3.5 text-green-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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>
Reposted {{ _repostedUrl | replace("https://", "") | truncate(35) }}
{% elif _replyToUrl %}
<svg class="w-3.5 h-3.5 text-primary-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/></svg>
Reply to {{ _replyToUrl | replace("https://", "") | truncate(35) }}
{% else %}
{{ _prevPost.data.title or _prevPost.data.name or (_prevPost.templateContent | striptags | truncate(50)) or "Note" }}
{% endif %}
</span>
</a>
</div>
{% else %}
@@ -40,21 +46,27 @@
{% 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-primary-600 dark:text-primary-400 hover:underline line-clamp-2 flex items-center gap-1.5 {% if _prevPost %}justify-end{% endif %}">
{% 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) }}
{% elif _bookmarkedUrl %}
<svg class="w-3.5 h-3.5 text-amber-500 flex-shrink-0" 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>
{{ _nextPost.data.title or ("Bookmarked " + (_bookmarkedUrl | replace("https://", "") | truncate(30))) }}
{% elif _repostedUrl %}
<svg class="w-3.5 h-3.5 text-green-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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>
Reposted {{ _repostedUrl | replace("https://", "") | truncate(35) }}
{% elif _replyToUrl %}
<svg class="w-3.5 h-3.5 text-primary-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/></svg>
Reply to {{ _replyToUrl | replace("https://", "") | truncate(35) }}
{% else %}
{{ _nextPost.data.title or _nextPost.data.name or (_nextPost.templateContent | striptags | truncate(50)) or "Note" }}
{% set _nextHasOg = _nextPost.fileSlug | hasOgImage %}
<a href="{{ _nextPost.url }}" class="group flex items-start gap-3 text-sm text-primary-600 dark:text-primary-400 hover:underline {% if _prevPost %}justify-end{% endif %}">
<span class="line-clamp-2 flex items-center gap-1.5 {% if _prevPost %}justify-end{% endif %}">
{% 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) }}
{% elif _bookmarkedUrl %}
<svg class="w-3.5 h-3.5 text-amber-500 flex-shrink-0" 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>
{{ _nextPost.data.title or ("Bookmarked " + (_bookmarkedUrl | replace("https://", "") | truncate(30))) }}
{% elif _repostedUrl %}
<svg class="w-3.5 h-3.5 text-green-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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>
Reposted {{ _repostedUrl | replace("https://", "") | truncate(35) }}
{% elif _replyToUrl %}
<svg class="w-3.5 h-3.5 text-primary-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/></svg>
Reply to {{ _replyToUrl | replace("https://", "") | truncate(35) }}
{% else %}
{{ _nextPost.data.title or _nextPost.data.name or (_nextPost.templateContent | striptags | truncate(50)) or "Note" }}
{% endif %}
</span>
{% if _nextHasOg %}
<img src="/og/{{ _nextPost.fileSlug }}.png" alt="" class="w-20 h-[42px] object-cover rounded flex-shrink-0 opacity-80 group-hover:opacity-100 transition-opacity" loading="lazy" eleventy:ignore>
{% endif %}
</a>
</div>

View File

@@ -27,22 +27,24 @@
<h3 class="text-sm font-semibold text-surface-600 dark:text-surface-400 uppercase tracking-wide mb-3">
{{ likes.length }} Like{% if likes.length != 1 %}s{% endif %}
</h3>
<div class="avatar-row">
<is-land on:visible>
<div class="facepile">
{% for like in likes %}
<a href="{{ like.author.url }}"
class="inline-block"
class="facepile-avatar"
title="{{ like.author.name }}"
target="_blank"
rel="noopener">
<img
src="{{ like.author.photo or '/images/default-avatar.svg' }}"
alt="{{ like.author.name }}"
class="w-8 h-8 rounded-full"
class="w-8 h-8 rounded-full ring-2 ring-white dark:ring-surface-900"
loading="lazy"
>
</a>
{% endfor %}
</div>
</is-land>
</div>
{% endif %}
@@ -53,22 +55,52 @@
<h3 class="text-sm font-semibold text-surface-600 dark:text-surface-400 uppercase tracking-wide mb-3">
{{ reposts.length }} Repost{% if reposts.length != 1 %}s{% endif %}
</h3>
<div class="avatar-row">
<is-land on:visible>
<div class="facepile">
{% for repost in reposts %}
<a href="{{ repost.author.url }}"
class="inline-block"
class="facepile-avatar"
title="{{ repost.author.name }}"
target="_blank"
rel="noopener">
<img
src="{{ repost.author.photo or '/images/default-avatar.svg' }}"
alt="{{ repost.author.name }}"
class="w-8 h-8 rounded-full"
class="w-8 h-8 rounded-full ring-2 ring-white dark:ring-surface-900"
loading="lazy"
>
</a>
{% endfor %}
</div>
</is-land>
</div>
{% endif %}
{# Bookmarks #}
{% set bookmarks = mentions | webmentionsByType('bookmarks') %}
{% if bookmarks.length %}
<div class="webmention-bookmarks mb-6">
<h3 class="text-sm font-semibold text-surface-600 dark:text-surface-400 uppercase tracking-wide mb-3">
{{ bookmarks.length }} Bookmark{% if bookmarks.length != 1 %}s{% endif %}
</h3>
<is-land on:visible>
<div class="facepile">
{% for bookmark in bookmarks %}
<a href="{{ bookmark.author.url }}"
class="facepile-avatar"
title="{{ bookmark.author.name }}"
target="_blank"
rel="noopener">
<img
src="{{ bookmark.author.photo or '/images/default-avatar.svg' }}"
alt="{{ bookmark.author.name }}"
class="w-8 h-8 rounded-full ring-2 ring-white dark:ring-surface-900"
loading="lazy"
>
</a>
{% endfor %}
</div>
</is-land>
</div>
{% endif %}

View File

@@ -1,4 +1,5 @@
{# Blogroll Widget - Dynamic loading from API with source tabs #}
<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-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -106,3 +107,4 @@ function blogrollWidget() {
};
}
</script>
</is-land>

View File

@@ -221,6 +221,7 @@
}
</style>
<is-land on:visible>
<div class="widget" x-data="feedlandWidget()" x-init="init()">
<div class="fl-wrap" tabindex="0">
{# Title + menu #}
@@ -379,3 +380,4 @@ function feedlandWidget() {
};
}
</script>
</is-land>

View File

@@ -1,5 +1,6 @@
{# Funkwhale Now Playing Widget #}
{% if funkwhaleActivity and (funkwhaleActivity.nowPlaying or funkwhaleActivity.stats) %}
<is-land on:visible>
<div class="widget">
<h3 class="widget-title flex items-center gap-2">
<svg class="w-5 h-5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -68,4 +69,5 @@
<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>
</div>
</is-land>
{% endif %}

View File

@@ -1,4 +1,5 @@
{# GitHub Activity Widget - Tabbed Commits/Repos/Featured/PRs with live API data #}
<is-land on:visible>
<div class="widget" x-data="githubWidget('{{ site.feeds.github }}')" x-init="init()">
<h3 class="widget-title flex items-center gap-2">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
@@ -209,3 +210,4 @@ function githubWidget(username) {
};
}
</script>
</is-land>

View File

@@ -1,5 +1,6 @@
{# Social Feed Widget - Tabbed Bluesky/Mastodon #}
{% if (blueskyFeed and blueskyFeed.length) or (mastodonFeed and mastodonFeed.length) %}
<is-land on:visible>
<div class="widget" x-data="{ activeTab: 'bluesky' }">
<h3 class="widget-title">Social Activity</h3>
@@ -91,4 +92,5 @@
</div>
{% endif %}
</div>
</is-land>
{% endif %}

View File

@@ -58,13 +58,19 @@
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="stylesheet" href="/pagefind/pagefind-ui.css">
{# Critical CSS — inlined for fast first paint #}
<style>{{ "css/critical.css" | inlineFile | safe }}</style>
{# Defer full stylesheet — loads after first paint #}
<link rel="stylesheet" href="/css/style.css?v={{ '/css/style.css' | hash }}" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="/css/style.css?v={{ '/css/style.css' | hash }}"></noscript>
<link rel="stylesheet" href="/css/prism-theme.css?v={{ '/css/prism-theme.css' | hash }}" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="/css/prism-theme.css?v={{ '/css/prism-theme.css' | hash }}"></noscript>
<link rel="stylesheet" href="/pagefind/pagefind-ui.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="/pagefind/pagefind-ui.css"></noscript>
<script>
var _pfQueue = [];
function initPagefind(sel, opts) { _pfQueue.push([sel, opts]); }
</script>
<link rel="stylesheet" href="/css/style.css?v={{ '/css/style.css' | hash }}">
<link rel="stylesheet" href="/css/prism-theme.css?v={{ '/css/prism-theme.css' | hash }}">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/lite-youtube-embed@0.3.2/src/lite-yt-embed.min.css">
<script src="https://cdn.jsdelivr.net/npm/lite-youtube-embed@0.3.2/src/lite-yt-embed.min.js" defer></script>
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>
@@ -409,6 +415,14 @@
document.documentElement.addEventListener('mouseover', prefetch, { capture: true, passive: true });
document.documentElement.addEventListener('touchstart', prefetch, { capture: true, passive: true });
</script>
{# Island architecture - lazy hydration for widgets #}
<script type="module" src="/js/is-land.js"></script>
{# Relative date display - progressively enhances <time> elements #}
<script src="/js/time-difference.js?v={{ '/js/time-difference.js' | hash }}" defer></script>
{# Responsive tables - auto-enhances <table> on narrow screens #}
<script type="module" src="/js/table-saw.js"></script>
{# Client-side filtering for archive pages #}
<script type="module" src="/js/filter-container.js"></script>
{# Client-side webmention fetcher - supplements build-time cache with real-time data #}
<script src="/js/webmentions.js?v={{ '/js/webmentions.js' | hash }}" defer></script>
{# Admin auth detection - shows dashboard link + FAB when logged in #}

View File

@@ -8,9 +8,9 @@ withBlogSidebar: true
{% endif %}
<div class="post-meta mb-4 sm:mb-6">
<time class="dt-published" datetime="{{ date.toISOString() }}">
<time-difference><time class="dt-published" datetime="{{ date.toISOString() }}">
{{ date | dateDisplay }}
</time>
</time></time-difference>
{% if category %}
<span class="post-categories">
{# Handle both string and array categories #}

View File

@@ -9,22 +9,43 @@ pagination:
permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber + 1 }}/{% endif %}"
---
<div class="h-feed">
<h1 class="text-2xl sm:text-3xl font-bold text-surface-900 dark:text-surface-100 mb-2">Blog</h1>
<div class="flex flex-wrap items-center gap-4 mb-2">
<h1 class="text-2xl sm:text-3xl font-bold text-surface-900 dark:text-surface-100">Blog</h1>
{% set sparklineData = collections.posts | postingFrequency %}
{% if sparklineData %}
<img src="https://v1.sparkline.11ty.dev/{{ sparklineData }}/" alt="Posting frequency over the last 12 months" width="200" height="30" class="opacity-60 dark:invert dark:opacity-40" loading="lazy">
{% endif %}
</div>
<p class="text-surface-600 dark:text-surface-400 mb-6 sm:mb-8">
All posts including articles and notes.
<span class="text-sm">({{ collections.posts.length }} total)</span>
</p>
{% if paginatedPosts.length > 0 %}
<filter-container oninit leave-url-alone>
<div class="flex flex-wrap gap-3 mb-6">
<select data-filter-key="type" class="px-3 py-1.5 text-sm bg-white dark:bg-surface-800 border border-surface-200 dark:border-surface-700 rounded-lg">
<option value="">All Types</option>
<option value="article">Articles</option>
<option value="note">Notes</option>
<option value="photo">Photos</option>
<option value="bookmark">Bookmarks</option>
<option value="like">Likes</option>
<option value="reply">Replies</option>
<option value="repost">Reposts</option>
</select>
<span data-filter-results class="text-sm text-surface-500 dark:text-surface-400 self-center"></span>
</div>
<ul class="post-list">
{% for post in paginatedPosts %}
<li class="h-entry post-card">
{# Detect post type from frontmatter properties #}
{% set likedUrl = post.data.likeOf or post.data.like_of %}
{% set bookmarkedUrl = post.data.bookmarkOf or post.data.bookmark_of %}
{% set repostedUrl = post.data.repostOf or post.data.repost_of %}
{% set replyToUrl = post.data.inReplyTo or post.data.in_reply_to %}
{% set hasPhotos = post.data.photo and post.data.photo.length %}
{# Detect post type from frontmatter properties #}
{% set likedUrl = post.data.likeOf or post.data.like_of %}
{% set bookmarkedUrl = post.data.bookmarkOf or post.data.bookmark_of %}
{% set repostedUrl = post.data.repostOf or post.data.repost_of %}
{% set replyToUrl = post.data.inReplyTo or post.data.in_reply_to %}
{% set hasPhotos = post.data.photo and post.data.photo.length %}
{% set _postType %}{% if likedUrl %}like{% elif bookmarkedUrl %}bookmark{% elif repostedUrl %}repost{% elif replyToUrl %}reply{% elif hasPhotos %}photo{% elif post.data.title %}article{% else %}note{% endif %}{% endset %}
<li class="h-entry post-card" data-filter-type="{{ _postType | trim }}">
{% if likedUrl %}
{# ── Like card ── #}
@@ -37,9 +58,9 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
<div class="flex-1 min-w-0">
<div class="post-meta">
<span class="font-medium text-red-600 dark:text-red-400">Liked</span>
<time class="dt-published" datetime="{{ post.date | isoDate }}">
<time-difference><time class="dt-published" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time>
</time></time-difference>
{% if post.data.category %}
<span class="post-categories">
{% if post.data.category is string %}
@@ -77,9 +98,9 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
<div class="flex-1 min-w-0">
<div class="post-meta">
<span class="font-medium text-amber-600 dark:text-amber-400">Bookmarked</span>
<time class="dt-published" datetime="{{ post.date | isoDate }}">
<time-difference><time class="dt-published" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time>
</time></time-difference>
{% if post.data.category %}
<span class="post-categories">
{% if post.data.category is string %}
@@ -122,9 +143,9 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
<div class="flex-1 min-w-0">
<div class="post-meta">
<span class="font-medium text-green-600 dark:text-green-400">Reposted</span>
<time class="dt-published" datetime="{{ post.date | isoDate }}">
<time-difference><time class="dt-published" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time>
</time></time-difference>
{% if post.data.category %}
<span class="post-categories">
{% if post.data.category is string %}
@@ -162,9 +183,9 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
<div class="flex-1 min-w-0">
<div class="post-meta">
<span class="font-medium text-primary-600 dark:text-primary-400">In reply to</span>
<time class="dt-published" datetime="{{ post.date | isoDate }}">
<time-difference><time class="dt-published" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time>
</time></time-difference>
{% if post.data.category %}
<span class="post-categories">
{% if post.data.category is string %}
@@ -201,9 +222,9 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
<div class="flex-1 min-w-0">
<div class="post-meta">
<span class="font-medium text-purple-600 dark:text-purple-400">Photo</span>
<time class="dt-published" datetime="{{ post.date | isoDate }}">
<time-difference><time class="dt-published" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time>
</time></time-difference>
{% if post.data.category %}
<span class="post-categories">
{% if post.data.category is string %}
@@ -272,9 +293,9 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
{# ── Note card (unchanged) ── #}
<div class="post-header">
<a class="u-url" href="{{ post.url }}">
<time class="dt-published text-sm text-primary-600 dark:text-primary-400 font-medium" datetime="{{ post.date | isoDate }}">
<time-difference><time class="dt-published text-sm text-primary-600 dark:text-primary-400 font-medium" datetime="{{ post.date | isoDate }}">
{{ post.date | dateDisplay }}
</time>
</time></time-difference>
</a>
{% if post.data.category %}
<span class="post-categories ml-2">
@@ -300,6 +321,7 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
</li>
{% endfor %}
</ul>
</filter-container>
{# Pagination controls #}
{% if pagination.pages.length > 1 %}

57
css/critical.css Normal file
View File

@@ -0,0 +1,57 @@
/* Critical CSS — inlined in <head> for first paint */
/* Covers: layout shell, header, dark mode toggle, font display, basic typography */
*,*::before,*::after{box-sizing:border-box}
body{margin:0;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;line-height:1.5;-webkit-font-smoothing:antialiased}
/* Dark mode base */
body{background-color:#fff;color:#18181b}
.dark body{background-color:#09090b;color:#f4f4f5}
/* Container */
.container{max-width:64rem;margin-left:auto;margin-right:auto;padding-left:1rem;padding-right:1rem}
/* Header — sticky, visible immediately */
.site-header{background-color:#fff;border-bottom:1px solid #e4e4e7;padding-top:1rem;padding-bottom:1rem;position:sticky;top:0;z-index:50}
.dark .site-header{background-color:#18181b;border-bottom-color:#3f3f46}
.header-container{display:flex;align-items:center;justify-content:space-between}
.site-title{font-size:1.25rem;font-weight:700;color:#18181b;text-decoration:none}
.dark .site-title{color:#fff}
/* Header actions — hidden on mobile */
.header-actions{display:none}
@media(min-width:768px){.header-actions{display:flex;align-items:center;gap:1rem}}
/* Mobile menu toggle */
.menu-toggle{display:block;padding:0.5rem;border-radius:0.5rem;background:none;border:none;color:#52525b;cursor:pointer}
@media(min-width:768px){.menu-toggle{display:none}}
.dark .menu-toggle{color:#a1a1aa}
/* Hidden utility */
.hidden{display:none!important}
[x-cloak]{display:none!important}
/* Dark mode theme toggle icons */
.theme-toggle .sun-icon{display:none}
.theme-toggle .moon-icon{display:block}
.dark .theme-toggle .sun-icon{display:block}
.dark .theme-toggle .moon-icon{display:none}
/* Main content padding */
main.container{padding-top:1.5rem;padding-bottom:1.5rem}
@media(min-width:768px){main.container{padding-top:2rem;padding-bottom:2rem}}
/* Layout with sidebar */
.layout-with-sidebar{display:grid;grid-template-columns:1fr;gap:1.5rem}
@media(min-width:1024px){.layout-with-sidebar{grid-template-columns:2fr 1fr;gap:2rem}}
.main-content{min-width:0;overflow-x:hidden}
/* Basic typography — prevent FOUT */
h1,h2,h3,h4{margin:0;line-height:1.25}
a{color:#2563eb}
.dark a{color:#60a5fa}
/* Prevent flash of unstyled content for nav */
.site-nav{display:flex;align-items:center;gap:1rem}
.site-nav>a,.site-nav .nav-dropdown-trigger{color:#52525b;text-decoration:none;padding-top:0.5rem;padding-bottom:0.5rem}
.dark .site-nav>a,.dark .site-nav .nav-dropdown-trigger{color:#a1a1aa}

View File

@@ -237,12 +237,16 @@
@apply inline-block px-2 py-0.5 text-xs bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200 rounded;
}
/* Webmention styles */
.webmention-likes .avatar-row {
@apply flex flex-wrap gap-1;
/* Webmention facepile - overlapping avatar display */
.facepile {
@apply flex flex-wrap items-center;
}
.webmention-likes img {
.facepile-avatar {
@apply inline-block -ml-2 first:ml-0 transition-transform hover:z-10 hover:scale-110;
}
.facepile-avatar img {
@apply w-8 h-8 rounded-full;
}

View File

@@ -244,6 +244,13 @@ export default function (eleventyConfig) {
eleventyConfig.addPassthroughCopy("favicon.ico");
eleventyConfig.addPassthroughCopy({ ".cache/og": "og" });
// Copy vendor web components from node_modules
eleventyConfig.addPassthroughCopy({
"node_modules/@zachleat/table-saw/table-saw.js": "js/table-saw.js",
"node_modules/@11ty/is-land/is-land.js": "js/is-land.js",
"node_modules/@zachleat/filter-container/filter-container.js": "js/filter-container.js",
});
// Watch for content changes
eleventyConfig.addWatchTarget("./content/");
eleventyConfig.addWatchTarget("./css/");
@@ -352,6 +359,16 @@ export default function (eleventyConfig) {
return existsSync(ogPath);
});
// Inline file contents (for critical CSS inlining)
eleventyConfig.addFilter("inlineFile", (filePath) => {
try {
const fullPath = resolve(__dirname, filePath.startsWith("/") ? `.${filePath}` : filePath);
return readFileSync(fullPath, "utf-8");
} catch {
return "";
}
});
// Current timestamp filter (for client-side JS buildtime)
eleventyConfig.addFilter("timestamp", () => Date.now());
@@ -426,6 +443,22 @@ export default function (eleventyConfig) {
: null;
});
// Posting frequency — compute posts-per-month for last 12 months (for sparkline)
eleventyConfig.addFilter("postingFrequency", (posts) => {
if (!Array.isArray(posts) || posts.length === 0) return "";
const now = new Date();
const counts = new Array(12).fill(0);
for (const post of posts) {
const postDate = new Date(post.date || post.data?.date);
if (isNaN(postDate.getTime())) continue;
const monthsAgo = (now.getFullYear() - postDate.getFullYear()) * 12 + (now.getMonth() - postDate.getMonth());
if (monthsAgo >= 0 && monthsAgo < 12) {
counts[11 - monthsAgo]++;
}
}
return counts.join(",");
});
// Collections for different post types
// Note: content path is content/ due to symlink structure
// "posts" shows ALL content types combined

91
js/time-difference.js Normal file
View File

@@ -0,0 +1,91 @@
/**
* <time-difference> Web Component
* Progressively enhances <time> elements with relative date display.
* Falls back to static date text when JS is unavailable.
*
* Usage: <time-difference><time datetime="2026-02-15T...">February 15, 2026</time></time-difference>
*
* Inspired by zachleat.com's time-difference component.
*/
class TimeDifference extends HTMLElement {
static register(tagName = "time-difference") {
if ("customElements" in window) {
customElements.define(tagName, TimeDifference);
}
}
connectedCallback() {
this.update();
// Auto-update every 60 seconds
this._interval = setInterval(() => this.update(), 60000);
}
disconnectedCallback() {
clearInterval(this._interval);
}
update() {
const time = this.querySelector("time[datetime]");
if (!time) return;
const datetime = time.getAttribute("datetime");
if (!datetime) return;
const date = new Date(datetime);
if (isNaN(date.getTime())) return;
const now = new Date();
const diffMs = now - date;
// Don't show relative time for future dates
if (diffMs < 0) return;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
const diffWeek = Math.floor(diffDay / 7);
const diffMonth = Math.floor(diffDay / 30.44);
const diffYear = Math.floor(diffDay / 365.25);
let value, unit;
if (diffSec < 60) {
value = -diffSec;
unit = "second";
} else if (diffMin < 60) {
value = -diffMin;
unit = "minute";
} else if (diffHour < 24) {
value = -diffHour;
unit = "hour";
} else if (diffDay < 7) {
value = -diffDay;
unit = "day";
} else if (diffWeek < 4) {
value = -diffWeek;
unit = "week";
} else if (diffMonth < 12) {
value = -diffMonth;
unit = "month";
} else {
value = -diffYear;
unit = "year";
}
try {
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
const relative = rtf.format(value, unit);
// Store original text as title for hover tooltip
if (!time.hasAttribute("title")) {
time.setAttribute("title", time.textContent.trim());
}
time.textContent = relative;
} catch {
// Intl.RelativeTimeFormat not supported, keep static text
}
}
}
TimeDifference.register();

25
package-lock.json generated
View File

@@ -13,11 +13,14 @@
"@11ty/eleventy-img": "^6.0.0",
"@11ty/eleventy-plugin-rss": "^2.0.2",
"@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2",
"@11ty/is-land": "^5.0.1",
"@atproto/api": "^0.12.0",
"@chrisburnell/eleventy-cache-webmentions": "^2.2.7",
"@fontsource/inter": "^5.2.8",
"@quasibit/eleventy-plugin-sitemap": "^2.2.0",
"@resvg/resvg-js": "^2.6.2",
"@zachleat/filter-container": "^4.0.0",
"@zachleat/table-saw": "^1.0.7",
"eleventy-plugin-embed-everything": "^1.21.0",
"gray-matter": "^4.0.3",
"html-minifier-terser": "^7.0.0",
@@ -259,6 +262,16 @@
"url": "https://opencollective.com/11ty"
}
},
"node_modules/@11ty/is-land": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@11ty/is-land/-/is-land-5.0.1.tgz",
"integrity": "sha512-Rh/sLhE4vrc2JaSjeY385v2UxnDY9BhnQtitETb3SKyr0A48Q5Vn06q2AvDBHObtk9+dcFWsoZX4jhT+O9g+xQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/11ty"
}
},
"node_modules/@11ty/lodash-custom": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/@11ty/lodash-custom/-/lodash-custom-4.17.21.tgz",
@@ -1338,6 +1351,18 @@
"@types/node": "*"
}
},
"node_modules/@zachleat/filter-container": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@zachleat/filter-container/-/filter-container-4.0.0.tgz",
"integrity": "sha512-4etUifhHciQTMtlDymb4cus03qYZrKiuK0gY0cOU4Jrt0LA3ZvPVTwO6esqUdvbpte5ypodmpx84wwbT3Fy5Jw==",
"license": "MIT"
},
"node_modules/@zachleat/table-saw": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@zachleat/table-saw/-/table-saw-1.0.7.tgz",
"integrity": "sha512-AxYShldkUoofdzqyH/pgbiXBgoRbFtoQYKbCjTK7diViqepM2SqZoMZm7ofc9UeGaDWgktAOmoJg3T+yN0SAxA==",
"license": "MIT"
},
"node_modules/a-sync-waterfall": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz",

View File

@@ -14,11 +14,14 @@
"@11ty/eleventy-img": "^6.0.0",
"@11ty/eleventy-plugin-rss": "^2.0.2",
"@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2",
"@11ty/is-land": "^5.0.1",
"@atproto/api": "^0.12.0",
"@chrisburnell/eleventy-cache-webmentions": "^2.2.7",
"@fontsource/inter": "^5.2.8",
"@quasibit/eleventy-plugin-sitemap": "^2.2.0",
"@resvg/resvg-js": "^2.6.2",
"@zachleat/filter-container": "^4.0.0",
"@zachleat/table-saw": "^1.0.7",
"eleventy-plugin-embed-everything": "^1.21.0",
"gray-matter": "^4.0.3",
"html-minifier-terser": "^7.0.0",