feat: blob images

This commit is contained in:
Julien Calixte
2026-02-08 15:12:41 +01:00
parent fa3481b071
commit 9f2a34554b
3 changed files with 297 additions and 11 deletions

181
publish.ts Normal file
View File

@@ -0,0 +1,181 @@
/**
* 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<string, string> = {
".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(
/(?<!!)\[([^\]]*)\]\(([^)]+)\)/g,
(match, text, href) => {
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<BlobRef> {
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<string, unknown> = {
$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 };
}