/** * Bluesky cross-posting helpers * * Builds app.bsky.feed.post records with link cards and rich text facets. */ import type { BlobRef } from "./atproto.js"; // ── Pre-compiled regexes ──────────────────────────────────────── const TEMPLATE_TITLE_RE = /\{title\}/g; const TEMPLATE_URL_RE = /\{url\}/g; const TEMPLATE_EXCERPT_RE = /\{excerpt\}/g; const TRAILING_PUNCTUATION_RE = /[.,;:!?'"]+$/; // Global regexes for facet detection -- reset lastIndex before each use const URL_REGEX = /https?:\/\/[^\s)>\]]+/g; const HASHTAG_REGEX = /(?<=\s|^)#([a-zA-Z0-9_]+)/g; // ── Types ─────────────────────────────────────────────────────── export interface BskyPost { $type: "app.bsky.feed.post"; text: string; createdAt: string; langs?: string[]; facets?: BskyFacet[]; embed?: BskyEmbed; } export interface BskyFacet { index: { byteStart: number; byteEnd: number }; features: Array< | { $type: "app.bsky.richtext.facet#link"; uri: string } | { $type: "app.bsky.richtext.facet#tag"; tag: string } >; } export type BskyEmbed = { $type: "app.bsky.embed.external"; external: { uri: string; title: string; description: string; thumb?: BlobRef; }; }; // ── Post builder ──────────────────────────────────────────────── /** * Build a Bluesky post record for cross-posting published content. */ export function buildBskyPost(opts: { template: string; content: Record; siteUrl: string; thumbBlob?: BlobRef; langs?: string[]; }): BskyPost { const { template, content, siteUrl, thumbBlob, langs } = opts; const title = (content.title as string) || "Untitled"; const slug = content.slug as string; const excerpt = (content.excerpt || content.description || "") as string; const url = slug ? `${stripTrailingSlash(siteUrl)}/${slug}` : siteUrl; // Apply template -- substitute before truncation so we can detect // if the URL survives intact after truncation const fullText = template .replace(TEMPLATE_TITLE_RE, title) .replace(TEMPLATE_URL_RE, url) .replace(TEMPLATE_EXCERPT_RE, excerpt); // Truncate to 300 graphemes (Bluesky limit) const text = truncateGraphemes(fullText, 300); const wasTruncated = text !== fullText; const post: BskyPost = { $type: "app.bsky.feed.post", text, createdAt: new Date().toISOString(), }; if (langs && langs.length > 0) { post.langs = langs.slice(0, 3); // Max 3 per spec } // Auto-detect URLs in text and build facets. // If text was truncated, skip facets -- truncation may have cut // a URL mid-string, producing a broken link facet. if (!wasTruncated) { const facets = buildFacets(text); if (facets.length > 0) { post.facets = facets; } } // Link card embed post.embed = { $type: "app.bsky.embed.external", external: { uri: url, title, description: truncateGraphemes(excerpt, 300), ...(thumbBlob ? { thumb: thumbBlob } : {}), }, }; return post; } // ── Rich text facets ──────────────────────────────────────────── /** * Build rich text facets for URLs and hashtags in text. * * CRITICAL: Facet byte offsets use UTF-8 bytes, not JavaScript string indices. */ export function buildFacets(text: string): BskyFacet[] { const encoder = new TextEncoder(); const facets: BskyFacet[] = []; // Detect URLs let match: RegExpExecArray | null; URL_REGEX.lastIndex = 0; while ((match = URL_REGEX.exec(text)) !== null) { // Strip trailing punctuation that was captured by the greedy regex const cleanUrl = match[0].replace(TRAILING_PUNCTUATION_RE, ""); const beforeBytes = encoder.encode(text.slice(0, match.index)); const matchBytes = encoder.encode(cleanUrl); facets.push({ index: { byteStart: beforeBytes.length, byteEnd: beforeBytes.length + matchBytes.length, }, features: [{ $type: "app.bsky.richtext.facet#link", uri: cleanUrl }], }); } // Detect hashtags HASHTAG_REGEX.lastIndex = 0; while ((match = HASHTAG_REGEX.exec(text)) !== null) { const tag = match[1]; if (!tag) continue; // Include the # in the byte range const beforeBytes = encoder.encode(text.slice(0, match.index)); const matchBytes = encoder.encode(match[0]); facets.push({ index: { byteStart: beforeBytes.length, byteEnd: beforeBytes.length + matchBytes.length, }, features: [{ $type: "app.bsky.richtext.facet#tag", tag }], }); } return facets; } // ── Utilities ─────────────────────────────────────────────────── /** * Truncate a string to a maximum number of graphemes. * Uses Intl.Segmenter for correct Unicode handling. */ function truncateGraphemes(text: string, maxGraphemes: number): string { // Intl.Segmenter handles multi-codepoint graphemes (emoji, combining chars) const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); const segments = [...segmenter.segment(text)]; if (segments.length <= maxGraphemes) return text; // Truncate and add ellipsis return ( segments .slice(0, maxGraphemes - 1) .map((s) => s.segment) .join("") + "\u2026" ); } function stripTrailingSlash(url: string): string { return url.endsWith("/") ? url.slice(0, -1) : url; }