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.

Upload held images + cover to the PDS at publish time

+126
+59
src/lib/write/upload-held.test.ts
··· 1 + import { describe, it, expect, vi } from 'vitest'; 2 + import type { Agent } from '@atproto/api'; 3 + import { uploadHeldAssets } from './upload-held'; 4 + import type { BlockNode } from '../blocks/render'; 5 + 6 + const DATA = 'data:image/png;base64,QUFB'; // "AAA" 7 + 8 + function fakeAgent() { 9 + const uploadBlob = vi.fn( async ( _file: Blob ) => ( { 10 + data: { blob: { ref: { toString: () => 'bafyCID' }, mimeType: 'image/png', size: 3 } }, 11 + } ) ); 12 + return { agent: { uploadBlob } as unknown as Agent, uploadBlob }; 13 + } 14 + 15 + describe( 'uploadHeldAssets', () => { 16 + it( 'uploads each held image, attaches skypressBlob + a getBlob url, leaves externals alone', async () => { 17 + const { agent, uploadBlob } = fakeAgent(); 18 + const blocks: BlockNode[] = [ 19 + { name: 'core/image', attributes: { url: DATA }, innerBlocks: [] }, 20 + { name: 'core/image', attributes: { url: 'https://ext/x.png' }, innerBlocks: [] }, 21 + ]; 22 + 23 + const out = await uploadHeldAssets( agent, { 24 + blocks, 25 + coverDataUrl: null, 26 + did: 'did:plc:me', 27 + pdsUrl: 'https://pds.example.com', 28 + } ); 29 + 30 + expect( uploadBlob ).toHaveBeenCalledTimes( 1 ); 31 + expect( out.blocks[ 0 ].attributes!.skypressBlob ).toEqual( { 32 + $type: 'blob', 33 + ref: { $link: 'bafyCID' }, 34 + mimeType: 'image/png', 35 + size: 3, 36 + } ); 37 + expect( out.blocks[ 0 ].attributes!.url ).toBe( 38 + 'https://pds.example.com/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Ame&cid=bafyCID' 39 + ); 40 + expect( out.blocks[ 1 ].attributes!.url ).toBe( 'https://ext/x.png' ); 41 + expect( out.coverImage ).toBeUndefined(); 42 + } ); 43 + 44 + it( 'uploads a held cover into a BlobRefJson', async () => { 45 + const { agent } = fakeAgent(); 46 + const out = await uploadHeldAssets( agent, { 47 + blocks: [], 48 + coverDataUrl: DATA, 49 + did: 'did:plc:me', 50 + pdsUrl: 'https://pds.example.com', 51 + } ); 52 + expect( out.coverImage ).toEqual( { 53 + $type: 'blob', 54 + ref: { $link: 'bafyCID' }, 55 + mimeType: 'image/png', 56 + size: 3, 57 + } ); 58 + } ); 59 + } );
+67
src/lib/write/upload-held.ts
··· 1 + import type { Agent } from '@atproto/api'; 2 + import type { BlockNode } from '../blocks/render'; 3 + import { 4 + attachBlobRefs, 5 + buildGetBlobUrl, 6 + type BlobRefJson, 7 + type BlobUpload, 8 + } from '../media/blob'; 9 + import { isDataUrl, dataUrlToBlob } from './held-assets'; 10 + 11 + export interface PreparedPublishContent { 12 + blocks: BlockNode[]; 13 + coverImage?: BlobRefJson; 14 + } 15 + 16 + /** Upload one held `data:` URL to the PDS and return its portable blob ref + getBlob URL. */ 17 + async function uploadOne( 18 + agent: Agent, 19 + dataUrl: string, 20 + did: string, 21 + pdsUrl: string 22 + ): Promise< BlobUpload > { 23 + const blob = dataUrlToBlob( dataUrl ); 24 + const res = await agent.uploadBlob( blob, { encoding: blob.type } ); 25 + const out = res.data.blob; 26 + const cid = out.ref.toString(); 27 + return { 28 + ref: { $type: 'blob', ref: { $link: cid }, mimeType: out.mimeType, size: out.size }, 29 + url: buildGetBlobUrl( pdsUrl, did, cid ), 30 + }; 31 + } 32 + 33 + /** 34 + * Publish-time bridge for the writing-first flow: walk the block tree, upload every held 35 + * (`data:`) image to the writer's PDS, and rewrite those blocks via `attachBlobRefs` so they 36 + * carry `skypressBlob` + a portable getBlob URL (byte-identical to the eager path). External 37 + * image URLs are left untouched. A held cover is uploaded into a `BlobRefJson` for the document 38 + * record. Each distinct data URL uploads once. 39 + */ 40 + export async function uploadHeldAssets( 41 + agent: Agent, 42 + input: { blocks: BlockNode[]; coverDataUrl: string | null; did: string; pdsUrl: string } 43 + ): Promise< PreparedPublishContent > { 44 + const { blocks, coverDataUrl, did, pdsUrl } = input; 45 + 46 + // Collect every distinct held image URL in the tree (depth-first), upload once each. 47 + const registry = new Map< string, BlobUpload >(); 48 + const collect = ( nodes: BlockNode[] ): string[] => 49 + nodes.flatMap( ( node ) => { 50 + const url = node.attributes?.url; 51 + const here = node.name === 'core/image' && isDataUrl( url ) ? [ url as string ] : []; 52 + return [ ...here, ...collect( node.innerBlocks ?? [] ) ]; 53 + } ); 54 + 55 + for ( const url of new Set( collect( blocks ) ) ) { 56 + registry.set( url, await uploadOne( agent, url, did, pdsUrl ) ); 57 + } 58 + 59 + const prepared = attachBlobRefs( blocks, ( url ) => registry.get( url ) ); 60 + 61 + let coverImage: BlobRefJson | undefined; 62 + if ( isDataUrl( coverDataUrl ) ) { 63 + coverImage = ( await uploadOne( agent, coverDataUrl, did, pdsUrl ) ).ref; 64 + } 65 + 66 + return { blocks: prepared, coverImage }; 67 + }