Merge pull request #1 from svemagie/claude/fix-activitypub-og-image-CrCGI

fix(ap): fix OG image not included in ActivityPub activities
This commit is contained in:
svemagie
2026-03-21 21:17:13 +01:00
committed by GitHub
2 changed files with 144 additions and 2 deletions

View File

@@ -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 |

View File

@@ -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)`);
}