A calm place to write long-form, and publish it to the open social web. skypress.blog/
0

Configure Feed

Select the types of activity you want to include in your feed.

Add a thumbnail to the companion Bluesky post embed

The companion app.bsky.feed.post already carried a clickable link facet
and associatedRefs for the rich standard.site card (Decision 0013), but
embed.external.thumb was unset. Clients that don't resolve standard.site
cards — and the default card before the AppView resolves the refs —
therefore showed no image, unlike the reference post (pckt.blog).

Set embed.external.thumb to the first uploaded content image's existing
in-repo blob ref (image/*, size <= 1MB), selected depth-first from the
document's block tree. Omit thumb when there is no usable image; the
card still renders via associatedRefs.

Reuse the existing blob ref rather than re-uploading: atproto blobs are
content-addressed and repo-scoped, so re-uploading identical bytes only
yields the same CID — there is no separate post-owned copy. The publish
flow creates the document first, committing the image blob, before the
post, so the post can reference that same CID and the AppView resolves
the thumb by did + cid. This needs no uploadBlob, no byte-fetch, and so
no SSRF guard.

Researched against pckt.blog (live record) and Leaflet (source): both
upload a fresh, OG-cropped 1.91:1 blob server-side (sharp / screenshot).
SkyPress is a browser client + static renderer with no such pipeline, so
v1 follows the spirit (a real image blob) not the letter (no OG-shaping;
bsky center-crops the native image). Decision 0014 records the rationale
and the follow-ups (canvas OG-crop, cover-image picker, icon fallback).

+355 -2
+3 -2
docs/decisions/0013-bsky-post-link-facet-and-associated-refs.md
··· 55 55 - Publish is now three PDS writes instead of two (publish is not a hot path). 56 56 - Edit (`updateDocument`) is unchanged: it never created a post and still doesn't; the 57 57 original post's facet + refs keep pointing at the stable URL. 58 - - **Thumbnail (`thumb`) is intentionally deferred** — it needs a per-post image blob 59 - upload and is tracked as a follow-up. The standard.site card renders without it. 58 + - **Thumbnail (`thumb`) was deferred here** and is now added in **Decision 0014** (it reuses 59 + the first uploaded content image's in-repo blob ref). The standard.site card renders with 60 + or without it. 60 61 - No SkyPress lexicon change: facets/`associatedRefs` are Bluesky-native fields on 61 62 `app.bsky.feed.post`, not part of the `site.standard.*` schema.
+67
docs/decisions/0014-bsky-post-thumb.md
··· 1 + # 0014 — Thumbnail (`embed.external.thumb`) on the companion Bluesky post 2 + 3 + - **Status:** Accepted 4 + - **Date:** 2026-06-09 5 + - **Scope:** The `app.bsky.feed.post` SkyPress writes alongside each published document 6 + (Decision 0005 publish flow; link facet + `associatedRefs` added in Decision 0013). 7 + 8 + ## Context 9 + 10 + Decision 0013 gave the companion post a clickable link facet and `associatedRefs` for the 11 + rich standard.site card, but left `embed.external.thumb` unset (explicitly deferred). Without 12 + a `thumb`, clients that don't resolve standard.site cards — and the default card before the 13 + AppView resolves the refs — show no image. The reference post (`bsky.app/profile/pckt.blog`) 14 + includes a `thumb` blob. 15 + 16 + ### What the references do (researched against live records + source, 2026-06-09) 17 + 18 + - **pckt.blog**: `thumb` is a **dedicated, OG-shaped blob** (JPEG, 2000×1050, 1.91:1, 805 KB) 19 + — a *different CID* from the document's own `coverImage` (JPEG, 1200×630, 45 KB). The thumb 20 + CID is not present anywhere inside the referenced document. It is a purpose-built share card. 21 + - **Leaflet** (`actions/.../publishBskyPost.ts`): sources a **cover image** (re-fetched from 22 + the PDS by CID) or a **server-rendered page screenshot**, then always `sharp`-resizes to 23 + 1200×630 (`fit: cover`), re-encodes to webp q85, and `uploadBlob`s a **fresh** blob. 24 + 25 + Shared pattern: a fresh, OG-cropped (1.91:1), re-encoded blob from a designated cover image, 26 + with a screenshot fallback — done **server-side** (`sharp` + a screenshot service). 27 + 28 + ## Decision 29 + 30 + Set `embed.external.thumb` to the **first uploaded content image's existing in-repo blob ref** 31 + — `image/*`, `0 < size ≤ 1,000,000` bytes — selected depth-first from the document's block 32 + tree. Omit `thumb` entirely when there is no usable uploaded image; the standard.site card 33 + still renders via `associatedRefs`. 34 + 35 + - `firstImageBlobRef(blocks)` (`src/lib/media/blob.ts`, pure) selects the ref; `BSKY_THUMB_MAX_BYTES` 36 + (1,000,000) encodes the post lexicon's thumb size cap. 37 + - `buildBskyPost` (`src/lib/publish/records.ts`) gains an optional `thumb` field, included only 38 + when provided — `records.ts` stays free of `@atproto/*` (`thumb` is just a typed `BlobRefJson`). 39 + - `publish` (`src/lib/publish/publisher.ts`) computes the thumb from the prepared blocks and 40 + passes it through. `updateDocument` is unchanged — edits never create a post. 41 + 42 + ### Why reuse the existing blob ref, not re-upload 43 + 44 + atproto blobs are **content-addressed and repo-scoped**: re-uploading identical bytes yields 45 + the *same CID*, so there is no separate "post-owned" copy — "ownership" is per-repo-by-CID, not 46 + per-record. The Decision 0013 write order creates the **document first**, committing/retaining 47 + the image blob, *before* the post — so the post can reference that same CID and the AppView 48 + resolves `thumb` by `did + cid` from the PDS. (Leaflet re-uploads only to **OG-reshape**; its 49 + cover blob already lives in the same PDS, confirming ownership is not the constraint.) Reuse 50 + needs no `uploadBlob`, no byte-fetch, and therefore no SSRF guard. 51 + 52 + ## Consequences 53 + 54 + - **Divergence from the references:** the thumb is the image at its native aspect ratio, which 55 + bsky.app center-crops into the ~1.91:1 card frame — no OG-shaping in v1. Acceptable; the card 56 + still gets a real image. 57 + - **External images** (remote URL, no `skypressBlob`) are skipped — making a thumb from one 58 + would need a byte-fetch + the SSRF guard (`src/lib/net/safe-fetch.ts`), deferred. 59 + - No SkyPress lexicon change: `thumb` is a Bluesky-native field on `app.bsky.feed.post`. 60 + - Publish is still three PDS writes (Decision 0013); no extra round-trip for the thumb. 61 + 62 + ## Follow-ups (out of scope) 63 + 64 + - Browser-canvas OG-crop (first image → 1200×630 → webp → fresh `uploadBlob`) for a proper 65 + 1.91:1 card, matching pckt/Leaflet. 66 + - A designated **cover-image** picker (the source the references prefer). 67 + - Publication-`icon` fallback when no content image exists.
+114
docs/superpowers/specs/2026-06-09-bsky-post-thumb-design.md
··· 1 + # Bluesky post embed thumbnail (`embed.external.thumb`) — design 2 + 3 + - **Date:** 2026-06-09 4 + - **Scope:** The companion `app.bsky.feed.post` SkyPress writes alongside each published 5 + document (Decision 0005 publish flow, extended by Decision 0013). 6 + - **Decision doc:** `docs/decisions/0014-bsky-post-thumb.md` (to be written with the code). 7 + 8 + ## Problem 9 + 10 + The companion Bluesky post already carries a clickable link facet + `associatedRefs` for 11 + the rich standard.site card (Decision 0013), but no `embed.external.thumb` blob. Clients 12 + that don't understand standard.site cards (and the default card before the AppView resolves 13 + the refs) show no image. The reference post (`pckt.blog`) includes a `thumb` blob. 14 + 15 + ## What the references actually do (research, 2026-06-09) 16 + 17 + - **pckt.blog**: `embed.external.thumb` is a **dedicated, OG-shaped blob** (JPEG, 2000×1050, 18 + 1.91:1, 805 KB) — a *different CID* from the document's own `coverImage` (JPEG, 1200×630, 19 + 45 KB). The thumb CID appears nowhere inside the referenced document. It is a purpose-built 20 + social-card image. 21 + - **Leaflet** (`actions/.../publishBskyPost.ts`): sources a **cover image** (re-fetched from 22 + the PDS by CID) or, failing that, a **server-rendered page screenshot**, then *always* 23 + `sharp`-resizes to **1200×630 `fit: cover`**, re-encodes to **webp q85**, and 24 + `uploadBlob`s a **fresh** blob — `thumb: blob.data.blob`. 25 + 26 + **Shared pattern:** a fresh, OG-cropped (1.91:1), re-encoded blob from a *designated cover 27 + image*, with a screenshot fallback. Both do this **server-side** (Next.js + `sharp` + 28 + screenshot service). 29 + 30 + ## Why SkyPress diverges (constraints) 31 + 32 + SkyPress is a **browser public client** that publishes from the client, plus a **static 33 + Cloudflare Workers renderer**. It has no server-side image pipeline (`sharp`, screenshots), 34 + no `coverImage` concept on the document yet (Decision 0006 deferred it), and OG-image 35 + generation is explicitly out of scope for v1. Full pckt/Leaflet parity = a cover-image 36 + picker **and** an OG-card pipeline — a separate project. 37 + 38 + Therefore v1 follows the *spirit* (give the card a real image blob), not the letter 39 + (OG-shaped, freshly processed). 40 + 41 + ## Decision (v1) 42 + 43 + Set `embed.external.thumb` to the **first uploaded content image's existing blob ref** — 44 + no re-upload, no OG-shaping. Omit `thumb` entirely when the document has no usable uploaded 45 + image (the card still renders via `associatedRefs`). 46 + 47 + ### Why reuse the existing blob ref (not re-upload) 48 + 49 + atproto blobs are **content-addressed and repo-scoped**: re-uploading identical bytes yields 50 + the *same CID*, so there is no separate "post-owned" copy — "ownership" is per-repo-by-CID, 51 + not per-record. The publish order (Decision 0013) writes the **document first**, which 52 + commits/retains the image blob, *before* the post is created — so the post can reference that 53 + same CID and the AppView resolves `thumb` by `did + cid` from the PDS. (Leaflet re-uploads 54 + only to **OG-reshape**, not for ownership; its cover blob already lives in the same PDS.) 55 + 56 + The single divergence from the references: the thumb is the image at its native aspect ratio, 57 + which bsky.app center-crops into the ~1.91:1 card frame. Acceptable for v1. 58 + 59 + ## Components 60 + 61 + ### 1. `src/lib/media/blob.ts` — `firstImageBlobRef` (pure, new) 62 + 63 + ```ts 64 + /** bsky `app.bsky.embed.external` thumb constraint: image/*, ≤ 1,000,000 bytes. */ 65 + export const BSKY_THUMB_MAX_BYTES = 1_000_000; 66 + 67 + /** 68 + * The blob ref to use as a companion Bluesky post's `embed.external.thumb`: the first 69 + * uploaded `core/image` block's `skypressBlob` (depth-first, innerBlocks included) that the 70 + * post lexicon will accept (`image/*`, 0 < size ≤ 1 MB). Returns undefined when there is no 71 + * usable uploaded image. Reuses `IMAGE_BLOCKS` + `normalizeBlobRefJson`. 72 + */ 73 + export function firstImageBlobRef( blocks: BlockNode[] ): BlobRefJson | undefined; 74 + ``` 75 + 76 + - Walks the tree depth-first. For each `core/image` block, normalises `attributes.skypressBlob` 77 + via `normalizeBlobRefJson` (defensive: handles both JSON and `BlobRef` shapes; drops refs 78 + with no CID). Accepts it only if `mimeType` starts with `image/` **and** 79 + `0 < size ≤ BSKY_THUMB_MAX_BYTES`. Returns the first acceptable ref. 80 + - External images (no `skypressBlob`) and non-image blocks are skipped — making a thumb from a 81 + remote URL would need byte-fetch + the SSRF guard, deferred. 82 + 83 + ### 2. `src/lib/publish/records.ts` — typed `thumb` field (stays pure) 84 + 85 + - `BskyPostRecord.embed.external.thumb?: BlobRefJson`. 86 + - `buildBskyPost` input gains `thumb?: BlobRefJson`; the field is included in `external` only 87 + when provided (same conditional-spread style as `associatedRefs`). No `@atproto/*` import. 88 + 89 + ### 3. `src/lib/publish/publisher.ts` — wiring (no `uploadBlob`) 90 + 91 + - In `publish()`: `const thumb = firstImageBlobRef( input.blocks );` then pass `thumb` to 92 + `buildBskyPost`. `input.blocks` is already `attachBlobRefs`-prepared by `PublishPanel`, so 93 + `skypressBlob` is present on session-uploaded images. 94 + - `updateDocument()` unchanged — edits never create a post. 95 + 96 + ## Testing (TDD — failing test first) 97 + 98 + - **`blob.test.ts` / `firstImageBlobRef`:** first `core/image` ref returned; recurses into 99 + `innerBlocks`; picks the *first* usable when several; skips images without `skypressBlob`; 100 + skips non-image blocks; skips `size > 1 MB`; skips non-`image/*` mimeType; `undefined` when 101 + none usable. 102 + - **`records.test.ts` / `buildBskyPost`:** includes `embed.external.thumb` when a ref is 103 + passed; omits the `thumb` key otherwise. 104 + - **`publisher.test.ts` / `publish`:** with blocks containing a usable uploaded image, the 105 + created post's `embed.external.thumb` equals that image's blob ref; with no usable image, the 106 + created post has no `thumb` key. 107 + 108 + ## Out of scope / follow-ups 109 + 110 + - Browser-canvas OG-crop (draw first image → 1200×630 → webp → fresh `uploadBlob`) for a 111 + proper 1.91:1 card. 112 + - A designated **cover-image** picker (the source pckt/Leaflet prefer). 113 + - Publication-`icon` fallback when no content image exists. 114 + - No SkyPress lexicon change: `thumb` is a Bluesky-native field on `app.bsky.feed.post`.
+75
src/lib/media/blob.test.ts
··· 4 4 attachBlobRefs, 5 5 resolveBlobImageUrls, 6 6 normalizeBlobRefJson, 7 + firstImageBlobRef, 8 + BSKY_THUMB_MAX_BYTES, 7 9 type BlobRefJson, 8 10 } from './blob'; 9 11 import type { BlockNode } from '../blocks/render'; ··· 102 104 attachBlobRefs( blocks, lookup ); 103 105 expect( 'skypressBlob' in ( blocks[ 0 ].attributes ?? {} ) ).toBe( false ); 104 106 expect( blocks[ 0 ].attributes?.url ).toBe( 'blob:preview-1' ); 107 + } ); 108 + } ); 109 + 110 + describe( 'firstImageBlobRef', () => { 111 + const img = ( skypressBlob?: unknown, extra: Record< string, unknown > = {} ): BlockNode => ( { 112 + name: 'core/image', 113 + attributes: { ...( skypressBlob ? { skypressBlob } : {} ), ...extra }, 114 + innerBlocks: [], 115 + } ); 116 + 117 + it( 'returns the first uploaded core/image ref (depth-first, into innerBlocks)', () => { 118 + const blocks: BlockNode[] = [ 119 + { name: 'core/paragraph', attributes: { content: 'Intro' }, innerBlocks: [] }, 120 + { 121 + name: 'core/gallery', 122 + attributes: {}, 123 + innerBlocks: [ img( REF ) ], 124 + }, 125 + ]; 126 + expect( firstImageBlobRef( blocks ) ).toEqual( REF ); 127 + } ); 128 + 129 + it( 'picks the first usable image when several are present', () => { 130 + const second: BlobRefJson = { 131 + $type: 'blob', 132 + ref: { $link: 'bafysecond' }, 133 + mimeType: 'image/jpeg', 134 + size: 100, 135 + }; 136 + expect( firstImageBlobRef( [ img( REF ), img( second ) ] ) ).toEqual( REF ); 137 + } ); 138 + 139 + it( 'skips external images (no skypressBlob) and non-image blocks', () => { 140 + const blocks: BlockNode[] = [ 141 + { name: 'core/heading', attributes: { content: 'Title' }, innerBlocks: [] }, 142 + img( undefined, { url: 'https://example.com/cat.jpg' } ), 143 + ]; 144 + expect( firstImageBlobRef( blocks ) ).toBeUndefined(); 145 + } ); 146 + 147 + it( 'skips images whose blob exceeds the bsky thumb size limit', () => { 148 + const tooBig: BlobRefJson = { 149 + $type: 'blob', 150 + ref: { $link: 'bafytoobig' }, 151 + mimeType: 'image/png', 152 + size: BSKY_THUMB_MAX_BYTES + 1, 153 + }; 154 + const ok: BlobRefJson = { 155 + $type: 'blob', 156 + ref: { $link: 'bafyok' }, 157 + mimeType: 'image/png', 158 + size: BSKY_THUMB_MAX_BYTES, 159 + }; 160 + expect( firstImageBlobRef( [ img( tooBig ) ] ) ).toBeUndefined(); 161 + // The oversized one is skipped, the in-limit one after it is picked. 162 + expect( firstImageBlobRef( [ img( tooBig ), img( ok ) ] ) ).toEqual( ok ); 163 + } ); 164 + 165 + it( 'skips non-image mime types', () => { 166 + const pdf: BlobRefJson = { 167 + $type: 'blob', 168 + ref: { $link: 'bafypdf' }, 169 + mimeType: 'application/pdf', 170 + size: 10, 171 + }; 172 + expect( firstImageBlobRef( [ img( pdf ) ] ) ).toBeUndefined(); 173 + } ); 174 + 175 + it( 'returns undefined when there are no usable images', () => { 176 + expect( firstImageBlobRef( [] ) ).toBeUndefined(); 177 + expect( 178 + firstImageBlobRef( [ { name: 'core/paragraph', attributes: {}, innerBlocks: [] } ] ) 179 + ).toBeUndefined(); 105 180 } ); 106 181 } ); 107 182
+39
src/lib/media/blob.ts
··· 25 25 const IMAGE_BLOCKS = new Set( [ 'core/image' ] ); 26 26 27 27 /** 28 + * The `app.bsky.embed.external` thumb blob constraint: `image/*`, ≤ 1,000,000 bytes. A blob 29 + * larger than this is rejected by the post lexicon, so we skip it rather than fail the publish. 30 + */ 31 + export const BSKY_THUMB_MAX_BYTES = 1_000_000; 32 + 33 + /** 34 + * The blob ref to use as a companion Bluesky post's `embed.external.thumb` (Decision 0014): 35 + * the first uploaded `core/image` block's `skypressBlob` (depth-first, `innerBlocks` included) 36 + * that the post lexicon will accept — `image/*` and `0 < size ≤ BSKY_THUMB_MAX_BYTES`. 37 + * 38 + * Returns undefined when the document has no usable uploaded image; the caller then omits 39 + * `thumb` (the standard.site card still renders via `associatedRefs`). External images (no 40 + * `skypressBlob`) are skipped — turning a remote URL into a thumb would need a byte-fetch + 41 + * the SSRF guard, deferred. Reuses the in-repo blob ref (no re-upload): atproto blobs are 42 + * content-addressed and repo-scoped, and the publish flow commits the document (which 43 + * references this blob) before the post, so the post can reference the same CID. 44 + */ 45 + export function firstImageBlobRef( blocks: BlockNode[] ): BlobRefJson | undefined { 46 + for ( const block of blocks ) { 47 + if ( IMAGE_BLOCKS.has( block.name ) ) { 48 + const ref = normalizeBlobRefJson( block.attributes?.skypressBlob ); 49 + if ( 50 + ref && 51 + ref.mimeType.startsWith( 'image/' ) && 52 + ref.size > 0 && 53 + ref.size <= BSKY_THUMB_MAX_BYTES 54 + ) { 55 + return ref; 56 + } 57 + } 58 + const nested = firstImageBlobRef( block.innerBlocks ?? [] ); 59 + if ( nested ) { 60 + return nested; 61 + } 62 + } 63 + return undefined; 64 + } 65 + 66 + /** 28 67 * Reader-side (SP4): rewrite each blob-backed image's `url` to a fresh `getBlob` URL 29 68 * built from the article author's current PDS + DID and the stored CID. This is what 30 69 * makes images portable — the stored `url` host may be stale after a PDS migration, but
+32
src/lib/publish/publisher.test.ts
··· 87 87 expect( created.some( ( c ) => c.collection === 'site.standard.publication' ) ).toBe( false ); 88 88 } ); 89 89 90 + it( 'sets embed.external.thumb to the first uploaded image blob (Decision 0014)', async () => { 91 + const thumb = { 92 + $type: 'blob', 93 + ref: { $link: 'bafyimg' }, 94 + mimeType: 'image/jpeg', 95 + size: 5000, 96 + }; 97 + const blocksWithImage: BlockNode[] = [ 98 + { name: 'core/paragraph', attributes: { content: 'Intro' }, innerBlocks: [] }, 99 + { name: 'core/image', attributes: { url: 'getblob://x', skypressBlob: thumb }, innerBlocks: [] }, 100 + ]; 101 + const { agent, created } = mockAgent(); 102 + await publish( 103 + agent, 104 + { did: DID, handle: HANDLE }, 105 + { title: 'Hello', blocks: blocksWithImage, ...TARGET } 106 + ); 107 + const post = created.find( ( c ) => c.collection === 'app.bsky.feed.post' )!.record as { 108 + embed: { external: { thumb?: unknown } }; 109 + }; 110 + expect( post.embed.external.thumb ).toEqual( thumb ); 111 + } ); 112 + 113 + it( 'omits thumb when no usable image is present', async () => { 114 + const { agent, created } = mockAgent(); 115 + await publish( agent, { did: DID, handle: HANDLE }, { title: 'Hello', blocks: BLOCKS, ...TARGET } ); 116 + const post = created.find( ( c ) => c.collection === 'app.bsky.feed.post' )!.record as { 117 + embed: { external: Record< string, unknown > }; 118 + }; 119 + expect( 'thumb' in post.embed.external ).toBe( false ); 120 + } ); 121 + 90 122 it( 'writes the document first so the post can embed its strongRef (standard.site card)', async () => { 91 123 const { agent, created, put } = mockAgent(); 92 124 await publish( agent, { did: DID, handle: HANDLE }, { title: 'Hello', blocks: BLOCKS, ...TARGET } );
+3
src/lib/publish/publisher.ts
··· 7 7 type StrongRef, 8 8 } from './records'; 9 9 import { listPublications } from './publications'; 10 + import { firstImageBlobRef } from '../media/blob'; 10 11 import { blocksToText, type BlockNode } from '../blocks/render'; 11 12 12 13 const DOCUMENT_COLLECTION = 'site.standard.document'; ··· 105 106 articleUrl, 106 107 description: input.description, 107 108 createdAt: now, 109 + // Reuse the first uploaded image's in-repo blob as the card thumb (Decision 0014). 110 + thumb: firstImageBlobRef( input.blocks ), 108 111 associatedRefs: [ documentRef, publicationRef ], 109 112 } ) 110 113 ),
+17
src/lib/publish/records.test.ts
··· 303 303 } ); 304 304 expect( 'associatedRefs' in post.embed.external ).toBe( false ); 305 305 } ); 306 + 307 + it( 'includes embed.external.thumb only when a blob ref is provided (Decision 0014)', () => { 308 + const base = { 309 + title: 'Hello, World!', 310 + articleUrl: ARTICLE_URL, 311 + createdAt: '2026-06-08T12:00:00.000Z', 312 + }; 313 + expect( 'thumb' in buildBskyPost( base ).embed.external ).toBe( false ); 314 + 315 + const thumb: BlobRefJson = { 316 + $type: 'blob', 317 + ref: { $link: 'bafythumb' }, 318 + mimeType: 'image/png', 319 + size: 4242, 320 + }; 321 + expect( buildBskyPost( { ...base, thumb } ).embed.external.thumb ).toEqual( thumb ); 322 + } ); 306 323 } );
+5
src/lib/publish/records.ts
··· 240 240 uri: string; 241 241 title: string; 242 242 description: string; 243 + /** An image blob (≤1MB, image/*) shown on the link card — the og:image fallback (Decision 0014). */ 244 + thumb?: BlobRefJson; 243 245 /** strongRefs to the document + publication; drives the standard.site link card. */ 244 246 associatedRefs?: AssociatedRef[]; 245 247 }; ··· 261 263 articleUrl: string; 262 264 createdAt: string; 263 265 description?: string; 266 + /** Optional image blob for the card's `thumb` (the og:image fallback, Decision 0014). */ 267 + thumb?: BlobRefJson; 264 268 /** Document + publication strongRefs, in that order, for the standard.site card. */ 265 269 associatedRefs?: StrongRef[]; 266 270 } ): BskyPostRecord { ··· 285 289 uri: input.articleUrl, 286 290 title: input.title, 287 291 description: input.description ?? '', 292 + ...( input.thumb ? { thumb: input.thumb } : {} ), 288 293 ...( input.associatedRefs && input.associatedRefs.length 289 294 ? { 290 295 associatedRefs: input.associatedRefs.map( ( ref ) => ( {