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 pure held-asset split/merge + data-URL helpers

+165
+72
src/lib/write/held-assets.test.ts
··· 1 + // @vitest-environment node 2 + // Pure module — no DOM needed. Node's global Blob exposes `.text()`, which the 3 + // repo's pinned jsdom Blob does not, so run this file under the node environment. 4 + import { describe, it, expect } from 'vitest'; 5 + import { 6 + isDataUrl, 7 + dataUrlToBlob, 8 + splitAssets, 9 + mergeAssets, 10 + } from './held-assets'; 11 + import type { BlockNode } from '../blocks/render'; 12 + 13 + const DATA_A = 'data:image/png;base64,QUFB'; // "AAA" 14 + const DATA_B = 'data:image/jpeg;base64,QkJC'; // "BBB" 15 + 16 + function tree(): BlockNode[] { 17 + return [ 18 + { name: 'core/paragraph', attributes: { content: 'hi' }, innerBlocks: [] }, 19 + { name: 'core/image', attributes: { url: DATA_A }, innerBlocks: [] }, 20 + { 21 + name: 'core/columns', 22 + attributes: {}, 23 + innerBlocks: [ 24 + { name: 'core/image', attributes: { url: DATA_B }, innerBlocks: [] }, 25 + { name: 'core/image', attributes: { url: 'https://ext/img.png' }, innerBlocks: [] }, 26 + ], 27 + }, 28 + ]; 29 + } 30 + 31 + describe( 'isDataUrl', () => { 32 + it( 'matches data: URLs only', () => { 33 + expect( isDataUrl( DATA_A ) ).toBe( true ); 34 + expect( isDataUrl( 'https://x/y.png' ) ).toBe( false ); 35 + expect( isDataUrl( undefined ) ).toBe( false ); 36 + } ); 37 + } ); 38 + 39 + describe( 'dataUrlToBlob', () => { 40 + it( 'reconstructs bytes + mime from a data: URL', async () => { 41 + const blob = dataUrlToBlob( DATA_A ); 42 + expect( blob.type ).toBe( 'image/png' ); 43 + expect( await blob.text() ).toBe( 'AAA' ); 44 + } ); 45 + } ); 46 + 47 + describe( 'splitAssets / mergeAssets', () => { 48 + it( 'extracts every data: image URL (depth-first) + cover into tokens and round-trips', () => { 49 + const { skeleton, assets } = splitAssets( tree(), DATA_A ); 50 + 51 + // Body data URLs replaced by tokens; external + non-image untouched. 52 + expect( skeleton.blocks[ 1 ].attributes!.url ).toBe( 'a0' ); 53 + expect( skeleton.blocks[ 2 ].innerBlocks![ 0 ].attributes!.url ).toBe( 'a1' ); 54 + expect( skeleton.blocks[ 2 ].innerBlocks![ 1 ].attributes!.url ).toBe( 'https://ext/img.png' ); 55 + expect( skeleton.cover ).toBe( 'cover' ); 56 + expect( assets ).toEqual( { a0: DATA_A, a1: DATA_B, cover: DATA_A } ); 57 + 58 + const merged = mergeAssets( skeleton, assets ); 59 + expect( merged.blocks ).toEqual( tree() ); 60 + expect( merged.coverDataUrl ).toBe( DATA_A ); 61 + } ); 62 + 63 + it( 'leaves a null cover null and a tree with no data URLs unchanged', () => { 64 + const plain: BlockNode[] = [ 65 + { name: 'core/image', attributes: { url: 'https://ext/x.png' }, innerBlocks: [] }, 66 + ]; 67 + const { skeleton, assets } = splitAssets( plain, null ); 68 + expect( assets ).toEqual( {} ); 69 + expect( skeleton.cover ).toBe( null ); 70 + expect( mergeAssets( skeleton, assets ) ).toEqual( { blocks: plain, coverDataUrl: null } ); 71 + } ); 72 + } );
+93
src/lib/write/held-assets.ts
··· 1 + import type { BlockNode } from '../blocks/render'; 2 + 3 + /** Block names whose `url` attribute may hold a held (data:) image. Mirrors blob.ts. */ 4 + const IMAGE_BLOCKS = new Set( [ 'core/image' ] ); 5 + 6 + /** True when `value` is a `data:` URL string. */ 7 + export function isDataUrl( value: unknown ): value is string { 8 + return typeof value === 'string' && value.startsWith( 'data:' ); 9 + } 10 + 11 + /** Rebuild a `Blob` (bytes + mime type) from a base64 `data:` URL. */ 12 + export function dataUrlToBlob( dataUrl: string ): Blob { 13 + const comma = dataUrl.indexOf( ',' ); 14 + const header = dataUrl.slice( 5, comma ); // after "data:" 15 + const mimeType = header.split( ';' )[ 0 ] || 'application/octet-stream'; 16 + const base64 = dataUrl.slice( comma + 1 ); 17 + const binary = atob( base64 ); 18 + const bytes = new Uint8Array( binary.length ); 19 + for ( let i = 0; i < binary.length; i++ ) { 20 + bytes[ i ] = binary.charCodeAt( i ); 21 + } 22 + return new Blob( [ bytes ], { type: mimeType } ); 23 + } 24 + 25 + export interface AssetSkeleton { 26 + blocks: BlockNode[]; 27 + /** `'cover'` when a held cover exists, else `null`. */ 28 + cover: string | null; 29 + } 30 + 31 + /** 32 + * Replace every held (`data:`) image URL in the tree — and the cover — with a short token, 33 + * returning the lightweight skeleton plus a `token → dataUrl` map. Body tokens are `a0`, 34 + * `a1`, … allocated depth-first; the cover token is the literal `'cover'`. External image 35 + * URLs and non-image blocks are left untouched. Pure; returns new objects. 36 + */ 37 + export function splitAssets( 38 + blocks: BlockNode[], 39 + coverDataUrl: string | null 40 + ): { skeleton: AssetSkeleton; assets: Record< string, string > } { 41 + const assets: Record< string, string > = {}; 42 + let n = 0; 43 + 44 + const walk = ( nodes: BlockNode[] ): BlockNode[] => 45 + nodes.map( ( node ) => { 46 + const url = node.attributes?.url; 47 + const held = IMAGE_BLOCKS.has( node.name ) && isDataUrl( url ); 48 + let attributes = node.attributes ? { ...node.attributes } : {}; 49 + if ( held ) { 50 + const token = `a${ n++ }`; 51 + assets[ token ] = url as string; 52 + attributes = { ...attributes, url: token }; 53 + } 54 + return { 55 + name: node.name, 56 + attributes, 57 + innerBlocks: walk( node.innerBlocks ?? [] ), 58 + }; 59 + } ); 60 + 61 + const skeletonBlocks = walk( blocks ); 62 + let cover: string | null = null; 63 + if ( isDataUrl( coverDataUrl ) ) { 64 + assets.cover = coverDataUrl; 65 + cover = 'cover'; 66 + } 67 + return { skeleton: { blocks: skeletonBlocks, cover }, assets }; 68 + } 69 + 70 + /** Inverse of `splitAssets`: swap each token back to its `data:` URL. Pure. */ 71 + export function mergeAssets( 72 + skeleton: AssetSkeleton, 73 + assets: Record< string, string > 74 + ): { blocks: BlockNode[]; coverDataUrl: string | null } { 75 + const walk = ( nodes: BlockNode[] ): BlockNode[] => 76 + nodes.map( ( node ) => { 77 + const url = node.attributes?.url; 78 + const restore = 79 + IMAGE_BLOCKS.has( node.name ) && typeof url === 'string' && url in assets; 80 + return { 81 + name: node.name, 82 + attributes: restore 83 + ? { ...node.attributes, url: assets[ url as string ] } 84 + : { ...( node.attributes ?? {} ) }, 85 + innerBlocks: walk( node.innerBlocks ?? [] ), 86 + }; 87 + } ); 88 + 89 + return { 90 + blocks: walk( skeleton.blocks ), 91 + coverDataUrl: skeleton.cover ? assets[ skeleton.cover ] ?? null : null, 92 + }; 93 + }