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,34 @@
{
"name": "@emdashcms/plugin-atproto",
"version": "0.0.1",
"description": "AT Protocol / standard.site syndication plugin for EmDash CMS",
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./sandbox": "./src/sandbox-entry.ts"
},
"files": ["src"],
"keywords": [
"emdash",
"cms",
"plugin",
"atproto",
"bluesky",
"standard-site",
"syndication",
"fediverse"
],
"author": "Matt Kane",
"license": "MIT",
"peerDependencies": {
"emdash": "workspace:*"
},
"devDependencies": {
"vitest": "catalog:"
},
"scripts": {
"test": "vitest run",
"typecheck": "tsgo --noEmit"
}
}

View File

@@ -0,0 +1,408 @@
/**
* AT Protocol client helpers
*
* Handles session management, record CRUD, and handle resolution.
* All HTTP goes through ctx.http.fetch() for sandbox compatibility.
*/
import type { PluginContext } from "emdash";
// ── Types ───────────────────────────────────────────────────────
export interface AtSession {
accessJwt: string;
refreshJwt: string;
did: string;
handle: string;
}
export interface AtRecord {
uri: string;
cid: string;
}
export interface BlobRef {
$type: "blob";
ref: { $link: string };
mimeType: string;
size: number;
}
// ── Helpers ─────────────────────────────────────────────────────
/** Get the HTTP client from plugin context, or throw a helpful error. */
export function requireHttp(ctx: PluginContext) {
if (!ctx.http) {
throw new Error("AT Protocol plugin requires the network:fetch capability");
}
return ctx.http;
}
/** Validate that a PDS response contains expected string fields. */
function requireString(data: Record<string, unknown>, field: string, context: string): string {
const value = data[field];
if (typeof value !== "string") {
throw new Error(`${context}: missing or invalid '${field}' in response`);
}
return value;
}
// ── Session management ──────────────────────────────────────────
/**
* Create a new session with the PDS using an app password.
*/
export async function createSession(
ctx: PluginContext,
pdsHost: string,
identifier: string,
password: string,
): Promise<AtSession> {
const http = requireHttp(ctx);
const res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.server.createSession`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identifier, password }),
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`createSession failed (${res.status}): ${body}`);
}
const data = (await res.json()) as Record<string, unknown>;
return {
accessJwt: requireString(data, "accessJwt", "createSession"),
refreshJwt: requireString(data, "refreshJwt", "createSession"),
did: requireString(data, "did", "createSession"),
handle: requireString(data, "handle", "createSession"),
};
}
/**
* Refresh an existing session using the refresh token.
*/
export async function refreshSession(
ctx: PluginContext,
pdsHost: string,
refreshJwt: string,
): Promise<AtSession> {
const http = requireHttp(ctx);
const res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.server.refreshSession`, {
method: "POST",
headers: { Authorization: `Bearer ${refreshJwt}` },
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`refreshSession failed (${res.status}): ${body}`);
}
const data = (await res.json()) as Record<string, unknown>;
return {
accessJwt: requireString(data, "accessJwt", "refreshSession"),
refreshJwt: requireString(data, "refreshJwt", "refreshSession"),
did: requireString(data, "did", "refreshSession"),
handle: requireString(data, "handle", "refreshSession"),
};
}
/**
* In-flight refresh promise for deduplication.
* Prevents concurrent publishes from racing on token refresh,
* which would corrupt tokens since PDS invalidates refresh tokens after use.
*/
let refreshInFlight: Promise<AtSession> | null = null;
/**
* Get a valid access token, refreshing if needed.
* Uses promise deduplication to prevent concurrent refresh races.
*/
export async function ensureSession(ctx: PluginContext): Promise<{
accessJwt: string;
did: string;
pdsHost: string;
}> {
const pdsHost = (await ctx.kv.get<string>("settings:pdsHost")) || "bsky.social";
const handle = await ctx.kv.get<string>("settings:handle");
const appPassword = await ctx.kv.get<string>("settings:appPassword");
if (!handle || !appPassword) {
throw new Error("AT Protocol credentials not configured");
}
// Try existing tokens first
const existingAccess = await ctx.kv.get<string>("state:accessJwt");
const existingRefresh = await ctx.kv.get<string>("state:refreshJwt");
const existingDid = await ctx.kv.get<string>("state:did");
if (existingAccess && existingDid) {
return { accessJwt: existingAccess, did: existingDid, pdsHost };
}
// Try refresh if we have a refresh token (deduplicated)
if (existingRefresh) {
if (!refreshInFlight) {
refreshInFlight = refreshSession(ctx, pdsHost, existingRefresh)
.then(async (session) => {
await persistSession(ctx, session);
return session;
})
.finally(() => {
refreshInFlight = null;
});
}
try {
const session = await refreshInFlight;
return { accessJwt: session.accessJwt, did: session.did, pdsHost };
} catch {
// Refresh failed, fall through to full login
}
}
// Full login
const session = await createSession(ctx, pdsHost, handle, appPassword);
await persistSession(ctx, session);
return { accessJwt: session.accessJwt, did: session.did, pdsHost };
}
async function persistSession(ctx: PluginContext, session: AtSession): Promise<void> {
await ctx.kv.set("state:accessJwt", session.accessJwt);
await ctx.kv.set("state:refreshJwt", session.refreshJwt);
await ctx.kv.set("state:did", session.did);
}
// ── Record CRUD ─────────────────────────────────────────────────
/**
* Create a record on the PDS. Returns the AT-URI and CID.
* Retries once on 401 (expired token) by refreshing the session.
*/
export async function createRecord(
ctx: PluginContext,
pdsHost: string,
accessJwt: string,
did: string,
collection: string,
record: unknown,
): Promise<AtRecord> {
const http = requireHttp(ctx);
let res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.repo.createRecord`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessJwt}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ repo: did, collection, record }),
});
// Retry once on 401 with refreshed token
if (res.status === 401) {
const refreshed = await ensureSessionFresh(ctx, pdsHost);
if (refreshed) {
res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.repo.createRecord`, {
method: "POST",
headers: {
Authorization: `Bearer ${refreshed.accessJwt}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ repo: refreshed.did, collection, record }),
});
}
}
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`createRecord failed (${res.status}): ${body}`);
}
const data = (await res.json()) as Record<string, unknown>;
return {
uri: requireString(data, "uri", "createRecord"),
cid: requireString(data, "cid", "createRecord"),
};
}
/**
* Update (upsert) a record on the PDS.
* Retries once on 401 (expired token).
*/
export async function putRecord(
ctx: PluginContext,
pdsHost: string,
accessJwt: string,
did: string,
collection: string,
rkey: string,
record: unknown,
): Promise<AtRecord> {
const http = requireHttp(ctx);
let res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.repo.putRecord`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessJwt}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ repo: did, collection, rkey, record }),
});
if (res.status === 401) {
const refreshed = await ensureSessionFresh(ctx, pdsHost);
if (refreshed) {
res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.repo.putRecord`, {
method: "POST",
headers: {
Authorization: `Bearer ${refreshed.accessJwt}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ repo: refreshed.did, collection, rkey, record }),
});
}
}
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`putRecord failed (${res.status}): ${body}`);
}
const data = (await res.json()) as Record<string, unknown>;
return {
uri: requireString(data, "uri", "putRecord"),
cid: requireString(data, "cid", "putRecord"),
};
}
/**
* Delete a record from the PDS.
* Retries once on 401 (expired token).
*/
export async function deleteRecord(
ctx: PluginContext,
pdsHost: string,
accessJwt: string,
did: string,
collection: string,
rkey: string,
): Promise<void> {
const http = requireHttp(ctx);
let res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.repo.deleteRecord`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessJwt}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ repo: did, collection, rkey }),
});
if (res.status === 401) {
const refreshed = await ensureSessionFresh(ctx, pdsHost);
if (refreshed) {
res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.repo.deleteRecord`, {
method: "POST",
headers: {
Authorization: `Bearer ${refreshed.accessJwt}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ repo: refreshed.did, collection, rkey }),
});
}
}
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`deleteRecord failed (${res.status}): ${body}`);
}
}
/**
* Force a session refresh (for 401 retry). Clears the stale access token
* and delegates to ensureSession, which handles refresh deduplication.
* Returns null if refresh fails.
*/
async function ensureSessionFresh(
ctx: PluginContext,
_pdsHost: string,
): Promise<{ accessJwt: string; did: string } | null> {
// Clear stale access token so ensureSession will attempt a refresh
await ctx.kv.set("state:accessJwt", "");
try {
const result = await ensureSession(ctx);
return { accessJwt: result.accessJwt, did: result.did };
} catch {
return null;
}
}
// ── Handle resolution ───────────────────────────────────────────
/**
* Resolve an AT Protocol handle to a DID.
* Uses the public API -- no auth required.
*/
export async function resolveHandle(ctx: PluginContext, handle: string): Promise<string> {
const http = requireHttp(ctx);
const res = await http.fetch(
`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`,
);
if (!res.ok) {
throw new Error(`resolveHandle failed for ${handle} (${res.status})`);
}
const data = (await res.json()) as Record<string, unknown>;
return requireString(data, "did", "resolveHandle");
}
// ── Blob upload ─────────────────────────────────────────────────
/**
* Upload a blob (image) to the PDS. Returns a blob reference for embedding.
*/
export async function uploadBlob(
ctx: PluginContext,
pdsHost: string,
accessJwt: string,
imageBytes: ArrayBuffer,
mimeType: string,
): Promise<BlobRef> {
const http = requireHttp(ctx);
const res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.repo.uploadBlob`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessJwt}`,
"Content-Type": mimeType,
},
body: imageBytes,
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`uploadBlob failed (${res.status}): ${body}`);
}
const data = (await res.json()) as Record<string, unknown>;
if (!data.blob || typeof data.blob !== "object") {
throw new Error("uploadBlob: missing 'blob' in response");
}
const blob = data.blob as Record<string, unknown>;
if (!blob.ref || typeof blob.ref !== "object") {
throw new Error("uploadBlob: malformed blob reference in response");
}
return data.blob as BlobRef;
}
// ── Utilities ───────────────────────────────────────────────────
/**
* Extract the rkey from an AT-URI.
* at://did:plc:xxx/collection/rkey -> rkey
*/
export function rkeyFromUri(uri: string): string {
const parts = uri.split("/");
const rkey = parts.at(-1);
if (!rkey) {
throw new Error(`Invalid AT-URI: ${uri}`);
}
return rkey;
}

View File

@@ -0,0 +1,185 @@
/**
* Bluesky cross-posting helpers
*
* Builds app.bsky.feed.post records with link cards and rich text facets.
*/
import type { BlobRef } from "./atproto.js";
// ── Pre-compiled regexes ────────────────────────────────────────
const TEMPLATE_TITLE_RE = /\{title\}/g;
const TEMPLATE_URL_RE = /\{url\}/g;
const TEMPLATE_EXCERPT_RE = /\{excerpt\}/g;
const TRAILING_PUNCTUATION_RE = /[.,;:!?'"]+$/;
// Global regexes for facet detection -- reset lastIndex before each use
const URL_REGEX = /https?:\/\/[^\s)>\]]+/g;
const HASHTAG_REGEX = /(?<=\s|^)#([a-zA-Z0-9_]+)/g;
// ── Types ───────────────────────────────────────────────────────
export interface BskyPost {
$type: "app.bsky.feed.post";
text: string;
createdAt: string;
langs?: string[];
facets?: BskyFacet[];
embed?: BskyEmbed;
}
export interface BskyFacet {
index: { byteStart: number; byteEnd: number };
features: Array<
| { $type: "app.bsky.richtext.facet#link"; uri: string }
| { $type: "app.bsky.richtext.facet#tag"; tag: string }
>;
}
export type BskyEmbed = {
$type: "app.bsky.embed.external";
external: {
uri: string;
title: string;
description: string;
thumb?: BlobRef;
};
};
// ── Post builder ────────────────────────────────────────────────
/**
* Build a Bluesky post record for cross-posting published content.
*/
export function buildBskyPost(opts: {
template: string;
content: Record<string, unknown>;
siteUrl: string;
thumbBlob?: BlobRef;
langs?: string[];
}): BskyPost {
const { template, content, siteUrl, thumbBlob, langs } = opts;
const title = (content.title as string) || "Untitled";
const slug = content.slug as string;
const excerpt = (content.excerpt || content.description || "") as string;
const url = slug ? `${stripTrailingSlash(siteUrl)}/${slug}` : siteUrl;
// Apply template -- substitute before truncation so we can detect
// if the URL survives intact after truncation
const fullText = template
.replace(TEMPLATE_TITLE_RE, title)
.replace(TEMPLATE_URL_RE, url)
.replace(TEMPLATE_EXCERPT_RE, excerpt);
// Truncate to 300 graphemes (Bluesky limit)
const text = truncateGraphemes(fullText, 300);
const wasTruncated = text !== fullText;
const post: BskyPost = {
$type: "app.bsky.feed.post",
text,
createdAt: new Date().toISOString(),
};
if (langs && langs.length > 0) {
post.langs = langs.slice(0, 3); // Max 3 per spec
}
// Auto-detect URLs in text and build facets.
// If text was truncated, skip facets -- truncation may have cut
// a URL mid-string, producing a broken link facet.
if (!wasTruncated) {
const facets = buildFacets(text);
if (facets.length > 0) {
post.facets = facets;
}
}
// Link card embed
post.embed = {
$type: "app.bsky.embed.external",
external: {
uri: url,
title,
description: truncateGraphemes(excerpt, 300),
...(thumbBlob ? { thumb: thumbBlob } : {}),
},
};
return post;
}
// ── Rich text facets ────────────────────────────────────────────
/**
* Build rich text facets for URLs and hashtags in text.
*
* CRITICAL: Facet byte offsets use UTF-8 bytes, not JavaScript string indices.
*/
export function buildFacets(text: string): BskyFacet[] {
const encoder = new TextEncoder();
const facets: BskyFacet[] = [];
// Detect URLs
let match: RegExpExecArray | null;
URL_REGEX.lastIndex = 0;
while ((match = URL_REGEX.exec(text)) !== null) {
// Strip trailing punctuation that was captured by the greedy regex
const cleanUrl = match[0].replace(TRAILING_PUNCTUATION_RE, "");
const beforeBytes = encoder.encode(text.slice(0, match.index));
const matchBytes = encoder.encode(cleanUrl);
facets.push({
index: {
byteStart: beforeBytes.length,
byteEnd: beforeBytes.length + matchBytes.length,
},
features: [{ $type: "app.bsky.richtext.facet#link", uri: cleanUrl }],
});
}
// Detect hashtags
HASHTAG_REGEX.lastIndex = 0;
while ((match = HASHTAG_REGEX.exec(text)) !== null) {
const tag = match[1];
if (!tag) continue;
// Include the # in the byte range
const beforeBytes = encoder.encode(text.slice(0, match.index));
const matchBytes = encoder.encode(match[0]);
facets.push({
index: {
byteStart: beforeBytes.length,
byteEnd: beforeBytes.length + matchBytes.length,
},
features: [{ $type: "app.bsky.richtext.facet#tag", tag }],
});
}
return facets;
}
// ── Utilities ───────────────────────────────────────────────────
/**
* Truncate a string to a maximum number of graphemes.
* Uses Intl.Segmenter for correct Unicode handling.
*/
function truncateGraphemes(text: string, maxGraphemes: number): string {
// Intl.Segmenter handles multi-codepoint graphemes (emoji, combining chars)
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const segments = [...segmenter.segment(text)];
if (segments.length <= maxGraphemes) return text;
// Truncate and add ellipsis
return (
segments
.slice(0, maxGraphemes - 1)
.map((s) => s.segment)
.join("") + "\u2026"
);
}
function stripTrailingSlash(url: string): string {
return url.endsWith("/") ? url.slice(0, -1) : url;
}

View File

@@ -0,0 +1,42 @@
/**
* AT Protocol / standard.site Plugin for EmDash CMS
*
* Syndicates published content to the AT Protocol network using the
* standard.site lexicons, with optional cross-posting to Bluesky.
*
* Features:
* - Creates site.standard.publication record (one per site)
* - Creates site.standard.document records on publish
* - Optional Bluesky cross-post with link card
* - Automatic <link rel="site.standard.document"> injection via page:metadata
* - Sync status tracking in plugin storage
*
* Designed for sandboxed execution:
* - All HTTP via ctx.http.fetch()
* - Block Kit admin UI (no React components)
* - Capabilities: read:content, network:fetch:any
*/
import type { PluginDescriptor } from "emdash";
// ── Descriptor ──────────────────────────────────────────────────
/**
* Create the AT Protocol plugin descriptor.
* Import this in your astro.config.mjs / live.config.ts.
*/
export function atprotoPlugin(): PluginDescriptor {
return {
id: "atproto",
version: "0.1.0",
format: "standard",
entrypoint: "@emdashcms/plugin-atproto/sandbox",
capabilities: ["read:content", "network:fetch:any"],
storage: {
publications: { indexes: ["contentId", "platform", "publishedAt"] },
},
// Block Kit admin pages (no adminEntry needed -- sandboxed)
adminPages: [{ path: "/status", label: "AT Protocol", icon: "globe" }],
adminWidgets: [{ id: "sync-status", title: "AT Protocol", size: "third" }],
};
}

View File

@@ -0,0 +1,671 @@
/**
* Sandbox Entry Point -- AT Protocol
*
* Canonical plugin implementation using the standard format.
* The bundler (tsdown) inlines all local imports from atproto.ts,
* bluesky.ts, and standard-site.ts into a single self-contained file.
*/
import { definePlugin } from "emdash";
import type { PluginContext } from "emdash";
import {
ensureSession,
createRecord,
putRecord,
deleteRecord,
rkeyFromUri,
uploadBlob,
requireHttp,
} from "./atproto.js";
import { buildBskyPost } from "./bluesky.js";
import { buildPublication, buildDocument } from "./standard-site.js";
// ── Types ───────────────────────────────────────────────────────
interface SyndicationRecord {
collection: string;
contentId: string;
atUri: string;
atCid: string;
bskyPostUri?: string;
bskyPostCid?: string;
publishedAt: string;
lastSyncedAt: string;
status: "synced" | "error" | "pending";
errorMessage?: string;
retryCount?: number;
}
// ── Helpers ─────────────────────────────────────────────────────
async function isCollectionAllowed(ctx: PluginContext, collection: string): Promise<boolean> {
const setting = await ctx.kv.get<string>("settings:collections");
if (!setting || setting.trim() === "") return true;
const allowed = setting.split(",").map((s) => s.trim().toLowerCase());
return allowed.includes(collection.toLowerCase());
}
async function syndicateContent(
ctx: PluginContext,
collection: string,
contentId: string,
content: Record<string, unknown>,
): Promise<void> {
const storageKey = `${collection}:${contentId}`;
const existing = (await ctx.storage.records!.get(storageKey)) as SyndicationRecord | null;
if (existing && existing.status === "synced") {
const syncOnUpdate = (await ctx.kv.get<boolean>("settings:syncOnUpdate")) ?? true;
if (!syncOnUpdate) return;
}
const siteUrl = await ctx.kv.get<string>("settings:siteUrl");
if (!siteUrl) throw new Error("Site URL not configured");
const publicationUri = await ctx.kv.get<string>("state:publicationUri");
if (!publicationUri)
throw new Error("Publication record not created yet. Use Sync Publication first.");
const { accessJwt, did, pdsHost } = await ensureSession(ctx);
// Upload cover image if present
let coverImageBlob;
const rawCoverImage = content.cover_image as string | undefined;
if (rawCoverImage) {
let imageUrl = rawCoverImage;
if (imageUrl.startsWith("/")) imageUrl = `${siteUrl}${imageUrl}`;
if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) {
try {
const http = requireHttp(ctx);
const imageRes = await http.fetch(imageUrl);
if (imageRes.ok) {
const bytes = await imageRes.arrayBuffer();
if (bytes.byteLength <= 1_000_000) {
const mimeType = imageRes.headers.get("content-type") || "image/jpeg";
coverImageBlob = await uploadBlob(ctx, pdsHost, accessJwt, bytes, mimeType);
}
}
} catch (error) {
ctx.log.warn("Failed to upload cover image, skipping", error);
}
}
}
let bskyPostRef: { uri: string; cid: string } | undefined;
if (existing && existing.atUri) {
const rkey = rkeyFromUri(existing.atUri);
const doc = buildDocument({
publicationUri,
content,
coverImageBlob,
bskyPostRef:
existing.bskyPostUri && existing.bskyPostCid
? { uri: existing.bskyPostUri, cid: existing.bskyPostCid }
: undefined,
});
const result = await putRecord(
ctx,
pdsHost,
accessJwt,
did,
"site.standard.document",
rkey,
doc,
);
await ctx.storage.records!.put(storageKey, {
collection: existing.collection,
contentId: existing.contentId,
atUri: result.uri,
atCid: result.cid,
bskyPostUri: existing.bskyPostUri,
bskyPostCid: existing.bskyPostCid,
publishedAt: existing.publishedAt,
lastSyncedAt: new Date().toISOString(),
status: "synced",
retryCount: 0,
} satisfies SyndicationRecord);
ctx.log.info(`Updated AT Protocol document for ${collection}/${contentId}`);
} else {
const doc = buildDocument({ publicationUri, content, coverImageBlob });
const result = await createRecord(ctx, pdsHost, accessJwt, did, "site.standard.document", doc);
const enableCrosspost = (await ctx.kv.get<boolean>("settings:enableBskyCrosspost")) ?? true;
if (enableCrosspost) {
try {
const template =
(await ctx.kv.get<string>("settings:crosspostTemplate")) || "{title}\n\n{url}";
const langsStr = (await ctx.kv.get<string>("settings:langs")) || "en";
const langs = langsStr
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.slice(0, 3);
const post = buildBskyPost({
template,
content,
siteUrl,
thumbBlob: coverImageBlob,
langs,
});
const postResult = await createRecord(
ctx,
pdsHost,
accessJwt,
did,
"app.bsky.feed.post",
post,
);
bskyPostRef = { uri: postResult.uri, cid: postResult.cid };
const rkey = rkeyFromUri(result.uri);
const updatedDoc = buildDocument({ publicationUri, content, coverImageBlob, bskyPostRef });
await putRecord(ctx, pdsHost, accessJwt, did, "site.standard.document", rkey, updatedDoc);
ctx.log.info(`Cross-posted ${collection}/${contentId} to Bluesky`);
} catch (error) {
ctx.log.warn("Failed to cross-post to Bluesky, document still synced", error);
}
}
await ctx.storage.records!.put(storageKey, {
collection,
contentId,
atUri: result.uri,
atCid: result.cid,
bskyPostUri: bskyPostRef?.uri,
bskyPostCid: bskyPostRef?.cid,
publishedAt: (content.published_at as string) || new Date().toISOString(),
lastSyncedAt: new Date().toISOString(),
status: "synced",
} satisfies SyndicationRecord);
ctx.log.info(`Created AT Protocol document for ${collection}/${contentId}`);
}
}
// ── Plugin definition ───────────────────────────────────────────
export default definePlugin({
hooks: {
"plugin:install": async (_event: unknown, ctx: PluginContext) => {
ctx.log.info("AT Protocol plugin installed");
},
"content:afterSave": {
handler: async (
event: { content: Record<string, unknown>; collection: string; isNew: boolean },
ctx: PluginContext,
) => {
const { content, collection } = event;
const contentId = typeof content.id === "string" ? content.id : String(content.id);
const status = content.status as string | undefined;
if (status !== "published") return;
if (!(await isCollectionAllowed(ctx, collection))) return;
try {
await syndicateContent(ctx, collection, contentId, content);
} catch (error) {
ctx.log.error(`Failed to syndicate ${collection}/${contentId}`, error);
const storageKey = `${collection}:${contentId}`;
const existing = await ctx.storage.records!.get(storageKey);
const record = (existing as SyndicationRecord | null) || {
collection,
contentId,
atUri: "",
atCid: "",
publishedAt: new Date().toISOString(),
};
await ctx.storage.records!.put(storageKey, {
...record,
status: "error",
lastSyncedAt: new Date().toISOString(),
errorMessage: error instanceof Error ? error.message : String(error),
retryCount: ((record as SyndicationRecord).retryCount || 0) + 1,
});
}
},
},
"content:afterDelete": {
handler: async (event: { id: string; collection: string }, ctx: PluginContext) => {
const { id, collection } = event;
const deleteOnUnpublish = (await ctx.kv.get<boolean>("settings:deleteOnUnpublish")) ?? true;
if (!deleteOnUnpublish) return;
const storageKey = `${collection}:${id}`;
const existing = (await ctx.storage.records!.get(storageKey)) as SyndicationRecord | null;
if (!existing || !existing.atUri) return;
try {
const { accessJwt, did, pdsHost } = await ensureSession(ctx);
const rkey = rkeyFromUri(existing.atUri);
await deleteRecord(ctx, pdsHost, accessJwt, did, "site.standard.document", rkey);
if (existing.bskyPostUri) {
const postRkey = rkeyFromUri(existing.bskyPostUri);
await deleteRecord(ctx, pdsHost, accessJwt, did, "app.bsky.feed.post", postRkey);
}
await ctx.storage.records!.delete(storageKey);
ctx.log.info(`Deleted AT Protocol records for ${collection}/${id}`);
} catch (error) {
ctx.log.error(`Failed to delete AT Protocol records for ${collection}/${id}`, error);
}
},
},
"page:metadata": async (
event: { page: { content?: { collection: string; id: string } } },
ctx: PluginContext,
) => {
const pageContent = event.page.content;
if (!pageContent) return null;
const storageKey = `${pageContent.collection}:${pageContent.id}`;
const record = (await ctx.storage.records!.get(storageKey)) as SyndicationRecord | null;
if (!record || !record.atUri || record.status !== "synced") return null;
return {
kind: "link" as const,
rel: "site.standard.document",
href: record.atUri,
key: "atproto-document",
};
},
},
routes: {
status: {
handler: async (_routeCtx: unknown, ctx: PluginContext) => {
try {
const handle = await ctx.kv.get<string>("settings:handle");
const did = await ctx.kv.get<string>("state:did");
const pubUri = await ctx.kv.get<string>("state:publicationUri");
const synced = await ctx.storage.records!.count({
status: "synced",
});
const errors = await ctx.storage.records!.count({
status: "error",
});
const pending = await ctx.storage.records!.count({
status: "pending",
});
return {
configured: !!handle,
connected: !!did,
handle: handle || null,
did: did || null,
publicationUri: pubUri || null,
stats: { synced, errors, pending },
};
} catch (error) {
ctx.log.error("Failed to get status", error);
return {
configured: false,
connected: false,
handle: null,
did: null,
publicationUri: null,
stats: { synced: 0, errors: 0, pending: 0 },
};
}
},
},
"test-connection": {
handler: async (_routeCtx: unknown, ctx: PluginContext) => {
try {
const session = await ensureSession(ctx);
return {
success: true,
did: session.did,
pdsHost: session.pdsHost,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
},
},
"sync-publication": {
handler: async (_routeCtx: unknown, ctx: PluginContext) => {
try {
const siteUrl = await ctx.kv.get<string>("settings:siteUrl");
const siteName = await ctx.kv.get<string>("settings:siteName");
if (!siteUrl || !siteName)
return {
success: false,
error: "Site URL and name are required",
};
const { accessJwt, did, pdsHost } = await ensureSession(ctx);
const publication = buildPublication(siteUrl, siteName);
const existingUri = await ctx.kv.get<string>("state:publicationUri");
let result;
if (existingUri) {
const rkey = rkeyFromUri(existingUri);
result = await putRecord(
ctx,
pdsHost,
accessJwt,
did,
"site.standard.publication",
rkey,
publication,
);
} else {
result = await createRecord(
ctx,
pdsHost,
accessJwt,
did,
"site.standard.publication",
publication,
);
}
await ctx.kv.set("state:publicationUri", result.uri);
await ctx.kv.set("state:publicationCid", result.cid);
return {
success: true,
uri: result.uri,
cid: result.cid,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
},
},
"recent-syncs": {
handler: async (_routeCtx: unknown, ctx: PluginContext) => {
try {
const result = await ctx.storage.records!.query({
orderBy: { lastSyncedAt: "desc" },
limit: 20,
});
return {
items: result.items.map((item: { id: string; data: unknown }) => ({
id: item.id,
...(item.data as SyndicationRecord),
})),
};
} catch (error) {
ctx.log.error("Failed to get recent syncs", error);
return { items: [] };
}
},
},
verification: {
handler: async (_routeCtx: unknown, ctx: PluginContext) => {
const pubUri = await ctx.kv.get<string>("state:publicationUri");
const siteUrl = await ctx.kv.get<string>("settings:siteUrl");
return {
publicationUri: pubUri || null,
siteUrl: siteUrl || null,
wellKnownPath: "/.well-known/site.standard.publication",
wellKnownContent: pubUri || "(not configured yet)",
};
},
},
admin: {
handler: async (routeCtx: any, ctx: PluginContext) => {
const interaction = routeCtx.input as {
type: string;
page?: string;
action_id?: string;
values?: Record<string, unknown>;
};
if (interaction.type === "page_load" && interaction.page === "widget:sync-status") {
return buildSyncWidget(ctx);
}
if (interaction.type === "page_load" && interaction.page === "/status") {
return buildStatusPage(ctx);
}
if (interaction.type === "form_submit" && interaction.action_id === "save_settings") {
return saveSettings(ctx, interaction.values ?? {});
}
if (interaction.type === "block_action" && interaction.action_id === "test_connection") {
return testConnection(ctx);
}
return { blocks: [] };
},
},
},
});
// ── Block Kit admin helpers ─────────────────────────────────────
async function buildSyncWidget(ctx: PluginContext) {
try {
const handle = await ctx.kv.get<string>("settings:handle");
const did = await ctx.kv.get<string>("state:did");
const synced = await ctx.storage.records!.count({ status: "synced" });
const errors = await ctx.storage.records!.count({ status: "error" });
if (!handle) {
return {
blocks: [
{ type: "context", text: "Not configured -- set your handle in AT Protocol settings." },
],
};
}
return {
blocks: [
{
type: "fields",
fields: [
{ label: "Handle", value: `@${handle}` },
{ label: "Status", value: did ? "Connected" : "Not connected" },
{ label: "Synced", value: String(synced) },
{ label: "Errors", value: String(errors) },
],
},
],
};
} catch (error) {
ctx.log.error("Failed to build sync widget", error);
return { blocks: [{ type: "context", text: "Failed to load status" }] };
}
}
async function buildStatusPage(ctx: PluginContext) {
try {
const handle = await ctx.kv.get<string>("settings:handle");
const appPassword = await ctx.kv.get<string>("settings:appPassword");
const pdsHost = await ctx.kv.get<string>("settings:pdsHost");
const siteUrl = await ctx.kv.get<string>("settings:siteUrl");
const enableCrosspost = await ctx.kv.get<boolean>("settings:enableCrosspost");
const did = await ctx.kv.get<string>("state:did");
const pubUri = await ctx.kv.get<string>("state:publicationUri");
const blocks: unknown[] = [
{ type: "header", text: "AT Protocol" },
{
type: "section",
text: "Syndicate content to the AT Protocol network (Bluesky, standard.site).",
},
{ type: "divider" },
];
if (did) {
blocks.push({
type: "banner",
style: "success",
text: `Connected as ${handle} (${did})`,
});
} else if (handle) {
blocks.push({
type: "banner",
style: "warning",
text: "Handle configured but not yet connected. Save settings and test the connection.",
});
}
blocks.push({
type: "form",
block_id: "atproto-settings",
fields: [
{
type: "text_input",
action_id: "handle",
label: "AT Protocol Handle",
initial_value: handle ?? "",
},
{ type: "secret_input", action_id: "appPassword", label: "App Password" },
{
type: "text_input",
action_id: "pdsHost",
label: "PDS Host",
initial_value: pdsHost ?? "https://bsky.social",
},
{
type: "text_input",
action_id: "siteUrl",
label: "Site URL",
initial_value: siteUrl ?? "",
},
{
type: "toggle",
action_id: "enableCrosspost",
label: "Cross-post to Bluesky",
initial_value: enableCrosspost ?? false,
},
],
submit: { label: "Save Settings", action_id: "save_settings" },
});
blocks.push({
type: "actions",
elements: [
{
type: "button",
text: "Test Connection",
action_id: "test_connection",
style: handle && appPassword ? "primary" : undefined,
},
],
});
if (did) {
const result = await ctx.storage.records!.query({
orderBy: { lastSyncedAt: "desc" },
limit: 10,
});
const items = result.items.map((item: { id: string; data: unknown }) => ({
id: item.id,
...(item.data as SyndicationRecord),
}));
if (items.length > 0) {
blocks.push(
{ type: "divider" },
{ type: "header", text: "Recent Syncs" },
{
type: "table",
columns: [
{ key: "collection", label: "Collection", format: "text" },
{ key: "contentId", label: "Content", format: "code" },
{ key: "status", label: "Status", format: "badge" },
{ key: "lastSyncedAt", label: "Synced", format: "relative_time" },
],
rows: items.map((r) => ({
collection: r.collection,
contentId: r.contentId,
status: r.status,
lastSyncedAt: r.lastSyncedAt,
})),
emptyText: "No syncs yet",
},
);
}
if (pubUri) {
blocks.push(
{ type: "divider" },
{ type: "header", text: "Verification" },
{
type: "fields",
fields: [
{ label: "Publication URI", value: pubUri },
{ label: "Well-known path", value: "/.well-known/site.standard.publication" },
],
},
{
type: "context",
text: "Add this path to your site to verify ownership on the AT Protocol network.",
},
);
}
}
return { blocks };
} catch (error) {
ctx.log.error("Failed to build status page", error);
return { blocks: [{ type: "banner", style: "error", text: "Failed to load settings" }] };
}
}
async function saveSettings(ctx: PluginContext, values: Record<string, unknown>) {
try {
if (typeof values.handle === "string") await ctx.kv.set("settings:handle", values.handle);
if (typeof values.appPassword === "string" && values.appPassword)
await ctx.kv.set("settings:appPassword", values.appPassword);
if (typeof values.pdsHost === "string") await ctx.kv.set("settings:pdsHost", values.pdsHost);
if (typeof values.siteUrl === "string") await ctx.kv.set("settings:siteUrl", values.siteUrl);
if (typeof values.enableCrosspost === "boolean")
await ctx.kv.set("settings:enableCrosspost", values.enableCrosspost);
const page = await buildStatusPage(ctx);
return { ...page, toast: { message: "Settings saved", type: "success" } };
} catch (error) {
ctx.log.error("Failed to save settings", error);
return {
blocks: [{ type: "banner", style: "error", text: "Failed to save settings" }],
toast: { message: "Failed to save settings", type: "error" },
};
}
}
async function testConnection(ctx: PluginContext) {
try {
const session = await ensureSession(ctx);
const page = await buildStatusPage(ctx);
return {
...page,
toast: { message: `Connected to ${session.pdsHost} as ${session.did}`, type: "success" },
};
} catch (error) {
const page = await buildStatusPage(ctx);
return {
...page,
toast: {
message: `Connection failed: ${error instanceof Error ? error.message : "Unknown error"}`,
type: "error",
},
};
}
}

View File

@@ -0,0 +1,195 @@
/**
* standard.site record builders
*
* Builds site.standard.publication and site.standard.document records
* from EmDash content.
*/
// ── Types ───────────────────────────────────────────────────────
export interface StandardPublication {
$type: "site.standard.publication";
url: string;
name: string;
description?: string;
}
export interface StandardDocument {
$type: "site.standard.document";
/** AT-URI of the publication record, or HTTPS URL for loose documents */
site: string;
title: string;
publishedAt: string;
/** Path component -- combined with publication URL to form canonical URL */
path?: string;
description?: string;
textContent?: string;
tags?: string[];
updatedAt?: string;
coverImage?: BlobRefLike;
/** Strong reference to a Bluesky post for off-platform comments */
bskyPostRef?: { uri: string; cid: string };
}
interface BlobRefLike {
$type: "blob";
ref: { $link: string };
mimeType: string;
size: number;
}
// ── Builders ────────────────────────────────────────────────────
/**
* Build a site.standard.publication record.
*/
export function buildPublication(
siteUrl: string,
siteName: string,
description?: string,
): StandardPublication {
return {
$type: "site.standard.publication",
url: stripTrailingSlash(siteUrl),
name: siteName,
...(description ? { description } : {}),
};
}
/**
* Build a site.standard.document record from EmDash content.
*/
export function buildDocument(opts: {
publicationUri: string;
content: Record<string, unknown>;
coverImageBlob?: BlobRefLike;
bskyPostRef?: { uri: string; cid: string };
}): StandardDocument {
const { publicationUri, content, coverImageBlob, bskyPostRef } = opts;
const slug = getString(content, "slug");
const title = getString(content, "title") || "Untitled";
const description = getString(content, "excerpt") || getString(content, "description");
const publishedAt = getString(content, "published_at") || new Date().toISOString();
const updatedAt = getString(content, "updated_at");
const tags = extractTags(content);
const doc: StandardDocument = {
$type: "site.standard.document",
site: publicationUri,
title,
publishedAt,
};
if (slug) {
doc.path = `/${slug}`;
}
if (description) {
doc.description = description;
}
const plainText = extractPlainText(content);
if (plainText) {
doc.textContent = plainText;
}
if (tags.length > 0) {
doc.tags = tags;
}
if (updatedAt) {
doc.updatedAt = updatedAt;
}
if (coverImageBlob) {
doc.coverImage = coverImageBlob;
}
if (bskyPostRef) {
doc.bskyPostRef = bskyPostRef;
}
return doc;
}
// ── Helpers ─────────────────────────────────────────────────────
function stripTrailingSlash(url: string): string {
return url.endsWith("/") ? url.slice(0, -1) : url;
}
// Pre-compiled regexes
const HTML_TAG_RE = /<[^>]+>/g;
const NBSP_RE = /&nbsp;/g;
const AMP_RE = /&amp;/g;
const LT_RE = /&lt;/g;
const GT_RE = /&gt;/g;
const QUOT_RE = /&quot;/g;
const APOS_RE = /&#39;/g;
const WHITESPACE_RE = /\s+/g;
const HASH_PREFIX_RE = /^#/;
const MAX_TEXT_CONTENT_LENGTH = 10_000;
function getString(obj: Record<string, unknown>, key: string): string | undefined {
const v = obj[key];
return typeof v === "string" && v.length > 0 ? v : undefined;
}
/**
* Extract tags from content. Handles both string arrays and
* tag objects with a name property.
*/
function extractTags(content: Record<string, unknown>): string[] {
const raw = content.tags;
if (!Array.isArray(raw)) return [];
const tags: string[] = [];
for (const item of raw) {
if (typeof item === "string") {
tags.push(item.replace(HASH_PREFIX_RE, ""));
} else if (
typeof item === "object" &&
item !== null &&
"name" in item &&
typeof (item as Record<string, unknown>).name === "string"
) {
tags.push(((item as Record<string, unknown>).name as string).replace(HASH_PREFIX_RE, ""));
}
}
return tags;
}
/**
* Extract plain text from content for the textContent field.
* Strips HTML tags and collapses whitespace.
*/
export function extractPlainText(content: Record<string, unknown>): string | undefined {
// Try common content field names
const body =
getString(content, "body") || getString(content, "content") || getString(content, "text");
if (!body) return undefined;
// Strip HTML tags (simple -- not a full parser, but sufficient for plain text extraction).
// Decode &amp; last to avoid double-decoding (e.g. &amp;lt; -> &lt; -> <).
let text = body
.replace(HTML_TAG_RE, " ")
.replace(NBSP_RE, " ")
.replace(LT_RE, "<")
.replace(GT_RE, ">")
.replace(QUOT_RE, '"')
.replace(APOS_RE, "'")
.replace(AMP_RE, "&")
.replace(WHITESPACE_RE, " ")
.trim();
if (!text) return undefined;
// Truncate to 10,000 chars to avoid exceeding PDS record size limits (~100KB)
if (text.length > MAX_TEXT_CONTENT_LENGTH) {
text = text.slice(0, MAX_TEXT_CONTENT_LENGTH - 1) + "\u2026";
}
return text;
}

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

View File

@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["tests/**/*.test.ts"],
},
});