From 67f1f2802e856eb48d8a073aefea0a16cccd6ede Mon Sep 17 00:00:00 2001 From: Hugo Date: Fri, 15 May 2026 12:16:09 +0200 Subject: [PATCH] fix: better og images --- app/islands/action-editors/bsky-post.tsx | 2 ++ app/islands/action-editors/calendar-rsvp.tsx | 8 +++++--- app/islands/action-editors/follow.tsx | 1 + .../action-editors/margin-bookmark.tsx | 2 ++ app/islands/action-editors/patch-record.tsx | 1 + app/islands/action-editors/record.tsx | 1 + app/islands/action-editors/semble-save.tsx | 2 ++ app/islands/action-editors/types.ts | 5 +++++ app/routes/og/u/[handle]/[rkey].ts | 11 +++++++---- lib/actions/bsky-post.ts | 5 ++--- lib/actions/calendar-rsvp.ts | 16 ++++++++++------ lib/actions/margin-bookmark.ts | 5 ++--- lib/actions/semble-save.ts | 5 ++--- lib/actions/target-collections.ts | 13 +++++++++++++ lib/lexicons/nsid.ts | 11 +++++++++++ lib/lexicons/resolver.test.ts | 19 +++++++++++++++++++ lib/lexicons/resolver.ts | 2 +- lib/og/automation.ts | 6 +++--- 18 files changed, 89 insertions(+), 26 deletions(-) create mode 100644 lib/actions/target-collections.ts diff --git a/app/islands/action-editors/bsky-post.tsx b/app/islands/action-editors/bsky-post.tsx index 5ff5049..7fe383e 100644 --- a/app/islands/action-editors/bsky-post.tsx +++ b/app/islands/action-editors/bsky-post.tsx @@ -1,3 +1,4 @@ +import { BSKY_POST_COLLECTION } from "../../../lib/actions/target-collections.js"; import type { BskyPostAction } from "../../../lib/db/schema.js"; import { MessageSquare } from "../../icons.ts"; import { CodeBlock } from "../../components/CodeBlock/index.tsx"; @@ -196,4 +197,5 @@ export const bskyPostUiDefinition: ActionUIDefinition "bsky.app", + getTargetNsid: () => BSKY_POST_COLLECTION, }; diff --git a/app/islands/action-editors/calendar-rsvp.tsx b/app/islands/action-editors/calendar-rsvp.tsx index 0a76407..273553e 100644 --- a/app/islands/action-editors/calendar-rsvp.tsx +++ b/app/islands/action-editors/calendar-rsvp.tsx @@ -1,3 +1,4 @@ +import { CALENDAR_RSVP_COLLECTION } from "../../../lib/actions/target-collections.js"; import type { CalendarRsvpAction } from "../../../lib/db/schema.js"; import { CalendarCheck } from "../../icons.ts"; import { InlineCode } from "../../components/CodeBlock/index.tsx"; @@ -73,8 +74,8 @@ function CalendarRsvpActionEditor({ - Writes a community.lexicon.calendar.rsvp record. Skips automatically - if the event record can't be fetched or you've already RSVP'd to it. + Writes a {CALENDAR_RSVP_COLLECTION} record. Skips automatically if + the event record can't be fetched or you've already RSVP'd to it. @@ -92,7 +93,7 @@ function CalendarRsvpDisplayBlock({ action }: { action: CalendarRsvpAction }) {
{STATUS_LABELS[action.status]}
Collection
- community.lexicon.calendar.rsvp + {CALENDAR_RSVP_COLLECTION}
); @@ -132,4 +133,5 @@ export const calendarRsvpUiDefinition: ActionUIDefinition "lexicon.community", + getTargetNsid: () => CALENDAR_RSVP_COLLECTION, }; diff --git a/app/islands/action-editors/follow.tsx b/app/islands/action-editors/follow.tsx index 8f27cf0..87196e0 100644 --- a/app/islands/action-editors/follow.tsx +++ b/app/islands/action-editors/follow.tsx @@ -91,4 +91,5 @@ export const followUiDefinition: ActionUIDefinition = EditorBlock: FollowActionEditor, DisplayBlock: FollowDisplayBlock, getFaviconDomain: (action) => FOLLOW_TARGETS[action.target].faviconDomain, + getTargetNsid: (action) => FOLLOW_TARGETS[action.target].collection, }; diff --git a/app/islands/action-editors/margin-bookmark.tsx b/app/islands/action-editors/margin-bookmark.tsx index f0ebb50..505ad28 100644 --- a/app/islands/action-editors/margin-bookmark.tsx +++ b/app/islands/action-editors/margin-bookmark.tsx @@ -1,3 +1,4 @@ +import { MARGIN_BOOKMARK_COLLECTION } from "../../../lib/actions/target-collections.js"; import type { MarginBookmarkAction } from "../../../lib/db/schema.js"; import { Bookmark } from "../../icons.ts"; import { CodeBlock, InlineCode } from "../../components/CodeBlock/index.tsx"; @@ -159,4 +160,5 @@ export const marginBookmarkUiDefinition: ActionUIDefinition< EditorBlock: MarginBookmarkActionEditor, DisplayBlock: MarginBookmarkDisplayBlock, getFaviconDomain: () => "margin.at", + getTargetNsid: () => MARGIN_BOOKMARK_COLLECTION, }; diff --git a/app/islands/action-editors/patch-record.tsx b/app/islands/action-editors/patch-record.tsx index e3bb621..764c0d0 100644 --- a/app/islands/action-editors/patch-record.tsx +++ b/app/islands/action-editors/patch-record.tsx @@ -180,4 +180,5 @@ export const patchRecordUiDefinition: ActionUIDefinition nsidToDomain(action.targetCollection), + getTargetNsid: (action) => action.targetCollection, }; diff --git a/app/islands/action-editors/record.tsx b/app/islands/action-editors/record.tsx index 94ab222..1289e77 100644 --- a/app/islands/action-editors/record.tsx +++ b/app/islands/action-editors/record.tsx @@ -144,4 +144,5 @@ export const recordUiDefinition: ActionUIDefinition = EditorBlock: RecordActionEditor, DisplayBlock: RecordDisplayBlock, getFaviconDomain: (action) => nsidToDomain(action.targetCollection), + getTargetNsid: (action) => action.targetCollection, }; diff --git a/app/islands/action-editors/semble-save.tsx b/app/islands/action-editors/semble-save.tsx index 7047d52..dfa56eb 100644 --- a/app/islands/action-editors/semble-save.tsx +++ b/app/islands/action-editors/semble-save.tsx @@ -1,3 +1,4 @@ +import { SEMBLE_SAVE_COLLECTION } from "../../../lib/actions/target-collections.js"; import type { SembleSaveAction } from "../../../lib/db/schema.js"; import { BookmarkPlus } from "../../icons.ts"; import { InlineCode } from "../../components/CodeBlock/index.tsx"; @@ -73,4 +74,5 @@ export const sembleSaveUiDefinition: ActionUIDefinition "semble.so", + getTargetNsid: () => SEMBLE_SAVE_COLLECTION, }; diff --git a/app/islands/action-editors/types.ts b/app/islands/action-editors/types.ts index ef3484a..3105727 100644 --- a/app/islands/action-editors/types.ts +++ b/app/islands/action-editors/types.ts @@ -107,4 +107,9 @@ export type ActionUIDefinition< * Returning null hides the favicon (the caller already gates on whether * the action is record-producing). */ getFaviconDomain?: (action: TAction) => string | null; + /** NSID of the record this action writes to (e.g. `bsky-post` → + * `app.bsky.feed.post`; `record.targetCollection` for the generic record + * action). Used by the OG image renderer for the target label. Should be + * set on every record-producing action; webhook returns null. */ + getTargetNsid?: (action: TAction) => string | null; }; diff --git a/app/routes/og/u/[handle]/[rkey].ts b/app/routes/og/u/[handle]/[rkey].ts index 4966dc9..926a7f5 100644 --- a/app/routes/og/u/[handle]/[rkey].ts +++ b/app/routes/og/u/[handle]/[rkey].ts @@ -3,6 +3,7 @@ import { and, eq } from "drizzle-orm"; import { resolveHandle } from "@/auth/client.js"; import { db } from "@/db/index.js"; import { automations, users } from "@/db/schema.js"; +import { nsidNamePart } from "@/lexicons/nsid.js"; import { renderAutomationOg } from "@/og/automation.js"; import { ACTION_UI_REGISTRY } from "../../../../islands/action-editors/registry.js"; @@ -36,16 +37,18 @@ export const GET = createRoute(async (c) => { if (!auto) return fallbackRedirect(); // Collect up to 3 distinct targets, matching the LexiconFlow component at - // app/components/LexiconFlow/index.tsx:10. For record actions we use the - // target NSID as the label; webhooks fall through to their URL hostname. + // app/components/LexiconFlow/index.tsx:10. Label is the NSID name part + // (e.g. "calendar.rsvp") for record-producing actions; webhooks fall back + // to their callback hostname. const seen = new Set(); const targets: Array<{ domain: string; label: string }> = []; for (const a of auto.actions) { const def = ACTION_UI_REGISTRY[a.$type]; let domain = def?.getFaviconDomain?.(a) ?? null; + const nsid = def?.getTargetNsid?.(a) ?? null; let label: string; - if (domain) { - label = a.$type === "record" || a.$type === "patch-record" ? a.targetCollection : domain; + if (nsid && domain) { + label = nsidNamePart(nsid); } else if (a.$type === "webhook") { try { const host = new URL(a.callbackUrl).hostname; diff --git a/lib/actions/bsky-post.ts b/lib/actions/bsky-post.ts index bdec27d..ad9cab8 100644 --- a/lib/actions/bsky-post.ts +++ b/lib/actions/bsky-post.ts @@ -8,10 +8,9 @@ import { AUTOMATION_LIMITS } from "../automations/limits.js"; import { BCP47_RE, VALID_BSKY_LABELS } from "./validation.js"; import { fetchURLMetadata, fetchExternalImage } from "../url-metadata.js"; import type { ActionDefinition } from "./registry.js"; +import { BSKY_POST_COLLECTION } from "./target-collections.js"; import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js"; -const TARGET_COLLECTION = "app.bsky.feed.post"; - async function execute( match: MatchedEvent, action: BskyPostAction, @@ -64,7 +63,7 @@ async function execute( } try { - const created = await createArbitraryRecord(automation.did, TARGET_COLLECTION, record); + const created = await createArbitraryRecord(automation.did, BSKY_POST_COLLECTION, record); return { statusCode: 200, uri: created.uri, cid: created.cid }; } catch (err) { return parsePdsError(err); diff --git a/lib/actions/calendar-rsvp.ts b/lib/actions/calendar-rsvp.ts index 71a25ec..8f9a315 100644 --- a/lib/actions/calendar-rsvp.ts +++ b/lib/actions/calendar-rsvp.ts @@ -6,9 +6,9 @@ import { renderTextTemplate, validateTextTemplate, type FetchContext } from "./t import { SKIP_STATUS, parsePdsError, wrapWithDelivery, type ActionResult } from "./delivery.js"; import type { MatchedEvent } from "../jetstream/consumer.js"; import type { ActionDefinition } from "./registry.js"; +import { CALENDAR_RSVP_COLLECTION } from "./target-collections.js"; import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js"; -const RSVP_COLLECTION = "community.lexicon.calendar.rsvp"; const VALID_STATUSES = new Set(["going", "interested"] as const); const SUBJECT_MAX = 2048; // Mirrors lib/pds/resolver.ts:32 — kept inline so we surface a 400 with a @@ -39,7 +39,7 @@ async function checkNotAlreadyRsvpd( kind: "search", name: "__rsvp_preflight_already_rsvpd", repo: match.automation.did, - collection: RSVP_COLLECTION, + collection: CALENDAR_RSVP_COLLECTION, where: [{ field: "subject.uri", operator: "eq", value: eventUri }], limit: 1, }; @@ -109,11 +109,11 @@ async function execute( const record: Record = { subject: { uri: subject, cid }, - status: `${RSVP_COLLECTION}#${action.status}`, + status: `${CALENDAR_RSVP_COLLECTION}#${action.status}`, }; try { - const created = await createArbitraryRecord(automation.did, RSVP_COLLECTION, record); + const created = await createArbitraryRecord(automation.did, CALENDAR_RSVP_COLLECTION, record); return { statusCode: 200, uri: created.uri, cid: created.cid }; } catch (err) { return parsePdsError(err); @@ -126,7 +126,7 @@ export const executeCalendarRsvp = wrapWithDelivery( execute, (action) => JSON.stringify({ - collection: RSVP_COLLECTION, + collection: CALENDAR_RSVP_COLLECTION, subject: action.subject, status: action.status, }), @@ -212,7 +212,11 @@ async function dryRunDescribe( ).trim(); return { message: `Would RSVP "${statusLabel(action.status)}" to ${subject || "(empty)"} (will skip if the record can't be fetched or already RSVP'd)${ctx.itemSuffix}`, - payload: JSON.stringify({ collection: RSVP_COLLECTION, status: action.status, subject }), + payload: JSON.stringify({ + collection: CALENDAR_RSVP_COLLECTION, + status: action.status, + subject, + }), error: null, }; } catch (err) { diff --git a/lib/actions/margin-bookmark.ts b/lib/actions/margin-bookmark.ts index e3d6d1a..1b9402d 100644 --- a/lib/actions/margin-bookmark.ts +++ b/lib/actions/margin-bookmark.ts @@ -8,10 +8,9 @@ import { fetchURLMetadata } from "../url-metadata.js"; import { config } from "../config.js"; import { MARGIN_BOOKMARK_LIMITS } from "../automations/limits.js"; import type { ActionDefinition } from "./registry.js"; +import { MARGIN_BOOKMARK_COLLECTION } from "./target-collections.js"; import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js"; -const TARGET_COLLECTION = "at.margin.note"; - /** Normalize a URL for hashing: lowercase protocol+host, preserve path/query/hash. */ export function normalizeUrlForHash(raw: string): string { const url = new URL(raw.trim()); @@ -119,7 +118,7 @@ async function execute( } try { - const created = await createArbitraryRecord(automation.did, TARGET_COLLECTION, record); + const created = await createArbitraryRecord(automation.did, MARGIN_BOOKMARK_COLLECTION, record); return { statusCode: 200, uri: created.uri, cid: created.cid }; } catch (err) { return parsePdsError(err); diff --git a/lib/actions/semble-save.ts b/lib/actions/semble-save.ts index a8f8dce..a6f3432 100644 --- a/lib/actions/semble-save.ts +++ b/lib/actions/semble-save.ts @@ -6,10 +6,9 @@ import type { MatchedEvent } from "../jetstream/consumer.js"; import { fetchURLMetadata, type UrlMetadata } from "../url-metadata.js"; import { SEMBLE_SAVE_LIMITS } from "../automations/limits.js"; import type { ActionDefinition } from "./registry.js"; +import { SEMBLE_SAVE_COLLECTION } from "./target-collections.js"; import type { DryRunContext, DryRunDescription, ValidationContext } from "./types.js"; -const TARGET_COLLECTION = "network.cosmik.card"; - // Allow either a literal http(s):// prefix or a leading {{...}} placeholder // (so the entire URL can come from event/action data). A literal non-http // scheme like `javascript:` is rejected here so it can't be persisted to the @@ -82,7 +81,7 @@ async function execute( } try { - const created = await createArbitraryRecord(automation.did, TARGET_COLLECTION, record); + const created = await createArbitraryRecord(automation.did, SEMBLE_SAVE_COLLECTION, record); return { statusCode: 200, uri: created.uri, cid: created.cid }; } catch (err) { return parsePdsError(err); diff --git a/lib/actions/target-collections.ts b/lib/actions/target-collections.ts new file mode 100644 index 0000000..72cd119 --- /dev/null +++ b/lib/actions/target-collections.ts @@ -0,0 +1,13 @@ +// NSIDs of the records each statically-targeted action writes to. Shared so +// the server execute path and the UI/OG renderer can't drift. Actions whose +// target is part of the action data (`record`, `patch-record` via +// `targetCollection`; `follow` via `FOLLOW_TARGETS`) read their NSID from +// the action itself and don't appear here. +// +// Pure-data module: no imports, so it's safe to consume from both server +// and client/island code. + +export const BSKY_POST_COLLECTION = "app.bsky.feed.post"; +export const MARGIN_BOOKMARK_COLLECTION = "at.margin.note"; +export const SEMBLE_SAVE_COLLECTION = "network.cosmik.card"; +export const CALENDAR_RSVP_COLLECTION = "community.lexicon.calendar.rsvp"; diff --git a/lib/lexicons/nsid.ts b/lib/lexicons/nsid.ts index 13213eb..bcdeb34 100644 --- a/lib/lexicons/nsid.ts +++ b/lib/lexicons/nsid.ts @@ -23,3 +23,14 @@ export function nsidToDomain(nsid: string): string { const parts = nsid.split("."); return parts.slice(0, 2).reverse().join("."); } + +/** + * NSID name part — everything after the authority (first two) segments. + * e.g. "community.lexicon.calendar.event" -> "calendar.event". + * Falls back to the full NSID for inputs with fewer than 3 segments. + */ +export function nsidNamePart(nsid: string): string { + const parts = nsid.split("."); + if (parts.length < 3) return nsid; + return parts.slice(2).join("."); +} diff --git a/lib/lexicons/resolver.test.ts b/lib/lexicons/resolver.test.ts index 1d7ce42..85f59ac 100644 --- a/lib/lexicons/resolver.test.ts +++ b/lib/lexicons/resolver.test.ts @@ -7,6 +7,7 @@ vi.mock("./cache.js", () => ({ import { isValidNsid, + nsidNamePart, nsidToAuthority, isNsidAllowed, nsidRequiresWantedDids, @@ -53,6 +54,24 @@ describe("nsidToAuthority", () => { }); }); +describe("nsidNamePart", () => { + it("drops the authority and returns the trailing name (3-segment NSID)", () => { + expect(nsidNamePart("app.bsky.feed")).toBe("feed"); + }); + + it("preserves multi-segment names (4-segment NSID)", () => { + expect(nsidNamePart("community.lexicon.calendar.event")).toBe("calendar.event"); + }); + + it("preserves camelCase segments", () => { + expect(nsidNamePart("site.exosphere.featureRequest.status")).toBe("featureRequest.status"); + }); + + it("returns the input unchanged when fewer than 3 segments", () => { + expect(nsidNamePart("app.bsky")).toBe("app.bsky"); + }); +}); + describe("isNsidAllowed", () => { it("allows everything when both lists are empty", () => { expect(isNsidAllowed("app.bsky.feed.like", [], [])).toBe(true); diff --git a/lib/lexicons/resolver.ts b/lib/lexicons/resolver.ts index 1009738..68a657b 100644 --- a/lib/lexicons/resolver.ts +++ b/lib/lexicons/resolver.ts @@ -4,7 +4,7 @@ import { resolveTxt } from "node:dns/promises"; import { resolveDid } from "../pds/resolver.js"; import { getCachedRaw, setCacheRaw } from "./cache.js"; import { nsidToAuthority } from "./nsid.js"; -export { isValidNsid, nsidToAuthority, nsidToDomain } from "./nsid.js"; +export { isValidNsid, nsidNamePart, nsidToAuthority, nsidToDomain } from "./nsid.js"; export type LexiconField = { path: string; diff --git a/lib/og/automation.ts b/lib/og/automation.ts index f217094..115aaa6 100644 --- a/lib/og/automation.ts +++ b/lib/og/automation.ts @@ -10,7 +10,7 @@ import { resolve } from "node:path"; import satori from "satori"; import { Resvg } from "@resvg/resvg-js"; import { getFavicon } from "../favicon.js"; -import { nsidToDomain } from "../lexicons/nsid.js"; +import { nsidNamePart, nsidToDomain } from "../lexicons/nsid.js"; import { STATIC_FAVICONS, STATIC_FAVICON_CONTENT_TYPES } from "../static-favicons.js"; // satori can't introspect ICO dimensions from a data URI and emits @@ -338,7 +338,7 @@ export async function renderAutomationOg(input: OgInput): Promise { letterSpacing: "-0.01em", }, }, - truncate(input.lexicon, 36), + truncate(nsidNamePart(input.lexicon), 36), ), ); @@ -430,6 +430,6 @@ export async function renderAutomationOg(input: OgInput): Promise { ], }); - const png = new Resvg(svg, { fitTo: { mode: "width", value: 1200 } }).render().asPng(); + const png = new Resvg(svg, { fitTo: { mode: "width", value: 2400 } }).render().asPng(); return new Uint8Array(png); } -- 2.51.2