diff --git a/README.md b/README.md index 68ad8fa3..16766c91 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ Posts are converted from Indiekit's JF2 format to ActivityStreams 2.0 in two mod - Permalink appended to content body - Nested hashtags normalized: `on/art/music` → `#music` (Mastodon doesn't support path-style tags) - Sensitive posts flagged with `sensitive: true`; summary doubles as CW text for notes -- Per-post OG image added to Note/Article objects (`/og/{year}-{month}-{day}-{slug}.png`) for fediverse preview cards +- Per-post OG image added to Note/Article objects (`/og/{slug}.png`) for fediverse preview cards ### Express ↔ Fedify bridge @@ -160,15 +160,21 @@ Posts are converted from Indiekit's JF2 format to ActivityStreams 2.0 in two mod ### AP-specific patches -These patches are applied to `node_modules` via postinstall and at serve startup. They're needed because the lockfile pins the fork to v2.10.1 which predates some fixes, and because some fixes cannot be upstreamed. +These patches are applied to `node_modules` via postinstall and at serve startup. They're needed because some fixes cannot be upstreamed or because they adapt upstream behaviour to this blog's specific URL structure. | Patch | Target | What it does | |---|---|---| | `patch-ap-allow-private-address` | federation-setup.js | Adds `signatureTimeWindow` and `allowPrivateAddress` to `createFederation()` | | `patch-ap-url-lookup-api` | Adds new route | Public `GET /activitypub/api/ap-url` resolves blog URL → AP object URL | +| `patch-ap-og-image` | jf2-to-as2.js | Fixes OG image URL generation — see below | | `patch-federation-unlisted-guards` | endpoint-syndicate | Prevents unlisted posts from being re-syndicated (AP fork has this natively) | | `patch-endpoint-activitypub-locales` | locales | Injects German (`de`) translations for the AP endpoint UI | +**`patch-ap-og-image.mjs`** +The fork (both 842fc5af and 45f8ba9) attempts to derive the OG image path by matching a date-based URL pattern like `/articles/2024/01/15/slug/`. This blog uses flat URLs (`/articles/slug/`) with no date component, so the regex never matches and no `image` property is set on ActivityPub objects — Mastodon and other clients never show a preview card. + +The patch replaces the broken date-from-URL regex with a simple last-path-segment extraction, producing `/og/{slug}.png` — the actual filename the Eleventy build generates (e.g. `/og/2615b.png`). Applied to both `jf2ToActivityStreams()` (plain JSON-LD) and `jf2ToAS2Activity()` (Fedify vocab objects). + ### AP environment variables | Variable | Default | Purpose | diff --git a/scripts/patch-ap-og-image.mjs b/scripts/patch-ap-og-image.mjs new file mode 100644 index 00000000..8874707e --- /dev/null +++ b/scripts/patch-ap-og-image.mjs @@ -0,0 +1,136 @@ +/** + * Patch: fix OG image URL generation in ActivityPub jf2-to-as2.js. + * + * Root cause: + * Both 842fc5af and 45f8ba9 versions of jf2-to-as2.js try to extract the + * post slug from the URL using a regex that expects date-based URLs like + * /articles/2024/01/15/slug/ but this blog uses flat URLs like /articles/slug/. + * The regex never matches so the `image` property is never set — no OG image + * preview card reaches Mastodon or other fediverse servers. + * + * Fix: + * Replace the date-from-URL regex with a simple last-path-segment extraction. + * Constructs /og/{slug}.png — the actual filename pattern the Eleventy build + * generates for static OG preview images (e.g. /og/2615b.png). + * + * Both jf2ToActivityStreams() (plain JSON-LD) and jf2ToAS2Activity() (Fedify + * vocab objects) are patched. Both 842fc5af and 45f8ba9 variants are handled + * so the patch works regardless of which commit npm install resolved. + */ + +import { access, readFile, writeFile } from "node:fs/promises"; + +const candidates = [ + "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/jf2-to-as2.js", + "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/jf2-to-as2.js", +]; + +const MARKER = "// og-image fix"; + +// --------------------------------------------------------------------------- +// Use JS regex patterns to locate the OG image blocks. +// Both 842fc5af and 45f8ba9 share the same variable names (ogMatch / ogMatchF) +// and the same if-block structure, differing only in the URL construction. +// +// Pattern: matches from "const ogMatch[F] = postUrl && postUrl.match(" to the +// closing "}" (2-space indent) of the if block. +// --------------------------------------------------------------------------- +const CN_BLOCK_RE = + / const ogMatch = postUrl && postUrl\.match\([^\n]+\n if \(ogMatch\) \{[\s\S]*?\n \}/; + +const AS2_BLOCK_RE = + / const ogMatchF = postUrl && postUrl\.match\([^\n]+\n if \(ogMatchF\) \{[\s\S]*?\n \}/; + +// --------------------------------------------------------------------------- +// Replacement: extract slug from last URL path segment. +// Build /og/{slug}.png to match the Eleventy OG filenames (e.g. /og/2615b.png). +// +// Template literal note: backslashes inside the injected regex are doubled so +// they survive the template literal → string conversion: +// \\\/ → \/ (escaped slash in regex) +// [\\\w-] → [\w-] (word char class) +// --------------------------------------------------------------------------- +const NEW_CN = ` const ogSlug = postUrl && postUrl.match(/\\/([\\\w-]+)\\/?$/)?.[1]; // og-image fix + if (ogSlug) { // og-image fix + object.image = { + type: "Image", + url: \`\${publicationUrl.replace(/\\/$/, "")}/og/\${ogSlug}.png\`, // og-image fix + mediaType: "image/png", + }; + }`; + +const NEW_AS2 = ` const ogSlugF = postUrl && postUrl.match(/\\/([\\\w-]+)\\/?$/)?.[1]; // og-image fix + if (ogSlugF) { // og-image fix + noteOptions.image = new Image({ + url: new URL(\`\${publicationUrl.replace(/\\/$/, "")}/og/\${ogSlugF}.png\`), // og-image fix + mediaType: "image/png", + }); + }`; + +// --------------------------------------------------------------------------- + +async function exists(filePath) { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +let checked = 0; +let patched = 0; + +for (const filePath of candidates) { + if (!(await exists(filePath))) { + continue; + } + + checked += 1; + const source = await readFile(filePath, "utf8"); + + if (source.includes(MARKER)) { + console.log(`[postinstall] patch-ap-og-image: already applied to ${filePath}`); + continue; + } + + let updated = source; + let changed = false; + + // Fix the jf2ToActivityStreams OG block + if (CN_BLOCK_RE.test(updated)) { + updated = updated.replace(CN_BLOCK_RE, NEW_CN); + changed = true; + } else { + console.warn( + `[postinstall] patch-ap-og-image: jf2ToActivityStreams OG block not found in ${filePath} — skipping`, + ); + } + + // Fix the jf2ToAS2Activity OG block + if (AS2_BLOCK_RE.test(updated)) { + updated = updated.replace(AS2_BLOCK_RE, NEW_AS2); + changed = true; + } else { + console.warn( + `[postinstall] patch-ap-og-image: jf2ToAS2Activity OG block not found in ${filePath} — skipping`, + ); + } + + if (!changed || updated === source) { + console.log(`[postinstall] patch-ap-og-image: no changes applied to ${filePath}`); + continue; + } + + await writeFile(filePath, updated, "utf8"); + patched += 1; + console.log(`[postinstall] Applied patch-ap-og-image to ${filePath}`); +} + +if (checked === 0) { + console.log("[postinstall] patch-ap-og-image: no target files found"); +} else if (patched === 0) { + console.log("[postinstall] patch-ap-og-image: already up to date"); +} else { + console.log(`[postinstall] patch-ap-og-image: patched ${patched}/${checked} file(s)`); +}