first commit
This commit is contained in:
34
packages/plugins/atproto/package.json
Normal file
34
packages/plugins/atproto/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
408
packages/plugins/atproto/src/atproto.ts
Normal file
408
packages/plugins/atproto/src/atproto.ts
Normal 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;
|
||||
}
|
||||
185
packages/plugins/atproto/src/bluesky.ts
Normal file
185
packages/plugins/atproto/src/bluesky.ts
Normal 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;
|
||||
}
|
||||
42
packages/plugins/atproto/src/index.ts
Normal file
42
packages/plugins/atproto/src/index.ts
Normal 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" }],
|
||||
};
|
||||
}
|
||||
671
packages/plugins/atproto/src/sandbox-entry.ts
Normal file
671
packages/plugins/atproto/src/sandbox-entry.ts
Normal 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",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
195
packages/plugins/atproto/src/standard-site.ts
Normal file
195
packages/plugins/atproto/src/standard-site.ts
Normal 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 = / /g;
|
||||
const AMP_RE = /&/g;
|
||||
const LT_RE = /</g;
|
||||
const GT_RE = />/g;
|
||||
const QUOT_RE = /"/g;
|
||||
const APOS_RE = /'/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 & last to avoid double-decoding (e.g. &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;
|
||||
}
|
||||
19
packages/plugins/atproto/tests/atproto.test.ts
Normal file
19
packages/plugins/atproto/tests/atproto.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
209
packages/plugins/atproto/tests/bluesky.test.ts
Normal file
209
packages/plugins/atproto/tests/bluesky.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
82
packages/plugins/atproto/tests/plugin.test.ts
Normal file
82
packages/plugins/atproto/tests/plugin.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
174
packages/plugins/atproto/tests/standard-site.test.ts
Normal file
174
packages/plugins/atproto/tests/standard-site.test.ts
Normal 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 & 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);
|
||||
});
|
||||
});
|
||||
9
packages/plugins/atproto/tsconfig.json
Normal file
9
packages/plugins/atproto/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
9
packages/plugins/atproto/vitest.config.ts
Normal file
9
packages/plugins/atproto/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user