/** * Publish a markdown note as an AT Protocol record. * * - Strips links to unpublished notes (keeps just the link text) * - Uploads local images as blobs and replaces paths with CIDs * - Creates the space.litenote.note record */ import { resolve, dirname } from "path"; interface BlobRef { $type: "blob"; ref: { $link: string }; mimeType: string; size: number; } interface ImageEntry { image: BlobRef; alt: string; } interface PublishOptions { /** PDS host, e.g. "https://bsky.social" */ pdsHost: string; /** Author DID */ did: string; /** Auth token for XRPC calls */ accessJwt: string; /** Note title */ title: string; /** Raw markdown content */ content: string; /** Base directory for resolving relative image paths */ baseDir: string; /** Predicate that returns true if a link target is an unpublished note */ isUnpublishedLink?: (href: string) => boolean; } const MIME_TYPES: Record = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml", ".avif": "image/avif", }; /** * Strip markdown links where the href points to an unpublished note. * `[text](unpublished-ref)` → `text` */ export function stripUnpublishedLinks( markdown: string, isUnpublished: (href: string) => boolean, ): string { // Match [text](href) but not ![alt](src) (images start with !) return markdown.replace( /(? { if (isUnpublished(href)) { return text; } return match; }, ); } /** * Extract local image references from markdown. * Returns matches for `![alt](path)` where path is not a URL. */ export function extractLocalImages( markdown: string, ): Array<{ full: string; alt: string; path: string }> { const results: Array<{ full: string; alt: string; path: string }> = []; const regex = /!\[([^\]]*)\]\(([^)]+)\)/g; let m: RegExpExecArray | null; while ((m = regex.exec(markdown)) !== null) { const [full, alt, path] = m; // Skip URLs if (/^https?:\/\//i.test(path)) continue; results.push({ full, alt, path }); } return results; } function getMimeType(filePath: string): string { const ext = filePath.toLowerCase().match(/\.[^.]+$/)?.[0] ?? ""; return MIME_TYPES[ext] ?? "application/octet-stream"; } async function uploadBlob( pdsHost: string, accessJwt: string, filePath: string, ): Promise { const file = Bun.file(filePath); const bytes = await file.arrayBuffer(); const mimeType = getMimeType(filePath); const res = await fetch(`${pdsHost}/xrpc/com.atproto.repo.uploadBlob`, { method: "POST", headers: { "Content-Type": mimeType, Authorization: `Bearer ${accessJwt}`, }, body: bytes, }); if (!res.ok) { const text = await res.text(); throw new Error(`uploadBlob failed (${res.status}): ${text}`); } const data = (await res.json()) as { blob: BlobRef }; return data.blob; } export async function publish(opts: PublishOptions) { const { pdsHost, did, accessJwt, title, baseDir } = opts; let content = opts.content; // 1. Strip links to unpublished notes if (opts.isUnpublishedLink) { content = stripUnpublishedLinks(content, opts.isUnpublishedLink); } // 2. Extract & upload local images const localImages = extractLocalImages(content); const images: ImageEntry[] = []; for (const img of localImages) { const absPath = resolve(baseDir, img.path); const blob = await uploadBlob(pdsHost, accessJwt, absPath); const cid = blob.ref.$link; // Replace the local path with the CID in markdown content = content.replace(img.full, `![${img.alt}](${cid})`); images.push({ image: blob, alt: img.alt }); } // 3. Create the record const now = new Date().toISOString(); const record: Record = { $type: "space.litenote.note", title, content, publishedAt: now, createdAt: now, }; if (images.length > 0) { record.images = images; } const res = await fetch( `${pdsHost}/xrpc/com.atproto.repo.createRecord`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessJwt}`, }, body: JSON.stringify({ repo: did, collection: "space.litenote.note", record, }), }, ); if (!res.ok) { const text = await res.text(); throw new Error(`createRecord failed (${res.status}): ${text}`); } return (await res.json()) as { uri: string; cid: string }; }