feat: blob images
This commit is contained in:
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
|
"lexicon": 1,
|
||||||
|
"id": "space.litenote.note",
|
||||||
"defs": {
|
"defs": {
|
||||||
"main": {
|
"main": {
|
||||||
|
"type": "record",
|
||||||
"description": "A markdown blog post with LaTeX, GitHub notes, Mermaid, YouTube and Bluesky extensions.",
|
"description": "A markdown blog post with LaTeX, GitHub notes, Mermaid, YouTube and Bluesky extensions.",
|
||||||
"key": "tid",
|
"key": "tid",
|
||||||
"record": {
|
"record": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": ["title", "content"],
|
||||||
"title",
|
|
||||||
"content"
|
|
||||||
],
|
|
||||||
"properties": {
|
"properties": {
|
||||||
"title": {
|
"title": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -16,9 +16,15 @@
|
|||||||
},
|
},
|
||||||
"content": {
|
"content": {
|
||||||
"type": "string",
|
"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
|
"maxLength": 10000
|
||||||
},
|
},
|
||||||
|
"images": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Blob references for images embedded in the markdown content.",
|
||||||
|
"items": { "type": "ref", "ref": "#image" },
|
||||||
|
"maxLength": 20
|
||||||
|
},
|
||||||
"publishedAt": {
|
"publishedAt": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "datetime"
|
"format": "datetime"
|
||||||
@@ -28,10 +34,24 @@
|
|||||||
"format": "datetime"
|
"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
85
publish.test.ts
Normal 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 = "";
|
||||||
|
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 = "";
|
||||||
|
const result = extractLocalImages(md);
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ full: "", alt: "photo", path: "./images/photo.png" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips URL images", () => {
|
||||||
|
const md = "";
|
||||||
|
expect(extractLocalImages(md)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extracts multiple local images", () => {
|
||||||
|
const md = `
|
||||||
|

|
||||||
|
Some text
|
||||||
|

|
||||||
|

|
||||||
|
`;
|
||||||
|
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 = "";
|
||||||
|
const result = extractLocalImages(md);
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ full: "", alt: "", path: "./no-alt.png" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles http:// URLs (case insensitive)", () => {
|
||||||
|
const md = "";
|
||||||
|
expect(extractLocalImages(md)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
181
publish.ts
Normal file
181
publish.ts
Normal 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  (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 `` 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, ``);
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user