first commit

This commit is contained in:
Matt Kane
2026-04-01 10:44:22 +01:00
commit 43fcb9a131
1789 changed files with 395041 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
import { describe, it, expect } from "vitest";
import { rkeyFromUri } from "../src/atproto.js";
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");
});
});

View File

@@ -0,0 +1,209 @@
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("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,82 @@
import { describe, it, expect } from "vitest";
import { atprotoPlugin, createPlugin } 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("@emdashcms/plugin-atproto");
expect(descriptor.adminPages).toHaveLength(1);
expect(descriptor.adminWidgets).toHaveLength(1);
});
it("passes options through", () => {
const descriptor = atprotoPlugin({});
expect(descriptor.options).toEqual({});
});
});
describe("createPlugin", () => {
it("returns a valid ResolvedPlugin", () => {
const plugin = createPlugin();
expect(plugin.id).toBe("atproto");
expect(plugin.version).toBe("0.1.0");
expect(plugin.capabilities).toContain("read:content");
expect(plugin.capabilities).toContain("network:fetch:any");
});
it("uses unrestricted network access (implies network:fetch)", () => {
const plugin = createPlugin();
expect(plugin.capabilities).toContain("network:fetch:any");
// network:fetch:any implies network:fetch via definePlugin normalization
expect(plugin.capabilities).toContain("network:fetch");
});
it("declares storage with records collection", () => {
const plugin = createPlugin();
expect(plugin.storage).toHaveProperty("records");
expect(plugin.storage!.records!.indexes).toContain("contentId");
expect(plugin.storage!.records!.indexes).toContain("status");
});
it("has content:afterSave hook with errorPolicy continue", () => {
const plugin = createPlugin();
const hook = plugin.hooks!["content:afterSave"];
expect(hook).toBeDefined();
// Hook is configured with full config object
expect((hook as { errorPolicy: string }).errorPolicy).toBe("continue");
});
it("has content:afterDelete hook", () => {
const plugin = createPlugin();
expect(plugin.hooks!["content:afterDelete"]).toBeDefined();
});
it("has page:metadata hook", () => {
const plugin = createPlugin();
expect(plugin.hooks!["page:metadata"]).toBeDefined();
});
it("has settings schema with required fields", () => {
const plugin = createPlugin();
const schema = plugin.admin!.settingsSchema!;
expect(schema).toHaveProperty("handle");
expect(schema).toHaveProperty("appPassword");
expect(schema).toHaveProperty("siteUrl");
expect(schema).toHaveProperty("enableBskyCrosspost");
expect(schema).toHaveProperty("crosspostTemplate");
expect(schema).toHaveProperty("langs");
expect(schema.appPassword!.type).toBe("secret");
});
it("has routes for status, test-connection, sync-publication", () => {
const plugin = createPlugin();
expect(plugin.routes).toHaveProperty("status");
expect(plugin.routes).toHaveProperty("test-connection");
expect(plugin.routes).toHaveProperty("sync-publication");
expect(plugin.routes).toHaveProperty("recent-syncs");
expect(plugin.routes).toHaveProperty("verification");
});
});

View File

@@ -0,0 +1,174 @@
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("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);
});
});