Emdash source with visual editor image upload fix

Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { getAdminPageTarget } from "../src/admin-routing.js";
describe("getAdminPageTarget", () => {
it.each([
[undefined, "status"],
[{}, "status"],
[{ type: "page_load" }, "status"],
[{ type: "page_load", page: "/" }, "status"],
[{ type: "page_load", page: "/settings" }, "status"],
[{ type: "page_load", page: "/status" }, "status"],
[{ type: "page_load", page: "widget:sync-status" }, "sync-widget"],
[{ type: "page_load", page: "/unknown" }, null],
[{ type: "block_action", page: "/status" }, null],
])("maps %j to %s", (interaction, expected) => {
expect(getAdminPageTarget(interaction)).toBe(expected);
});
});

View File

@@ -0,0 +1,106 @@
import { describe, it, expect, vi } from "vitest";
import { createRecord, normalizePdsHost, rkeyFromUri } from "../src/atproto.js";
describe("normalizePdsHost", () => {
it("defaults to bsky.social", () => {
expect(normalizePdsHost(undefined)).toBe("bsky.social");
});
it("accepts host-only values", () => {
expect(normalizePdsHost("bsky.social")).toBe("bsky.social");
});
it("accepts full PDS URLs", () => {
expect(normalizePdsHost("https://bsky.social")).toBe("bsky.social");
expect(normalizePdsHost("https://example.com/")).toBe("example.com");
});
it("preserves ports for https URLs", () => {
expect(normalizePdsHost("https://localhost:2583")).toBe("localhost:2583");
});
it("rejects non-https protocols", () => {
expect(() => normalizePdsHost("http://localhost:2583")).toThrow(
"Invalid PDS host protocol: http:",
);
});
});
describe("rkeyFromUri", () => {
it("extracts rkey from a standard AT-URI", () => {
const rkey = rkeyFromUri("at://did:plc:abc123/site.standard.document/3lwafzkjqm25s");
expect(rkey).toBe("3lwafzkjqm25s");
});
it("extracts rkey from a Bluesky post URI", () => {
const rkey = rkeyFromUri("at://did:plc:abc123/app.bsky.feed.post/3k4duaz5vfs2b");
expect(rkey).toBe("3k4duaz5vfs2b");
});
it("throws on empty URI", () => {
expect(() => rkeyFromUri("")).toThrow("Invalid AT-URI");
});
});
describe("createRecord", () => {
it("refreshes the session when the PDS returns a 400 ExpiredToken response", async () => {
const kv = new Map<string, unknown>([
["settings:pdsHost", "bsky.social"],
["settings:handle", "example.com"],
["settings:appPassword", "app-password"],
["state:accessJwt", "stale-access"],
["state:refreshJwt", "refresh-token"],
["state:did", "did:plc:test"],
]);
const fetch = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify({ error: "ExpiredToken", message: "Token has expired" }), {
status: 400,
}),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({
accessJwt: "fresh-access",
refreshJwt: "fresh-refresh",
did: "did:plc:test",
handle: "example.com",
}),
{ status: 200 },
),
)
.mockResolvedValueOnce(
new Response(
JSON.stringify({ uri: "at://did:plc:test/site.standard.publication/abc", cid: "cid" }),
{
status: 200,
},
),
);
const ctx = {
http: { fetch },
kv: {
get: vi.fn(async (key: string) => kv.get(key)),
set: vi.fn(async (key: string, value: unknown) => {
kv.set(key, value);
}),
},
} as any;
const result = await createRecord(
ctx,
"bsky.social",
"stale-access",
"did:plc:test",
"site.standard.publication",
{ name: "Example Site" },
);
expect(result).toEqual({ uri: "at://did:plc:test/site.standard.publication/abc", cid: "cid" });
expect(fetch).toHaveBeenCalledTimes(3);
expect(kv.get("state:accessJwt")).toBe("fresh-access");
expect(kv.get("state:refreshJwt")).toBe("fresh-refresh");
});
});

View File

@@ -0,0 +1,223 @@
import { describe, it, expect } from "vitest";
import { buildBskyPost, buildFacets } from "../src/bluesky.js";
describe("buildFacets", () => {
it("detects URLs and returns correct byte offsets", () => {
const text = "Check out https://example.com for more";
const facets = buildFacets(text);
expect(facets).toHaveLength(1);
const facet = facets[0]!;
expect(facet.features[0]).toEqual({
$type: "app.bsky.richtext.facet#link",
uri: "https://example.com",
});
// Verify byte offsets match
const encoder = new TextEncoder();
const bytes = encoder.encode(text);
const extracted = new TextDecoder().decode(
bytes.slice(facet.index.byteStart, facet.index.byteEnd),
);
expect(extracted).toBe("https://example.com");
});
it("handles multiple URLs", () => {
const text = "Visit https://a.com and https://b.com today";
const facets = buildFacets(text);
expect(facets).toHaveLength(2);
expect(facets[0]!.features[0]).toHaveProperty("uri", "https://a.com");
expect(facets[1]!.features[0]).toHaveProperty("uri", "https://b.com");
});
it("detects hashtags", () => {
const text = "Hello #world #atproto";
const facets = buildFacets(text);
const tagFacets = facets.filter((f) => f.features[0]?.$type === "app.bsky.richtext.facet#tag");
expect(tagFacets).toHaveLength(2);
expect(tagFacets[0]!.features[0]).toHaveProperty("tag", "world");
expect(tagFacets[1]!.features[0]).toHaveProperty("tag", "atproto");
});
it("handles UTF-8 multibyte characters before URLs", () => {
// Emoji is multiple UTF-8 bytes but one grapheme
const text = "Great post! 🎉 https://example.com";
const facets = buildFacets(text);
expect(facets).toHaveLength(1);
const encoder = new TextEncoder();
const bytes = encoder.encode(text);
const extracted = new TextDecoder().decode(
bytes.slice(facets[0]!.index.byteStart, facets[0]!.index.byteEnd),
);
expect(extracted).toBe("https://example.com");
});
it("returns empty array for text with no URLs or hashtags", () => {
const facets = buildFacets("Just some plain text here");
expect(facets).toEqual([]);
});
it("does not match hashtag at start of word", () => {
// Hashtag requires preceding whitespace or start of string
const text = "foo#bar";
const facets = buildFacets(text);
const tagFacets = facets.filter((f) => f.features[0]?.$type === "app.bsky.richtext.facet#tag");
expect(tagFacets).toHaveLength(0);
});
it("strips trailing punctuation from URLs", () => {
const text = "Visit https://example.com/post. More text";
const facets = buildFacets(text);
expect(facets).toHaveLength(1);
expect(facets[0]!.features[0]).toHaveProperty("uri", "https://example.com/post");
});
it("strips trailing comma from URL", () => {
const text = "See https://example.com/a, https://example.com/b";
const facets = buildFacets(text);
expect(facets).toHaveLength(2);
expect(facets[0]!.features[0]).toHaveProperty("uri", "https://example.com/a");
expect(facets[1]!.features[0]).toHaveProperty("uri", "https://example.com/b");
});
it("strips trailing exclamation from URL", () => {
const text = "Check https://example.com!";
const facets = buildFacets(text);
expect(facets[0]!.features[0]).toHaveProperty("uri", "https://example.com");
});
});
describe("buildBskyPost", () => {
const baseContent = {
title: "My Article",
slug: "my-article",
excerpt: "A short description",
};
it("builds a post with template substitution", () => {
const post = buildBskyPost({
template: "{title}\n\n{url}",
content: baseContent,
siteUrl: "https://myblog.com",
});
expect(post.$type).toBe("app.bsky.feed.post");
expect(post.text).toBe("My Article\n\nhttps://myblog.com/my-article");
expect(post.createdAt).toBeDefined();
});
it("includes langs when provided", () => {
const post = buildBskyPost({
template: "{title}",
content: baseContent,
siteUrl: "https://myblog.com",
langs: ["en", "fr"],
});
expect(post.langs).toEqual(["en", "fr"]);
});
it("limits langs to 3", () => {
const post = buildBskyPost({
template: "{title}",
content: baseContent,
siteUrl: "https://myblog.com",
langs: ["en", "fr", "de", "es"],
});
expect(post.langs).toHaveLength(3);
});
it("includes link card embed", () => {
const post = buildBskyPost({
template: "{title}",
content: baseContent,
siteUrl: "https://myblog.com",
});
expect(post.embed).toEqual({
$type: "app.bsky.embed.external",
external: {
uri: "https://myblog.com/my-article",
title: "My Article",
description: "A short description",
},
});
});
it("includes thumb in embed when provided", () => {
const thumb = {
$type: "blob" as const,
ref: { $link: "bafkrei123" },
mimeType: "image/jpeg",
size: 45000,
};
const post = buildBskyPost({
template: "{title}",
content: baseContent,
siteUrl: "https://myblog.com",
thumbBlob: thumb,
});
expect(post.embed?.external.thumb).toBe(thumb);
});
it("auto-detects URLs in text for facets", () => {
const post = buildBskyPost({
template: "New post: {url}",
content: baseContent,
siteUrl: "https://myblog.com",
});
expect(post.facets).toBeDefined();
expect(post.facets!.length).toBeGreaterThan(0);
expect(post.facets![0]!.features[0]).toHaveProperty("uri", "https://myblog.com/my-article");
});
it("substitutes {excerpt} in template", () => {
const post = buildBskyPost({
template: "{title}: {excerpt}",
content: baseContent,
siteUrl: "https://myblog.com",
});
expect(post.text).toBe("My Article: A short description");
});
it("strips trailing slash from siteUrl", () => {
const post = buildBskyPost({
template: "{url}",
content: baseContent,
siteUrl: "https://myblog.com/",
});
expect(post.text).toBe("https://myblog.com/my-article");
});
it("reads slug from content.data when building URLs", () => {
const post = buildBskyPost({
template: "{url}",
collection: "posts",
content: {
title: "Nested Slug",
data: { slug: "nested-slug" },
},
siteUrl: "https://myblog.com",
});
expect(post.text).toBe("https://myblog.com/posts/nested-slug");
expect(post.embed?.external.uri).toBe("https://myblog.com/posts/nested-slug");
});
it("skips facets when text is truncated to avoid partial URL links", () => {
// Create content with very long excerpt that forces truncation
const longExcerpt = "A".repeat(300);
const post = buildBskyPost({
template: "{excerpt} {url}",
content: { ...baseContent, excerpt: longExcerpt },
siteUrl: "https://myblog.com",
});
// Text was truncated (>300 graphemes), so facets should be omitted
expect(post.facets).toBeUndefined();
// But embed should still have the full URL
expect(post.embed?.external.uri).toBe("https://myblog.com/my-article");
});
});

View File

@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import { atprotoPlugin } from "../src/index.js";
describe("atprotoPlugin descriptor", () => {
it("returns a valid PluginDescriptor", () => {
const descriptor = atprotoPlugin();
expect(descriptor.id).toBe("atproto");
expect(descriptor.version).toBe("0.1.0");
expect(descriptor.entrypoint).toBe("@emdash-cms/plugin-atproto/sandbox");
expect(descriptor.adminPages).toHaveLength(1);
expect(descriptor.adminWidgets).toHaveLength(1);
});
it("uses standard format", () => {
const descriptor = atprotoPlugin();
expect(descriptor.format).toBe("standard");
});
it("declares required capabilities", () => {
const descriptor = atprotoPlugin();
expect(descriptor.capabilities).toContain("read:content");
expect(descriptor.capabilities).toContain("network:fetch:any");
});
it("declares the storage used by the sandbox implementation", () => {
const descriptor = atprotoPlugin();
expect(descriptor.storage).toHaveProperty("records");
expect(descriptor.storage!.records!.indexes).toContain("contentId");
expect(descriptor.storage!.records!.indexes).toContain("status");
expect(descriptor.storage!.records!.indexes).toContain("lastSyncedAt");
});
it("exposes an admin status page and widget", () => {
const descriptor = atprotoPlugin();
expect(descriptor.adminPages).toEqual([
{ path: "/status", label: "AT Protocol", icon: "globe" },
]);
expect(descriptor.adminWidgets).toEqual([
{ id: "sync-status", title: "AT Protocol", size: "third" },
]);
});
});

View File

@@ -0,0 +1,102 @@
import { describe, it, expect, vi } from "vitest";
vi.mock("emdash", () => ({
definePlugin: (definition: unknown) => definition,
}));
function createCtx() {
return {
kv: {
get: vi.fn(async () => undefined),
set: vi.fn(async () => undefined),
},
storage: {
records: {
get: vi.fn(async () => null),
put: vi.fn(async () => undefined),
},
},
http: {
fetch: vi.fn(),
},
log: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
};
}
describe("sandbox hooks", () => {
it("does not create syndication records from afterSave when published content has not been synced", async () => {
const { default: plugin } = await import("../src/sandbox-entry.js");
const ctx = createCtx();
const handler = (plugin as any).hooks["content:afterSave"].handler;
await handler(
{
collection: "posts",
isNew: false,
content: {
id: "post-1",
status: "published",
title: "A published edit",
},
},
ctx,
);
expect(ctx.storage.records.get).toHaveBeenCalledWith("posts:post-1");
expect(ctx.storage.records.put).not.toHaveBeenCalled();
expect(ctx.http.fetch).not.toHaveBeenCalled();
expect(ctx.kv.get).not.toHaveBeenCalledWith("settings:siteUrl");
});
it("does not syndicate pages by default", async () => {
const { default: plugin } = await import("../src/sandbox-entry.js");
const ctx = createCtx();
const handler = (plugin as any).hooks["content:afterPublish"].handler;
await handler(
{
collection: "pages",
content: {
id: "page-1",
status: "published",
title: "About",
},
},
ctx,
);
expect(ctx.kv.get).toHaveBeenCalledWith("settings:collections");
expect(ctx.storage.records.get).not.toHaveBeenCalled();
expect(ctx.storage.records.put).not.toHaveBeenCalled();
expect(ctx.http.fetch).not.toHaveBeenCalled();
});
it("does not expose standard.site metadata for pages by default", async () => {
const { default: plugin } = await import("../src/sandbox-entry.js");
const ctx = createCtx();
ctx.storage.records.get.mockResolvedValueOnce({
atUri: "at://did:example/site.standard.document/abc",
status: "synced",
});
const result = await (plugin as any).hooks["page:metadata"](
{
page: {
content: {
collection: "pages",
id: "page-1",
},
},
},
ctx,
);
expect(result).toBeNull();
expect(ctx.kv.get).toHaveBeenCalledWith("settings:collections");
expect(ctx.storage.records.get).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,187 @@
import { describe, it, expect } from "vitest";
import { buildPublication, buildDocument, extractPlainText } from "../src/standard-site.js";
describe("buildPublication", () => {
it("builds a publication record with required fields", () => {
const pub = buildPublication("https://myblog.com", "My Blog");
expect(pub).toEqual({
$type: "site.standard.publication",
url: "https://myblog.com",
name: "My Blog",
});
});
it("strips trailing slash from URL", () => {
const pub = buildPublication("https://myblog.com/", "My Blog");
expect(pub.url).toBe("https://myblog.com");
});
it("includes description when provided", () => {
const pub = buildPublication("https://myblog.com", "My Blog", "A personal blog");
expect(pub.description).toBe("A personal blog");
});
it("omits description when not provided", () => {
const pub = buildPublication("https://myblog.com", "My Blog");
expect(pub).not.toHaveProperty("description");
});
});
describe("buildDocument", () => {
const baseOpts = {
publicationUri: "at://did:plc:abc123/site.standard.publication/3lwafz",
content: {
title: "Hello World",
slug: "hello-world",
excerpt: "A great post",
published_at: "2025-01-15T12:00:00.000Z",
updated_at: "2025-01-16T10:00:00.000Z",
body: "<p>This is the body</p>",
tags: ["tech", "web"],
},
};
it("builds a document with all fields", () => {
const doc = buildDocument(baseOpts);
expect(doc.$type).toBe("site.standard.document");
expect(doc.site).toBe(baseOpts.publicationUri);
expect(doc.title).toBe("Hello World");
expect(doc.path).toBe("/hello-world");
expect(doc.description).toBe("A great post");
expect(doc.publishedAt).toBe("2025-01-15T12:00:00.000Z");
expect(doc.updatedAt).toBe("2025-01-16T10:00:00.000Z");
expect(doc.tags).toEqual(["tech", "web"]);
expect(doc.textContent).toBe("This is the body");
});
it("uses excerpt field for description", () => {
const doc = buildDocument({
...baseOpts,
content: { ...baseOpts.content, excerpt: undefined, description: "fallback desc" },
});
expect(doc.description).toBe("fallback desc");
});
it("defaults title to Untitled", () => {
const doc = buildDocument({
...baseOpts,
content: { published_at: "2025-01-15T12:00:00.000Z" },
});
expect(doc.title).toBe("Untitled");
});
it("omits path when slug is missing", () => {
const doc = buildDocument({
...baseOpts,
content: { title: "No Slug", published_at: "2025-01-15T12:00:00.000Z" },
});
expect(doc.path).toBeUndefined();
});
it("reads slug from content.data", () => {
const doc = buildDocument({
...baseOpts,
collection: "posts",
content: {
title: "Nested Slug",
data: { slug: "nested-slug" },
published_at: "2025-01-15T12:00:00.000Z",
},
});
expect(doc.path).toBe("/posts/nested-slug");
});
it("includes bskyPostRef when provided", () => {
const doc = buildDocument({
...baseOpts,
bskyPostRef: { uri: "at://did:plc:xyz/app.bsky.feed.post/abc", cid: "bafyrei123" },
});
expect(doc.bskyPostRef).toEqual({
uri: "at://did:plc:xyz/app.bsky.feed.post/abc",
cid: "bafyrei123",
});
});
it("includes coverImage when provided", () => {
const blob = {
$type: "blob" as const,
ref: { $link: "bafkrei123" },
mimeType: "image/jpeg",
size: 45000,
};
const doc = buildDocument({
...baseOpts,
coverImageBlob: blob,
});
expect(doc.coverImage).toBe(blob);
});
it("handles tag objects with name property", () => {
const doc = buildDocument({
...baseOpts,
content: {
...baseOpts.content,
tags: [{ name: "javascript" }, { name: "#python" }],
},
});
expect(doc.tags).toEqual(["javascript", "python"]);
});
it("strips # prefix from string tags", () => {
const doc = buildDocument({
...baseOpts,
content: { ...baseOpts.content, tags: ["#tech", "web", "#dev"] },
});
expect(doc.tags).toEqual(["tech", "web", "dev"]);
});
});
describe("extractPlainText", () => {
it("strips HTML tags", () => {
const text = extractPlainText({ body: "<p>Hello <strong>world</strong></p>" });
expect(text).toBe("Hello world");
});
it("decodes HTML entities", () => {
const text = extractPlainText({ body: "Tom &amp; Jerry &lt;3 &gt; &quot;fun&quot;" });
expect(text).toBe('Tom & Jerry <3 > "fun"');
});
it("collapses whitespace", () => {
const text = extractPlainText({ body: "<p>Hello</p>\n\n<p>World</p>" });
expect(text).toBe("Hello World");
});
it("tries body, content, then text fields", () => {
expect(extractPlainText({ body: "from body" })).toBe("from body");
expect(extractPlainText({ content: "from content" })).toBe("from content");
expect(extractPlainText({ text: "from text" })).toBe("from text");
});
it("returns undefined when no content field exists", () => {
expect(extractPlainText({ title: "just a title" })).toBeUndefined();
});
it("returns undefined for empty body", () => {
expect(extractPlainText({ body: "" })).toBeUndefined();
});
it("handles &nbsp;", () => {
const text = extractPlainText({ body: "hello&nbsp;world" });
expect(text).toBe("hello world");
});
it("does not double-decode &amp;lt;", () => {
// &amp;lt; should become &lt; (literal text), not <
const text = extractPlainText({ body: "code: &amp;lt;div&amp;gt;" });
expect(text).toBe("code: &lt;div&gt;");
});
it("truncates very long text content", () => {
const longBody = "A".repeat(20_000);
const text = extractPlainText({ body: longBody });
expect(text!.length).toBeLessThanOrEqual(10_000);
expect(text!.endsWith("\u2026")).toBe(true);
});
});