A calm place to write long-form, and publish it to the open social web.
skypress.blog/
1import type { BlockNode } from '../blocks/render';
2
3/** Block names whose `url` attribute may hold a held (data:) image. Mirrors blob.ts. */
4const IMAGE_BLOCKS = new Set( [ 'core/image' ] );
5
6/** True when `value` is a `data:` URL string. */
7export 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. */
12export 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
25export 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 */
37export 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/** A body asset token allocated by `splitAssets` (`a0`, `a1`, …). */
71function isAssetToken( value: string ): boolean {
72 return /^a\d+$/.test( value );
73}
74
75/** Inverse of `splitAssets`: swap each token back to its `data:` URL. Pure. */
76export function mergeAssets(
77 skeleton: AssetSkeleton,
78 assets: Record< string, string >
79): { blocks: BlockNode[]; coverDataUrl: string | null } {
80 const walk = ( nodes: BlockNode[] ): BlockNode[] =>
81 nodes.map( ( node ) => {
82 const url = node.attributes?.url;
83 const isImage = IMAGE_BLOCKS.has( node.name ) && typeof url === 'string';
84 let attributes: Record< string, unknown >;
85 if ( isImage && ( url as string ) in assets ) {
86 attributes = { ...node.attributes, url: assets[ url as string ] };
87 } else if ( isImage && isAssetToken( url as string ) ) {
88 // Held bytes missing (e.g. IndexedDB evicted while the localStorage skeleton
89 // survived): drop the dangling token rather than emit a broken `a0` src that
90 // would also be published verbatim. External image URLs are not tokens, so
91 // they fall through and are preserved.
92 const { url: _dropped, ...rest } = node.attributes ?? {};
93 attributes = { ...rest };
94 } else {
95 attributes = { ...( node.attributes ?? {} ) };
96 }
97 return {
98 name: node.name,
99 attributes,
100 innerBlocks: walk( node.innerBlocks ?? [] ),
101 };
102 } );
103
104 return {
105 blocks: walk( skeleton.blocks ),
106 coverDataUrl: skeleton.cover ? assets[ skeleton.cover ] ?? null : null,
107 };
108}