From 9f2a34554b4afb32c684154f58b4aa9b9f3c9786 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sun, 8 Feb 2026 15:12:41 +0100 Subject: [PATCH] feat: blob images --- lexicons/space/litenote/note.json | 42 +++++-- publish.test.ts | 85 ++++++++++++++ publish.ts | 181 ++++++++++++++++++++++++++++++ 3 files changed, 297 insertions(+), 11 deletions(-) create mode 100644 publish.test.ts create mode 100644 publish.ts diff --git a/lexicons/space/litenote/note.json b/lexicons/space/litenote/note.json index a9b0752..70a821e 100644 --- a/lexicons/space/litenote/note.json +++ b/lexicons/space/litenote/note.json @@ -1,14 +1,14 @@ { + "lexicon": 1, + "id": "space.litenote.note", "defs": { "main": { + "type": "record", "description": "A markdown blog post with LaTeX, GitHub notes, Mermaid, YouTube and Bluesky extensions.", "key": "tid", "record": { "type": "object", - "required": [ - "title", - "content" - ], + "required": ["title", "content"], "properties": { "title": { "type": "string", @@ -16,9 +16,15 @@ }, "content": { "type": "string", - "description": "Markdown content of the blog post", + "description": "Markdown content. Local image paths are replaced with blob CIDs at publish time.", "maxLength": 10000 }, + "images": { + "type": "array", + "description": "Blob references for images embedded in the markdown content.", + "items": { "type": "ref", "ref": "#image" }, + "maxLength": 20 + }, "publishedAt": { "type": "string", "format": "datetime" @@ -28,10 +34,24 @@ "format": "datetime" } } - }, - "type": "record" + } + }, + "image": { + "type": "object", + "descriptions": "Images from the markdown content, separately uploaded and link in markdown content with blob CID.", + "required": ["image"], + "properties": { + "image": { + "type": "blob", + "accept": ["image/*"], + "maxSize": 2000000 + }, + "alt": { + "type": "string", + "maxLength": 2000, + "description": "Alt text for the image." + } + } } - }, - "id": "space.litenote.note", - "lexicon": 1 -} \ No newline at end of file + } +} diff --git a/publish.test.ts b/publish.test.ts new file mode 100644 index 0000000..38088aa --- /dev/null +++ b/publish.test.ts @@ -0,0 +1,85 @@ +import { test, expect, describe } from "bun:test"; +import { stripUnpublishedLinks, extractLocalImages } from "./publish"; + +describe("stripUnpublishedLinks", () => { + const isUnpublished = (href: string) => + !href.startsWith("http://") && !href.startsWith("https://"); + + test("strips links to unpublished notes", () => { + const md = "Check out [my draft](draft-note) for details."; + expect(stripUnpublishedLinks(md, isUnpublished)).toBe( + "Check out my draft for details.", + ); + }); + + test("keeps links to published URLs", () => { + const md = "Visit [Google](https://google.com) today."; + expect(stripUnpublishedLinks(md, isUnpublished)).toBe(md); + }); + + test("strips multiple unpublished links", () => { + const md = "See [note A](note-a) and [note B](note-b)."; + expect(stripUnpublishedLinks(md, isUnpublished)).toBe( + "See note A and note B.", + ); + }); + + test("handles mixed links", () => { + const md = + "Read [docs](https://docs.example.com) and [my note](local-note)."; + expect(stripUnpublishedLinks(md, isUnpublished)).toBe( + "Read [docs](https://docs.example.com) and my note.", + ); + }); + + test("does not strip image references", () => { + const md = "![alt text](./images/photo.png)"; + expect(stripUnpublishedLinks(md, isUnpublished)).toBe(md); + }); + + test("handles empty link text", () => { + const md = "[](unpublished-ref)"; + expect(stripUnpublishedLinks(md, isUnpublished)).toBe(""); + }); +}); + +describe("extractLocalImages", () => { + test("extracts local image paths", () => { + const md = "![photo](./images/photo.png)"; + const result = extractLocalImages(md); + expect(result).toEqual([ + { full: "![photo](./images/photo.png)", alt: "photo", path: "./images/photo.png" }, + ]); + }); + + test("skips URL images", () => { + const md = "![logo](https://example.com/logo.png)"; + expect(extractLocalImages(md)).toEqual([]); + }); + + test("extracts multiple local images", () => { + const md = ` +![a](./a.png) +Some text +![b](../assets/b.jpg) +![c](https://cdn.example.com/c.webp) +`; + const result = extractLocalImages(md); + expect(result).toHaveLength(2); + expect(result[0].path).toBe("./a.png"); + expect(result[1].path).toBe("../assets/b.jpg"); + }); + + test("handles images with empty alt", () => { + const md = "![](./no-alt.png)"; + const result = extractLocalImages(md); + expect(result).toEqual([ + { full: "![](./no-alt.png)", alt: "", path: "./no-alt.png" }, + ]); + }); + + test("handles http:// URLs (case insensitive)", () => { + const md = "![img](HTTP://example.com/img.png)"; + expect(extractLocalImages(md)).toEqual([]); + }); +}); diff --git a/publish.ts b/publish.ts new file mode 100644 index 0000000..ee163d2 --- /dev/null +++ b/publish.ts @@ -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 = { + ".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 }; +}