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.

1/** 2 * Pure builders for the records a SkyPress publish writes (Decision 0005). 3 * 4 * No `@atproto/*` or network here — just record-shaping, so it's fully unit-testable. 5 * Orchestration (createRecord via the Agent) lives in `publisher.ts`. 6 */ 7import type { BlockNode } from '../blocks/render'; 8import type { BlobRefJson } from '../media/blob'; 9import { parseBasicTheme, type BasicTheme } from './themes'; 10import { assemblePostText, type PostFacet } from './post-text'; 11import { graphemeLength } from './grapheme'; 12import type { Mention } from './mentions'; 13 14/** 15 * Public origin for the stored publication + article URLs (and the Bluesky post link). 16 * Set `PUBLIC_SITE_URL` to your own domain when self-hosting; it's baked into the build. 17 * Must match the origin the app is actually served from. 18 */ 19export const SITE_BASE = 20 import.meta.env.PUBLIC_SITE_URL ?? 'https://skypress.blog'; 21 22export const CONTENT_TYPE = 'blog.skypress.content.gutenberg'; 23export const CONTENT_VERSION = 1; 24 25export interface StrongRef { 26 uri: string; 27 cid: string; 28} 29 30/** 31 * A publication's `url` — handle-namespaced under SkyPress (Decision 0010): 32 * `<site>/@<handle>/<slug>`. The slug is frozen at creation; see `uniquePublicationSlug`. 33 */ 34export function publicationHomeUrl( handle: string, slug: string ): string { 35 return `${ SITE_BASE }/@${ handle }/${ slug }`; 36} 37 38/** 39 * The document `path` (leading slash, per the lexicon). SkyPress addresses documents 40 * by their record key (rkey), not a slug — stable across title edits and a direct 41 * `getRecord` lookup for the renderer. The path stays publication-relative. 42 */ 43export function articlePath( rkey: string ): string { 44 return `/${ rkey }`; 45} 46 47/** Canonical article URL = publication url + path = `<base>/@<handle>/<slug>/<rkey>`. */ 48export function canonicalArticleUrl( handle: string, slug: string, rkey: string ): string { 49 return `${ publicationHomeUrl( handle, slug ) }${ articlePath( rkey ) }`; 50} 51 52/** 53 * The trailing slug segment of a publication `url` (the resolution key, Decision 0010), 54 * or null for a slugless/legacy/malformed url. Matching by this segment (rather than the 55 * full url) keeps resolution robust if the writer's handle later changes. 56 */ 57export function publicationSlugFromUrl( url: string ): string | null { 58 try { 59 const segments = new URL( url ).pathname.split( '/' ).filter( Boolean ); 60 return segments.length >= 2 ? decodeURIComponent( segments[ segments.length - 1 ] ) : null; 61 } catch { 62 return null; 63 } 64} 65 66/** 67 * Whether a `site.standard.publication` url belongs to SkyPress (its origin === `SITE_BASE`). 68 * SkyPress lists/edits/deletes/renders only its OWN publications — a writer may hold records 69 * from other tools sharing the lexicon (Leaflet, …) and we must not touch those (Decision 0010). 70 */ 71export function isSkyPressPublicationUrl( url: string ): boolean { 72 try { 73 return new URL( url ).origin === new URL( SITE_BASE ).origin; 74 } catch { 75 return false; 76 } 77} 78 79/** 80 * Derive a URL-safe slug from a publication name (Decision 0010): NFKD-normalise (so accents 81 * fold to their base letter) → lowercase → trim → spaces to `-` → strip to `[a-z0-9-]` → 82 * collapse and trim dashes. Returns '' for empty / emoji-only names; the caller then applies 83 * the `pub-{rkey}` fallback. 84 */ 85export function slugify( name: string ): string { 86 return name 87 .normalize( 'NFKD' ) 88 .replace( /[\u0300-\u036f]/g, '' ) 89 .toLowerCase() 90 .trim() 91 .replace( /\s+/g, '-' ) 92 .replace( /[^a-z0-9-]/g, '' ) 93 .replace( /-+/g, '-' ) 94 .replace( /^-+|-+$/g, '' ); 95} 96 97/** 98 * The frozen slug for a new publication: `slugify(name)`, de-duplicated against the writer's 99 * existing SkyPress slugs by appending `-2`, `-3`, … (uniqueness is per-repo only — no global 100 * registry, Decision 0010). Returns '' for an unslugifiable name so the caller can fall back 101 * to `pub-{rkey}`. 102 */ 103export function uniquePublicationSlug( name: string, existingSlugs: Iterable< string > ): string { 104 const base = slugify( name ); 105 if ( ! base ) { 106 return ''; 107 } 108 const taken = new Set( existingSlugs ); 109 if ( ! taken.has( base ) ) { 110 return base; 111 } 112 for ( let n = 2; ; n++ ) { 113 const candidate = `${ base }-${ n }`; 114 if ( ! taken.has( candidate ) ) { 115 return candidate; 116 } 117 } 118} 119 120/** 121 * Normalise live editor blocks to plain `BlockNode` JSON for storage: keep only 122 * `name`/`attributes`/`innerBlocks`, drop the transient `clientId`, and JSON-round-trip 123 * attributes so rich-text values serialise to plain strings. 124 */ 125export function normalizeBlocks( 126 blocks: ReadonlyArray< { name: string; attributes?: unknown; innerBlocks?: unknown[] } > 127): BlockNode[] { 128 return blocks.map( ( block ) => ( { 129 name: block.name, 130 attributes: JSON.parse( JSON.stringify( block.attributes ?? {} ) ), 131 innerBlocks: normalizeBlocks( 132 ( block.innerBlocks ?? [] ) as Parameters< typeof normalizeBlocks >[ 0 ] 133 ), 134 } ) ); 135} 136 137export interface GutenbergContent { 138 $type: typeof CONTENT_TYPE; 139 version: number; 140 blocks: BlockNode[]; 141 /** 142 * Optional flat list of accounts this document mentions, derived from the inline 143 * `<a data-did>` anchors. Additive interop hint for other appviews (Decision 0019); 144 * SkyPress's own reader resolves mentions from the anchors, not this field. 145 */ 146 mentions?: Array< { did: string; handle: string } >; 147} 148 149/** Wrap the block tree as the document's `content` union member. */ 150export function buildContentObject( 151 blocks: BlockNode[], 152 mentions: Mention[] = [] 153): GutenbergContent { 154 return { 155 $type: CONTENT_TYPE, 156 version: CONTENT_VERSION, 157 blocks, 158 ...( mentions.length 159 ? { mentions: mentions.map( ( m ) => ( { did: m.did, handle: m.handle } ) ) } 160 : {} ), 161 }; 162} 163 164export interface PublicationRecord { 165 $type: 'site.standard.publication'; 166 url: string; 167 name: string; 168 description?: string; 169 /** The publication logo (≤1MB, image/*) — the lexicon's `icon` blob (Decision 0010). */ 170 icon?: BlobRefJson; 171 /** Optional sky-phase colour theme — the lexicon's `basicTheme` object (Decision 0012). */ 172 basicTheme?: BasicTheme; 173} 174 175export function buildPublicationRecord( input: { 176 handle: string; 177 /** Frozen at creation; preserved verbatim on edit so the url never breaks. */ 178 slug: string; 179 name?: string; 180 description?: string; 181 icon?: BlobRefJson; 182 basicTheme?: BasicTheme; 183} ): PublicationRecord { 184 const trimmedName = input.name?.trim(); 185 const description = input.description?.trim(); 186 // Validate the theme at the write boundary too (symmetric with the read path): an invalid 187 // theme is dropped rather than persisted, so no out-of-range channel can ever reach storage. 188 const basicTheme = parseBasicTheme( input.basicTheme ); 189 return { 190 $type: 'site.standard.publication', 191 url: publicationHomeUrl( input.handle, input.slug ), 192 name: trimmedName || input.handle, 193 ...( description ? { description } : {} ), 194 ...( input.icon ? { icon: input.icon } : {} ), 195 ...( basicTheme ? { basicTheme } : {} ), 196 }; 197} 198 199export interface DocumentRecord { 200 $type: 'site.standard.document'; 201 site: string; 202 title: string; 203 path: string; 204 publishedAt: string; 205 textContent: string; 206 content: GutenbergContent; 207 /** Optional per-article cover (≤1MB, image/*) — the standard.site coverImage field. */ 208 coverImage?: BlobRefJson; 209 description?: string; 210 bskyPostRef?: StrongRef; 211 updatedAt?: string; 212} 213 214export function buildDocumentRecord( input: { 215 title: string; 216 rkey: string; 217 blocks: BlockNode[]; 218 textContent: string; 219 siteUri: string; 220 publishedAt: string; 221 /** Optional per-article cover blob ref to persist (design 2026-06-10). */ 222 coverImage?: BlobRefJson; 223 description?: string; 224 bskyPostRef?: StrongRef; 225 /** Set on edit (Decision 0008); preserves publishedAt + bskyPostRef. */ 226 updatedAt?: string; 227 /** Flat mention list to record on the content object (additive interop hint). */ 228 mentions?: Mention[]; 229} ): DocumentRecord { 230 return { 231 $type: 'site.standard.document', 232 site: input.siteUri, 233 title: input.title, 234 path: articlePath( input.rkey ), 235 publishedAt: input.publishedAt, 236 textContent: input.textContent, 237 content: buildContentObject( input.blocks, input.mentions ), 238 ...( input.coverImage ? { coverImage: input.coverImage } : {} ), 239 ...( input.description ? { description: input.description } : {} ), 240 ...( input.bskyPostRef ? { bskyPostRef: input.bskyPostRef } : {} ), 241 ...( input.updatedAt ? { updatedAt: input.updatedAt } : {} ), 242 }; 243} 244 245/** A `com.atproto.repo.strongRef` as embedded in `external.associatedRefs` (Decision 0013). */ 246export interface AssociatedRef extends StrongRef { 247 $type: 'com.atproto.repo.strongRef'; 248} 249 250export interface BskyPostRecord { 251 $type: 'app.bsky.feed.post'; 252 text: string; 253 createdAt: string; 254 /** Marks the article URL as a clickable link, plus a #mention facet per cc-ed account (Decision 0013). */ 255 facets: PostFacet[]; 256 embed: { 257 $type: 'app.bsky.embed.external'; 258 external: { 259 uri: string; 260 title: string; 261 description: string; 262 /** An image blob (≤1MB, image/*) shown on the link card — the og:image fallback (Decision 0014). */ 263 thumb?: BlobRefJson; 264 /** strongRefs to the document + publication; drives the standard.site link card. */ 265 associatedRefs?: AssociatedRef[]; 266 }; 267 }; 268} 269 270/** Bluesky rejects posts longer than this many graphemes. */ 271export const BSKY_POST_MAX_GRAPHEMES = 300; 272 273/** 274 * Throw if the companion post would exceed Bluesky's grapheme limit. Call this as a 275 * PRE-FLIGHT in `publish()` (before any record is written) so the guard can't leave an 276 * orphaned document; `buildBskyPost` also calls it as a final backstop. Inputs match 277 * `assemblePostText`, so the assembled text is identical to what gets published. 278 */ 279export function assertBskyPostWithinLimit( input: { 280 title: string; 281 articleUrl: string; 282 bodyLede?: string; 283 mentions?: Mention[]; 284} ): void { 285 const { text } = assemblePostText( input ); 286 const length = graphemeLength( text ); 287 if ( length > BSKY_POST_MAX_GRAPHEMES ) { 288 throw new Error( 289 `Bluesky post is ${ length } characters; the limit is ${ BSKY_POST_MAX_GRAPHEMES }. Shorten the subtitle or remove a mention.` 290 ); 291 } 292} 293 294/** 295 * Build the companion Bluesky post (Decision 0005). Body text + facets come from 296 * `assemblePostText` (shared with the editor's live counter): the body is 297 * `title + lede + cc line + URL` (lede and cc line omitted when empty), and `facets` carries 298 * the article-URL link facet plus a `#mention` facet per cc-ed account. `description` 299 * populates ONLY the embed card subtitle; the writer-typed lede goes in the body via 300 * `bodyLede`. `associatedRefs` drive the standard.site rich card (Decision 0013). 301 */ 302export function buildBskyPost( input: { 303 title: string; 304 articleUrl: string; 305 createdAt: string; 306 description?: string; 307 /** The writer-typed lede, placed in the post body (omitted when blank). */ 308 bodyLede?: string; 309 /** Accounts to cc + notify via #mention facets. */ 310 mentions?: Mention[]; 311 /** Optional image blob for the card's `thumb` (the og:image fallback, Decision 0014). */ 312 thumb?: BlobRefJson; 313 /** Document + publication strongRefs, in that order, for the standard.site card. */ 314 associatedRefs?: StrongRef[]; 315} ): BskyPostRecord { 316 const { text, facets } = assemblePostText( { 317 title: input.title, 318 articleUrl: input.articleUrl, 319 bodyLede: input.bodyLede, 320 mentions: input.mentions, 321 } ); 322 323 // Backstop: `publish()` already pre-flights this before writing any record. 324 assertBskyPostWithinLimit( { 325 title: input.title, 326 articleUrl: input.articleUrl, 327 bodyLede: input.bodyLede, 328 mentions: input.mentions, 329 } ); 330 331 return { 332 $type: 'app.bsky.feed.post', 333 text, 334 createdAt: input.createdAt, 335 facets, 336 embed: { 337 $type: 'app.bsky.embed.external', 338 external: { 339 uri: input.articleUrl, 340 title: input.title, 341 description: input.description ?? '', 342 ...( input.thumb ? { thumb: input.thumb } : {} ), 343 ...( input.associatedRefs && input.associatedRefs.length 344 ? { 345 associatedRefs: input.associatedRefs.map( ( ref ) => ( { 346 $type: 'com.atproto.repo.strongRef' as const, 347 uri: ref.uri, 348 cid: ref.cid, 349 } ) ), 350 } 351 : {} ), 352 }, 353 }, 354 }; 355}