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

View File

@@ -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
}
}

85
publish.test.ts Normal file
View File

@@ -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([]);
});
});

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 };
}