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 link facet + standard.site card refs to companion Bluesky post

The companion app.bsky.feed.post created on publish (Decision 0005)
was missing two things versus a reference standard.site post: the
article URL in the post text wasn't clickable, and the link card was
bare with no standard.site enrichment.

buildBskyPost now always emits an app.bsky.richtext.facet#link over
the URL's UTF-8 byte range (offsets computed with TextEncoder, so a
multibyte title shifts them correctly) and embeds associatedRefs —
the document and publication strongRefs, in that order — which the
Bluesky AppView resolves to render the rich card.

Embedding the document's strongRef means the post needs the document's
cid before it's written, while the document still keeps bskyPostRef
back at the post. To satisfy that mutual reference, publish is now
three writes: create document (no bskyPostRef) -> create post (facet +
associatedRefs) -> putRecord document to add bskyPostRef. Step 3
re-cids the document, leaving the post's document ref one version
stale; this is harmless because the AppView resolves associatedRefs by
URI, the same way standard.site itself behaves after an edit.

Publication gains a cid field (captured from list/create/putRecord
responses) threaded through PublishPanel as publicationCid. See
Decision 0013 for the full rationale; thumbnail enrichment is
deferred as a follow-up.

+275 -20
+5
docs/decisions/0005-lexicon-and-publish-model.md
··· 43 43 pre-chosen rkey (content + textContent + bskyPostRef)`. One document write, no follow-up 44 44 update. 45 45 46 + > **Superseded by Decision 0013.** To embed the standard.site link card, the post must 47 + > carry the document's strongRef, so the order is now `create document (no bskyPostRef) → 48 + > create post (facet + associatedRefs) → putRecord document (add bskyPostRef)`. Two 49 + > document writes; see 0013 for why the resulting stale ref is harmless. 50 + 46 51 ## The SkyPress content lexicon — `blog.skypress.content.gutenberg` 47 52 48 53 Goes inside the document's open `content` union (brief §3). The block tree is canonical
+61
docs/decisions/0013-bsky-post-link-facet-and-associated-refs.md
··· 1 + # 0013 — Clickable link + standard.site card in 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) 7 + 8 + ## Context 9 + 10 + Publishing creates a companion `app.bsky.feed.post` whose `embed` is an 11 + `app.bsky.embed.external` card pointing at the article (Decision 0005). Two things were 12 + missing versus a reference standard.site post (`bsky.app/profile/pckt.blog`): 13 + 14 + 1. **The article URL in the post text was not clickable.** Bluesky renders URLs in `text` 15 + as plain text unless the record carries a richtext **facet** 16 + (`app.bsky.richtext.facet#link`) marking the URL's byte range. 17 + 2. **The link card was bare — no standard.site enrichment.** The richer card (publication 18 + source, theme, reading time, associated profiles) is driven by 19 + `embed.external.associatedRefs`: an array of `com.atproto.repo.strongRef` pointing at 20 + the `site.standard.document` and `site.standard.publication` records. The Bluesky 21 + AppView resolves those to render the enhanced card. We emitted none. 22 + 23 + ## Decision 24 + 25 + `buildBskyPost` (`src/lib/publish/records.ts`) now always emits a **link facet** over the 26 + article URL, and embeds **`associatedRefs: [documentRef, publicationRef]`** when those 27 + strongRefs are available. 28 + 29 + - **Facet offsets are UTF-8 byte ranges**, not JS string indices. The URL is the trailing 30 + segment of `text` (`"<title>\n\n<url>"`), so `byteStart = utf8len("<title>\n\n")` and 31 + `byteEnd = byteStart + utf8len(url)`. Computed with `TextEncoder` — a multibyte title 32 + (emoji) shifts the offsets correctly. `records.ts` stays pure (no `@atproto/*`). 33 + - **`associatedRefs` order is `[document, publication]`**, matching the reference post. 34 + 35 + ### Record ordering: three writes 36 + 37 + The post needs the document's strongRef (uri + **cid**), and the document keeps a 38 + `bskyPostRef` back at the post (Decision 0008, for unpublish / cascade-delete). That's a 39 + mutual reference. `publish` (`src/lib/publish/publisher.ts`) now writes in this order: 40 + 41 + 1. **Create the document** (no `bskyPostRef`) → yields its strongRef. 42 + 2. **Create the post** with the link facet + `associatedRefs` to the document & publication. 43 + 3. **`putRecord` the document** to add `bskyPostRef`. 44 + 45 + Step 3 re-cids the document, leaving the post's document ref one version stale. **This is 46 + harmless**: the AppView resolves `associatedRefs` by URI (verified against the reference 47 + post, whose own document ref is already stale after an edit yet still renders the rich 48 + card). Keeping `bskyPostRef` preserves the existing unpublish/cascade-delete paths 49 + unchanged. 50 + 51 + ## Consequences 52 + 53 + - `PublishInput` gains `publicationCid`; `Publication` gains `cid` (captured from 54 + `listRecords`/`createRecord`/`putRecord` responses), threaded from `PublishPanel`. 55 + - Publish is now three PDS writes instead of two (publish is not a hot path). 56 + - Edit (`updateDocument`) is unchanged: it never created a post and still doesn't; the 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. 60 + - No SkyPress lexicon change: facets/`associatedRefs` are Bluesky-native fields on 61 + `app.bsky.feed.post`, not part of the `site.standard.*` schema.
+4 -3
docs/specs/sp2-lexicon-and-publish.md
··· 16 16 2. **Pure record builders** produce valid `site.standard.publication`, 17 17 `site.standard.document` (with the Gutenberg content object + `textContent`), and 18 18 `app.bsky.feed.post` records — unit-tested. 19 - 3. **Publisher** orchestrates via the `Agent`: ensure-publication (list/reuse or create), 20 - create post, create document with `bskyPostRef`. Returns the document URI, post URI, 21 - and canonical article URL. 19 + 3. **Publisher** orchestrates via the `Agent`: pick the target publication, create the 20 + document, create the post (clickable link facet + standard.site `associatedRefs`), then 21 + `putRecord` the document with `bskyPostRef` (order/why: Decision 0013). Returns the 22 + document URI, post URI, and canonical article URL. 22 23 4. **Write scope**: the dev loopback client requests `atproto transition:generic` so 23 24 `createRecord` is authorized (one re-auth). 24 25 5. **Publish UI**: title field + publish action that **unmistakably states it also posts
+3 -1
src/components/PublicationForm.test.tsx
··· 52 52 it( 'pre-selects the matching preset when editing a themed publication', () => { 53 53 const existing: Publication = { 54 54 uri: 'at://x', 55 + cid: 'bafyx', 55 56 rkey: 'r', 56 57 slug: 'blog', 57 58 name: 'Blog', ··· 65 66 // It must remain visible + selected so an unrelated edit doesn't silently erase it. 66 67 const existing: Publication = { 67 68 uri: 'at://x', 69 + cid: 'bafyx', 68 70 rkey: 'r', 69 71 slug: 'blog', 70 72 name: 'Blog', ··· 83 85 } ); 84 86 85 87 it( 'shows no "Current" option for an unthemed publication', () => { 86 - const existing: Publication = { uri: 'at://x', rkey: 'r', slug: 'blog', name: 'Blog' }; 88 + const existing: Publication = { uri: 'at://x', cid: 'bafyx', rkey: 'r', slug: 'blog', name: 'Blog' }; 87 89 const markup = renderForm( existing ); 88 90 expect( radios( markup ) ).toHaveLength( THEME_PRESETS.length + 1 ); 89 91 expect( checkedValue( markup ) ).toBe( '' );
+8 -2
src/components/PublishPanel.tsx
··· 126 126 } ); 127 127 setResultUrl( res.articleUrl ); 128 128 } else { 129 + // Non-editing: the target is always a full Publication picked from `pubs`. 130 + const pub = pubs.find( ( candidate ) => candidate.uri === targetUri ); 131 + if ( ! pub ) { 132 + return; 133 + } 129 134 const res = await publish( agent, identity, { 130 135 title: title.trim(), 131 136 blocks: prepared, 132 - publicationUri: target.uri, 133 - publicationSlug: target.slug, 137 + publicationUri: pub.uri, 138 + publicationCid: pub.cid, 139 + publicationSlug: pub.slug, 134 140 } ); 135 141 setResultUrl( res.articleUrl ); 136 142 }
+2
src/lib/publish/publications.test.ts
··· 196 196 const { agent, put } = mockAgent(); 197 197 const existing = { 198 198 uri: `at://${ DID }/site.standard.publication/a`, 199 + cid: 'bafy-a', 199 200 rkey: 'a', 200 201 slug: 'my-blog', 201 202 name: 'My Blog', ··· 217 218 const { agent, put } = mockAgent(); 218 219 const existing = { 219 220 uri: `at://${ DID }/site.standard.publication/a`, 221 + cid: 'bafy-a', 220 222 rkey: 'a', 221 223 slug: 'my-blog', 222 224 name: 'My Blog',
+7 -1
src/lib/publish/publications.ts
··· 35 35 /** A SkyPress publication, resolved to the shape the dashboard + editor consume. */ 36 36 export interface Publication { 37 37 uri: string; 38 + /** Current record cid — needed for the post's `associatedRefs` strongRef (Decision 0013). */ 39 + cid: string; 38 40 rkey: string; 39 41 slug: string; 40 42 name: string; ··· 54 56 /** Map a raw repo record to a Publication, or null if it isn't a usable SkyPress publication. */ 55 57 function toPublication( record: { 56 58 uri: string; 59 + cid: string; 57 60 value: unknown; 58 61 } ): Publication | null { 59 62 const value = record.value as Partial< PublicationRecord > | undefined; ··· 70 73 const basicTheme = parseBasicTheme( value.basicTheme ); 71 74 return { 72 75 uri: record.uri, 76 + cid: record.cid, 73 77 rkey: rkeyFromUri( record.uri ), 74 78 slug, 75 79 name: value.name ?? slug, ··· 125 129 } ); 126 130 return { 127 131 uri: res.data.uri, 132 + cid: res.data.cid, 128 133 rkey, 129 134 slug, 130 135 name: record.name, ··· 153 158 icon: input.icon, 154 159 basicTheme: input.basicTheme, 155 160 } ); 156 - await agent.com.atproto.repo.putRecord( { 161 + const res = await agent.com.atproto.repo.putRecord( { 157 162 repo: did, 158 163 collection: PUBLICATION_COLLECTION, 159 164 rkey: existing.rkey, ··· 161 166 } ); 162 167 return { 163 168 uri: existing.uri, 169 + cid: res.data.cid, 164 170 rkey: existing.rkey, 165 171 slug: existing.slug, 166 172 name: record.name,
+38
src/lib/publish/publisher.test.ts
··· 58 58 59 59 const TARGET = { 60 60 publicationUri: `at://${ DID }/site.standard.publication/pub1`, 61 + publicationCid: 'bafy-pub1', 61 62 publicationSlug: 'my-blog', 62 63 }; 63 64 ··· 84 85 85 86 // It does NOT auto-create a publication (no ensurePublication anymore). 86 87 expect( created.some( ( c ) => c.collection === 'site.standard.publication' ) ).toBe( false ); 88 + } ); 89 + 90 + it( 'writes the document first so the post can embed its strongRef (standard.site card)', async () => { 91 + const { agent, created, put } = mockAgent(); 92 + await publish( agent, { did: DID, handle: HANDLE }, { title: 'Hello', blocks: BLOCKS, ...TARGET } ); 93 + 94 + // 3-write order: document (no ref yet) → post → putRecord document with bskyPostRef. 95 + expect( created.map( ( c ) => c.collection ) ).toEqual( [ 96 + 'site.standard.document', 97 + 'app.bsky.feed.post', 98 + ] ); 99 + expect( created[ 0 ].record.bskyPostRef ).toBeUndefined(); 100 + 101 + const docUri = `at://${ DID }/site.standard.document/${ created[ 0 ].rkey }`; 102 + const postUri = `at://${ DID }/app.bsky.feed.post/gen-2`; 103 + 104 + // The post embeds associatedRefs to the document AND the publication. 105 + const post = created[ 1 ].record as { 106 + facets: unknown[]; 107 + embed: { external: { associatedRefs: Array< { uri: string } > } }; 108 + }; 109 + expect( post.embed.external.associatedRefs ).toEqual( [ 110 + { $type: 'com.atproto.repo.strongRef', uri: docUri, cid: 'bafy-new' }, 111 + { 112 + $type: 'com.atproto.repo.strongRef', 113 + uri: TARGET.publicationUri, 114 + cid: TARGET.publicationCid, 115 + }, 116 + ] ); 117 + // And the link is clickable. 118 + expect( post.facets ).toHaveLength( 1 ); 119 + 120 + // The document is then updated to point back at the post (preserved for unpublish). 121 + expect( put ).toHaveLength( 1 ); 122 + expect( put[ 0 ].collection ).toBe( 'site.standard.document' ); 123 + expect( put[ 0 ].rkey ).toBe( created[ 0 ].rkey ); 124 + expect( put[ 0 ].record.bskyPostRef ).toEqual( { uri: postUri, cid: 'bafy-new' } ); 87 125 } ); 88 126 } ); 89 127
+36 -8
src/lib/publish/publisher.ts
··· 34 34 description?: string; 35 35 /** The chosen target publication's AT-URI (Decision 0010 — no more auto-create). */ 36 36 publicationUri: string; 37 + /** Its current cid, for the post's `associatedRefs` strongRef (Decision 0013). */ 38 + publicationCid: string; 37 39 /** Its frozen slug, needed to build the article URL. */ 38 40 publicationSlug: string; 39 41 } ··· 46 48 } 47 49 48 50 /** 49 - * The two-record publish (Decision 0005), now targeting a CHOSEN publication (Decision 0010). 50 - * Order avoids a circular dependency: the article URL is known from handle+slug+rkey, so we 51 - * create the Bluesky post first, then the document with `bskyPostRef`. 51 + * The publish flow (Decision 0005), targeting a CHOSEN publication (Decision 0010) and embedding 52 + * the standard.site link card refs in the Bluesky post (Decision 0013). 53 + * 54 + * Three writes, in an order that satisfies the mutual references: 55 + * 1. Create the DOCUMENT (no `bskyPostRef` yet) — yields its strongRef (uri + cid). 56 + * 2. Create the POST with a link facet + `associatedRefs` to the document & publication. 57 + * 3. `putRecord` the document to add `bskyPostRef` (kept for unpublish/cascade-delete). 58 + * Step 3 re-cids the document, so the post's document ref is one version stale — harmless, as the 59 + * AppView resolves `associatedRefs` by URI (this is how standard.site itself behaves). 52 60 * 53 61 * NOTE: this also creates a PUBLIC Bluesky post. Callers must have made that unmistakable to 54 62 * the user first (brief §10). ··· 67 75 const articleUrl = canonicalArticleUrl( handle, input.publicationSlug, rkey ); 68 76 const textContent = blocksToText( input.blocks ); 69 77 78 + // 1. Document first (no bskyPostRef yet) so the post can embed its strongRef. 79 + const docRes = await agent.com.atproto.repo.createRecord( { 80 + repo: did, 81 + collection: DOCUMENT_COLLECTION, 82 + rkey, 83 + record: asRecord( 84 + buildDocumentRecord( { 85 + title: input.title, 86 + rkey, 87 + blocks: input.blocks, 88 + textContent, 89 + siteUri: input.publicationUri, 90 + publishedAt: now, 91 + description: input.description, 92 + } ) 93 + ), 94 + } ); 95 + const documentRef: StrongRef = { uri: docRes.data.uri, cid: docRes.data.cid }; 96 + const publicationRef: StrongRef = { uri: input.publicationUri, cid: input.publicationCid }; 97 + 98 + // 2. Post with the clickable link facet + standard.site associatedRefs (document, publication). 70 99 const postRes = await agent.com.atproto.repo.createRecord( { 71 100 repo: did, 72 101 collection: POST_COLLECTION, ··· 76 105 articleUrl, 77 106 description: input.description, 78 107 createdAt: now, 108 + associatedRefs: [ documentRef, publicationRef ], 79 109 } ) 80 110 ), 81 111 } ); 82 - const bskyPostRef: StrongRef = { 83 - uri: postRes.data.uri, 84 - cid: postRes.data.cid, 85 - }; 112 + const bskyPostRef: StrongRef = { uri: postRes.data.uri, cid: postRes.data.cid }; 86 113 87 - const docRes = await agent.com.atproto.repo.createRecord( { 114 + // 3. Point the document back at its companion post (preserved for unpublish/cascade-delete). 115 + await agent.com.atproto.repo.putRecord( { 88 116 repo: did, 89 117 collection: DOCUMENT_COLLECTION, 90 118 rkey,
+62 -4
src/lib/publish/records.test.ts
··· 228 228 } ); 229 229 230 230 describe( 'buildBskyPost', () => { 231 + const ARTICLE_URL = 'https://skypress.blog/@alice.bsky.social/my-blog/3kabcrkey'; 232 + 231 233 it( 'creates a post with an external embed pointing at the article', () => { 232 234 const post = buildBskyPost( { 233 235 title: 'Hello, World!', 234 - articleUrl: 'https://skypress.blog/@alice.bsky.social/my-blog/3kabcrkey', 236 + articleUrl: ARTICLE_URL, 235 237 description: 'An excerpt', 236 238 createdAt: '2026-06-08T12:00:00.000Z', 237 239 } ); ··· 239 241 expect( post.text ).toContain( 'Hello, World!' ); 240 242 expect( post.createdAt ).toBe( '2026-06-08T12:00:00.000Z' ); 241 243 expect( post.embed.$type ).toBe( 'app.bsky.embed.external' ); 242 - expect( post.embed.external.uri ).toBe( 243 - 'https://skypress.blog/@alice.bsky.social/my-blog/3kabcrkey' 244 - ); 244 + expect( post.embed.external.uri ).toBe( ARTICLE_URL ); 245 245 expect( post.embed.external.title ).toBe( 'Hello, World!' ); 246 246 expect( typeof post.embed.external.description ).toBe( 'string' ); 247 + } ); 248 + 249 + it( 'marks the article URL as a clickable link facet over its byte range', () => { 250 + const post = buildBskyPost( { 251 + title: 'Hello, World!', 252 + articleUrl: ARTICLE_URL, 253 + createdAt: '2026-06-08T12:00:00.000Z', 254 + } ); 255 + const bytes = ( s: string ) => new TextEncoder().encode( s ).length; 256 + const byteStart = bytes( `Hello, World!\n\n` ); 257 + expect( post.facets ).toEqual( [ 258 + { 259 + $type: 'app.bsky.richtext.facet', 260 + index: { byteStart, byteEnd: byteStart + bytes( ARTICLE_URL ) }, 261 + features: [ { $type: 'app.bsky.richtext.facet#link', uri: ARTICLE_URL } ], 262 + }, 263 + ] ); 264 + } ); 265 + 266 + it( 'computes facet byte offsets in UTF-8 (multibyte title)', () => { 267 + const post = buildBskyPost( { 268 + title: '🍔 Burgers', 269 + articleUrl: ARTICLE_URL, 270 + createdAt: '2026-06-08T12:00:00.000Z', 271 + } ); 272 + const bytes = ( s: string ) => new TextEncoder().encode( s ).length; 273 + // The emoji is 4 UTF-8 bytes, so the link starts well past its JS string index. 274 + expect( post.facets[ 0 ].index.byteStart ).toBe( bytes( '🍔 Burgers\n\n' ) ); 275 + expect( post.facets[ 0 ].index.byteEnd ).toBe( 276 + bytes( '🍔 Burgers\n\n' ) + bytes( ARTICLE_URL ) 277 + ); 278 + } ); 279 + 280 + it( 'embeds associatedRefs (document, publication strongRefs) when provided', () => { 281 + const documentRef = { uri: 'at://did:plc:abc/site.standard.document/d1', cid: 'bafydoc' }; 282 + const publicationRef = { 283 + uri: 'at://did:plc:abc/site.standard.publication/p1', 284 + cid: 'bafypub', 285 + }; 286 + const post = buildBskyPost( { 287 + title: 'Hello, World!', 288 + articleUrl: ARTICLE_URL, 289 + createdAt: '2026-06-08T12:00:00.000Z', 290 + associatedRefs: [ documentRef, publicationRef ], 291 + } ); 292 + expect( post.embed.external.associatedRefs ).toEqual( [ 293 + { $type: 'com.atproto.repo.strongRef', ...documentRef }, 294 + { $type: 'com.atproto.repo.strongRef', ...publicationRef }, 295 + ] ); 296 + } ); 297 + 298 + it( 'omits associatedRefs when none are provided', () => { 299 + const post = buildBskyPost( { 300 + title: 'Hello, World!', 301 + articleUrl: ARTICLE_URL, 302 + createdAt: '2026-06-08T12:00:00.000Z', 303 + } ); 304 + expect( 'associatedRefs' in post.embed.external ).toBe( false ); 247 305 } ); 248 306 } );
+49 -1
src/lib/publish/records.ts
··· 216 216 }; 217 217 } 218 218 219 + /** A richtext link facet (`app.bsky.richtext.facet#link`) over a UTF-8 byte range of `text`. */ 220 + export interface BskyLinkFacet { 221 + $type: 'app.bsky.richtext.facet'; 222 + index: { byteStart: number; byteEnd: number }; 223 + features: Array< { $type: 'app.bsky.richtext.facet#link'; uri: string } >; 224 + } 225 + 226 + /** A `com.atproto.repo.strongRef` as embedded in `external.associatedRefs` (Decision 0013). */ 227 + export interface AssociatedRef extends StrongRef { 228 + $type: 'com.atproto.repo.strongRef'; 229 + } 230 + 219 231 export interface BskyPostRecord { 220 232 $type: 'app.bsky.feed.post'; 221 233 text: string; 222 234 createdAt: string; 235 + /** Always present: marks the article URL in `text` as a clickable link (Decision 0013). */ 236 + facets: BskyLinkFacet[]; 223 237 embed: { 224 238 $type: 'app.bsky.embed.external'; 225 239 external: { 226 240 uri: string; 227 241 title: string; 228 242 description: string; 243 + /** strongRefs to the document + publication; drives the standard.site link card. */ 244 + associatedRefs?: AssociatedRef[]; 229 245 }; 230 246 }; 231 247 } 232 248 249 + /** UTF-8 byte length — facet offsets are byte-based, not JS string-index based. */ 250 + function utf8ByteLength( value: string ): number { 251 + return new TextEncoder().encode( value ).length; 252 + } 253 + 254 + /** 255 + * Build the companion Bluesky post (Decision 0005). The article URL is appended to the text and 256 + * marked as a clickable link via a richtext facet; `associatedRefs` (the document + publication 257 + * strongRefs) are embedded so Bluesky renders the rich standard.site link card (Decision 0013). 258 + */ 233 259 export function buildBskyPost( input: { 234 260 title: string; 235 261 articleUrl: string; 236 262 createdAt: string; 237 263 description?: string; 264 + /** Document + publication strongRefs, in that order, for the standard.site card. */ 265 + associatedRefs?: StrongRef[]; 238 266 } ): BskyPostRecord { 267 + const text = `${ input.title }\n\n${ input.articleUrl }`; 268 + // The URL is the trailing segment of `text`; mark its byte range as a link facet. 269 + const byteStart = utf8ByteLength( `${ input.title }\n\n` ); 270 + const byteEnd = byteStart + utf8ByteLength( input.articleUrl ); 239 271 return { 240 272 $type: 'app.bsky.feed.post', 241 - text: `${ input.title }\n\n${ input.articleUrl }`, 273 + text, 242 274 createdAt: input.createdAt, 275 + facets: [ 276 + { 277 + $type: 'app.bsky.richtext.facet', 278 + index: { byteStart, byteEnd }, 279 + features: [ { $type: 'app.bsky.richtext.facet#link', uri: input.articleUrl } ], 280 + }, 281 + ], 243 282 embed: { 244 283 $type: 'app.bsky.embed.external', 245 284 external: { 246 285 uri: input.articleUrl, 247 286 title: input.title, 248 287 description: input.description ?? '', 288 + ...( input.associatedRefs && input.associatedRefs.length 289 + ? { 290 + associatedRefs: input.associatedRefs.map( ( ref ) => ( { 291 + $type: 'com.atproto.repo.strongRef' as const, 292 + uri: ref.uri, 293 + cid: ref.cid, 294 + } ) ), 295 + } 296 + : {} ), 249 297 }, 250 298 }, 251 299 };