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:
19
packages/plugins/atproto/tests/admin-routing.test.ts
Normal file
19
packages/plugins/atproto/tests/admin-routing.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
106
packages/plugins/atproto/tests/atproto.test.ts
Normal file
106
packages/plugins/atproto/tests/atproto.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
223
packages/plugins/atproto/tests/bluesky.test.ts
Normal file
223
packages/plugins/atproto/tests/bluesky.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
43
packages/plugins/atproto/tests/plugin.test.ts
Normal file
43
packages/plugins/atproto/tests/plugin.test.ts
Normal 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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
102
packages/plugins/atproto/tests/sandbox-entry.test.ts
Normal file
102
packages/plugins/atproto/tests/sandbox-entry.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
187
packages/plugins/atproto/tests/standard-site.test.ts
Normal file
187
packages/plugins/atproto/tests/standard-site.test.ts
Normal 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 & Jerry <3 > "fun"" });
|
||||
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 ", () => {
|
||||
const text = extractPlainText({ body: "hello world" });
|
||||
expect(text).toBe("hello world");
|
||||
});
|
||||
|
||||
it("does not double-decode &lt;", () => {
|
||||
// &lt; should become < (literal text), not <
|
||||
const text = extractPlainText({ body: "code: &lt;div&gt;" });
|
||||
expect(text).toBe("code: <div>");
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user