Files
indiekit-blog/CLAUDE.md
svemagie 139e4b608b docs: update CLAUDE.md for Gitea migration
- Sharp restore path corrected to src/build/Release/ (0.33.x)
- GITEA_URL / GITEA_ORG added to deploy env var reference
- Document store-github → Gitea config (trailing slash gotcha, GH_CONTENT_TOKEN alias)
- Document custom gitea_runner rc service and Micropub dispatch patch
- Document Gitea sidebar widget and changelog migration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 12:59:22 +02:00

13 KiB

CLAUDE.md — svemagie/blog

This is svemagie's personal IndieWeb blog at blog.giersig.eu. Stack: Eleventy 3 · Tailwind CSS 3 · Alpine.js · IndieKit (Micropub) · Self hosting.

The theme lives in a separate repo (svemagie/blog-eleventy-indiekit, tracked as the theme remote). This repo is the live site and has diverged significantly from that upstream — treat them as related but independent.


Architecture

Core files

  • eleventy.config.js — monolithic config: all plugins, filters, shortcodes, collections, passthrough copies
  • _data/site.js — all site config driven by env vars; no hardcoded personal values in source
  • _data/*.js — individual data files for feeds (GitHub, Bluesky, Mastodon, Last.fm, etc.)
  • _includes/layouts/ — page layout templates (base.njk, post.njk, etc.)
  • _includes/components/ — reusable Nunjucks partials
  • lib/ — build-time JS utilities (og.js, unfurl-shortcode.js, data-fetch.js, cache-funkwhale-image.js)
  • scripts/ — maintenance scripts (check-upstream-widget-drift.mjs)

Content

content/ is a symlink to IndieKit's managed content directory — it is gitignored but Eleventy processes it via setUseGitIgnore(false). Never edit posts in content/ directly; they are created and updated via IndieKit's Micropub endpoint.

Post types: articles, notes, bookmarks, likes, replies, reposts, photos, pages.

Build output

_site/ — generated site, not committed. Also excluded from Eleventy processing to prevent loops.


Key custom systems

Digital Garden (gardenStage)

Posts carry a gardenStage front-matter value: seedling, budding, cultivating, or evergreen. Stage can also be derived from nested tags (garden/cultivatecultivating, etc.).

Garden badge component (_includes/components/garden-badge.njk):

  • In post-list templates, set gardenStage from post.data.gardenStage before including, or rely on the component's own fallback.
  • The badge is included once per post-type branch ({% if post.type == "article" %}...{% elif %}...{% endif %}). Do not add it outside those branches — it will render for every post regardless of type and produce duplicate badges.

AI Disclosure

Posts declare AI involvement level in front matter (e.g. aiCode: T1/C2). Rendered as a badge below post content and as a hidden .p-ai-code-level span in list cards.

Soft-delete filtering

Posts with deleted: true in frontmatter are excluded from all Eleventy collections by the isPublished() helper in eleventy.config.js. This supports ActivityPub soft-delete — when a post is deleted via the AP admin, it disappears from the blog without removing the file.

Content warnings

Posts with contentWarning or content_warning in frontmatter are handled in two contexts:

  • Single post page (post.njk): Content is wrapped in a collapsible <details> element with an amber warning banner. The user must click to reveal the content.
  • Listing pages (blog.njk): All 7 card types (like, bookmark, repost, reply, photo, article, note) replace content with a warning label + "View post" link. Photo cards also hide the gallery.

Nested tags

Categories use Obsidian-style path notation (lang/de, tech/programming). The nestedSlugify() function in eleventy.config.js preserves / separators during slug generation. Slugification is applied per segment.

Changelog

changelog.njk — public page at /changelog/ showing development activity. Uses Alpine.js to fetch commits from the IndieKit server's GitHub endpoint (/github/api/changelog). Commits are categorised by commit-message prefix (feat: → Features, fix: → Fixes, perf: → Performance, a11y: → Accessibility, docs: → Docs, everything else → Other). The server-side categorisation is applied by the postinstall patch patch-endpoint-github-changelog-categories.mjs in indiekit-blog. Tabs, labels, and colours in changelog.njk must stay in sync with that patch.

Unfurl shortcode

{% unfurl url %} generates a rich link preview card with caching. Cache lives in .cache/unfurl/. The shortcode is registered from lib/unfurl-shortcode.js.

OG image generation

lib/og.js + lib/og-cli.js — generates Open Graph images at build time using Satori and resvg-js. Avatar is pulled from AUTHOR_AVATAR env var.

Funkwhale cover image cache

Funkwhale stores album art on Wasabi S3 with presigned URLs that expire after ~1 hour. Serving those URLs directly causes broken images on the listening page after the first hour.

lib/cache-funkwhale-image.js downloads cover art at build time and serves it from a stable local path:

  • Cache dir: .cache/funkwhale-images/ (gitignored, persisted between CI runs — the self-hosted runner workspace is reused across runs, so the cache survives without any explicit upload/restore step)
  • Public path: /images/funkwhale-cache/<md5-of-url-path>.<ext>
  • Cache key: MD5 of the URL path (stable across re-signings — query params are stripped)
  • No TTL: files are kept forever; gcFunkwhaleImages() deletes any file not referenced by the current build's data

Why eleventy.after, not addPassthroughCopy: passthrough copy runs before the data cascade, so .cache/funkwhale-images/ is empty when Eleventy scans it. The copy to _site/images/funkwhale-cache/ is done explicitly in the eleventy.after hook, after _data/funkwhaleActivity.js has finished downloading.

Edge case: if FUNKWHALE_FETCH_CACHE_DURATION is longer than ~1 hour, a new track appearing between builds will try to download using an expired presigned URL from the EleventyFetch cache, fall back to the original URL, and break after an hour. Keep the duration under 55m to avoid this.

Upstream drift check

npm run check:upstream-widgets         # Report widget drift vs theme remote
npm run check:upstream-widgets:strict  # Exit 1 if any drift found

Templates — things to know

blog.njk

The main blog listing. Each post type (article, note, bookmark, like, repost, reply, photo) has its own {% if/elif %} branch. The AI badge and pagination are outside those branches at the <li> / <nav> level. Garden badge must stay inside each branch.

base.njk

Site-wide layout. Header nav uses Alpine.js for dropdowns (x-data="{ open: false }"). Dashboard link is auth-gated. Mobile nav mirrors desktop nav.

_data/site.js

All values come from env vars. The SITE_SOCIAL env var uses pipe-and-comma encoding: "Name|URL|icon,Name|URL|icon". If not set, social links are auto-derived from feed env vars (GITHUB_USERNAME, BLUESKY_HANDLE, etc.).


Deploy workflow

CI runs on a self-hosted Gitea instance (gitea jail, 10.100.0.90:3000), org giersig.eu, repo indiekit-blog. The runner is act_runner in host mode (label freebsd:host), running as the git user on FreeBSD.

Trigger: push to main, or workflow_dispatch. Workflow file: .github/workflows/deploy.yml.

Steps:

  1. actions/checkout@v4
  2. npm ci
  3. Build or restore sharp for FreeBSD (see below)
  4. Fetch homepage.json from node jail via SSH → content/.indiekit/homepage.json
  5. npm run build:css
  6. Write .env from secrets using printf (no interpolation issues)
  7. npm run build with env vars injected
  8. Rsync _site/ to deploy user on host → /usr/local/bastille/jails/web/root/usr/local/www/blog/
  9. Trigger syndication webhook at http://10.100.0.20:3000/syndicate (internal URL — public URL fails due to hairpin NAT)

Sharp on FreeBSD

There is no prebuilt sharp binary for FreeBSD. The CI step builds from source on first run, then caches the .node binary:

  • Cache location: /usr/local/git/.cache/sharp-freebsd/sharp-freebsd-x64-{VERSION}.node
  • Build: npm install node-addon-api node-gyp && npm install sharp --build-from-source (requires libvips — installed via pkg install vips in the gitea jail)
  • Restore: copies cached binary into node_modules/sharp/src/build/Release/ — skips ~3 min compile (Sharp 0.33.x loads from ../src/build/Release/, not ../build/Release/)

Rsync deploy user

Rsync deploys as deploy user (uid=1002, in www group). The smg user (uid=1001) cannot write to the blog dir directly — it is owned www:www. The runner's SSH key must be in /home/deploy/.ssh/authorized_keys.

BSD shell gotchas in the workflow

The runner shell is POSIX sh (not bash/tcsh). Watch for GNU-only constructs:

  • head -n -1 (skip last line) does not work on BSD — use sed '$d' instead
  • tail -1 works fine (positive count only needed)

Repo names (Gitea)

  • giersig.eu/indiekit-blog — this repo (Eleventy + content). Local dir: indiekit-blog
  • giersig.eu/indiekit-server — IndieKit server (Micropub, AP, etc.). Local dir: indiekit-server

IndieKit store-github → Gitea

store-github is configured to write posts to Gitea instead of GitHub. Key points:

  • GITEA_BASE_URL must end with a trailing slash: http://10.100.0.90:3000/api/v1/ Without it, new URL(apiPath, baseUrl) strips the v1 segment → 404 on all writes.
  • GH_CONTENT_TOKEN must be set in .env (the Gitea PAT) — start.sh rejects startup if neither GH_CONTENT_TOKEN nor GITHUB_TOKEN is present.
  • GITEA_CONTENT_USER = giersig.eu (the org, not a personal username)
  • GITEA_CONTENT_REPO = indiekit-blog

Pushing workflow changes

The server runs tcsh which mangles long echo/printf commands. Use a Python heredoc from the server to push via Gitea API:

python3 << 'PYEOF'
import urllib.request, json, base64
# Read file, get SHA, PUT new content to Gitea contents API
PYEOF

Always generate base64 from the local file with base64 -i file | tr -d '\n' — never copy b64 strings from session history (they can contain corruption).


Common tasks

Add a new Nunjucks filter: Register in eleventy.config.js with eleventyConfig.addFilter(...).

Add a new post type: Create the template page + add a branch in blog.njk + add to _data/enabledPostTypes.js.

Check what's drifted from theme upstream:

npm run check:upstream-widgets

Rebuild CSS only:

npm run build:css

Local dev:

npm run dev   # Eleventy + live reload on localhost:8080

Env vars (quick ref)

See README.md for the full table. Essential ones:

SITE_URL          https://blog.giersig.eu
SITE_NAME         giersig.
AUTHOR_NAME       svemagie
SITE_LOCALE       de
ACTIVITYPUB_HANDLE  svemagie
GITHUB_USERNAME   svemagie
BLUESKY_HANDLE    svemagie

The following are set directly in .github/workflows/deploy.yml (not secrets) because they are internal network addresses or stable infrastructure values:

INDIEKIT_URL        http://10.100.0.20:3000   # node jail — IndieKit API, Funkwhale proxy, etc.
FUNKWHALE_INSTANCE  http://10.100.0.40:5000   # Funkwhale jail
GITEA_URL           https://gitea.giersig.eu  # used by _data/githubActivity.js, githubRepos.js, widget JS
GITEA_ORG           giersig.eu                # Gitea org that owns the repos

What's diverged from upstream (summary)

  • Digital Garden system — gardenStage, badges, /garden/ page, nested garden/* tags
  • AI disclosure — aiCode front matter, badge component, p-ai-code-level
  • Nested tags — Obsidian-style path categories
  • Navigation redesign — curated header nav with Alpine.js dropdowns; footer restructured
  • Fedify ActivityPub — own AP actor at @svemagie@blog.giersig.eu
  • OG image generation — Satori + resvg build-time generation
  • Webmention self-filter — own Bluesky account filtered from interactions
  • Markdown Agents — clean Markdown served to AI crawlers
  • Mermaid diagramseleventy-plugin-mermaid integrated
  • Changelog page — commit-type tabs (feat/fix/perf/a11y/docs) via IndieKit GitHub endpoint
  • Soft-delete filtering — posts with deleted: true excluded from all collections
  • Content-warning support — collapsible content on post pages, hidden content on listings
  • Upstream drift check scriptscripts/check-upstream-widget-drift.mjs
  • Self-hosted Gitea CI — replaced GitHub Actions; act_runner on FreeBSD (custom gitea_runner rc service, su - git + nohup), sharp built from source with persistent binary cache (src/build/Release/), rsync via deploy user, syndication webhook via internal jail URL
  • Gitea content store@indiekit/store-github pointed at http://10.100.0.90:3000/api/v1/ (trailing slash required for new URL() resolution); GH_CONTENT_TOKEN in .env satisfies start.sh preflight; GITEA_CONTENT_USER=giersig.eu, GITEA_BASE_URL in IndieKit .env
  • Micropub → Gitea dispatchpatch-micropub-gitea-dispatch.mjs fires workflow_dispatch after each Micropub create/update (Gitea Contents API commits do not trigger on: push)
  • Gitea sidebar widgetgithub-repos.njk renamed to "Gitea"; runtime JS fetches commits/repos/PRs directly from gitea.giersig.eu API; build-time data via _data/githubActivity.js + _data/githubRepos.js (both use Gitea org API). Widget configured via site.gitea.{url,org,repos} in _data/site.js
  • Changelog → Gitea/changelog page fetches commits directly from Gitea API (both repos), with client-side commit categorisation (feat/fix/docs/chore/refactor); no longer depends on IndieKit's GitHub proxy endpoint