Files
indiekit-blog/_includes/layouts/post.njk
svemagie c7d000f4c5 feat: add per-post interactions section before comments
Shows inbound webmentions (likes, reposts, replies, mentions) in card
style between the post and comments section. Hidden when no interactions.
Fetches from both webmentions and conversations APIs with deduplication.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 11:03:57 +01:00

324 lines
22 KiB
Plaintext

---
layout: layouts/base.njk
withBlogSidebar: true
---
{# AI metadata compatibility: support nested ai object plus legacy top-level keys #}
{% set aiMeta = ai or {} %}
{% set aiTextLevel = aiTextLevel or ai_text_level or aiMeta.textLevel or aiMeta.aiTextLevel or "0" %}
{% set aiCodeLevel = aiCodeLevel or ai_code_level or aiMeta.codeLevel or aiMeta.aiCodeLevel or "0" %}
{% set aiTools = aiTools or ai_tools or aiMeta.aiTools or aiMeta.tools %}
{% set aiDescription = aiDescription or ai_description or aiMeta.aiDescription or aiMeta.description %}
{% set aiUsed = (aiTextLevel and aiTextLevel !== "0") or (aiCodeLevel and aiCodeLevel !== "0") %}
<article class="h-entry post{% if aiUsed %} h-ai-usage{% endif %}" x-data="lightbox" @keydown.window="onKeydown($event)" data-ai-text-level="{{ aiTextLevel }}" data-ai-code-level="{{ aiCodeLevel or '0' }}" data-ai-used="{% if aiUsed %}true{% else %}false{% endif %}"{% if aiTools %} data-ai-tools="{{ aiTools }}"{% endif %}>
{# Support both camelCase (Indiekit Eleventy preset) and underscore (legacy) property names #}
{% set bookmarkedUrl = bookmarkOf or bookmark_of %}
{% set likedUrl = likeOf or like_of %}
{% set replyTo = inReplyTo or in_reply_to %}
{% set repostedUrl = repostOf or repost_of %}
{% if title and not repostedUrl %}
<h1 class="p-name text-2xl sm:text-3xl font-bold text-surface-900 dark:text-surface-100 mb-3 sm:mb-4">{{ title }}</h1>
{% else %}
<div class="flex items-center gap-2 mb-1">
<span class="text-sm font-medium text-surface-600 dark:text-surface-400">
{% if replyTo %}&#8617; Reply{% elif likedUrl %}&#9829; Like{% elif repostedUrl %}&#9851; Repost{% elif bookmarkedUrl %}&#128278; Bookmark{% else %}&#9998; Note{% endif %}
</span>
</div>
{% endif %}
<div class="post-meta mb-4 sm:mb-6">
<time-difference><time class="dt-published font-mono text-sm" datetime="{{ date.toISOString() }}">
{{ date | dateDisplay }}
</time></time-difference>
{% if category %}
<ul class="post-categories flex flex-wrap gap-2 list-none p-0 m-0" role="list" aria-label="Categories">
{# Handle both string and array categories #}
{% if category is string %}
<li><a href="/categories/{{ category | slugify }}/" class="p-category">{{ category }}</a></li>
{% else %}
{% for cat in category %}
<li><a href="/categories/{{ cat | slugify }}/" class="p-category">{{ cat }}</a></li>
{% endfor %}
{% endif %}
</ul>
{% endif %}
</div>
{# Bridgy syndication content - controls what gets posted to social networks #}
{# For interaction types (bookmarks, likes, replies, reposts), include the target URL #}
{% set bridgySummary = description or summary or (content | ogDescription(280)) %}
{% set interactionUrl = bookmarkedUrl or likedUrl or replyTo or repostedUrl %}
{% if bridgySummary or interactionUrl %}
<p class="p-summary e-bridgy-mastodon-content e-bridgy-bluesky-content hidden">{% if bookmarkedUrl %}🔖 {{ bookmarkedUrl }}{% if bridgySummary %} - {{ bridgySummary }}{% endif %}{% elif likedUrl %}❤️ {{ likedUrl }}{% if bridgySummary %} - {{ bridgySummary }}{% endif %}{% elif replyTo %}↩️ {{ replyTo }}{% if bridgySummary %} - {{ bridgySummary }}{% endif %}{% elif repostedUrl %}🔁 {{ repostedUrl }}{% if bridgySummary %} - {{ bridgySummary }}{% endif %}{% else %}{{ bridgySummary }}{% endif %}</p>
{% endif %}
{# Render photo(s) from frontmatter for photo posts - use eleventy:ignore to skip image transform #}
{% if photo %}
<div class="photo-gallery mb-6">
{% for img in photo %}
{% set photoUrl = img.url %}
{% if photoUrl and photoUrl[0] != '/' and 'http' not in photoUrl %}
{% set photoUrl = '/' + photoUrl %}
{% endif %}
<img src="{{ photoUrl }}" alt="{{ img.alt | default('Photo from: ' + title) }}" class="u-photo rounded-lg max-w-full" loading="lazy" eleventy:ignore>
{% endfor %}
</div>
{% endif %}
{% set isInteraction = replyTo or likedUrl or repostedUrl or bookmarkedUrl %}
{% set hasContent = content and content | striptags | trim %}
<div class="e-content prose prose-surface dark:prose-invert max-w-none{% if isInteraction and hasContent %} border-l-[3px] border-l-accent-500 dark:border-l-accent-400 pl-4{% endif %}">
{{ content | safe }}
</div>
{# Rich reply context with h-cite microformat #}
{% include "components/reply-context.njk" %}
{# AI usage disclosure — articles and notes only #}
{% if '/articles/' in page.url or '/notes/' in page.url %}
<details class="mt-4 text-xs text-surface-600 dark:text-surface-400">
<summary class="cursor-pointer hover:text-surface-600 dark:hover:text-surface-300 list-none flex items-center gap-1.5 [&::-webkit-details-marker]:hidden">
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714a2.25 2.25 0 00.659 1.591L19 14.5M14.25 3.104c.251.023.501.05.75.082M19 14.5l-2.47 2.47a2.25 2.25 0 01-1.59.659H9.06a2.25 2.25 0 01-1.591-.659L5 14.5m14 0V17a2 2 0 01-2 2H7a2 2 0 01-2-2v-2.5"/>
</svg>
<span>AI:
Text {% if aiTextLevel === "0" %}None{% elif aiTextLevel === "1" %}Editorial{% elif aiTextLevel === "2" %}Co-drafted{% elif aiTextLevel === "3" %}AI-generated{% endif %}{% if aiCodeLevel %} · Code {% if aiCodeLevel === "0" %}Human{% elif aiCodeLevel === "1" %}AI-assisted{% elif aiCodeLevel === "2" %}AI-generated{% endif %}{% endif %}{% if aiTools %} · {{ aiTools }}{% endif %}
</span>
</summary>
{% if aiDescription %}
<p class="mt-1 pl-5">{{ aiDescription }}</p>
{% endif %}
<p class="mt-1 pl-5"><a href="/ai/" class="hover:text-accent-600 dark:hover:text-accent-400 underline">Learn more about AI usage on this site</a></p>
<div class="hidden">
<span class="p-ai-text-level">{{ aiTextLevel }}</span>
<span class="p-ai-code-level">{{ aiCodeLevel or "0" }}</span>
{% if aiTools %}
<span class="p-ai-tools">{{ aiTools }}</span>
{% endif %}
{% if aiDescription %}
<span class="e-ai-description">{{ aiDescription }}</span>
{% endif %}
</div>
</details>
{% endif %}
{# Pending syndication targets (for services like IndieNews that require u-syndication before webmention) #}
{% if mpSyndicateTo %}
<div class="hidden">
{% for url in mpSyndicateTo %}
{% if "news.indieweb.org" in url %}
<a href="{{ url }}" class="u-syndication" rel="syndication">IndieNews</a>
{% endif %}
{% endfor %}
</div>
{% endif %}
{# Syndication Footer - shows where this post was also published #}
{# Separate self-hosted AP URLs from external syndication targets #}
{% set externalSyndication = [] %}
{% set selfHostedApUrl = "" %}
{% if syndication %}
{% for url in syndication %}
{% if url.indexOf(site.url) == 0 %}
{% set selfHostedApUrl = url %}
{% else %}
{% set externalSyndication = (externalSyndication.push(url), externalSyndication) %}
{% endif %}
{% endfor %}
{% endif %}
{% if externalSyndication.length or selfHostedApUrl %}
<footer class="post-footer mt-8 pt-6 border-t border-surface-200 dark:border-surface-700">
<div class="flex flex-wrap items-center gap-4">
<span class="text-sm text-surface-600 dark:text-surface-400">Also on:</span>
<div class="flex flex-wrap gap-3">
{# Fediverse remote interaction button (self-hosted ActivityPub) #}
{% if selfHostedApUrl %}
<span x-data="fediverseInteract('{{ selfHostedApUrl }}', 'interact')" class="inline-flex">
<a class="u-syndication inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-[#a730b8]/10 text-[#a730b8] hover:bg-[#a730b8]/20 transition-colors text-sm font-medium cursor-pointer"
href="{{ selfHostedApUrl }}"
rel="syndication"
title="Interact from your fediverse instance (Shift+click to change)"
@click="handleClick($event)">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path 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"/><path d="M10.91 4.43L0 10.73v2.51l8.74-5.03v10.09l2.18 1.28V4.43zM6.56 12L2.18 14.51l4.35 2.51V12z"/></svg>
<span>Fediverse</span>
</a>
{% set modalTitle = "Fediverse Interaction" %}
{% set modalDescription = "Choose your instance to like, boost, or reply." %}
{% include "components/fediverse-modal.njk" %}
</span>
{% endif %}
{# External syndication buttons #}
{% for url in externalSyndication %}
{% if "bsky.app" in url or "bluesky" in url %}
<a class="u-syndication inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-[#0085ff]/10 text-[#0085ff] hover:bg-[#0085ff]/20 transition-colors text-sm font-medium" href="{{ url }}" target="_blank" rel="noopener syndication" title="View on Bluesky">
<svg class="w-4 h-4" viewBox="0 0 568 501" fill="currentColor" aria-hidden="true">
<path 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"/>
</svg>
<span>Bluesky</span>
</a>
{% elif site.feeds.mastodon.instance and site.feeds.mastodon.instance in url %}
<a class="u-syndication inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-[#6364ff]/10 text-[#6364ff] hover:bg-[#6364ff]/20 transition-colors text-sm font-medium" href="{{ url }}" target="_blank" rel="noopener syndication" title="View on Mastodon">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path 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"/>
</svg>
<span>Mastodon</span>
</a>
{% elif "linkedin.com" in url %}
<a class="u-syndication inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-[#0a66c2]/10 text-[#0a66c2] hover:bg-[#0a66c2]/20 transition-colors text-sm font-medium" href="{{ url }}" target="_blank" rel="noopener syndication" title="View on LinkedIn">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
<span>LinkedIn</span>
</a>
{% elif "news.indieweb.org" in url %}
<a class="u-syndication inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-[#ff5c00]/10 text-[#ff5c00] hover:bg-[#ff5c00]/20 transition-colors text-sm font-medium" href="{{ url }}" target="_blank" rel="noopener syndication" title="View on IndieNews">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2"/><line x1="10" y1="6" x2="18" y2="6"/><line x1="10" y1="10" x2="18" y2="10"/><line x1="10" y1="14" x2="14" y2="14"/>
</svg>
<span>IndieNews</span>
</a>
{% elif "/@" in url %}
{# Mastodon-compatible instance (any URL with /@username pattern) #}
{% set mastoHandle = url | replace("https://", "") | replace("http://", "") %}
{% set mastoHandle = mastoHandle.split("/")[0] + "/" + mastoHandle.split("/")[1] %}
<a class="u-syndication inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-[#6364ff]/10 text-[#6364ff] hover:bg-[#6364ff]/20 transition-colors text-sm font-medium" href="{{ url }}" target="_blank" rel="noopener syndication" title="View on Mastodon">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path 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"/>
</svg>
<span>{{ mastoHandle }}</span>
</a>
{% else %}
<a class="u-syndication inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-300 hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors text-sm font-medium" href="{{ url }}" target="_blank" rel="noopener syndication">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
<span>{{ url | replace("https://", "") | truncate(20) }}</span>
</a>
{% endif %}
{% endfor %}
</div>
</div>
</footer>
{% endif %}
<a class="u-url" href="{{ page.url }}" hidden>Permalink</a>
{# Author h-card for IndieWeb authorship #}
<span class="p-author h-card hidden">
<a class="p-name u-url" href="{{ site.author.url }}">{{ site.author.name }}</a>
<img class="u-photo" src="{{ site.author.avatar }}" alt="{{ site.author.name }}" hidden>
</span>
{# Pagefind filter metadata — hidden elements for search filtering #}
<div hidden>
{% if replyTo %}<span data-pagefind-filter="type">Reply</span>
{% elif likedUrl %}<span data-pagefind-filter="type">Like</span>
{% elif repostedUrl %}<span data-pagefind-filter="type">Repost</span>
{% elif bookmarkedUrl %}<span data-pagefind-filter="type">Bookmark</span>
{% elif photo and photo.length %}<span data-pagefind-filter="type">Photo</span>
{% elif title %}<span data-pagefind-filter="type">Article</span>
{% else %}<span data-pagefind-filter="type">Note</span>
{% endif %}
<span data-pagefind-filter="year">{{ date | date("yyyy") }}</span>
<span data-pagefind-filter="ai-text-level">{{ aiTextLevel }}</span>
<span data-pagefind-filter="ai-code-level">{{ aiCodeLevel or "0" }}</span>
<span data-pagefind-filter="ai-used">{% if aiUsed %}yes{% else %}no{% endif %}</span>
{% if category %}
{% if category is string %}
<span data-pagefind-filter="category">{{ category }}</span>
{% else %}
{% for cat in category %}
<span data-pagefind-filter="category">{{ cat }}</span>
{% endfor %}
{% endif %}
{% endif %}
</div>
{# JSON-LD Structured Data for SEO #}
{# Handle photo as potentially an array #}
{% set postImage = photo %}
{% if postImage %}
{# If photo is an array, use first element (check if first element looks like a URL) #}
{% if postImage[0] and (postImage[0] | length) > 10 %}
{% set postImage = postImage[0] %}
{% endif %}
{% endif %}
{% if not postImage or postImage == "" %}
{% set postImage = image or (content | extractFirstImage) %}
{% endif %}
{% set postDesc = description | default(content | ogDescription(160)) %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Article",
"headline": {{ (title or "Untitled") | dump | safe }},
"url": "{{ site.url }}{{ page.url }}",
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "{{ site.url }}{{ page.url }}"
},
"datePublished": "{{ date.toISOString() }}",
"dateModified": "{{ date.toISOString() }}",
"author": {
"@type": "Person",
"name": "{{ site.author.name }}",
"url": "{{ site.author.url }}"
},
"publisher": {
"@type": "Organization",
"name": "{{ site.name }}",
"url": "{{ site.url }}",
"logo": {
"@type": "ImageObject",
"url": "{{ site.url }}/images/og-default.png"
}
},
"description": {{ postDesc | dump | safe }}{% if postImage and postImage != "" and (postImage | length) > 10 %},
"image": ["{% if postImage.startsWith('http') %}{{ postImage }}{% elif '/' in postImage and postImage[0] == '/' %}{{ site.url }}{{ postImage }}{% else %}{{ site.url }}/{{ postImage }}{% endif %}"]{% endif %},
"usageInfo": "{{ site.url }}/ai"{% set _aiParts = [] %}{% if aiTextLevel %}{% set _textLabel %}{% if aiTextLevel === "0" %}None{% elif aiTextLevel === "1" %}Editorial assistance{% elif aiTextLevel === "2" %}Co-drafting{% elif aiTextLevel === "3" %}AI-generated{% endif %}{% endset %}{% set _aiParts = (_aiParts.push("Text: " + _textLabel), _aiParts) %}{% endif %}{% if aiCodeLevel %}{% set _codeLabel %}{% if aiCodeLevel === "0" %}Human-written{% elif aiCodeLevel === "1" %}AI-assisted{% elif aiCodeLevel === "2" %}AI-generated{% endif %}{% endset %}{% set _aiParts = (_aiParts.push("Code: " + _codeLabel), _aiParts) %}{% endif %}{% if aiTools %}{% set _aiParts = (_aiParts.push("Tools: " + aiTools), _aiParts) %}{% endif %},
"creativeWorkStatus": "{{ _aiParts | join(', ') }}",
"additionalProperty": [
{ "@type": "PropertyValue", "name": "aiTextLevel", "value": "{{ aiTextLevel }}" },
{ "@type": "PropertyValue", "name": "aiCodeLevel", "value": "{{ aiCodeLevel or '0' }}" }{% if aiTools %},
{ "@type": "PropertyValue", "name": "aiTools", "value": {{ aiTools | dump | safe }} }{% endif %}
]{% if aiDescription %},
"abstract": {{ aiDescription | dump | safe }}{% endif %}
}
</script>
{# Lightbox overlay for article images #}
<template x-teleport="body">
<div x-show="open" x-transition.opacity.duration.200ms
role="dialog" aria-modal="true" aria-label="Image viewer"
class="fixed inset-0 z-[9999] flex items-center justify-center bg-black/90 backdrop-blur-sm"
@click.self="close()">
<button x-ref="closeBtn" @click="close()" class="absolute top-4 right-4 text-white/70 hover:text-white text-3xl leading-none p-2 z-10" aria-label="Close lightbox">&times;</button>
<template x-if="images.length > 1">
<button @click="prev()" class="absolute left-4 top-1/2 -translate-y-1/2 text-white/70 hover:text-white text-4xl leading-none p-2 z-10" aria-label="Previous">&lsaquo;</button>
</template>
<template x-if="images.length > 1">
<button @click="next()" class="absolute right-4 top-1/2 -translate-y-1/2 text-white/70 hover:text-white text-4xl leading-none p-2 z-10" aria-label="Next">&rsaquo;</button>
</template>
<img :src="src" :alt="alt" class="max-h-[90vh] max-w-[90vw] object-contain" @click.stop>
<div x-show="alt" x-text="alt" class="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/80 text-sm max-w-2xl text-center px-4 py-2 bg-black/50 rounded-lg"></div>
</div>
</template>
</article>
{# Interactions — inbound webmentions for this post, hidden when none #}
{% include "components/post-interactions.njk" %}
{# Comments section #}
{% include "components/comments.njk" %}
{# Webmentions display - likes, reposts, replies #}
{% include "components/webmentions.njk" %}
{# Post Navigation - Previous/Next #}
{% include "components/post-navigation.njk" %}