A calm place to write long-form, and publish it to the open social web.
skypress.blog/
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}