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:
@@ -4,7 +4,7 @@
|
|||||||
* Used for conditional navigation — the blogroll page itself loads data client-side.
|
* 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";
|
const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ export default async function () {
|
|||||||
try {
|
try {
|
||||||
const url = `${INDIEKIT_URL}/blogrollapi/api/status`;
|
const url = `${INDIEKIT_URL}/blogrollapi/api/status`;
|
||||||
console.log(`[blogrollStatus] Checking API: ${url}`);
|
console.log(`[blogrollStatus] Checking API: ${url}`);
|
||||||
const data = await EleventyFetch(url, {
|
const data = await cachedFetch(url, {
|
||||||
duration: "15m",
|
duration: "15m",
|
||||||
type: "json",
|
type: "json",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import EleventyFetch from "@11ty/eleventy-fetch";
|
import { cachedFetch } from "../lib/data-fetch.js";
|
||||||
|
|
||||||
export default async function () {
|
export default async function () {
|
||||||
try {
|
try {
|
||||||
const data = await EleventyFetch(
|
const data = await cachedFetch(
|
||||||
"http://127.0.0.1:8080/conversations/api/mentions?per-page=10000",
|
"http://127.0.0.1:8080/conversations/api/mentions?per-page=10000",
|
||||||
{ duration: "15m", type: "json" }
|
{ duration: "15m", type: "json" }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Fetches public repositories from GitHub API
|
* Fetches public repositories from GitHub API
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import EleventyFetch from "@11ty/eleventy-fetch";
|
import { cachedFetch } from "../lib/data-fetch.js";
|
||||||
|
|
||||||
export default async function () {
|
export default async function () {
|
||||||
const username = process.env.GITHUB_USERNAME || "";
|
const username = process.env.GITHUB_USERNAME || "";
|
||||||
@@ -12,7 +12,7 @@ export default async function () {
|
|||||||
// Fetch public repos, sorted by updated date
|
// Fetch public repos, sorted by updated date
|
||||||
const url = `https://api.github.com/users/${username}/repos?sort=updated&per_page=10&type=owner`;
|
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
|
duration: "1h", // Cache for 1 hour
|
||||||
type: "json",
|
type: "json",
|
||||||
fetchOptions: {
|
fetchOptions: {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Fetches from Indiekit's endpoint-rss public API
|
* 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";
|
const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ async function fetchFromIndiekit(endpoint) {
|
|||||||
try {
|
try {
|
||||||
const url = `${INDIEKIT_URL}/rssapi/api/${endpoint}`;
|
const url = `${INDIEKIT_URL}/rssapi/api/${endpoint}`;
|
||||||
console.log(`[newsActivity] Fetching from Indiekit: ${url}`);
|
console.log(`[newsActivity] Fetching from Indiekit: ${url}`);
|
||||||
const data = await EleventyFetch(url, {
|
const data = await cachedFetch(url, {
|
||||||
duration: "15m",
|
duration: "15m",
|
||||||
type: "json",
|
type: "json",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Used for conditional navigation — the podroll page itself loads data client-side.
|
* 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";
|
const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ export default async function () {
|
|||||||
try {
|
try {
|
||||||
const url = `${INDIEKIT_URL}/podrollapi/api/status`;
|
const url = `${INDIEKIT_URL}/podrollapi/api/status`;
|
||||||
console.log(`[podrollStatus] Checking API: ${url}`);
|
console.log(`[podrollStatus] Checking API: ${url}`);
|
||||||
const data = await EleventyFetch(url, {
|
const data = await cachedFetch(url, {
|
||||||
duration: "15m",
|
duration: "15m",
|
||||||
type: "json",
|
type: "json",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Fetches the 5 most recent comments at build time for the sidebar widget.
|
* 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";
|
const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ export default async function () {
|
|||||||
try {
|
try {
|
||||||
const url = `${INDIEKIT_URL}/comments/api/comments?limit=5`;
|
const url = `${INDIEKIT_URL}/comments/api/comments?limit=5`;
|
||||||
console.log(`[recentComments] Fetching: ${url}`);
|
console.log(`[recentComments] Fetching: ${url}`);
|
||||||
const data = await EleventyFetch(url, {
|
const data = await cachedFetch(url, {
|
||||||
duration: "15m",
|
duration: "15m",
|
||||||
type: "json",
|
type: "json",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Supports single or multiple channels
|
* 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";
|
const INDIEKIT_URL = process.env.SITE_URL || "https://example.com";
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ async function fetchFromIndiekit(endpoint) {
|
|||||||
try {
|
try {
|
||||||
const url = `${INDIEKIT_URL}/youtubeapi/api/${endpoint}`;
|
const url = `${INDIEKIT_URL}/youtubeapi/api/${endpoint}`;
|
||||||
console.log(`[youtubeChannel] Fetching from Indiekit: ${url}`);
|
console.log(`[youtubeChannel] Fetching from Indiekit: ${url}`);
|
||||||
const data = await EleventyFetch(url, {
|
const data = await cachedFetch(url, {
|
||||||
duration: "5m",
|
duration: "5m",
|
||||||
type: "json",
|
type: "json",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Sign-in form (shown when not authenticated) #}
|
{# 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>
|
<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">
|
<form x-on:submit.prevent="startAuth()" class="flex gap-2 items-end flex-wrap">
|
||||||
<div class="flex-1 min-w-[200px]">
|
<div class="flex-1 min-w-[200px]">
|
||||||
@@ -46,12 +46,13 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Comment form (shown when authenticated) #}
|
{# Comment form (shown when authenticated via IndieAuth OR as site owner) #}
|
||||||
<div x-show="user" x-cloak>
|
<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">
|
<div class="flex items-center gap-2 mb-3 text-sm text-surface-600 dark:text-surface-400">
|
||||||
<span>Signed in as</span>
|
<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>
|
<a x-bind:href="user?.url || ownerProfile?.url" class="font-medium hover:underline"
|
||||||
<button x-on:click="signOut()" class="text-xs underline hover:no-underline">Sign out</button>
|
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>
|
</div>
|
||||||
|
|
||||||
<form x-on:submit.prevent="submitComment()">
|
<form x-on:submit.prevent="submitComment()">
|
||||||
@@ -76,28 +77,83 @@
|
|||||||
<p class="text-sm text-surface-600 dark:text-surface-400">Loading comments...</p>
|
<p class="text-sm text-surface-600 dark:text-surface-400">Loading comments...</p>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template x-for="comment in comments" x-bind:key="comment.published">
|
<template x-for="comment in comments.filter(c => !c.parent_id)" x-bind:key="comment._id || 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>
|
||||||
<div class="flex items-start gap-3">
|
{# Parent comment #}
|
||||||
<template x-if="comment.author?.photo">
|
<div class="p-4 bg-surface-50 dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700 shadow-sm">
|
||||||
<img x-bind:src="comment.author.photo" x-bind:alt="comment.author.name"
|
<div class="flex items-start gap-3">
|
||||||
class="w-8 h-8 rounded-full flex-shrink-0" loading="lazy">
|
<template x-if="comment.author?.photo">
|
||||||
</template>
|
<img x-bind:src="comment.author.photo" x-bind:alt="comment.author.name"
|
||||||
<template x-if="!comment.author?.photo">
|
class="w-8 h-8 rounded-full flex-shrink-0" loading="lazy">
|
||||||
<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"
|
</template>
|
||||||
x-text="(comment.author?.name || '?')[0].toUpperCase()">
|
<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>
|
</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>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
{{ authorName }}
|
{{ authorName }}
|
||||||
</h1>
|
</h1>
|
||||||
{% if authorTitle %}
|
{% 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 }}
|
{{ authorTitle }}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if authorDescription %}
|
{% if authorDescription %}
|
||||||
<details class="mb-4 sm:mb-6">
|
<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 ↓
|
More about me ↓
|
||||||
</summary>
|
</summary>
|
||||||
<p class="text-base sm:text-lg text-surface-700 dark:text-surface-300 mt-3">
|
<p class="text-base sm:text-lg text-surface-700 dark:text-surface-300 mt-3">
|
||||||
@@ -82,13 +82,13 @@
|
|||||||
<span>{{ cvOrg }}</span>
|
<span>{{ cvOrg }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if cvUrl %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% if cvEmail %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% if cvKeyUrl %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -5,20 +5,20 @@
|
|||||||
Include in sidebar widgets, author cards, etc.
|
Include in sidebar widgets, author cards, etc.
|
||||||
#}
|
#}
|
||||||
{% set id = homepageConfig.identity if (homepageConfig and homepageConfig.identity) else {} %}
|
{% set id = homepageConfig.identity if (homepageConfig and homepageConfig.identity) else {} %}
|
||||||
{% set authorName = id.name if (id.name is defined) else site.author.name %}
|
{% set authorName = id.name or site.author.name %}
|
||||||
{% set authorAvatar = id.avatar if (id.avatar is defined) else site.author.avatar %}
|
{% set authorAvatar = id.avatar or site.author.avatar %}
|
||||||
{% set authorTitle = id.title if (id.title is defined) else site.author.title %}
|
{% set authorTitle = id.title or site.author.title %}
|
||||||
{% set authorBio = id.bio if (id.bio is defined) else site.author.bio %}
|
{% set authorBio = id.bio or site.author.bio %}
|
||||||
{% set authorUrl = id.url if (id.url is defined and id.url) else site.author.url %}
|
{% set authorUrl = id.url or site.author.url %}
|
||||||
{% set authorPronoun = id.pronoun if (id.pronoun is defined) else site.author.pronoun %}
|
{% set authorPronoun = id.pronoun or site.author.pronoun %}
|
||||||
{% set authorLocality = id.locality if (id.locality is defined) else site.author.locality %}
|
{% set authorLocality = id.locality or site.author.locality %}
|
||||||
{% set authorCountry = id.country if (id.country is defined) else site.author.country %}
|
{% set authorCountry = id.country or site.author.country %}
|
||||||
{% set authorLocation = id.location if (id.location is defined) else site.author.location %}
|
{% set authorLocation = site.author.location %}
|
||||||
{% set authorOrg = id.org if (id.org is defined) else site.author.org %}
|
{% set authorOrg = id.org or site.author.org %}
|
||||||
{% set authorEmail = id.email if (id.email is defined) else site.author.email %}
|
{% set authorEmail = id.email or site.author.email %}
|
||||||
{% set authorKeyUrl = id.keyUrl if (id.keyUrl is defined) else site.author.keyUrl %}
|
{% set authorKeyUrl = id.keyUrl or site.author.keyUrl %}
|
||||||
{% set authorCategories = id.categories if (id.categories is defined) else site.author.categories %}
|
{% set authorCategories = id.categories if (id.categories and id.categories.length) else site.author.categories %}
|
||||||
{% set socialLinks = id.social if (id.social is defined) else site.social %}
|
{% 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">
|
<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) #}
|
{# Hidden u-photo for reliable microformat parsing (some parsers struggle with img inside links) #}
|
||||||
@@ -37,17 +37,15 @@
|
|||||||
>
|
>
|
||||||
</a>
|
</a>
|
||||||
<div>
|
<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 }}
|
{{ authorName }}
|
||||||
</a>
|
</a>
|
||||||
{% if authorPronoun %}
|
{% if authorPronoun %}
|
||||||
<span class="p-pronoun text-xs text-surface-600 dark:text-surface-400">({{ authorPronoun }})</span>
|
<span class="p-pronoun text-xs text-surface-600 dark:text-surface-400">({{ authorPronoun }})</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if authorTitle %}
|
|
||||||
<p class="p-job-title text-sm text-surface-600 dark:text-surface-400" itemprop="jobTitle">{{ authorTitle }}</p>
|
<p class="p-job-title text-sm text-surface-600 dark:text-surface-400" itemprop="jobTitle">{{ authorTitle }}</p>
|
||||||
{% endif %}
|
|
||||||
{# Structured address #}
|
{# 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 %}
|
{% if authorLocality %}
|
||||||
<span class="p-locality" itemprop="addressLocality">{{ authorLocality }}</span>{% if authorCountry %}, {% endif %}
|
<span class="p-locality" itemprop="addressLocality">{{ authorLocality }}</span>{% if authorCountry %}, {% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -63,9 +61,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Bio #}
|
{# Bio #}
|
||||||
{% if authorBio %}
|
|
||||||
<p class="p-note mt-3 text-sm text-surface-700 dark:text-surface-300" itemprop="description">{{ authorBio }}</p>
|
<p class="p-note mt-3 text-sm text-surface-700 dark:text-surface-300" itemprop="description">{{ authorBio }}</p>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# Organization #}
|
{# Organization #}
|
||||||
{% if authorOrg %}
|
{% if authorOrg %}
|
||||||
@@ -78,12 +74,12 @@
|
|||||||
<div class="mt-2 flex flex-wrap gap-3 text-sm">
|
<div class="mt-2 flex flex-wrap gap-3 text-sm">
|
||||||
{% if authorEmail %}
|
{% if authorEmail %}
|
||||||
{# Display text obfuscated to deter spam harvesters; href kept plain for browser compatibility #}
|
{# 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 }}
|
✉️ {{ authorEmail | obfuscateEmail | safe }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if authorKeyUrl %}
|
{% 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
|
🔐 PGP Key
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -106,7 +102,7 @@
|
|||||||
<a
|
<a
|
||||||
href="{{ link.url }}"
|
href="{{ link.url }}"
|
||||||
rel="{{ link.rel }} noopener"
|
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)"
|
aria-label="{{ link.name }} (opens in new tab)"
|
||||||
target="_blank">
|
target="_blank">
|
||||||
{{ socialIcon(link.icon, "w-5 h-5") }}
|
{{ socialIcon(link.icon, "w-5 h-5") }}
|
||||||
|
|||||||
@@ -39,8 +39,8 @@
|
|||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="p-4 sm:p-5">
|
<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">← Previous</span>
|
<span class="text-[10px] sm:text-xs font-semibold uppercase tracking-wide text-surface-600 dark:text-surface-400 block mb-2">← 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-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 }}
|
{{ _prevTitle }}
|
||||||
</span>
|
</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>
|
<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>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="p-4 sm:p-5 text-right">
|
<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 →</span>
|
<span class="text-[10px] sm:text-xs font-semibold uppercase tracking-wide text-surface-600 dark:text-surface-400 block mb-2">Next →</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-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 }}
|
{{ _nextTitle }}
|
||||||
</span>
|
</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>
|
<time class="text-xs text-surface-600 dark:text-surface-400 mt-1 block font-mono" datetime="{{ _nextPost.date | isoDate }}">{{ _nextPost.date | dateDisplay }}</time>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
{{ post.templateContent | safe }}
|
{{ post.templateContent | safe }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if post.data.title %}
|
{% if post.data.title %}
|
||||||
<h3 class="p-name font-semibold mt-1">
|
<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>
|
</h3>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ bookmarkedUrl | unfurlCard | safe }}
|
{{ bookmarkedUrl | unfurlCard | safe }}
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
{{ post.templateContent | safe }}
|
{{ post.templateContent | safe }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@
|
|||||||
{{ post.templateContent | safe }}
|
{{ post.templateContent | safe }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@
|
|||||||
{{ post.templateContent | safe }}
|
{{ post.templateContent | safe }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -193,14 +193,14 @@
|
|||||||
{{ post.templateContent | safe }}
|
{{ post.templateContent | safe }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% elif post.data.title %}
|
{% elif post.data.title %}
|
||||||
{# ── Article/Page card ── #}
|
{# ── Article/Page card ── #}
|
||||||
<h3 class="p-name font-semibold mb-1">
|
<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 }}
|
{{ post.data.title }}
|
||||||
</a>
|
</a>
|
||||||
</h3>
|
</h3>
|
||||||
@@ -239,7 +239,7 @@
|
|||||||
{{ post.templateContent | safe }}
|
{{ post.templateContent | safe }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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
|
Permalink
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -250,7 +250,7 @@
|
|||||||
|
|
||||||
{% if collections.featuredPosts.length > maxItems %}
|
{% if collections.featuredPosts.length > maxItems %}
|
||||||
<div class="mt-4 text-center">
|
<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 →
|
View all {{ collections.featuredPosts.length }} featured posts →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
{% set graphOptions = { limit: sectionConfig.limit } %}
|
{% set graphOptions = { limit: sectionConfig.limit } %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% postGraph collections.posts, graphOptions %}
|
{% 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
|
View full history
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
<is-land on:visible>
|
<is-land on:visible>
|
||||||
<ul class="facepile" role="list">
|
<ul class="facepile" role="list">
|
||||||
{% for like in likes %}
|
{% 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 }}"
|
<a href="{{ like.author.url }}"
|
||||||
class="facepile-avatar"
|
class="facepile-avatar"
|
||||||
aria-label="{{ like.author.name }} (opens in new tab)"
|
aria-label="{{ like.author.name }} (opens in new tab)"
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
<is-land on:visible>
|
<is-land on:visible>
|
||||||
<ul class="facepile" role="list">
|
<ul class="facepile" role="list">
|
||||||
{% for repost in reposts %}
|
{% 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 }}"
|
<a href="{{ repost.author.url }}"
|
||||||
class="facepile-avatar"
|
class="facepile-avatar"
|
||||||
aria-label="{{ repost.author.name }} (opens in new tab)"
|
aria-label="{{ repost.author.name }} (opens in new tab)"
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
<is-land on:visible>
|
<is-land on:visible>
|
||||||
<ul class="facepile" role="list">
|
<ul class="facepile" role="list">
|
||||||
{% for bookmark in bookmarks %}
|
{% 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 }}"
|
<a href="{{ bookmark.author.url }}"
|
||||||
class="facepile-avatar"
|
class="facepile-avatar"
|
||||||
aria-label="{{ bookmark.author.name }} (opens in new tab)"
|
aria-label="{{ bookmark.author.name }} (opens in new tab)"
|
||||||
@@ -119,7 +119,10 @@
|
|||||||
</h3>
|
</h3>
|
||||||
<ul class="space-y-4">
|
<ul class="space-y-4">
|
||||||
{% for reply in replies %}
|
{% 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">
|
<div class="flex gap-3">
|
||||||
<a href="{{ reply.author.url }}" target="_blank" rel="noopener">
|
<a href="{{ reply.author.url }}" target="_blank" rel="noopener">
|
||||||
<img
|
<img
|
||||||
@@ -130,13 +133,14 @@
|
|||||||
>
|
>
|
||||||
</a>
|
</a>
|
||||||
<div class="flex-1 min-w-0">
|
<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 }}"
|
<a href="{{ reply.author.url }}"
|
||||||
class="font-semibold text-surface-900 dark:text-surface-100 hover:underline"
|
class="font-semibold text-surface-900 dark:text-surface-100 hover:underline"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener">
|
rel="noopener">
|
||||||
{{ reply.author.name }}
|
{{ reply.author.name }}
|
||||||
</a>
|
</a>
|
||||||
|
<span class="wm-provenance-badge" data-detect="true"></span>
|
||||||
<a href="{{ reply.url }}"
|
<a href="{{ reply.url }}"
|
||||||
class="text-xs text-surface-600 dark:text-surface-400 hover:underline"
|
class="text-xs text-surface-600 dark:text-surface-400 hover:underline"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -149,8 +153,14 @@
|
|||||||
<div class="text-surface-700 dark:text-surface-300 prose dark:prose-invert prose-sm max-w-none">
|
<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 }}
|
{{ reply.content.html | safe if reply.content.html else reply.content.text }}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
<div class="wm-owner-reply-slot ml-13 mt-2"></div>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -168,7 +178,7 @@
|
|||||||
{% for mention in otherMentions %}
|
{% for mention in otherMentions %}
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ mention.url }}"
|
<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"
|
target="_blank"
|
||||||
rel="noopener">
|
rel="noopener">
|
||||||
{{ mention.author.name }} mentioned this on <time class="font-mono" datetime="{{ mention.published }}">{{ mention.published | date("MMM d, yyyy") }}</time>
|
{{ 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 %}
|
{% endif %}
|
||||||
|
|
||||||
{# Webmention send form — collapsed by default #}
|
{# Webmention send form — collapsed by default #}
|
||||||
<details class="mt-8">
|
<details class="webmention-form 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">
|
<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">
|
<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"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -206,7 +216,7 @@
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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
|
Send
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -18,11 +18,11 @@
|
|||||||
<div class="text-[10px] text-surface-600 dark:text-surface-400">Total</div>
|
<div class="text-[10px] text-surface-600 dark:text-surface-400">Total</div>
|
||||||
</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-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 class="text-[10px] text-surface-600 dark:text-surface-400">AI-involved</div>
|
||||||
</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-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 class="text-[10px] text-surface-600 dark:text-surface-400">Human-only</div>
|
||||||
</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-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" } %}
|
{% postGraph aiPostsList, { prefix: "ai-widget", limit: 1, noLabels: true, boxColorDark: "#44403c", highlightColorLight: "#d97706", highlightColorDark: "#fbbf24" } %}
|
||||||
{% endif %}
|
{% 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
|
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>
|
<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>
|
</a>
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
<is-land on:visible>
|
<is-land on:visible>
|
||||||
<div class="widget" x-data="blogrollWidget()" x-init="init()">
|
<div class="widget" x-data="blogrollWidget()" x-init="init()">
|
||||||
<h3 class="widget-title flex items-center gap-2">
|
<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"/>
|
<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>
|
</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>
|
</h3>
|
||||||
|
|
||||||
{# Source tabs - only shown when multiple sources exist #}
|
{# Source tabs - only shown when multiple sources exist #}
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
aria-controls="blogroll-panel"
|
aria-controls="blogroll-panel"
|
||||||
@click="activeTab = tab.key"
|
@click="activeTab = tab.key"
|
||||||
:class="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'"
|
: '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"
|
class="px-2 py-1 text-xs font-medium transition-colors -mb-px"
|
||||||
x-text="tab.label + ' (' + tab.count + ')'"
|
x-text="tab.label + ' (' + tab.count + ')'"
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
:href="blog.siteUrl || blog.feedUrl"
|
: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"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
>
|
>
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
No blogs loaded yet.
|
No blogs loaded yet.
|
||||||
</div>
|
</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
|
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>
|
<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>
|
</a>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
{% set _bookmarkedUrl = _prevPost.data.bookmarkOf or _prevPost.data.bookmark_of %}
|
{% set _bookmarkedUrl = _prevPost.data.bookmarkOf or _prevPost.data.bookmark_of %}
|
||||||
{% set _repostedUrl = _prevPost.data.repostOf or _prevPost.data.repost_of %}
|
{% set _repostedUrl = _prevPost.data.repostOf or _prevPost.data.repost_of %}
|
||||||
{% set _replyToUrl = _prevPost.data.inReplyTo or _prevPost.data.in_reply_to %}
|
{% 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 %}
|
{% 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>
|
<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) }}
|
Liked {{ _likedUrl | replace("https://", "") | truncate(35) }}
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
{% set _bookmarkedUrl = _nextPost.data.bookmarkOf or _nextPost.data.bookmark_of %}
|
{% set _bookmarkedUrl = _nextPost.data.bookmarkOf or _nextPost.data.bookmark_of %}
|
||||||
{% set _repostedUrl = _nextPost.data.repostOf or _nextPost.data.repost_of %}
|
{% set _repostedUrl = _nextPost.data.repostOf or _nextPost.data.repost_of %}
|
||||||
{% set _replyToUrl = _nextPost.data.inReplyTo or _nextPost.data.in_reply_to %}
|
{% 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 %}
|
{% 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>
|
<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) }}
|
Liked {{ _likedUrl | replace("https://", "") | truncate(35) }}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<span class="font-medium">{{ comment.author.name or "Anonymous" }}</span>
|
<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>
|
<p class="text-surface-600 dark:text-surface-400 line-clamp-2">{{ comment.content.text | truncate(80) }}</p>
|
||||||
{% if comment["comment-target"] %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,59 +1,27 @@
|
|||||||
{# Table of Contents Widget (for articles with headings) #}
|
{# Table of Contents Widget — client-side Alpine.js heading scanner with scroll spy #}
|
||||||
<is-land on:visible>
|
{# Only renders on Articles/Notes with 3+ headings (h2-h4) #}
|
||||||
<div class="widget">
|
{% if title or (not (bookmarkOf or bookmark_of or likeOf or like_of or repostOf or repost_of)) %}
|
||||||
<h3 class="widget-title">Contents</h3>
|
<div x-data="tocScanner" x-show="items.length > 0" x-cloak>
|
||||||
<nav class="toc" aria-label="Table of contents">
|
<div class="widget">
|
||||||
{% if toc and toc.length %}
|
<h3 class="widget-title">Contents</h3>
|
||||||
<ul class="space-y-1 text-sm">
|
<nav class="toc" aria-label="Table of contents">
|
||||||
{% for item in toc %}
|
<ul class="space-y-1 text-sm">
|
||||||
<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 %}">
|
<template x-for="item in items" :key="item.id">
|
||||||
<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">
|
<li :class="{
|
||||||
{{ item.text }}
|
'ml-3': item.level === 3,
|
||||||
</a>
|
'ml-6': item.level === 4
|
||||||
</li>
|
}">
|
||||||
{% endfor %}
|
<a :href="'#' + item.id"
|
||||||
</ul>
|
x-text="item.text"
|
||||||
{% else %}
|
class="transition-colors hover:underline"
|
||||||
<ul class="space-y-1 text-sm" data-toc-fallback-list></ul>
|
:class="item.active
|
||||||
<script>
|
? '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'"
|
||||||
const script = document.currentScript;
|
></a>
|
||||||
if (!script) return;
|
</li>
|
||||||
|
</template>
|
||||||
const widget = script.closest(".widget");
|
</ul>
|
||||||
const fallbackList = widget ? widget.querySelector("[data-toc-fallback-list]") : null;
|
</nav>
|
||||||
if (!widget || !fallbackList) return;
|
</div>
|
||||||
|
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</is-land>
|
{% endif %}
|
||||||
|
|||||||
@@ -14,14 +14,14 @@
|
|||||||
<div class="flex border-b border-surface-200 dark:border-surface-700 mb-3">
|
<div class="flex border-b border-surface-200 dark:border-surface-700 mb-3">
|
||||||
<button
|
<button
|
||||||
@click="tab = 'inbound'"
|
@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">
|
class="px-2 py-1.5 text-xs font-medium border-b-2 -mb-px transition-colors">
|
||||||
Received
|
Received
|
||||||
<span x-show="mentions.length" x-text="mentions.length" class="ml-0.5 text-xs opacity-75"></span>
|
<span x-show="mentions.length" x-text="mentions.length" class="ml-0.5 text-xs opacity-75"></span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="tab = 'outbound'"
|
@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">
|
class="px-2 py-1.5 text-xs font-medium border-b-2 -mb-px transition-colors">
|
||||||
Sent
|
Sent
|
||||||
</button>
|
</button>
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
|
|
||||||
{# Link to full interactions page #}
|
{# 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">
|
<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 →
|
View all →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 pt-2 border-t border-surface-200 dark:border-surface-700">
|
<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 →
|
View all →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,19 +150,7 @@ function webmentionsWidget() {
|
|||||||
merged.push(item);
|
merged.push(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use same self-Bluesky filter as post-interactions/interactions.njk
|
this.mentions = merged.sort((a, b) => {
|
||||||
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) => {
|
|
||||||
return new Date(b.published || b['wm-received'] || 0) - new Date(a.published || a['wm-received'] || 0);
|
return new Date(b.published || b['wm-received'] || 0) - new Date(a.published || a['wm-received'] || 0);
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -54,6 +54,11 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg">
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
<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 #}
|
{# Critical CSS — inlined for fast first paint #}
|
||||||
<style>{{ "css/critical.css" | inlineFile | safe }}</style>
|
<style>{{ "css/critical.css" | inlineFile | safe }}</style>
|
||||||
{# Defer full stylesheet — loads after first paint #}
|
{# 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>
|
<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>
|
<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) #}
|
{# 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/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/fediverse-interact.js?v={{ '/js/fediverse-interact.js' | hash }}" defer></script>
|
||||||
<script src="/js/lightbox.js?v={{ '/js/lightbox.js' | hash }}" defer></script>
|
<script src="/js/lightbox.js?v={{ '/js/lightbox.js' | hash }}" defer></script>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ eleventyExcludeFromCollections: true
|
|||||||
{{ site.author.name }}
|
{{ site.author.name }}
|
||||||
</h1>
|
</h1>
|
||||||
{% if site.author.title %}
|
{% 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 }}
|
{{ site.author.title }}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ withSidebar: false
|
|||||||
<template x-for="tab in tabs" :key="tab.key">
|
<template x-for="tab in tabs" :key="tab.key">
|
||||||
<button
|
<button
|
||||||
@click="activeTab = tab.key"
|
@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()"
|
:aria-selected="(activeTab === tab.key).toString()"
|
||||||
role="tab"
|
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"
|
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">
|
<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">
|
<div class="flex items-start gap-3">
|
||||||
<a :href="commit.url" target="_blank" rel="noopener"
|
<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>
|
x-text="commit.sha"></a>
|
||||||
<div class="min-w-0 flex-1">
|
<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>
|
<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"
|
x-text="categoryLabels[commit.category] || commit.category"
|
||||||
></span>
|
></span>
|
||||||
<a :href="commit.repoUrl" target="_blank" rel="noopener"
|
<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>
|
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 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>
|
<span class="text-xs text-surface-600 dark:text-surface-400" x-text="'by ' + commit.author"></span>
|
||||||
@@ -118,32 +118,34 @@ function changelogApp() {
|
|||||||
|
|
||||||
tabs: [
|
tabs: [
|
||||||
{ key: 'all', label: 'All' },
|
{ key: 'all', label: 'All' },
|
||||||
{ key: 'features', label: 'Features' },
|
{ key: 'core', label: 'Core' },
|
||||||
{ key: 'fixes', label: 'Fixes' },
|
{ key: 'deployment', label: 'Deployment' },
|
||||||
{ key: 'performance', label: 'Performance' },
|
{ key: 'theme', label: 'Theme' },
|
||||||
{ key: 'accessibility', label: 'Accessibility' },
|
{ key: 'endpoints', label: 'Endpoints' },
|
||||||
{ key: 'documentation', label: 'Docs' },
|
{ key: 'syndicators', label: 'Syndicators' },
|
||||||
{ key: 'style', label: 'Style' },
|
{ key: 'post-types', label: 'Post Types' },
|
||||||
{ key: 'other', label: 'Other' },
|
{ key: 'presets', label: 'Presets' },
|
||||||
],
|
],
|
||||||
|
|
||||||
categoryLabels: {
|
categoryLabels: {
|
||||||
features: 'Features',
|
core: 'Core',
|
||||||
fixes: 'Fixes',
|
deployment: 'Deployment',
|
||||||
performance: 'Performance',
|
theme: 'Theme',
|
||||||
accessibility: 'Accessibility',
|
endpoints: 'Endpoint',
|
||||||
documentation: 'Docs',
|
syndicators: 'Syndicator',
|
||||||
style: 'Style',
|
'post-types': 'Post Type',
|
||||||
|
presets: 'Preset',
|
||||||
other: 'Other',
|
other: 'Other',
|
||||||
},
|
},
|
||||||
|
|
||||||
categoryColors: {
|
categoryColors: {
|
||||||
features: 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300',
|
core: 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300',
|
||||||
fixes: 'bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-300',
|
deployment: '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',
|
theme: 'bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-300',
|
||||||
accessibility: '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',
|
||||||
documentation: 'bg-yellow-100 dark:bg-yellow-900 text-yellow-700 dark:text-yellow-300',
|
syndicators: 'bg-teal-100 dark:bg-teal-900 text-teal-700 dark:text-teal-300',
|
||||||
style: 'bg-pink-100 dark:bg-pink-900 text-pink-700 dark:text-pink-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',
|
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);
|
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) {
|
async fetchChangelog(days) {
|
||||||
try {
|
try {
|
||||||
const result = await this.fetchJson([
|
const response = await fetch('/githubapi/api/changelog?days=' + days);
|
||||||
'/github/api/changelog?days=' + days,
|
if (!response.ok) throw new Error('Failed to fetch');
|
||||||
'/githubapi/api/changelog?days=' + days,
|
const data = await response.json();
|
||||||
]);
|
|
||||||
|
|
||||||
if (!result.ok) throw new Error('Failed to fetch');
|
|
||||||
|
|
||||||
const data = result.data || {};
|
|
||||||
this.commits = data.commits || [];
|
this.commits = data.commits || [];
|
||||||
this.categories = data.categories || {};
|
this.categories = data.categories || {};
|
||||||
this.currentDays = data.days;
|
this.currentDays = data.days;
|
||||||
|
|||||||
@@ -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 */
|
/* Reserve sidebar space on desktop to prevent CLS when Alpine.js hydrates collapsible widgets */
|
||||||
@media(min-width:1024px){.sidebar{min-height:600px}}
|
@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 */
|
/* Basic typography — prevent FOUT */
|
||||||
h1,h2,h3,h4{margin:0;line-height:1.25}
|
h1,h2,h3,h4{margin:0;line-height:1.25}
|
||||||
a{color:#b45309}
|
a{color:#b45309}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Inter';
|
font-family: 'Inter';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: optional;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
src: url(/fonts/inter-latin-ext-400-normal.woff2) format('woff2');
|
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;
|
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-face {
|
||||||
font-family: 'Inter';
|
font-family: 'Inter';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: optional;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
src: url(/fonts/inter-latin-400-normal.woff2) format('woff2');
|
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;
|
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-face {
|
||||||
font-family: 'Inter';
|
font-family: 'Inter';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: optional;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
src: url(/fonts/inter-latin-ext-500-normal.woff2) format('woff2');
|
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;
|
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-face {
|
||||||
font-family: 'Inter';
|
font-family: 'Inter';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: optional;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
src: url(/fonts/inter-latin-500-normal.woff2) format('woff2');
|
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;
|
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-face {
|
||||||
font-family: 'Inter';
|
font-family: 'Inter';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: optional;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
src: url(/fonts/inter-latin-ext-600-normal.woff2) format('woff2');
|
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;
|
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-face {
|
||||||
font-family: 'Inter';
|
font-family: 'Inter';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: optional;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
src: url(/fonts/inter-latin-600-normal.woff2) format('woff2');
|
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;
|
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-face {
|
||||||
font-family: 'Inter';
|
font-family: 'Inter';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: optional;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
src: url(/fonts/inter-latin-ext-700-normal.woff2) format('woff2');
|
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;
|
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-face {
|
||||||
font-family: 'Inter';
|
font-family: 'Inter';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: optional;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
src: url(/fonts/inter-latin-700-normal.woff2) format('woff2');
|
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;
|
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
10
cv.njk
@@ -51,7 +51,7 @@ pagefindIgnore: true
|
|||||||
{{ authorName }}
|
{{ authorName }}
|
||||||
</h1>
|
</h1>
|
||||||
{% if authorTitle %}
|
{% 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 }}
|
{{ authorTitle }}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -86,13 +86,13 @@ pagefindIgnore: true
|
|||||||
<span>{{ cvOrg }}</span>
|
<span>{{ cvOrg }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if cvUrl %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% if cvEmail %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% if cvKeyUrl %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -139,7 +139,7 @@ pagefindIgnore: true
|
|||||||
<h1 class="text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4">CV</h1>
|
<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">
|
<p class="text-surface-600 dark:text-surface-400">
|
||||||
No CV data available yet. Add your experience, projects, and skills via the
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ permalink: "digest/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumb
|
|||||||
{% for d in paginatedDigests %}
|
{% 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">
|
<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">
|
<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 }}
|
{{ d.label }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-surface-600 dark:text-surface-400 mt-1">
|
<p class="text-sm text-surface-600 dark:text-surface-400 mt-1">
|
||||||
|
|||||||
12
digest.njk
12
digest.njk
@@ -49,7 +49,7 @@ permalink: "digest/{{ digest.slug }}/"
|
|||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<span class="text-red-500 flex-shrink-0">❤</span>
|
<span class="text-red-500 flex-shrink-0">❤</span>
|
||||||
<div>
|
<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">
|
<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>
|
<time class="dt-published font-mono text-sm" datetime="{{ post.date | isoDate }}">{{ post.date | dateDisplay }}</time>
|
||||||
· <a href="{{ post.url }}" class="hover:underline" aria-label="Permalink: {{ post.data.title or (post.date | dateDisplay) }}">Permalink</a>
|
· <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">🔖</span>
|
<span class="text-amber-500 flex-shrink-0">🔖</span>
|
||||||
<div>
|
<div>
|
||||||
{% if post.data.title %}
|
{% 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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
<div class="text-sm text-surface-600 dark:text-surface-400 mt-1">
|
<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>
|
<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">
|
<div class="flex items-start gap-2">
|
||||||
<span class="text-green-500 flex-shrink-0">🔁</span>
|
<span class="text-green-500 flex-shrink-0">🔁</span>
|
||||||
<div>
|
<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">
|
<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>
|
<time class="dt-published font-mono text-sm" datetime="{{ post.date | isoDate }}">{{ post.date | dateDisplay }}</time>
|
||||||
· <a href="{{ post.url }}" class="hover:underline" aria-label="Permalink: {{ post.data.title or (post.date | dateDisplay) }}">Permalink</a>
|
· <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>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if post.data.title %}
|
{% 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 %}
|
{% elif post.templateContent %}
|
||||||
<p class="text-surface-700 dark:text-surface-300 text-sm">{{ post.templateContent | striptags | truncate(120) }}</p>
|
<p class="text-surface-700 dark:text-surface-300 text-sm">{{ post.templateContent | striptags | truncate(120) }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -111,7 +111,7 @@ permalink: "digest/{{ digest.slug }}/"
|
|||||||
|
|
||||||
{% elif typeInfo.key == "articles" %}
|
{% elif typeInfo.key == "articles" %}
|
||||||
<div>
|
<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") }}
|
{{ post.data.title | default("Untitled") }}
|
||||||
</a>
|
</a>
|
||||||
{% if post.templateContent %}
|
{% if post.templateContent %}
|
||||||
|
|||||||
@@ -301,7 +301,12 @@ export default function (eleventyConfig) {
|
|||||||
// Custom transform to convert YouTube links to lite-youtube embeds
|
// Custom transform to convert YouTube links to lite-youtube embeds
|
||||||
// Catches bare YouTube links in Markdown that the embed plugin misses
|
// Catches bare YouTube links in Markdown that the embed plugin misses
|
||||||
eleventyConfig.addTransform("youtube-link-to-embed", function (content, outputPath) {
|
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;
|
return content;
|
||||||
}
|
}
|
||||||
// Match <a> tags where href contains youtube.com/watch or youtu.be
|
// Match <a> tags where href contains youtube.com/watch or youtu.be
|
||||||
@@ -370,6 +375,21 @@ export default function (eleventyConfig) {
|
|||||||
return content;
|
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).
|
// 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
|
// page.url is unreliable during parallel rendering, but outputPath IS correct
|
||||||
// since files are written to the correct location. Derives the OG slug from
|
// 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 pageUrlPath = `/${type}/${year}/${month}/${day}/${slug}/`;
|
||||||
const correctFullUrl = `${siteUrl}${pageUrlPath}`;
|
const correctFullUrl = `${siteUrl}${pageUrlPath}`;
|
||||||
const ogSlug = `${year}-${month}-${day}-${slug}`;
|
const ogSlug = `${year}-${month}-${day}-${slug}`;
|
||||||
const hasOg = existsSync(resolve(__dirname, ".cache", "og", `${ogSlug}.png`));
|
const hasOg = hasOgImage(ogSlug);
|
||||||
const ogImageUrl = hasOg
|
const ogImageUrl = hasOg
|
||||||
? `${siteUrl}/og/${ogSlug}.png`
|
? `${siteUrl}/og/${ogSlug}.png`
|
||||||
: `${siteUrl}/images/og-default.png`;
|
: `${siteUrl}/images/og-default.png`;
|
||||||
@@ -518,21 +538,34 @@ export default function (eleventyConfig) {
|
|||||||
key: "children",
|
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) => {
|
eleventyConfig.addFilter("dateDisplay", (dateObj) => {
|
||||||
if (!dateObj) return "";
|
if (!dateObj) return "";
|
||||||
const date = new Date(dateObj);
|
const key = dateObj instanceof Date ? dateObj.getTime() : dateObj;
|
||||||
return date.toLocaleDateString("en-GB", {
|
const cached = _dateDisplayCache.get(key);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
const result = new Date(dateObj).toLocaleDateString("en-GB", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
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) => {
|
eleventyConfig.addFilter("isoDate", (dateObj) => {
|
||||||
if (!dateObj) return "";
|
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
|
// Digest-to-HTML filter for RSS feed descriptions
|
||||||
@@ -1306,24 +1339,42 @@ export default function (eleventyConfig) {
|
|||||||
return digests;
|
return digests;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate OpenGraph images for posts without photos
|
// Generate OpenGraph images for posts without photos.
|
||||||
// Runs on every build (including watcher rebuilds) — manifest caching makes it fast
|
// Uses batch spawning: each invocation generates up to BATCH_SIZE images then exits,
|
||||||
// for incremental: only new posts without an OG image get generated (~200ms each)
|
// 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", () => {
|
eleventyConfig.on("eleventy.before", () => {
|
||||||
const contentDir = resolve(__dirname, "content");
|
const contentDir = resolve(__dirname, "content");
|
||||||
const cacheDir = resolve(__dirname, ".cache");
|
const cacheDir = resolve(__dirname, ".cache");
|
||||||
const siteName = process.env.SITE_NAME || "My IndieWeb Blog";
|
const siteName = process.env.SITE_NAME || "My IndieWeb Blog";
|
||||||
|
const BATCH_SIZE = 100;
|
||||||
try {
|
try {
|
||||||
execFileSync(process.execPath, [
|
// eslint-disable-next-line no-constant-condition
|
||||||
"--max-old-space-size=768",
|
while (true) {
|
||||||
resolve(__dirname, "lib", "og-cli.js"),
|
try {
|
||||||
contentDir,
|
execFileSync(process.execPath, [
|
||||||
cacheDir,
|
"--max-old-space-size=512",
|
||||||
siteName,
|
"--expose-gc",
|
||||||
], {
|
resolve(__dirname, "lib", "og-cli.js"),
|
||||||
stdio: "inherit",
|
contentDir,
|
||||||
env: { ...process.env, NODE_OPTIONS: "" },
|
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.
|
// Sync new OG images to output directory.
|
||||||
// During incremental builds, .cache/og is in watchIgnores so Eleventy's
|
// During incremental builds, .cache/og is in watchIgnores so Eleventy's
|
||||||
@@ -1377,8 +1428,26 @@ export default function (eleventyConfig) {
|
|||||||
walk(contentDir);
|
walk(contentDir);
|
||||||
|
|
||||||
if (urls.size === 0) return;
|
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.`);
|
console.log(`[unfurl] Pre-fetch complete.`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1561,6 +1630,19 @@ export default function (eleventyConfig) {
|
|||||||
console.error(`[websub] Hub notification failed for ${feedUrl}:`, err.message);
|
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 {
|
return {
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ permalink: "featured/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNu
|
|||||||
{% if post.templateContent %}
|
{% if post.templateContent %}
|
||||||
<p class="p-summary text-surface-700 dark:text-surface-300 mt-3">{{ post.templateContent | striptags | truncate(250) }}</p>
|
<p class="p-summary text-surface-700 dark:text-surface-300 mt-3">{{ post.templateContent | striptags | truncate(250) }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{{ post.url }}" class="text-sm text-accent-700 dark:text-accent-300 hover:underline mt-3 inline-block">Read more →</a>
|
<a href="{{ post.url }}" class="text-sm text-accent-600 dark:text-accent-400 hover:underline mt-3 inline-block">Read more →</a>
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
{# ── Note ── #}
|
{# ── 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>
|
<div class="e-content prose dark:prose-invert prose-sm mt-3 max-w-none">{{ post.templateContent | safe }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="post-footer mt-3">
|
<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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
BIN
images/rick.jpg
Normal file
BIN
images/rick.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
142
js/comments.js
142
js/comments.js
@@ -1,12 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Client-side comments component (Alpine.js)
|
* 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
|
* Registered via Alpine.data() so the component is available
|
||||||
* regardless of script loading order.
|
* regardless of script loading order.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
document.addEventListener("alpine:init", () => {
|
document.addEventListener("alpine:init", () => {
|
||||||
|
// Global owner state store — shared across components
|
||||||
|
Alpine.store("owner", {
|
||||||
|
isOwner: false,
|
||||||
|
profile: null,
|
||||||
|
syndicationTargets: {},
|
||||||
|
});
|
||||||
|
|
||||||
Alpine.data("commentsSection", (targetUrl) => ({
|
Alpine.data("commentsSection", (targetUrl) => ({
|
||||||
targetUrl,
|
targetUrl,
|
||||||
user: null,
|
user: null,
|
||||||
@@ -20,10 +27,21 @@ document.addEventListener("alpine:init", () => {
|
|||||||
statusType: "info",
|
statusType: "info",
|
||||||
maxLength: 2000,
|
maxLength: 2000,
|
||||||
showForm: false,
|
showForm: false,
|
||||||
|
isOwner: false,
|
||||||
|
ownerProfile: null,
|
||||||
|
syndicationTargets: {},
|
||||||
|
replyingTo: null,
|
||||||
|
replyText: "",
|
||||||
|
replySubmitting: false,
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
await this.checkSession();
|
await this.checkSession();
|
||||||
|
await this.checkOwner();
|
||||||
await this.loadComments();
|
await this.loadComments();
|
||||||
|
if (this.isOwner) {
|
||||||
|
// Notify webmentions.js that owner is detected (for reply buttons)
|
||||||
|
document.dispatchEvent(new CustomEvent("owner:detected"));
|
||||||
|
}
|
||||||
this.handleAuthReturn();
|
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() {
|
handleAuthReturn() {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const authError = params.get("auth_error");
|
const authError = params.get("auth_error");
|
||||||
@@ -62,6 +198,10 @@ document.addEventListener("alpine:init", () => {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
this.comments = data.children || [];
|
this.comments = data.children || [];
|
||||||
|
// Auto-expand if comments exist
|
||||||
|
if (this.comments.length > 0) {
|
||||||
|
this.showForm = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[Comments] Load error:", e);
|
console.error("[Comments] Load error:", e);
|
||||||
|
|||||||
@@ -55,60 +55,158 @@
|
|||||||
function processWebmentions(allChildren) {
|
function processWebmentions(allChildren) {
|
||||||
if (!allChildren || !allChildren.length) return;
|
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;
|
let mentionsToShow;
|
||||||
if (hasBuildTimeSection) {
|
if (hasBuildTimeSection) {
|
||||||
// Build-time section exists - only show NEW webmentions to avoid duplicates.
|
// Build-time section exists — deduplicate against what's actually rendered
|
||||||
// Both webmention.io and conversations items are included at build time,
|
// in the DOM rather than using timestamps (which miss webmentions that the
|
||||||
// so filter all by timestamp (only show items received after the build).
|
// build-time cache didn't include but that the API returns).
|
||||||
mentionsToShow = allChildren.filter((wm) => {
|
|
||||||
const wmTime = new Date(wm['wm-received']).getTime();
|
// Collect author URLs already shown in facepiles (likes, reposts, bookmarks)
|
||||||
return wmTime > buildTime;
|
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 {
|
} else {
|
||||||
// No build-time section - show ALL webmentions from API
|
// No build-time section - show ALL regular webmentions from API
|
||||||
mentionsToShow = allChildren;
|
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
|
if (likes.length) {
|
||||||
const likes = mentionsToShow.filter((m) => m['wm-property'] === 'like-of');
|
appendAvatars('.webmention-likes .facepile, .webmention-likes .avatar-row', likes, 'likes');
|
||||||
const reposts = mentionsToShow.filter((m) => m['wm-property'] === 'repost-of');
|
updateCount('.webmention-likes h3', likes.length, 'Like');
|
||||||
const replies = mentionsToShow.filter((m) => m['wm-property'] === 'in-reply-to');
|
}
|
||||||
const mentions = mentionsToShow.filter((m) => m['wm-property'] === 'mention-of');
|
|
||||||
|
|
||||||
// Append new likes
|
if (reposts.length) {
|
||||||
if (likes.length) {
|
appendAvatars('.webmention-reposts .facepile, .webmention-reposts .avatar-row', reposts, 'reposts');
|
||||||
appendAvatars('.webmention-likes .facepile, .webmention-likes .avatar-row', likes, 'likes');
|
updateCount('.webmention-reposts h3', reposts.length, 'Repost');
|
||||||
updateCount('.webmention-likes h3', likes.length, 'Like');
|
}
|
||||||
|
|
||||||
|
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
|
// Thread owner replies under their parent interaction cards
|
||||||
if (reposts.length) {
|
threadOwnerReplies(ownerReplies);
|
||||||
appendAvatars('.webmention-reposts .facepile, .webmention-reposts .avatar-row', reposts, 'reposts');
|
}
|
||||||
updateCount('.webmention-reposts h3', reposts.length, 'Repost');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append new replies
|
function threadOwnerReplies(ownerReplies) {
|
||||||
if (replies.length) {
|
if (!ownerReplies || !ownerReplies.length) return;
|
||||||
appendReplies('.webmention-replies ul', replies);
|
|
||||||
updateCount('.webmention-replies h3', replies.length, 'Repl', 'ies', 'y');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append new mentions
|
ownerReplies.forEach(function(reply) {
|
||||||
if (mentions.length) {
|
var parentUrl = reply.parent_url;
|
||||||
appendMentions('.webmention-mentions ul', mentions);
|
if (!parentUrl) return;
|
||||||
updateCount('.webmention-mentions h3', mentions.length, 'Mention');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update total count in main header
|
// Find the interaction card whose URL matches the parent
|
||||||
updateTotalCount(mentionsToShow.length);
|
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)
|
// Try cached data first (renders instantly on refresh)
|
||||||
const cached = getCachedData();
|
const cached = getCachedData();
|
||||||
if (cached) {
|
if (cached) {
|
||||||
processWebmentions(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)
|
// Conversations API URLs (dual-fetch for enriched data)
|
||||||
@@ -170,11 +268,54 @@
|
|||||||
if (!cached) {
|
if (!cached) {
|
||||||
processWebmentions(allChildren);
|
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) => {
|
.catch((err) => {
|
||||||
console.debug('[Webmentions] Error fetching:', err.message);
|
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) {
|
function appendAvatars(selector, items, type) {
|
||||||
let row = document.querySelector(selector);
|
let row = document.querySelector(selector);
|
||||||
|
|
||||||
@@ -247,6 +388,8 @@
|
|||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.className = 'p-4 bg-surface-100 dark:bg-surface-800 rounded-lg ring-2 ring-accent-500';
|
li.className = 'p-4 bg-surface-100 dark:bg-surface-800 rounded-lg ring-2 ring-accent-500';
|
||||||
li.dataset.new = 'true';
|
li.dataset.new = 'true';
|
||||||
|
li.dataset.platform = detectPlatform(item);
|
||||||
|
li.dataset.wmUrl = item.url || '';
|
||||||
|
|
||||||
// Build reply card using DOM methods
|
// Build reply card using DOM methods
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
@@ -292,10 +435,13 @@
|
|||||||
dateLink.appendChild(timeEl);
|
dateLink.appendChild(timeEl);
|
||||||
|
|
||||||
const newBadge = document.createElement('span');
|
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';
|
newBadge.textContent = 'NEW';
|
||||||
|
|
||||||
headerDiv.appendChild(authorLink);
|
headerDiv.appendChild(authorLink);
|
||||||
|
// Add provenance badge
|
||||||
|
var platform = detectPlatform(item);
|
||||||
|
headerDiv.appendChild(createProvenanceBadge(platform));
|
||||||
headerDiv.appendChild(dateLink);
|
headerDiv.appendChild(dateLink);
|
||||||
headerDiv.appendChild(newBadge);
|
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.className = 'text-surface-700 dark:text-surface-300 prose dark:prose-invert prose-sm max-w-none';
|
||||||
replyDiv.textContent = content.text || '';
|
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(headerDiv);
|
||||||
contentDiv.appendChild(replyDiv);
|
contentDiv.appendChild(replyDiv);
|
||||||
|
contentDiv.appendChild(replyBtn);
|
||||||
|
|
||||||
wrapper.appendChild(avatarLink);
|
wrapper.appendChild(avatarLink);
|
||||||
wrapper.appendChild(contentDiv);
|
wrapper.appendChild(contentDiv);
|
||||||
li.appendChild(wrapper);
|
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
|
// Prepend to show newest first
|
||||||
list.insertBefore(li, list.firstChild);
|
list.insertBefore(li, list.firstChild);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Wire up new reply buttons if owner is already detected
|
||||||
|
wireReplyButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendMentions(selector, items) {
|
function appendMentions(selector, items) {
|
||||||
@@ -343,13 +509,13 @@
|
|||||||
|
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = item.url || '#';
|
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.target = '_blank';
|
||||||
link.rel = 'noopener';
|
link.rel = 'noopener';
|
||||||
link.textContent = `${author.name || 'Someone'} mentioned this on ${formatDate(published)}`;
|
link.textContent = `${author.name || 'Someone'} mentioned this on ${formatDate(published)}`;
|
||||||
|
|
||||||
const badge = document.createElement('span');
|
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';
|
badge.textContent = 'NEW';
|
||||||
|
|
||||||
li.appendChild(link);
|
li.appendChild(link);
|
||||||
@@ -471,4 +637,236 @@
|
|||||||
year: 'numeric',
|
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();
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -4,16 +4,27 @@
|
|||||||
* CLI entry point for OG image generation.
|
* CLI entry point for OG image generation.
|
||||||
* Runs as a separate process to isolate memory from Eleventy.
|
* 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";
|
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) {
|
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);
|
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
273
lib/og.js
@@ -2,6 +2,9 @@
|
|||||||
* OpenGraph image generation for posts without photos.
|
* OpenGraph image generation for posts without photos.
|
||||||
* Uses Satori (layout → SVG) + @resvg/resvg-js (SVG → PNG).
|
* Uses Satori (layout → SVG) + @resvg/resvg-js (SVG → PNG).
|
||||||
* Generated images are cached in .cache/og/ and passthrough-copied to output.
|
* 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";
|
import satori from "satori";
|
||||||
@@ -22,14 +25,19 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|||||||
const WIDTH = 1200;
|
const WIDTH = 1200;
|
||||||
const HEIGHT = 630;
|
const HEIGHT = 630;
|
||||||
|
|
||||||
|
// Card design version — bump to force full regeneration
|
||||||
|
const DESIGN_VERSION = 3;
|
||||||
|
|
||||||
const COLORS = {
|
const COLORS = {
|
||||||
bg: "#09090b",
|
bg: "#ffffff",
|
||||||
title: "#f4f4f5",
|
title: "#24292f",
|
||||||
date: "#a1a1aa",
|
description: "#57606a",
|
||||||
siteName: "#71717a",
|
meta: "#57606a",
|
||||||
accent: "#3b82f6",
|
accent: "#3b82f6",
|
||||||
badge: "#2563eb",
|
badge: "#ddf4ff",
|
||||||
badgeText: "#ffffff",
|
badgeText: "#0969da",
|
||||||
|
border: "#d8dee4",
|
||||||
|
bar: "#3b82f6",
|
||||||
};
|
};
|
||||||
|
|
||||||
const POST_TYPE_MAP = {
|
const POST_TYPE_MAP = {
|
||||||
@@ -48,6 +56,18 @@ const POST_TYPE_MAP = {
|
|||||||
events: "Event",
|
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() {
|
function loadFonts() {
|
||||||
const fontsDir = resolve(
|
const fontsDir = resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
@@ -73,9 +93,9 @@ function loadFonts() {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeHash(title, date, postType, siteName) {
|
function computeHash(title, description, date, postType, siteName) {
|
||||||
return createHash("md5")
|
return createHash("md5")
|
||||||
.update(`${title}|${date}|${postType}|${siteName}`)
|
.update(`v${DESIGN_VERSION}|${title}|${description}|${date}|${postType}|${siteName}`)
|
||||||
.digest("hex")
|
.digest("hex")
|
||||||
.slice(0, 12);
|
.slice(0, 12);
|
||||||
}
|
}
|
||||||
@@ -97,7 +117,7 @@ function formatDate(dateStr) {
|
|||||||
if (Number.isNaN(d.getTime())) return "";
|
if (Number.isNaN(d.getTime())) return "";
|
||||||
return d.toLocaleDateString("en-US", {
|
return d.toLocaleDateString("en-US", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -107,77 +127,147 @@ function formatDate(dateStr) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Use the full filename (with date prefix) as the OG image slug.
|
* 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) {
|
function toOgSlug(filename) {
|
||||||
return filename;
|
return filename;
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncateTitle(title, max = 120) {
|
/**
|
||||||
if (!title || title.length <= max) return title || "Untitled";
|
* Sanitize text for Satori rendering — strip characters that cause NO GLYPH.
|
||||||
return title.slice(0, max).trim() + "\u2026";
|
*/
|
||||||
|
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(/^---[\s\S]*?---\s*/, "")
|
||||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
// Strip images
|
||||||
.replace(/[#*_~`>]/g, "")
|
|
||||||
.replace(/!\[[^\]]*\]\([^)]+\)/g, "")
|
.replace(/!\[[^\]]*\]\([^)]+\)/g, "")
|
||||||
.replace(/\n+/g, " ")
|
// Strip markdown tables (lines with pipes)
|
||||||
.trim();
|
.replace(/^\|.*\|$/gm, "")
|
||||||
if (!body) return "Untitled";
|
// Strip table separator rows
|
||||||
return body.length > 120 ? body.slice(0, 120).trim() + "\u2026" : body;
|
.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 {
|
return {
|
||||||
type: "div",
|
type: "div",
|
||||||
props: {
|
props: {
|
||||||
style: {
|
style: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
width: `${WIDTH}px`,
|
width: `${WIDTH}px`,
|
||||||
height: `${HEIGHT}px`,
|
height: `${HEIGHT}px`,
|
||||||
backgroundColor: COLORS.bg,
|
backgroundColor: COLORS.bg,
|
||||||
},
|
},
|
||||||
children: [
|
children: [
|
||||||
|
// Top accent bar
|
||||||
{
|
{
|
||||||
type: "div",
|
type: "div",
|
||||||
props: {
|
props: {
|
||||||
style: {
|
style: {
|
||||||
width: "6px",
|
width: "100%",
|
||||||
height: "100%",
|
height: "6px",
|
||||||
backgroundColor: COLORS.accent,
|
backgroundColor: COLORS.bar,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Main content — vertically centered
|
||||||
{
|
{
|
||||||
type: "div",
|
type: "div",
|
||||||
props: {
|
props: {
|
||||||
style: {
|
style: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
padding: "60px",
|
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflow: "hidden",
|
padding: "0 64px",
|
||||||
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
children: [
|
children: [
|
||||||
|
// Left: text content
|
||||||
{
|
{
|
||||||
type: "div",
|
type: "div",
|
||||||
props: {
|
props: {
|
||||||
style: {
|
style: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: "24px",
|
flex: 1,
|
||||||
|
gap: "16px",
|
||||||
|
overflow: "hidden",
|
||||||
|
paddingRight: avatar ? "48px" : "0",
|
||||||
},
|
},
|
||||||
children: [
|
children: [
|
||||||
|
// Post type badge + date inline
|
||||||
{
|
{
|
||||||
type: "div",
|
type: "div",
|
||||||
props: {
|
props: {
|
||||||
style: { display: "flex" },
|
style: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "12px",
|
||||||
|
color: COLORS.meta,
|
||||||
|
fontSize: "18px",
|
||||||
|
fontWeight: 400,
|
||||||
|
fontFamily: "Inter",
|
||||||
|
},
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
type: "span",
|
type: "span",
|
||||||
@@ -185,10 +275,10 @@ function buildCard(title, dateStr, postType, siteName) {
|
|||||||
style: {
|
style: {
|
||||||
backgroundColor: COLORS.badge,
|
backgroundColor: COLORS.badge,
|
||||||
color: COLORS.badgeText,
|
color: COLORS.badgeText,
|
||||||
fontSize: "16px",
|
fontSize: "14px",
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
fontFamily: "Inter",
|
fontFamily: "Inter",
|
||||||
padding: "6px 16px",
|
padding: "4px 12px",
|
||||||
borderRadius: "999px",
|
borderRadius: "999px",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: "0.05em",
|
letterSpacing: "0.05em",
|
||||||
@@ -196,9 +286,13 @@ function buildCard(title, dateStr, postType, siteName) {
|
|||||||
children: postType,
|
children: postType,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
formattedDate
|
||||||
|
? { type: "span", props: { children: formattedDate } }
|
||||||
|
: null,
|
||||||
|
].filter(Boolean),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Title
|
||||||
{
|
{
|
||||||
type: "div",
|
type: "div",
|
||||||
props: {
|
props: {
|
||||||
@@ -210,32 +304,75 @@ function buildCard(title, dateStr, postType, siteName) {
|
|||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
children: truncateTitle(title),
|
children: truncate(title, 120),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
dateStr
|
// Description (if available)
|
||||||
|
description
|
||||||
? {
|
? {
|
||||||
type: "div",
|
type: "div",
|
||||||
props: {
|
props: {
|
||||||
style: {
|
style: {
|
||||||
color: COLORS.date,
|
color: COLORS.description,
|
||||||
fontSize: "24px",
|
fontSize: "22px",
|
||||||
fontWeight: 400,
|
fontWeight: 400,
|
||||||
fontFamily: "Inter",
|
fontFamily: "Inter",
|
||||||
|
lineHeight: 1.4,
|
||||||
|
overflow: "hidden",
|
||||||
},
|
},
|
||||||
children: formatDate(dateStr),
|
children: truncate(description, 160),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
].filter(Boolean),
|
].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",
|
type: "div",
|
||||||
props: {
|
props: {
|
||||||
style: {
|
style: {
|
||||||
color: COLORS.siteName,
|
color: "#8b949e",
|
||||||
fontSize: "20px",
|
fontSize: "18px",
|
||||||
fontWeight: 400,
|
fontWeight: 400,
|
||||||
fontFamily: "Inter",
|
fontFamily: "Inter",
|
||||||
},
|
},
|
||||||
@@ -278,8 +415,10 @@ function scanContentFiles(contentDir) {
|
|||||||
* @param {string} contentDir - Path to content/ directory
|
* @param {string} contentDir - Path to content/ directory
|
||||||
* @param {string} cacheDir - Path to .cache/ directory
|
* @param {string} cacheDir - Path to .cache/ directory
|
||||||
* @param {string} siteName - Site name for the card
|
* @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");
|
const ogDir = join(cacheDir, "og");
|
||||||
mkdirSync(ogDir, { recursive: true });
|
mkdirSync(ogDir, { recursive: true });
|
||||||
|
|
||||||
@@ -296,8 +435,13 @@ export async function generateOgImages(contentDir, cacheDir, siteName) {
|
|||||||
|
|
||||||
let generated = 0;
|
let generated = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
const newManifest = {};
|
// Seed with existing manifest so unscanned entries survive batch writes
|
||||||
|
const newManifest = { ...manifest };
|
||||||
const SAVE_INTERVAL = 10;
|
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) {
|
for (const filePath of mdFiles) {
|
||||||
const raw = readFileSync(filePath, "utf8");
|
const raw = readFileSync(filePath, "utf8");
|
||||||
@@ -309,10 +453,18 @@ export async function generateOgImages(contentDir, cacheDir, siteName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const slug = toOgSlug(basename(filePath, ".md"));
|
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 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`))) {
|
if (manifest[slug]?.hash === hash && existsSync(join(ogDir, `${slug}.png`))) {
|
||||||
newManifest[slug] = manifest[slug];
|
newManifest[slug] = manifest[slug];
|
||||||
@@ -320,7 +472,7 @@ export async function generateOgImages(contentDir, cacheDir, siteName) {
|
|||||||
continue;
|
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 svg = await satori(card, { width: WIDTH, height: HEIGHT, fonts });
|
||||||
const resvg = new Resvg(svg, {
|
const resvg = new Resvg(svg, {
|
||||||
fitTo: { mode: "width", value: WIDTH },
|
fitTo: { mode: "width", value: WIDTH },
|
||||||
@@ -331,14 +483,35 @@ export async function generateOgImages(contentDir, cacheDir, siteName) {
|
|||||||
newManifest[slug] = { title: slug, hash };
|
newManifest[slug] = { title: slug, hash };
|
||||||
generated++;
|
generated++;
|
||||||
|
|
||||||
// Save manifest periodically to preserve progress
|
// Save manifest periodically to preserve progress on OOM kill
|
||||||
if (generated % SAVE_INTERVAL === 0) {
|
if (generated % SAVE_INTERVAL === 0) {
|
||||||
writeFileSync(manifestPath, JSON.stringify(newManifest, null, 2));
|
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));
|
writeFileSync(manifestPath, JSON.stringify(newManifest, null, 2));
|
||||||
|
const mem = process.memoryUsage();
|
||||||
|
if (mem.rss > peakRss) peakRss = mem.rss;
|
||||||
console.log(
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
20
news.njk
20
news.njk
@@ -12,7 +12,7 @@ withSidebar: true
|
|||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-surface-600 dark:text-surface-400 mt-2" x-show="lastUpdated">
|
<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>
|
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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
@@ -116,7 +116,7 @@ withSidebar: true
|
|||||||
</svg>
|
</svg>
|
||||||
Syncing...
|
Syncing...
|
||||||
</div>
|
</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">
|
<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>
|
<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>
|
<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">
|
<h2 class="font-semibold text-surface-900 dark:text-surface-100 mb-1">
|
||||||
<a
|
<a
|
||||||
:href="item.link"
|
: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"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
x-text="item.title || item.link"
|
x-text="item.title"
|
||||||
></a>
|
></a>
|
||||||
</h2>
|
</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>
|
<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>
|
<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">
|
<span class="hidden sm:inline" x-show="item.categories?.length">
|
||||||
<template x-for="cat in item.categories.slice(0, 3)" :key="cat">
|
<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>
|
</template>
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -208,10 +208,10 @@ withSidebar: true
|
|||||||
<h2 class="font-semibold text-surface-900 dark:text-surface-100 mb-2 line-clamp-2">
|
<h2 class="font-semibold text-surface-900 dark:text-surface-100 mb-2 line-clamp-2">
|
||||||
<a
|
<a
|
||||||
:href="item.link"
|
: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"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
x-text="item.title || item.link"
|
x-text="item.title"
|
||||||
></a>
|
></a>
|
||||||
</h2>
|
</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>
|
<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">
|
<h2 class="text-xl sm:text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4">
|
||||||
<a
|
<a
|
||||||
:href="item.link"
|
: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"
|
target="_blank"
|
||||||
rel="noopener"
|
rel="noopener"
|
||||||
x-text="item.title || item.link"
|
x-text="item.title"
|
||||||
></a>
|
></a>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
@@ -319,7 +319,7 @@ withSidebar: true
|
|||||||
</button>
|
</button>
|
||||||
<div x-show="item.categories?.length" class="flex flex-wrap gap-2">
|
<div x-show="item.categories?.length" class="flex flex-wrap gap-2">
|
||||||
<template x-for="cat in item.categories" :key="cat">
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ pagefindIgnore: true
|
|||||||
<noscript>
|
<noscript>
|
||||||
<div class="p-6 bg-surface-100 dark:bg-surface-800 rounded-lg mt-4">
|
<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-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>
|
</div>
|
||||||
</noscript>
|
</noscript>
|
||||||
|
|
||||||
|
|||||||
72
slashes.njk
72
slashes.njk
@@ -10,7 +10,7 @@ eleventyImport:
|
|||||||
<div class="h-feed">
|
<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>
|
<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">
|
<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>
|
</p>
|
||||||
|
|
||||||
{# Dynamic pages (created via Indiekit) #}
|
{# Dynamic pages (created via Indiekit) #}
|
||||||
@@ -22,7 +22,7 @@ eleventyImport:
|
|||||||
<li class="h-entry post-card">
|
<li class="h-entry post-card">
|
||||||
<div class="post-header">
|
<div class="post-header">
|
||||||
<h3 class="text-xl font-semibold">
|
<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 }}
|
/{{ page.fileSlug }}
|
||||||
</a>
|
</a>
|
||||||
</h3>
|
</h3>
|
||||||
@@ -72,7 +72,7 @@ eleventyImport:
|
|||||||
<li class="h-entry post-card">
|
<li class="h-entry post-card">
|
||||||
<div class="post-header">
|
<div class="post-header">
|
||||||
<h3 class="text-xl font-semibold">
|
<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>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-surface-600 dark:text-surface-400 mt-2">Sites I follow</p>
|
<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">
|
<li class="h-entry post-card">
|
||||||
<div class="post-header">
|
<div class="post-header">
|
||||||
<h3 class="text-xl font-semibold">
|
<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>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-surface-600 dark:text-surface-400 mt-2">Funkwhale activity</p>
|
<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">
|
<li class="h-entry post-card">
|
||||||
<div class="post-header">
|
<div class="post-header">
|
||||||
<h3 class="text-xl font-semibold">
|
<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>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-surface-600 dark:text-surface-400 mt-2">GitHub activity</p>
|
<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">
|
<li class="h-entry post-card">
|
||||||
<div class="post-header">
|
<div class="post-header">
|
||||||
<h3 class="text-xl font-semibold">
|
<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>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-surface-600 dark:text-surface-400 mt-2">Last.fm scrobbles</p>
|
<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">
|
<li class="h-entry post-card">
|
||||||
<div class="post-header">
|
<div class="post-header">
|
||||||
<h3 class="text-xl font-semibold">
|
<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>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-surface-600 dark:text-surface-400 mt-2">RSS feed aggregator</p>
|
<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">
|
<li class="h-entry post-card">
|
||||||
<div class="post-header">
|
<div class="post-header">
|
||||||
<h3 class="text-xl font-semibold">
|
<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>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-surface-600 dark:text-surface-400 mt-2">Podcasts I listen to</p>
|
<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">
|
<li class="h-entry post-card">
|
||||||
<div class="post-header">
|
<div class="post-header">
|
||||||
<h3 class="text-xl font-semibold">
|
<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>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-surface-600 dark:text-surface-400 mt-2">YouTube channel</p>
|
<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">
|
<li class="h-entry post-card">
|
||||||
<div class="post-header">
|
<div class="post-header">
|
||||||
<h3 class="text-xl font-semibold">
|
<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>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-surface-600 dark:text-surface-400 mt-2">All posts chronologically</p>
|
<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">
|
<li class="h-entry post-card">
|
||||||
<div class="post-header">
|
<div class="post-header">
|
||||||
<h3 class="text-xl font-semibold">
|
<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>
|
<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">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>
|
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-surface-600 dark:text-surface-400 mt-2">Curriculum vitae</p>
|
<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">
|
<li class="h-entry post-card">
|
||||||
<div class="post-header">
|
<div class="post-header">
|
||||||
<h3 class="text-xl font-semibold">
|
<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>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-surface-600 dark:text-surface-400 mt-2">Site changes and updates</p>
|
<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">
|
<li class="h-entry post-card">
|
||||||
<div class="post-header">
|
<div class="post-header">
|
||||||
<h3 class="text-xl font-semibold">
|
<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>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-surface-600 dark:text-surface-400 mt-2">Content digest</p>
|
<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">
|
<li class="h-entry post-card">
|
||||||
<div class="post-header">
|
<div class="post-header">
|
||||||
<h3 class="text-xl font-semibold">
|
<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>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-surface-600 dark:text-surface-400 mt-2">Featured posts</p>
|
<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">
|
<li class="h-entry post-card">
|
||||||
<div class="post-header">
|
<div class="post-header">
|
||||||
<h3 class="text-xl font-semibold">
|
<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>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-surface-600 dark:text-surface-400 mt-2">Content graph visualization</p>
|
<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">
|
<li class="h-entry post-card">
|
||||||
<div class="post-header">
|
<div class="post-header">
|
||||||
<h3 class="text-xl font-semibold">
|
<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>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-surface-600 dark:text-surface-400 mt-2">Social interactions (likes, reposts, replies)</p>
|
<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">
|
<li class="h-entry post-card">
|
||||||
<div class="post-header">
|
<div class="post-header">
|
||||||
<h3 class="text-xl font-semibold">
|
<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>
|
<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">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>
|
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-surface-600 dark:text-surface-400 mt-2">Read later queue</p>
|
<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">
|
<li class="h-entry post-card">
|
||||||
<div class="post-header">
|
<div class="post-header">
|
||||||
<h3 class="text-xl font-semibold">
|
<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>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-surface-600 dark:text-surface-400 mt-2">Full-text search</p>
|
<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">
|
<li class="h-entry post-card">
|
||||||
<div class="post-header">
|
<div class="post-header">
|
||||||
<h3 class="text-xl font-semibold">
|
<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>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-surface-600 dark:text-surface-400 mt-2">Starred GitHub repositories</p>
|
<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">
|
<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>
|
<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">
|
<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.
|
for inspiration on pages like <code>/now</code>, <code>/uses</code>, <code>/colophon</code>, <code>/blogroll</code>, and more.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -70,13 +70,13 @@ export default {
|
|||||||
typography: (theme) => ({
|
typography: (theme) => ({
|
||||||
DEFAULT: {
|
DEFAULT: {
|
||||||
css: {
|
css: {
|
||||||
"--tw-prose-links": theme("colors.accent.700"),
|
"--tw-prose-links": theme("colors.accent.600"),
|
||||||
maxWidth: "none",
|
maxWidth: "none",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
invert: {
|
invert: {
|
||||||
css: {
|
css: {
|
||||||
"--tw-prose-links": theme("colors.accent.300"),
|
"--tw-prose-links": theme("colors.accent.400"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ pagefindIgnore: true
|
|||||||
{% if allMentions.length > 0 or legacyUrls.length > 0 %}
|
{% if allMentions.length > 0 or legacyUrls.length > 0 %}
|
||||||
<tr class="hover:bg-surface-50 dark:hover:bg-surface-800/50">
|
<tr class="hover:bg-surface-50 dark:hover:bg-surface-800/50">
|
||||||
<td class="p-2">
|
<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 }}
|
{{ post.url }}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user