Emdash source with visual editor image upload fix
Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
44
packages/plugins/atproto/CHANGELOG.md
Normal file
44
packages/plugins/atproto/CHANGELOG.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# @emdash-cms/plugin-atproto
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#734](https://github.com/emdash-cms/emdash/pull/734) [`cf1edae`](https://github.com/emdash-cms/emdash/commit/cf1edae6ac3e5cd8c72fd43a09bb80bae5cc8031) Thanks [@huckabarry](https://github.com/huckabarry)! - Fixes AT Protocol plugin setup by declaring the storage collection used by the sandbox implementation, normalizing pasted PDS URLs, and exposing the missing site name and publication sync controls in the admin page.
|
||||
|
||||
- Updated dependencies [[`493e317`](https://github.com/emdash-cms/emdash/commit/493e3172d4539d8e041e6d2bf2d7d2dc89b2a10d), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`37ada52`](https://github.com/emdash-cms/emdash/commit/37ada52a62e94f4f0581f4356ba55dc978863f49), [`0557b62`](https://github.com/emdash-cms/emdash/commit/0557b62ec646e49eeb5e28686d50b4e8746338be), [`5a581d9`](https://github.com/emdash-cms/emdash/commit/5a581d966cc1da72637a76ad42a7ac3b81ec59c3), [`0ecd3b4`](https://github.com/emdash-cms/emdash/commit/0ecd3b4901eb721825b36eb4812506032e43da14), [`3138432`](https://github.com/emdash-cms/emdash/commit/31384322537070db8c35e4f93f4ffe8225d784d6), [`70924cd`](https://github.com/emdash-cms/emdash/commit/70924cd19b4227b3a1ecfad6618f1a80530a378b), [`1f0f6f2`](https://github.com/emdash-cms/emdash/commit/1f0f6f2507d026f2b5c60c254432bfc327b3474f), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`e402890`](https://github.com/emdash-cms/emdash/commit/e402890fcd8647fdfe847bb34aa9f9e7094473dd), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`f5658f0`](https://github.com/emdash-cms/emdash/commit/f5658f052f7294039f7ea8c5eb8b49af263beb0d), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`b6cb2e6`](https://github.com/emdash-cms/emdash/commit/b6cb2e6c7001d37a0558e22953eba41013457528), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`cf1edae`](https://github.com/emdash-cms/emdash/commit/cf1edae6ac3e5cd8c72fd43a09bb80bae5cc8031), [`b352e88`](https://github.com/emdash-cms/emdash/commit/b352e881fedb7f6fdc35f9d75402f67caba7f154), [`31333dc`](https://github.com/emdash-cms/emdash/commit/31333dc593e2b9128113e4e923455209f11853fd), [`da3d065`](https://github.com/emdash-cms/emdash/commit/da3d0656a4431365176cca65dc2bedf5eca19ce3), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`47978b5`](https://github.com/emdash-cms/emdash/commit/47978b5e1b69b671d2ea5c08ee0bbf4c72d1594d), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd)]:
|
||||
- emdash@1.0.0
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#363](https://github.com/emdash-cms/emdash/pull/363) [`91e31fb`](https://github.com/emdash-cms/emdash/commit/91e31fb2cab4c0470088c5d61bab6e2028821569) Thanks [@ascorbic](https://github.com/ascorbic)! - Fixes sandboxed plugin entries failing when package exports point to unbuilt TypeScript source. Adds build-time and bundle-time validation to catch misconfigured plugin exports early.
|
||||
|
||||
- Updated dependencies [[`422018a`](https://github.com/emdash-cms/emdash/commit/422018aeb227dffe3da7bfc772d86f9ce9c2bcd1), [`4221ba4`](https://github.com/emdash-cms/emdash/commit/4221ba48bc87ab9fa0b1bae144f6f2920beb4f5a), [`9269759`](https://github.com/emdash-cms/emdash/commit/9269759674bf254863f37d4cf1687fae56082063), [`d6cfc43`](https://github.com/emdash-cms/emdash/commit/d6cfc437f23e3e435a8862cab17d2c19363847d7), [`1bcfc50`](https://github.com/emdash-cms/emdash/commit/1bcfc502112d8756e34a720b8a170eb5486b425a), [`8c693b5`](https://github.com/emdash-cms/emdash/commit/8c693b582d7c5e29bd138161e81d9c8affb53689), [`5b3e33c`](https://github.com/emdash-cms/emdash/commit/5b3e33c26bc2eb30ab2a032960a5d57eb06f148a), [`9d10d27`](https://github.com/emdash-cms/emdash/commit/9d10d2791fe16be901d9d138e434bd79cf9335c4), [`91e31fb`](https://github.com/emdash-cms/emdash/commit/91e31fb2cab4c0470088c5d61bab6e2028821569), [`f112ac4`](https://github.com/emdash-cms/emdash/commit/f112ac48194d1c2302e93756d54b116d3d207c22), [`e9a6f7a`](https://github.com/emdash-cms/emdash/commit/e9a6f7ac3ceeaf5c2d0a557e4cf6cab5f3d7d764), [`b297fdd`](https://github.com/emdash-cms/emdash/commit/b297fdd88dadcabeb93f47abea9f24f70b7d4b71), [`d211452`](https://github.com/emdash-cms/emdash/commit/d2114523a55021f65ee46e44e11157b06334819e), [`8e28cfc`](https://github.com/emdash-cms/emdash/commit/8e28cfc5d66f58f0fb91aa35c02afdd426bb6555), [`38af118`](https://github.com/emdash-cms/emdash/commit/38af118ad517fd9aa83064368543bf64bc32c08a)]:
|
||||
- emdash@0.1.1
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#14](https://github.com/emdash-cms/emdash/pull/14) [`755b501`](https://github.com/emdash-cms/emdash/commit/755b5017906811f97f78f4c0b5a0b62e67b52ec4) Thanks [@ascorbic](https://github.com/ascorbic)! - First beta release
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`755b501`](https://github.com/emdash-cms/emdash/commit/755b5017906811f97f78f4c0b5a0b62e67b52ec4)]:
|
||||
- emdash@0.1.0
|
||||
|
||||
## 0.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`3c319ed`](https://github.com/emdash-cms/emdash/commit/3c319ed6411a595e6974a86bc58c2a308b91c214)]:
|
||||
- emdash@0.0.3
|
||||
|
||||
## 0.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`b09bfd5`](https://github.com/emdash-cms/emdash/commit/b09bfd51cece5e88fe8314668a591ab11de36b4d)]:
|
||||
- emdash@0.0.2
|
||||
48
packages/plugins/atproto/package.json
Normal file
48
packages/plugins/atproto/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@emdash-cms/plugin-atproto",
|
||||
"version": "0.1.2",
|
||||
"description": "AT Protocol / standard.site syndication plugin for EmDash CMS",
|
||||
"type": "module",
|
||||
"main": "dist/index.mjs",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.mts"
|
||||
},
|
||||
"./sandbox": "./dist/sandbox-entry.mjs"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src"
|
||||
],
|
||||
"keywords": [
|
||||
"emdash",
|
||||
"cms",
|
||||
"plugin",
|
||||
"atproto",
|
||||
"bluesky",
|
||||
"standard-site",
|
||||
"syndication",
|
||||
"fediverse"
|
||||
],
|
||||
"author": "Matt Kane",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"emdash": "workspace:>=0.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsdown": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --clean",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/emdash-cms/emdash.git",
|
||||
"directory": "packages/plugins/atproto"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
18
packages/plugins/atproto/src/admin-routing.ts
Normal file
18
packages/plugins/atproto/src/admin-routing.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface AdminInteraction {
|
||||
type?: string;
|
||||
page?: string;
|
||||
action_id?: string;
|
||||
values?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function getAdminPageTarget(
|
||||
interaction?: AdminInteraction,
|
||||
): "status" | "sync-widget" | null {
|
||||
const interactionType = interaction?.type ?? "page_load";
|
||||
const page = interaction?.page ?? "/status";
|
||||
|
||||
if (interactionType !== "page_load") return null;
|
||||
if (page === "widget:sync-status") return "sync-widget";
|
||||
if (page === "/" || page === "/status" || page === "/settings") return "status";
|
||||
return null;
|
||||
}
|
||||
448
packages/plugins/atproto/src/atproto.ts
Normal file
448
packages/plugins/atproto/src/atproto.ts
Normal file
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* 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 ─────────────────────────────────────────────────────
|
||||
|
||||
const URL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize user-entered PDS values to the host portion expected by the
|
||||
* AT Protocol XRPC helpers. Users often paste a full service URL.
|
||||
*/
|
||||
export function normalizePdsHost(value: string | null | undefined): string {
|
||||
const raw = value?.trim() || "bsky.social";
|
||||
const withScheme = URL_SCHEME_RE.test(raw) ? raw : `https://${raw}`;
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(withScheme);
|
||||
} catch {
|
||||
throw new Error(`Invalid PDS host: ${raw}`);
|
||||
}
|
||||
|
||||
if (url.protocol !== "https:") {
|
||||
throw new Error(`Invalid PDS host protocol: ${url.protocol}`);
|
||||
}
|
||||
|
||||
return url.host;
|
||||
}
|
||||
|
||||
function xrpcUrl(pdsHost: string, method: string): string {
|
||||
return `https://${normalizePdsHost(pdsHost)}/xrpc/${method}`;
|
||||
}
|
||||
|
||||
async function responseNeedsSessionRefresh(res: Response): Promise<boolean> {
|
||||
if (res.status === 401) return true;
|
||||
if (res.status !== 400) return false;
|
||||
|
||||
try {
|
||||
const body = (await res.clone().json()) as Record<string, unknown>;
|
||||
return body.error === "ExpiredToken";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 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(xrpcUrl(pdsHost, "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(xrpcUrl(pdsHost, "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 = normalizePdsHost(await ctx.kv.get<string>("settings:pdsHost"));
|
||||
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(xrpcUrl(pdsHost, "com.atproto.repo.createRecord"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessJwt}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ repo: did, collection, record }),
|
||||
});
|
||||
|
||||
// Retry once when the PDS reports an expired access token.
|
||||
if (await responseNeedsSessionRefresh(res)) {
|
||||
const refreshed = await ensureSessionFresh(ctx, pdsHost);
|
||||
if (refreshed) {
|
||||
res = await http.fetch(xrpcUrl(pdsHost, "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(xrpcUrl(pdsHost, "com.atproto.repo.putRecord"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessJwt}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ repo: did, collection, rkey, record }),
|
||||
});
|
||||
|
||||
if (await responseNeedsSessionRefresh(res)) {
|
||||
const refreshed = await ensureSessionFresh(ctx, pdsHost);
|
||||
if (refreshed) {
|
||||
res = await http.fetch(xrpcUrl(pdsHost, "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(xrpcUrl(pdsHost, "com.atproto.repo.deleteRecord"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessJwt}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ repo: did, collection, rkey }),
|
||||
});
|
||||
|
||||
if (await responseNeedsSessionRefresh(res)) {
|
||||
const refreshed = await ensureSessionFresh(ctx, pdsHost);
|
||||
if (refreshed) {
|
||||
res = await http.fetch(xrpcUrl(pdsHost, "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(xrpcUrl(pdsHost, "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;
|
||||
}
|
||||
188
packages/plugins/atproto/src/bluesky.ts
Normal file
188
packages/plugins/atproto/src/bluesky.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Bluesky cross-posting helpers
|
||||
*
|
||||
* Builds app.bsky.feed.post records with link cards and rich text facets.
|
||||
*/
|
||||
|
||||
import type { BlobRef } from "./atproto.js";
|
||||
import { buildContentPath, getContentString } from "./content.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;
|
||||
collection?: string;
|
||||
content: Record<string, unknown>;
|
||||
siteUrl: string;
|
||||
thumbBlob?: BlobRef;
|
||||
langs?: string[];
|
||||
}): BskyPost {
|
||||
const { template, collection, content, siteUrl, thumbBlob, langs } = opts;
|
||||
|
||||
const title = getContentString(content, "title") || "Untitled";
|
||||
const excerpt =
|
||||
getContentString(content, "excerpt") || getContentString(content, "description") || "";
|
||||
const path = buildContentPath(collection, content);
|
||||
const url = path ? `${stripTrailingSlash(siteUrl)}${path}` : 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;
|
||||
}
|
||||
36
packages/plugins/atproto/src/content.ts
Normal file
36
packages/plugins/atproto/src/content.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Shared content helpers for ATProto outputs.
|
||||
*
|
||||
* These helpers intentionally stay small and boring so standard.site and
|
||||
* Bluesky can share path/field lookup behavior without coupling their
|
||||
* output-specific formatting logic.
|
||||
*/
|
||||
|
||||
export function getString(obj: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = obj[key];
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
export function getContentData(content: Record<string, unknown>): Record<string, unknown> {
|
||||
return content.data && typeof content.data === "object"
|
||||
? (content.data as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
export function getContentString(
|
||||
content: Record<string, unknown>,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
return getString(content, key) || getString(getContentData(content), key);
|
||||
}
|
||||
|
||||
export function buildContentPath(
|
||||
collection: string | undefined,
|
||||
content: Record<string, unknown>,
|
||||
): string | undefined {
|
||||
const slug = getContentString(content, "slug");
|
||||
if (!slug) return undefined;
|
||||
|
||||
if (!collection || collection === "pages") return `/${slug}`;
|
||||
return `/${collection}/${slug}`;
|
||||
}
|
||||
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: "@emdash-cms/plugin-atproto/sandbox",
|
||||
capabilities: ["read:content", "network:fetch:any"],
|
||||
storage: {
|
||||
records: { indexes: ["contentId", "status", "lastSyncedAt"] },
|
||||
},
|
||||
// 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" }],
|
||||
};
|
||||
}
|
||||
796
packages/plugins/atproto/src/sandbox-entry.ts
Normal file
796
packages/plugins/atproto/src/sandbox-entry.ts
Normal file
@@ -0,0 +1,796 @@
|
||||
/**
|
||||
* 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 { getAdminPageTarget, type AdminInteraction } from "./admin-routing.js";
|
||||
import {
|
||||
ensureSession,
|
||||
createRecord,
|
||||
putRecord,
|
||||
deleteRecord,
|
||||
rkeyFromUri,
|
||||
uploadBlob,
|
||||
requireHttp,
|
||||
normalizePdsHost,
|
||||
} 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 ─────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_SYNDICATED_COLLECTIONS = ["posts"];
|
||||
|
||||
async function isCollectionAllowed(ctx: PluginContext, collection: string): Promise<boolean> {
|
||||
const setting = await ctx.kv.get<string>("settings:collections");
|
||||
const configured = setting?.trim();
|
||||
const allowed = configured
|
||||
? configured.split(",").map((s: string) => s.trim().toLowerCase())
|
||||
: DEFAULT_SYNDICATED_COLLECTIONS;
|
||||
return allowed.includes(collection.toLowerCase());
|
||||
}
|
||||
|
||||
async function syndicateContent(
|
||||
ctx: PluginContext,
|
||||
collection: string,
|
||||
contentId: string,
|
||||
content: Record<string, unknown>,
|
||||
options: { allowCreate?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const storageKey = `${collection}:${contentId}`;
|
||||
const existing = (await ctx.storage.records!.get(storageKey)) as SyndicationRecord | null;
|
||||
|
||||
if (!existing?.atUri && options.allowCreate === false) return;
|
||||
|
||||
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);
|
||||
bskyPostRef =
|
||||
existing.bskyPostUri && existing.bskyPostCid
|
||||
? { uri: existing.bskyPostUri, cid: existing.bskyPostCid }
|
||||
: undefined;
|
||||
|
||||
const enableCrosspost = (await ctx.kv.get<boolean>("settings:enableBskyCrosspost")) ?? true;
|
||||
if (enableCrosspost && existing.bskyPostUri) {
|
||||
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: string) => s.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
const post = buildBskyPost({
|
||||
template,
|
||||
collection,
|
||||
content,
|
||||
siteUrl,
|
||||
thumbBlob: coverImageBlob,
|
||||
langs,
|
||||
});
|
||||
const postResult = await putRecord(
|
||||
ctx,
|
||||
pdsHost,
|
||||
accessJwt,
|
||||
did,
|
||||
"app.bsky.feed.post",
|
||||
rkeyFromUri(existing.bskyPostUri),
|
||||
post,
|
||||
);
|
||||
bskyPostRef = { uri: postResult.uri, cid: postResult.cid };
|
||||
} catch (error) {
|
||||
ctx.log.warn("Failed to update Bluesky cross-post, document still synced", error);
|
||||
}
|
||||
}
|
||||
|
||||
const doc = buildDocument({
|
||||
publicationUri,
|
||||
collection,
|
||||
content,
|
||||
coverImageBlob,
|
||||
bskyPostRef,
|
||||
});
|
||||
|
||||
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: bskyPostRef?.uri,
|
||||
bskyPostCid: bskyPostRef?.cid,
|
||||
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, collection, 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: string) => s.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
const post = buildBskyPost({
|
||||
template,
|
||||
collection,
|
||||
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,
|
||||
collection,
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncPublication(ctx: PluginContext) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
async function syndicatePublishedContent(
|
||||
event: { content: Record<string, unknown>; collection: string },
|
||||
ctx: PluginContext,
|
||||
options: { allowCreate?: boolean } = {},
|
||||
) {
|
||||
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, options);
|
||||
} 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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,
|
||||
) => {
|
||||
await syndicatePublishedContent(event, ctx, { allowCreate: false });
|
||||
},
|
||||
},
|
||||
|
||||
"content:afterPublish": {
|
||||
handler: async (
|
||||
event: { content: Record<string, unknown>; collection: string },
|
||||
ctx: PluginContext,
|
||||
) => {
|
||||
await syndicatePublishedContent(event, ctx, { allowCreate: true });
|
||||
},
|
||||
},
|
||||
|
||||
"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;
|
||||
if (!(await isCollectionAllowed(ctx, pageContent.collection))) 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 {
|
||||
return await syncPublication(ctx);
|
||||
} 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 AdminInteraction | undefined;
|
||||
const interactionType = interaction?.type ?? "page_load";
|
||||
const pageTarget = getAdminPageTarget(interaction);
|
||||
|
||||
if (pageTarget === "sync-widget") return buildSyncWidget(ctx);
|
||||
if (pageTarget === "status") return buildStatusPage(ctx);
|
||||
if (interactionType === "form_submit" && interaction?.action_id === "save_settings") {
|
||||
return saveSettings(ctx, interaction.values ?? {});
|
||||
}
|
||||
if (interactionType === "block_action" && interaction?.action_id === "test_connection") {
|
||||
return testConnection(ctx);
|
||||
}
|
||||
if (interactionType === "block_action" && interaction?.action_id === "sync_publication") {
|
||||
return syncPublicationAdmin(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 siteName = await ctx.kv.get<string>("settings:siteName");
|
||||
const collections = await ctx.kv.get<string>("settings:collections");
|
||||
const enableBskyCrosspost =
|
||||
(await ctx.kv.get<boolean>("settings:enableBskyCrosspost")) ??
|
||||
(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",
|
||||
title: "Connected",
|
||||
description: `Connected as ${handle} (${did})`,
|
||||
});
|
||||
} else if (handle) {
|
||||
blocks.push({
|
||||
type: "banner",
|
||||
variant: "alert",
|
||||
title: "Not connected",
|
||||
description:
|
||||
"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 ?? "bsky.social",
|
||||
},
|
||||
{
|
||||
type: "text_input",
|
||||
action_id: "siteUrl",
|
||||
label: "Site URL",
|
||||
initial_value: siteUrl ?? "",
|
||||
},
|
||||
{
|
||||
type: "text_input",
|
||||
action_id: "siteName",
|
||||
label: "Site Name",
|
||||
initial_value: siteName ?? "",
|
||||
},
|
||||
{
|
||||
type: "text_input",
|
||||
action_id: "collections",
|
||||
label: "Collections to syndicate",
|
||||
initial_value: collections ?? DEFAULT_SYNDICATED_COLLECTIONS.join(","),
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
action_id: "enableBskyCrosspost",
|
||||
label: "Cross-post to Bluesky",
|
||||
initial_value: enableBskyCrosspost ?? true,
|
||||
},
|
||||
],
|
||||
submit: { label: "Save Settings", action_id: "save_settings" },
|
||||
});
|
||||
|
||||
blocks.push({
|
||||
type: "actions",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
label: "Test Connection",
|
||||
action_id: "test_connection",
|
||||
style: handle && appPassword ? "primary" : undefined,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
label: pubUri ? "Update Publication" : "Sync Publication",
|
||||
action_id: "sync_publication",
|
||||
style: did && siteUrl && siteName ? "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: SyndicationRecord & { id: string }) => ({
|
||||
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", variant: "error", title: "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", normalizePdsHost(values.pdsHost));
|
||||
if (typeof values.siteUrl === "string") await ctx.kv.set("settings:siteUrl", values.siteUrl);
|
||||
if (typeof values.siteName === "string") await ctx.kv.set("settings:siteName", values.siteName);
|
||||
if (typeof values.collections === "string")
|
||||
await ctx.kv.set("settings:collections", values.collections);
|
||||
if (typeof values.enableBskyCrosspost === "boolean")
|
||||
await ctx.kv.set("settings:enableBskyCrosspost", values.enableBskyCrosspost);
|
||||
if (typeof values.enableCrosspost === "boolean")
|
||||
await ctx.kv.set("settings:enableBskyCrosspost", 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", variant: "error", title: "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",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function syncPublicationAdmin(ctx: PluginContext) {
|
||||
try {
|
||||
const result = await syncPublication(ctx);
|
||||
const page = await buildStatusPage(ctx);
|
||||
return {
|
||||
...page,
|
||||
toast: result.success
|
||||
? { message: "Publication synced", type: "success" }
|
||||
: { message: result.error ?? "Failed to sync publication", type: "error" },
|
||||
};
|
||||
} catch (error) {
|
||||
const page = await buildStatusPage(ctx);
|
||||
return {
|
||||
...page,
|
||||
toast: {
|
||||
message: `Publication sync failed: ${
|
||||
error instanceof Error ? error.message : "Unknown error"
|
||||
}`,
|
||||
type: "error",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
197
packages/plugins/atproto/src/standard-site.ts
Normal file
197
packages/plugins/atproto/src/standard-site.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* standard.site record builders
|
||||
*
|
||||
* Builds site.standard.publication and site.standard.document records
|
||||
* from EmDash content.
|
||||
*/
|
||||
|
||||
import { buildContentPath, getContentData, getContentString, getString } from "./content.js";
|
||||
|
||||
// ── 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;
|
||||
collection?: string;
|
||||
content: Record<string, unknown>;
|
||||
coverImageBlob?: BlobRefLike;
|
||||
bskyPostRef?: { uri: string; cid: string };
|
||||
}): StandardDocument {
|
||||
const { publicationUri, collection, content, coverImageBlob, bskyPostRef } = opts;
|
||||
|
||||
const title = getContentString(content, "title") || "Untitled";
|
||||
const description =
|
||||
getContentString(content, "excerpt") || getContentString(content, "description");
|
||||
const publishedAt =
|
||||
getString(content, "publishedAt") ||
|
||||
getString(content, "published_at") ||
|
||||
new Date().toISOString();
|
||||
const updatedAt = getString(content, "updatedAt") || getString(content, "updated_at");
|
||||
const tags = extractTags(content);
|
||||
|
||||
const doc: StandardDocument = {
|
||||
$type: "site.standard.document",
|
||||
site: publicationUri,
|
||||
title,
|
||||
publishedAt,
|
||||
};
|
||||
|
||||
const path = buildContentPath(collection, content);
|
||||
if (path) doc.path = path;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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 || getContentData(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 =
|
||||
getContentString(content, "body") ||
|
||||
getContentString(content, "content") ||
|
||||
getContentString(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/admin-routing.test.ts
Normal file
19
packages/plugins/atproto/tests/admin-routing.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getAdminPageTarget } from "../src/admin-routing.js";
|
||||
|
||||
describe("getAdminPageTarget", () => {
|
||||
it.each([
|
||||
[undefined, "status"],
|
||||
[{}, "status"],
|
||||
[{ type: "page_load" }, "status"],
|
||||
[{ type: "page_load", page: "/" }, "status"],
|
||||
[{ type: "page_load", page: "/settings" }, "status"],
|
||||
[{ type: "page_load", page: "/status" }, "status"],
|
||||
[{ type: "page_load", page: "widget:sync-status" }, "sync-widget"],
|
||||
[{ type: "page_load", page: "/unknown" }, null],
|
||||
[{ type: "block_action", page: "/status" }, null],
|
||||
])("maps %j to %s", (interaction, expected) => {
|
||||
expect(getAdminPageTarget(interaction)).toBe(expected);
|
||||
});
|
||||
});
|
||||
106
packages/plugins/atproto/tests/atproto.test.ts
Normal file
106
packages/plugins/atproto/tests/atproto.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { createRecord, normalizePdsHost, rkeyFromUri } from "../src/atproto.js";
|
||||
|
||||
describe("normalizePdsHost", () => {
|
||||
it("defaults to bsky.social", () => {
|
||||
expect(normalizePdsHost(undefined)).toBe("bsky.social");
|
||||
});
|
||||
|
||||
it("accepts host-only values", () => {
|
||||
expect(normalizePdsHost("bsky.social")).toBe("bsky.social");
|
||||
});
|
||||
|
||||
it("accepts full PDS URLs", () => {
|
||||
expect(normalizePdsHost("https://bsky.social")).toBe("bsky.social");
|
||||
expect(normalizePdsHost("https://example.com/")).toBe("example.com");
|
||||
});
|
||||
|
||||
it("preserves ports for https URLs", () => {
|
||||
expect(normalizePdsHost("https://localhost:2583")).toBe("localhost:2583");
|
||||
});
|
||||
|
||||
it("rejects non-https protocols", () => {
|
||||
expect(() => normalizePdsHost("http://localhost:2583")).toThrow(
|
||||
"Invalid PDS host protocol: http:",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rkeyFromUri", () => {
|
||||
it("extracts rkey from a standard AT-URI", () => {
|
||||
const rkey = rkeyFromUri("at://did:plc:abc123/site.standard.document/3lwafzkjqm25s");
|
||||
expect(rkey).toBe("3lwafzkjqm25s");
|
||||
});
|
||||
|
||||
it("extracts rkey from a Bluesky post URI", () => {
|
||||
const rkey = rkeyFromUri("at://did:plc:abc123/app.bsky.feed.post/3k4duaz5vfs2b");
|
||||
expect(rkey).toBe("3k4duaz5vfs2b");
|
||||
});
|
||||
|
||||
it("throws on empty URI", () => {
|
||||
expect(() => rkeyFromUri("")).toThrow("Invalid AT-URI");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRecord", () => {
|
||||
it("refreshes the session when the PDS returns a 400 ExpiredToken response", async () => {
|
||||
const kv = new Map<string, unknown>([
|
||||
["settings:pdsHost", "bsky.social"],
|
||||
["settings:handle", "example.com"],
|
||||
["settings:appPassword", "app-password"],
|
||||
["state:accessJwt", "stale-access"],
|
||||
["state:refreshJwt", "refresh-token"],
|
||||
["state:did", "did:plc:test"],
|
||||
]);
|
||||
const fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ error: "ExpiredToken", message: "Token has expired" }), {
|
||||
status: 400,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
accessJwt: "fresh-access",
|
||||
refreshJwt: "fresh-refresh",
|
||||
did: "did:plc:test",
|
||||
handle: "example.com",
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({ uri: "at://did:plc:test/site.standard.publication/abc", cid: "cid" }),
|
||||
{
|
||||
status: 200,
|
||||
},
|
||||
),
|
||||
);
|
||||
const ctx = {
|
||||
http: { fetch },
|
||||
kv: {
|
||||
get: vi.fn(async (key: string) => kv.get(key)),
|
||||
set: vi.fn(async (key: string, value: unknown) => {
|
||||
kv.set(key, value);
|
||||
}),
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = await createRecord(
|
||||
ctx,
|
||||
"bsky.social",
|
||||
"stale-access",
|
||||
"did:plc:test",
|
||||
"site.standard.publication",
|
||||
{ name: "Example Site" },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ uri: "at://did:plc:test/site.standard.publication/abc", cid: "cid" });
|
||||
expect(fetch).toHaveBeenCalledTimes(3);
|
||||
expect(kv.get("state:accessJwt")).toBe("fresh-access");
|
||||
expect(kv.get("state:refreshJwt")).toBe("fresh-refresh");
|
||||
});
|
||||
});
|
||||
223
packages/plugins/atproto/tests/bluesky.test.ts
Normal file
223
packages/plugins/atproto/tests/bluesky.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { buildBskyPost, buildFacets } from "../src/bluesky.js";
|
||||
|
||||
describe("buildFacets", () => {
|
||||
it("detects URLs and returns correct byte offsets", () => {
|
||||
const text = "Check out https://example.com for more";
|
||||
const facets = buildFacets(text);
|
||||
expect(facets).toHaveLength(1);
|
||||
|
||||
const facet = facets[0]!;
|
||||
expect(facet.features[0]).toEqual({
|
||||
$type: "app.bsky.richtext.facet#link",
|
||||
uri: "https://example.com",
|
||||
});
|
||||
|
||||
// Verify byte offsets match
|
||||
const encoder = new TextEncoder();
|
||||
const bytes = encoder.encode(text);
|
||||
const extracted = new TextDecoder().decode(
|
||||
bytes.slice(facet.index.byteStart, facet.index.byteEnd),
|
||||
);
|
||||
expect(extracted).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("handles multiple URLs", () => {
|
||||
const text = "Visit https://a.com and https://b.com today";
|
||||
const facets = buildFacets(text);
|
||||
expect(facets).toHaveLength(2);
|
||||
expect(facets[0]!.features[0]).toHaveProperty("uri", "https://a.com");
|
||||
expect(facets[1]!.features[0]).toHaveProperty("uri", "https://b.com");
|
||||
});
|
||||
|
||||
it("detects hashtags", () => {
|
||||
const text = "Hello #world #atproto";
|
||||
const facets = buildFacets(text);
|
||||
const tagFacets = facets.filter((f) => f.features[0]?.$type === "app.bsky.richtext.facet#tag");
|
||||
expect(tagFacets).toHaveLength(2);
|
||||
expect(tagFacets[0]!.features[0]).toHaveProperty("tag", "world");
|
||||
expect(tagFacets[1]!.features[0]).toHaveProperty("tag", "atproto");
|
||||
});
|
||||
|
||||
it("handles UTF-8 multibyte characters before URLs", () => {
|
||||
// Emoji is multiple UTF-8 bytes but one grapheme
|
||||
const text = "Great post! 🎉 https://example.com";
|
||||
const facets = buildFacets(text);
|
||||
expect(facets).toHaveLength(1);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const bytes = encoder.encode(text);
|
||||
const extracted = new TextDecoder().decode(
|
||||
bytes.slice(facets[0]!.index.byteStart, facets[0]!.index.byteEnd),
|
||||
);
|
||||
expect(extracted).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("returns empty array for text with no URLs or hashtags", () => {
|
||||
const facets = buildFacets("Just some plain text here");
|
||||
expect(facets).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not match hashtag at start of word", () => {
|
||||
// Hashtag requires preceding whitespace or start of string
|
||||
const text = "foo#bar";
|
||||
const facets = buildFacets(text);
|
||||
const tagFacets = facets.filter((f) => f.features[0]?.$type === "app.bsky.richtext.facet#tag");
|
||||
expect(tagFacets).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("strips trailing punctuation from URLs", () => {
|
||||
const text = "Visit https://example.com/post. More text";
|
||||
const facets = buildFacets(text);
|
||||
expect(facets).toHaveLength(1);
|
||||
expect(facets[0]!.features[0]).toHaveProperty("uri", "https://example.com/post");
|
||||
});
|
||||
|
||||
it("strips trailing comma from URL", () => {
|
||||
const text = "See https://example.com/a, https://example.com/b";
|
||||
const facets = buildFacets(text);
|
||||
expect(facets).toHaveLength(2);
|
||||
expect(facets[0]!.features[0]).toHaveProperty("uri", "https://example.com/a");
|
||||
expect(facets[1]!.features[0]).toHaveProperty("uri", "https://example.com/b");
|
||||
});
|
||||
|
||||
it("strips trailing exclamation from URL", () => {
|
||||
const text = "Check https://example.com!";
|
||||
const facets = buildFacets(text);
|
||||
expect(facets[0]!.features[0]).toHaveProperty("uri", "https://example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBskyPost", () => {
|
||||
const baseContent = {
|
||||
title: "My Article",
|
||||
slug: "my-article",
|
||||
excerpt: "A short description",
|
||||
};
|
||||
|
||||
it("builds a post with template substitution", () => {
|
||||
const post = buildBskyPost({
|
||||
template: "{title}\n\n{url}",
|
||||
content: baseContent,
|
||||
siteUrl: "https://myblog.com",
|
||||
});
|
||||
|
||||
expect(post.$type).toBe("app.bsky.feed.post");
|
||||
expect(post.text).toBe("My Article\n\nhttps://myblog.com/my-article");
|
||||
expect(post.createdAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("includes langs when provided", () => {
|
||||
const post = buildBskyPost({
|
||||
template: "{title}",
|
||||
content: baseContent,
|
||||
siteUrl: "https://myblog.com",
|
||||
langs: ["en", "fr"],
|
||||
});
|
||||
expect(post.langs).toEqual(["en", "fr"]);
|
||||
});
|
||||
|
||||
it("limits langs to 3", () => {
|
||||
const post = buildBskyPost({
|
||||
template: "{title}",
|
||||
content: baseContent,
|
||||
siteUrl: "https://myblog.com",
|
||||
langs: ["en", "fr", "de", "es"],
|
||||
});
|
||||
expect(post.langs).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("includes link card embed", () => {
|
||||
const post = buildBskyPost({
|
||||
template: "{title}",
|
||||
content: baseContent,
|
||||
siteUrl: "https://myblog.com",
|
||||
});
|
||||
|
||||
expect(post.embed).toEqual({
|
||||
$type: "app.bsky.embed.external",
|
||||
external: {
|
||||
uri: "https://myblog.com/my-article",
|
||||
title: "My Article",
|
||||
description: "A short description",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("includes thumb in embed when provided", () => {
|
||||
const thumb = {
|
||||
$type: "blob" as const,
|
||||
ref: { $link: "bafkrei123" },
|
||||
mimeType: "image/jpeg",
|
||||
size: 45000,
|
||||
};
|
||||
|
||||
const post = buildBskyPost({
|
||||
template: "{title}",
|
||||
content: baseContent,
|
||||
siteUrl: "https://myblog.com",
|
||||
thumbBlob: thumb,
|
||||
});
|
||||
|
||||
expect(post.embed?.external.thumb).toBe(thumb);
|
||||
});
|
||||
|
||||
it("auto-detects URLs in text for facets", () => {
|
||||
const post = buildBskyPost({
|
||||
template: "New post: {url}",
|
||||
content: baseContent,
|
||||
siteUrl: "https://myblog.com",
|
||||
});
|
||||
|
||||
expect(post.facets).toBeDefined();
|
||||
expect(post.facets!.length).toBeGreaterThan(0);
|
||||
expect(post.facets![0]!.features[0]).toHaveProperty("uri", "https://myblog.com/my-article");
|
||||
});
|
||||
|
||||
it("substitutes {excerpt} in template", () => {
|
||||
const post = buildBskyPost({
|
||||
template: "{title}: {excerpt}",
|
||||
content: baseContent,
|
||||
siteUrl: "https://myblog.com",
|
||||
});
|
||||
expect(post.text).toBe("My Article: A short description");
|
||||
});
|
||||
|
||||
it("strips trailing slash from siteUrl", () => {
|
||||
const post = buildBskyPost({
|
||||
template: "{url}",
|
||||
content: baseContent,
|
||||
siteUrl: "https://myblog.com/",
|
||||
});
|
||||
expect(post.text).toBe("https://myblog.com/my-article");
|
||||
});
|
||||
|
||||
it("reads slug from content.data when building URLs", () => {
|
||||
const post = buildBskyPost({
|
||||
template: "{url}",
|
||||
collection: "posts",
|
||||
content: {
|
||||
title: "Nested Slug",
|
||||
data: { slug: "nested-slug" },
|
||||
},
|
||||
siteUrl: "https://myblog.com",
|
||||
});
|
||||
expect(post.text).toBe("https://myblog.com/posts/nested-slug");
|
||||
expect(post.embed?.external.uri).toBe("https://myblog.com/posts/nested-slug");
|
||||
});
|
||||
|
||||
it("skips facets when text is truncated to avoid partial URL links", () => {
|
||||
// Create content with very long excerpt that forces truncation
|
||||
const longExcerpt = "A".repeat(300);
|
||||
const post = buildBskyPost({
|
||||
template: "{excerpt} {url}",
|
||||
content: { ...baseContent, excerpt: longExcerpt },
|
||||
siteUrl: "https://myblog.com",
|
||||
});
|
||||
// Text was truncated (>300 graphemes), so facets should be omitted
|
||||
expect(post.facets).toBeUndefined();
|
||||
// But embed should still have the full URL
|
||||
expect(post.embed?.external.uri).toBe("https://myblog.com/my-article");
|
||||
});
|
||||
});
|
||||
43
packages/plugins/atproto/tests/plugin.test.ts
Normal file
43
packages/plugins/atproto/tests/plugin.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { atprotoPlugin } from "../src/index.js";
|
||||
|
||||
describe("atprotoPlugin descriptor", () => {
|
||||
it("returns a valid PluginDescriptor", () => {
|
||||
const descriptor = atprotoPlugin();
|
||||
expect(descriptor.id).toBe("atproto");
|
||||
expect(descriptor.version).toBe("0.1.0");
|
||||
expect(descriptor.entrypoint).toBe("@emdash-cms/plugin-atproto/sandbox");
|
||||
expect(descriptor.adminPages).toHaveLength(1);
|
||||
expect(descriptor.adminWidgets).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses standard format", () => {
|
||||
const descriptor = atprotoPlugin();
|
||||
expect(descriptor.format).toBe("standard");
|
||||
});
|
||||
|
||||
it("declares required capabilities", () => {
|
||||
const descriptor = atprotoPlugin();
|
||||
expect(descriptor.capabilities).toContain("read:content");
|
||||
expect(descriptor.capabilities).toContain("network:fetch:any");
|
||||
});
|
||||
|
||||
it("declares the storage used by the sandbox implementation", () => {
|
||||
const descriptor = atprotoPlugin();
|
||||
expect(descriptor.storage).toHaveProperty("records");
|
||||
expect(descriptor.storage!.records!.indexes).toContain("contentId");
|
||||
expect(descriptor.storage!.records!.indexes).toContain("status");
|
||||
expect(descriptor.storage!.records!.indexes).toContain("lastSyncedAt");
|
||||
});
|
||||
|
||||
it("exposes an admin status page and widget", () => {
|
||||
const descriptor = atprotoPlugin();
|
||||
expect(descriptor.adminPages).toEqual([
|
||||
{ path: "/status", label: "AT Protocol", icon: "globe" },
|
||||
]);
|
||||
expect(descriptor.adminWidgets).toEqual([
|
||||
{ id: "sync-status", title: "AT Protocol", size: "third" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
102
packages/plugins/atproto/tests/sandbox-entry.test.ts
Normal file
102
packages/plugins/atproto/tests/sandbox-entry.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
vi.mock("emdash", () => ({
|
||||
definePlugin: (definition: unknown) => definition,
|
||||
}));
|
||||
|
||||
function createCtx() {
|
||||
return {
|
||||
kv: {
|
||||
get: vi.fn(async () => undefined),
|
||||
set: vi.fn(async () => undefined),
|
||||
},
|
||||
storage: {
|
||||
records: {
|
||||
get: vi.fn(async () => null),
|
||||
put: vi.fn(async () => undefined),
|
||||
},
|
||||
},
|
||||
http: {
|
||||
fetch: vi.fn(),
|
||||
},
|
||||
log: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("sandbox hooks", () => {
|
||||
it("does not create syndication records from afterSave when published content has not been synced", async () => {
|
||||
const { default: plugin } = await import("../src/sandbox-entry.js");
|
||||
const ctx = createCtx();
|
||||
const handler = (plugin as any).hooks["content:afterSave"].handler;
|
||||
|
||||
await handler(
|
||||
{
|
||||
collection: "posts",
|
||||
isNew: false,
|
||||
content: {
|
||||
id: "post-1",
|
||||
status: "published",
|
||||
title: "A published edit",
|
||||
},
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(ctx.storage.records.get).toHaveBeenCalledWith("posts:post-1");
|
||||
expect(ctx.storage.records.put).not.toHaveBeenCalled();
|
||||
expect(ctx.http.fetch).not.toHaveBeenCalled();
|
||||
expect(ctx.kv.get).not.toHaveBeenCalledWith("settings:siteUrl");
|
||||
});
|
||||
|
||||
it("does not syndicate pages by default", async () => {
|
||||
const { default: plugin } = await import("../src/sandbox-entry.js");
|
||||
const ctx = createCtx();
|
||||
const handler = (plugin as any).hooks["content:afterPublish"].handler;
|
||||
|
||||
await handler(
|
||||
{
|
||||
collection: "pages",
|
||||
content: {
|
||||
id: "page-1",
|
||||
status: "published",
|
||||
title: "About",
|
||||
},
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(ctx.kv.get).toHaveBeenCalledWith("settings:collections");
|
||||
expect(ctx.storage.records.get).not.toHaveBeenCalled();
|
||||
expect(ctx.storage.records.put).not.toHaveBeenCalled();
|
||||
expect(ctx.http.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not expose standard.site metadata for pages by default", async () => {
|
||||
const { default: plugin } = await import("../src/sandbox-entry.js");
|
||||
const ctx = createCtx();
|
||||
ctx.storage.records.get.mockResolvedValueOnce({
|
||||
atUri: "at://did:example/site.standard.document/abc",
|
||||
status: "synced",
|
||||
});
|
||||
|
||||
const result = await (plugin as any).hooks["page:metadata"](
|
||||
{
|
||||
page: {
|
||||
content: {
|
||||
collection: "pages",
|
||||
id: "page-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(ctx.kv.get).toHaveBeenCalledWith("settings:collections");
|
||||
expect(ctx.storage.records.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
187
packages/plugins/atproto/tests/standard-site.test.ts
Normal file
187
packages/plugins/atproto/tests/standard-site.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { buildPublication, buildDocument, extractPlainText } from "../src/standard-site.js";
|
||||
|
||||
describe("buildPublication", () => {
|
||||
it("builds a publication record with required fields", () => {
|
||||
const pub = buildPublication("https://myblog.com", "My Blog");
|
||||
expect(pub).toEqual({
|
||||
$type: "site.standard.publication",
|
||||
url: "https://myblog.com",
|
||||
name: "My Blog",
|
||||
});
|
||||
});
|
||||
|
||||
it("strips trailing slash from URL", () => {
|
||||
const pub = buildPublication("https://myblog.com/", "My Blog");
|
||||
expect(pub.url).toBe("https://myblog.com");
|
||||
});
|
||||
|
||||
it("includes description when provided", () => {
|
||||
const pub = buildPublication("https://myblog.com", "My Blog", "A personal blog");
|
||||
expect(pub.description).toBe("A personal blog");
|
||||
});
|
||||
|
||||
it("omits description when not provided", () => {
|
||||
const pub = buildPublication("https://myblog.com", "My Blog");
|
||||
expect(pub).not.toHaveProperty("description");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDocument", () => {
|
||||
const baseOpts = {
|
||||
publicationUri: "at://did:plc:abc123/site.standard.publication/3lwafz",
|
||||
content: {
|
||||
title: "Hello World",
|
||||
slug: "hello-world",
|
||||
excerpt: "A great post",
|
||||
published_at: "2025-01-15T12:00:00.000Z",
|
||||
updated_at: "2025-01-16T10:00:00.000Z",
|
||||
body: "<p>This is the body</p>",
|
||||
tags: ["tech", "web"],
|
||||
},
|
||||
};
|
||||
|
||||
it("builds a document with all fields", () => {
|
||||
const doc = buildDocument(baseOpts);
|
||||
expect(doc.$type).toBe("site.standard.document");
|
||||
expect(doc.site).toBe(baseOpts.publicationUri);
|
||||
expect(doc.title).toBe("Hello World");
|
||||
expect(doc.path).toBe("/hello-world");
|
||||
expect(doc.description).toBe("A great post");
|
||||
expect(doc.publishedAt).toBe("2025-01-15T12:00:00.000Z");
|
||||
expect(doc.updatedAt).toBe("2025-01-16T10:00:00.000Z");
|
||||
expect(doc.tags).toEqual(["tech", "web"]);
|
||||
expect(doc.textContent).toBe("This is the body");
|
||||
});
|
||||
|
||||
it("uses excerpt field for description", () => {
|
||||
const doc = buildDocument({
|
||||
...baseOpts,
|
||||
content: { ...baseOpts.content, excerpt: undefined, description: "fallback desc" },
|
||||
});
|
||||
expect(doc.description).toBe("fallback desc");
|
||||
});
|
||||
|
||||
it("defaults title to Untitled", () => {
|
||||
const doc = buildDocument({
|
||||
...baseOpts,
|
||||
content: { published_at: "2025-01-15T12:00:00.000Z" },
|
||||
});
|
||||
expect(doc.title).toBe("Untitled");
|
||||
});
|
||||
|
||||
it("omits path when slug is missing", () => {
|
||||
const doc = buildDocument({
|
||||
...baseOpts,
|
||||
content: { title: "No Slug", published_at: "2025-01-15T12:00:00.000Z" },
|
||||
});
|
||||
expect(doc.path).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads slug from content.data", () => {
|
||||
const doc = buildDocument({
|
||||
...baseOpts,
|
||||
collection: "posts",
|
||||
content: {
|
||||
title: "Nested Slug",
|
||||
data: { slug: "nested-slug" },
|
||||
published_at: "2025-01-15T12:00:00.000Z",
|
||||
},
|
||||
});
|
||||
expect(doc.path).toBe("/posts/nested-slug");
|
||||
});
|
||||
|
||||
it("includes bskyPostRef when provided", () => {
|
||||
const doc = buildDocument({
|
||||
...baseOpts,
|
||||
bskyPostRef: { uri: "at://did:plc:xyz/app.bsky.feed.post/abc", cid: "bafyrei123" },
|
||||
});
|
||||
expect(doc.bskyPostRef).toEqual({
|
||||
uri: "at://did:plc:xyz/app.bsky.feed.post/abc",
|
||||
cid: "bafyrei123",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes coverImage when provided", () => {
|
||||
const blob = {
|
||||
$type: "blob" as const,
|
||||
ref: { $link: "bafkrei123" },
|
||||
mimeType: "image/jpeg",
|
||||
size: 45000,
|
||||
};
|
||||
const doc = buildDocument({
|
||||
...baseOpts,
|
||||
coverImageBlob: blob,
|
||||
});
|
||||
expect(doc.coverImage).toBe(blob);
|
||||
});
|
||||
|
||||
it("handles tag objects with name property", () => {
|
||||
const doc = buildDocument({
|
||||
...baseOpts,
|
||||
content: {
|
||||
...baseOpts.content,
|
||||
tags: [{ name: "javascript" }, { name: "#python" }],
|
||||
},
|
||||
});
|
||||
expect(doc.tags).toEqual(["javascript", "python"]);
|
||||
});
|
||||
|
||||
it("strips # prefix from string tags", () => {
|
||||
const doc = buildDocument({
|
||||
...baseOpts,
|
||||
content: { ...baseOpts.content, tags: ["#tech", "web", "#dev"] },
|
||||
});
|
||||
expect(doc.tags).toEqual(["tech", "web", "dev"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractPlainText", () => {
|
||||
it("strips HTML tags", () => {
|
||||
const text = extractPlainText({ body: "<p>Hello <strong>world</strong></p>" });
|
||||
expect(text).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("decodes HTML entities", () => {
|
||||
const text = extractPlainText({ body: "Tom & Jerry <3 > "fun"" });
|
||||
expect(text).toBe('Tom & Jerry <3 > "fun"');
|
||||
});
|
||||
|
||||
it("collapses whitespace", () => {
|
||||
const text = extractPlainText({ body: "<p>Hello</p>\n\n<p>World</p>" });
|
||||
expect(text).toBe("Hello World");
|
||||
});
|
||||
|
||||
it("tries body, content, then text fields", () => {
|
||||
expect(extractPlainText({ body: "from body" })).toBe("from body");
|
||||
expect(extractPlainText({ content: "from content" })).toBe("from content");
|
||||
expect(extractPlainText({ text: "from text" })).toBe("from text");
|
||||
});
|
||||
|
||||
it("returns undefined when no content field exists", () => {
|
||||
expect(extractPlainText({ title: "just a title" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for empty body", () => {
|
||||
expect(extractPlainText({ body: "" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles ", () => {
|
||||
const text = extractPlainText({ body: "hello world" });
|
||||
expect(text).toBe("hello world");
|
||||
});
|
||||
|
||||
it("does not double-decode &lt;", () => {
|
||||
// &lt; should become < (literal text), not <
|
||||
const text = extractPlainText({ body: "code: &lt;div&gt;" });
|
||||
expect(text).toBe("code: <div>");
|
||||
});
|
||||
|
||||
it("truncates very long text content", () => {
|
||||
const longBody = "A".repeat(20_000);
|
||||
const text = extractPlainText({ body: longBody });
|
||||
expect(text!.length).toBeLessThanOrEqual(10_000);
|
||||
expect(text!.endsWith("\u2026")).toBe(true);
|
||||
});
|
||||
});
|
||||
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