@emdash-cms/plugin-atproto 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +36 -0
- package/src/atproto.ts +408 -0
- package/src/bluesky.ts +185 -0
- package/src/index.ts +42 -0
- package/src/sandbox-entry.ts +671 -0
- package/src/standard-site.ts +195 -0
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@emdash-cms/plugin-atproto",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "AT Protocol / standard.site syndication plugin for EmDash CMS",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./sandbox": "./src/sandbox-entry.ts"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"emdash",
|
|
16
|
+
"cms",
|
|
17
|
+
"plugin",
|
|
18
|
+
"atproto",
|
|
19
|
+
"bluesky",
|
|
20
|
+
"standard-site",
|
|
21
|
+
"syndication",
|
|
22
|
+
"fediverse"
|
|
23
|
+
],
|
|
24
|
+
"author": "Matt Kane",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"emdash": "0.0.1"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"vitest": "^4.0.18"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "vitest run",
|
|
34
|
+
"typecheck": "tsgo --noEmit"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/atproto.ts
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AT Protocol client helpers
|
|
3
|
+
*
|
|
4
|
+
* Handles session management, record CRUD, and handle resolution.
|
|
5
|
+
* All HTTP goes through ctx.http.fetch() for sandbox compatibility.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PluginContext } from "emdash";
|
|
9
|
+
|
|
10
|
+
// ── Types ───────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export interface AtSession {
|
|
13
|
+
accessJwt: string;
|
|
14
|
+
refreshJwt: string;
|
|
15
|
+
did: string;
|
|
16
|
+
handle: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AtRecord {
|
|
20
|
+
uri: string;
|
|
21
|
+
cid: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface BlobRef {
|
|
25
|
+
$type: "blob";
|
|
26
|
+
ref: { $link: string };
|
|
27
|
+
mimeType: string;
|
|
28
|
+
size: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Helpers ─────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/** Get the HTTP client from plugin context, or throw a helpful error. */
|
|
34
|
+
export function requireHttp(ctx: PluginContext) {
|
|
35
|
+
if (!ctx.http) {
|
|
36
|
+
throw new Error("AT Protocol plugin requires the network:fetch capability");
|
|
37
|
+
}
|
|
38
|
+
return ctx.http;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Validate that a PDS response contains expected string fields. */
|
|
42
|
+
function requireString(data: Record<string, unknown>, field: string, context: string): string {
|
|
43
|
+
const value = data[field];
|
|
44
|
+
if (typeof value !== "string") {
|
|
45
|
+
throw new Error(`${context}: missing or invalid '${field}' in response`);
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── Session management ──────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Create a new session with the PDS using an app password.
|
|
54
|
+
*/
|
|
55
|
+
export async function createSession(
|
|
56
|
+
ctx: PluginContext,
|
|
57
|
+
pdsHost: string,
|
|
58
|
+
identifier: string,
|
|
59
|
+
password: string,
|
|
60
|
+
): Promise<AtSession> {
|
|
61
|
+
const http = requireHttp(ctx);
|
|
62
|
+
const res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.server.createSession`, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: { "Content-Type": "application/json" },
|
|
65
|
+
body: JSON.stringify({ identifier, password }),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
const body = await res.text().catch(() => "");
|
|
70
|
+
throw new Error(`createSession failed (${res.status}): ${body}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const data = (await res.json()) as Record<string, unknown>;
|
|
74
|
+
return {
|
|
75
|
+
accessJwt: requireString(data, "accessJwt", "createSession"),
|
|
76
|
+
refreshJwt: requireString(data, "refreshJwt", "createSession"),
|
|
77
|
+
did: requireString(data, "did", "createSession"),
|
|
78
|
+
handle: requireString(data, "handle", "createSession"),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Refresh an existing session using the refresh token.
|
|
84
|
+
*/
|
|
85
|
+
export async function refreshSession(
|
|
86
|
+
ctx: PluginContext,
|
|
87
|
+
pdsHost: string,
|
|
88
|
+
refreshJwt: string,
|
|
89
|
+
): Promise<AtSession> {
|
|
90
|
+
const http = requireHttp(ctx);
|
|
91
|
+
const res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.server.refreshSession`, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { Authorization: `Bearer ${refreshJwt}` },
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (!res.ok) {
|
|
97
|
+
const body = await res.text().catch(() => "");
|
|
98
|
+
throw new Error(`refreshSession failed (${res.status}): ${body}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const data = (await res.json()) as Record<string, unknown>;
|
|
102
|
+
return {
|
|
103
|
+
accessJwt: requireString(data, "accessJwt", "refreshSession"),
|
|
104
|
+
refreshJwt: requireString(data, "refreshJwt", "refreshSession"),
|
|
105
|
+
did: requireString(data, "did", "refreshSession"),
|
|
106
|
+
handle: requireString(data, "handle", "refreshSession"),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* In-flight refresh promise for deduplication.
|
|
112
|
+
* Prevents concurrent publishes from racing on token refresh,
|
|
113
|
+
* which would corrupt tokens since PDS invalidates refresh tokens after use.
|
|
114
|
+
*/
|
|
115
|
+
let refreshInFlight: Promise<AtSession> | null = null;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Get a valid access token, refreshing if needed.
|
|
119
|
+
* Uses promise deduplication to prevent concurrent refresh races.
|
|
120
|
+
*/
|
|
121
|
+
export async function ensureSession(ctx: PluginContext): Promise<{
|
|
122
|
+
accessJwt: string;
|
|
123
|
+
did: string;
|
|
124
|
+
pdsHost: string;
|
|
125
|
+
}> {
|
|
126
|
+
const pdsHost = (await ctx.kv.get<string>("settings:pdsHost")) || "bsky.social";
|
|
127
|
+
const handle = await ctx.kv.get<string>("settings:handle");
|
|
128
|
+
const appPassword = await ctx.kv.get<string>("settings:appPassword");
|
|
129
|
+
|
|
130
|
+
if (!handle || !appPassword) {
|
|
131
|
+
throw new Error("AT Protocol credentials not configured");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Try existing tokens first
|
|
135
|
+
const existingAccess = await ctx.kv.get<string>("state:accessJwt");
|
|
136
|
+
const existingRefresh = await ctx.kv.get<string>("state:refreshJwt");
|
|
137
|
+
const existingDid = await ctx.kv.get<string>("state:did");
|
|
138
|
+
|
|
139
|
+
if (existingAccess && existingDid) {
|
|
140
|
+
return { accessJwt: existingAccess, did: existingDid, pdsHost };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Try refresh if we have a refresh token (deduplicated)
|
|
144
|
+
if (existingRefresh) {
|
|
145
|
+
if (!refreshInFlight) {
|
|
146
|
+
refreshInFlight = refreshSession(ctx, pdsHost, existingRefresh)
|
|
147
|
+
.then(async (session) => {
|
|
148
|
+
await persistSession(ctx, session);
|
|
149
|
+
return session;
|
|
150
|
+
})
|
|
151
|
+
.finally(() => {
|
|
152
|
+
refreshInFlight = null;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const session = await refreshInFlight;
|
|
157
|
+
return { accessJwt: session.accessJwt, did: session.did, pdsHost };
|
|
158
|
+
} catch {
|
|
159
|
+
// Refresh failed, fall through to full login
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Full login
|
|
164
|
+
const session = await createSession(ctx, pdsHost, handle, appPassword);
|
|
165
|
+
await persistSession(ctx, session);
|
|
166
|
+
return { accessJwt: session.accessJwt, did: session.did, pdsHost };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function persistSession(ctx: PluginContext, session: AtSession): Promise<void> {
|
|
170
|
+
await ctx.kv.set("state:accessJwt", session.accessJwt);
|
|
171
|
+
await ctx.kv.set("state:refreshJwt", session.refreshJwt);
|
|
172
|
+
await ctx.kv.set("state:did", session.did);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Record CRUD ─────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Create a record on the PDS. Returns the AT-URI and CID.
|
|
179
|
+
* Retries once on 401 (expired token) by refreshing the session.
|
|
180
|
+
*/
|
|
181
|
+
export async function createRecord(
|
|
182
|
+
ctx: PluginContext,
|
|
183
|
+
pdsHost: string,
|
|
184
|
+
accessJwt: string,
|
|
185
|
+
did: string,
|
|
186
|
+
collection: string,
|
|
187
|
+
record: unknown,
|
|
188
|
+
): Promise<AtRecord> {
|
|
189
|
+
const http = requireHttp(ctx);
|
|
190
|
+
let res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.repo.createRecord`, {
|
|
191
|
+
method: "POST",
|
|
192
|
+
headers: {
|
|
193
|
+
Authorization: `Bearer ${accessJwt}`,
|
|
194
|
+
"Content-Type": "application/json",
|
|
195
|
+
},
|
|
196
|
+
body: JSON.stringify({ repo: did, collection, record }),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// Retry once on 401 with refreshed token
|
|
200
|
+
if (res.status === 401) {
|
|
201
|
+
const refreshed = await ensureSessionFresh(ctx, pdsHost);
|
|
202
|
+
if (refreshed) {
|
|
203
|
+
res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.repo.createRecord`, {
|
|
204
|
+
method: "POST",
|
|
205
|
+
headers: {
|
|
206
|
+
Authorization: `Bearer ${refreshed.accessJwt}`,
|
|
207
|
+
"Content-Type": "application/json",
|
|
208
|
+
},
|
|
209
|
+
body: JSON.stringify({ repo: refreshed.did, collection, record }),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (!res.ok) {
|
|
215
|
+
const body = await res.text().catch(() => "");
|
|
216
|
+
throw new Error(`createRecord failed (${res.status}): ${body}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const data = (await res.json()) as Record<string, unknown>;
|
|
220
|
+
return {
|
|
221
|
+
uri: requireString(data, "uri", "createRecord"),
|
|
222
|
+
cid: requireString(data, "cid", "createRecord"),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Update (upsert) a record on the PDS.
|
|
228
|
+
* Retries once on 401 (expired token).
|
|
229
|
+
*/
|
|
230
|
+
export async function putRecord(
|
|
231
|
+
ctx: PluginContext,
|
|
232
|
+
pdsHost: string,
|
|
233
|
+
accessJwt: string,
|
|
234
|
+
did: string,
|
|
235
|
+
collection: string,
|
|
236
|
+
rkey: string,
|
|
237
|
+
record: unknown,
|
|
238
|
+
): Promise<AtRecord> {
|
|
239
|
+
const http = requireHttp(ctx);
|
|
240
|
+
let res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.repo.putRecord`, {
|
|
241
|
+
method: "POST",
|
|
242
|
+
headers: {
|
|
243
|
+
Authorization: `Bearer ${accessJwt}`,
|
|
244
|
+
"Content-Type": "application/json",
|
|
245
|
+
},
|
|
246
|
+
body: JSON.stringify({ repo: did, collection, rkey, record }),
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
if (res.status === 401) {
|
|
250
|
+
const refreshed = await ensureSessionFresh(ctx, pdsHost);
|
|
251
|
+
if (refreshed) {
|
|
252
|
+
res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.repo.putRecord`, {
|
|
253
|
+
method: "POST",
|
|
254
|
+
headers: {
|
|
255
|
+
Authorization: `Bearer ${refreshed.accessJwt}`,
|
|
256
|
+
"Content-Type": "application/json",
|
|
257
|
+
},
|
|
258
|
+
body: JSON.stringify({ repo: refreshed.did, collection, rkey, record }),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (!res.ok) {
|
|
264
|
+
const body = await res.text().catch(() => "");
|
|
265
|
+
throw new Error(`putRecord failed (${res.status}): ${body}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const data = (await res.json()) as Record<string, unknown>;
|
|
269
|
+
return {
|
|
270
|
+
uri: requireString(data, "uri", "putRecord"),
|
|
271
|
+
cid: requireString(data, "cid", "putRecord"),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Delete a record from the PDS.
|
|
277
|
+
* Retries once on 401 (expired token).
|
|
278
|
+
*/
|
|
279
|
+
export async function deleteRecord(
|
|
280
|
+
ctx: PluginContext,
|
|
281
|
+
pdsHost: string,
|
|
282
|
+
accessJwt: string,
|
|
283
|
+
did: string,
|
|
284
|
+
collection: string,
|
|
285
|
+
rkey: string,
|
|
286
|
+
): Promise<void> {
|
|
287
|
+
const http = requireHttp(ctx);
|
|
288
|
+
let res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.repo.deleteRecord`, {
|
|
289
|
+
method: "POST",
|
|
290
|
+
headers: {
|
|
291
|
+
Authorization: `Bearer ${accessJwt}`,
|
|
292
|
+
"Content-Type": "application/json",
|
|
293
|
+
},
|
|
294
|
+
body: JSON.stringify({ repo: did, collection, rkey }),
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
if (res.status === 401) {
|
|
298
|
+
const refreshed = await ensureSessionFresh(ctx, pdsHost);
|
|
299
|
+
if (refreshed) {
|
|
300
|
+
res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.repo.deleteRecord`, {
|
|
301
|
+
method: "POST",
|
|
302
|
+
headers: {
|
|
303
|
+
Authorization: `Bearer ${refreshed.accessJwt}`,
|
|
304
|
+
"Content-Type": "application/json",
|
|
305
|
+
},
|
|
306
|
+
body: JSON.stringify({ repo: refreshed.did, collection, rkey }),
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (!res.ok) {
|
|
312
|
+
const body = await res.text().catch(() => "");
|
|
313
|
+
throw new Error(`deleteRecord failed (${res.status}): ${body}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Force a session refresh (for 401 retry). Clears the stale access token
|
|
319
|
+
* and delegates to ensureSession, which handles refresh deduplication.
|
|
320
|
+
* Returns null if refresh fails.
|
|
321
|
+
*/
|
|
322
|
+
async function ensureSessionFresh(
|
|
323
|
+
ctx: PluginContext,
|
|
324
|
+
_pdsHost: string,
|
|
325
|
+
): Promise<{ accessJwt: string; did: string } | null> {
|
|
326
|
+
// Clear stale access token so ensureSession will attempt a refresh
|
|
327
|
+
await ctx.kv.set("state:accessJwt", "");
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
const result = await ensureSession(ctx);
|
|
331
|
+
return { accessJwt: result.accessJwt, did: result.did };
|
|
332
|
+
} catch {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ── Handle resolution ───────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Resolve an AT Protocol handle to a DID.
|
|
341
|
+
* Uses the public API -- no auth required.
|
|
342
|
+
*/
|
|
343
|
+
export async function resolveHandle(ctx: PluginContext, handle: string): Promise<string> {
|
|
344
|
+
const http = requireHttp(ctx);
|
|
345
|
+
const res = await http.fetch(
|
|
346
|
+
`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`,
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
if (!res.ok) {
|
|
350
|
+
throw new Error(`resolveHandle failed for ${handle} (${res.status})`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const data = (await res.json()) as Record<string, unknown>;
|
|
354
|
+
return requireString(data, "did", "resolveHandle");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ── Blob upload ─────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Upload a blob (image) to the PDS. Returns a blob reference for embedding.
|
|
361
|
+
*/
|
|
362
|
+
export async function uploadBlob(
|
|
363
|
+
ctx: PluginContext,
|
|
364
|
+
pdsHost: string,
|
|
365
|
+
accessJwt: string,
|
|
366
|
+
imageBytes: ArrayBuffer,
|
|
367
|
+
mimeType: string,
|
|
368
|
+
): Promise<BlobRef> {
|
|
369
|
+
const http = requireHttp(ctx);
|
|
370
|
+
const res = await http.fetch(`https://${pdsHost}/xrpc/com.atproto.repo.uploadBlob`, {
|
|
371
|
+
method: "POST",
|
|
372
|
+
headers: {
|
|
373
|
+
Authorization: `Bearer ${accessJwt}`,
|
|
374
|
+
"Content-Type": mimeType,
|
|
375
|
+
},
|
|
376
|
+
body: imageBytes,
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
if (!res.ok) {
|
|
380
|
+
const body = await res.text().catch(() => "");
|
|
381
|
+
throw new Error(`uploadBlob failed (${res.status}): ${body}`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const data = (await res.json()) as Record<string, unknown>;
|
|
385
|
+
if (!data.blob || typeof data.blob !== "object") {
|
|
386
|
+
throw new Error("uploadBlob: missing 'blob' in response");
|
|
387
|
+
}
|
|
388
|
+
const blob = data.blob as Record<string, unknown>;
|
|
389
|
+
if (!blob.ref || typeof blob.ref !== "object") {
|
|
390
|
+
throw new Error("uploadBlob: malformed blob reference in response");
|
|
391
|
+
}
|
|
392
|
+
return data.blob as BlobRef;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ── Utilities ───────────────────────────────────────────────────
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Extract the rkey from an AT-URI.
|
|
399
|
+
* at://did:plc:xxx/collection/rkey -> rkey
|
|
400
|
+
*/
|
|
401
|
+
export function rkeyFromUri(uri: string): string {
|
|
402
|
+
const parts = uri.split("/");
|
|
403
|
+
const rkey = parts.at(-1);
|
|
404
|
+
if (!rkey) {
|
|
405
|
+
throw new Error(`Invalid AT-URI: ${uri}`);
|
|
406
|
+
}
|
|
407
|
+
return rkey;
|
|
408
|
+
}
|
package/src/bluesky.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bluesky cross-posting helpers
|
|
3
|
+
*
|
|
4
|
+
* Builds app.bsky.feed.post records with link cards and rich text facets.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { BlobRef } from "./atproto.js";
|
|
8
|
+
|
|
9
|
+
// ── Pre-compiled regexes ────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
const TEMPLATE_TITLE_RE = /\{title\}/g;
|
|
12
|
+
const TEMPLATE_URL_RE = /\{url\}/g;
|
|
13
|
+
const TEMPLATE_EXCERPT_RE = /\{excerpt\}/g;
|
|
14
|
+
const TRAILING_PUNCTUATION_RE = /[.,;:!?'"]+$/;
|
|
15
|
+
// Global regexes for facet detection -- reset lastIndex before each use
|
|
16
|
+
const URL_REGEX = /https?:\/\/[^\s)>\]]+/g;
|
|
17
|
+
const HASHTAG_REGEX = /(?<=\s|^)#([a-zA-Z0-9_]+)/g;
|
|
18
|
+
|
|
19
|
+
// ── Types ───────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export interface BskyPost {
|
|
22
|
+
$type: "app.bsky.feed.post";
|
|
23
|
+
text: string;
|
|
24
|
+
createdAt: string;
|
|
25
|
+
langs?: string[];
|
|
26
|
+
facets?: BskyFacet[];
|
|
27
|
+
embed?: BskyEmbed;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BskyFacet {
|
|
31
|
+
index: { byteStart: number; byteEnd: number };
|
|
32
|
+
features: Array<
|
|
33
|
+
| { $type: "app.bsky.richtext.facet#link"; uri: string }
|
|
34
|
+
| { $type: "app.bsky.richtext.facet#tag"; tag: string }
|
|
35
|
+
>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type BskyEmbed = {
|
|
39
|
+
$type: "app.bsky.embed.external";
|
|
40
|
+
external: {
|
|
41
|
+
uri: string;
|
|
42
|
+
title: string;
|
|
43
|
+
description: string;
|
|
44
|
+
thumb?: BlobRef;
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// ── Post builder ────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Build a Bluesky post record for cross-posting published content.
|
|
52
|
+
*/
|
|
53
|
+
export function buildBskyPost(opts: {
|
|
54
|
+
template: string;
|
|
55
|
+
content: Record<string, unknown>;
|
|
56
|
+
siteUrl: string;
|
|
57
|
+
thumbBlob?: BlobRef;
|
|
58
|
+
langs?: string[];
|
|
59
|
+
}): BskyPost {
|
|
60
|
+
const { template, content, siteUrl, thumbBlob, langs } = opts;
|
|
61
|
+
|
|
62
|
+
const title = (content.title as string) || "Untitled";
|
|
63
|
+
const slug = content.slug as string;
|
|
64
|
+
const excerpt = (content.excerpt || content.description || "") as string;
|
|
65
|
+
const url = slug ? `${stripTrailingSlash(siteUrl)}/${slug}` : siteUrl;
|
|
66
|
+
|
|
67
|
+
// Apply template -- substitute before truncation so we can detect
|
|
68
|
+
// if the URL survives intact after truncation
|
|
69
|
+
const fullText = template
|
|
70
|
+
.replace(TEMPLATE_TITLE_RE, title)
|
|
71
|
+
.replace(TEMPLATE_URL_RE, url)
|
|
72
|
+
.replace(TEMPLATE_EXCERPT_RE, excerpt);
|
|
73
|
+
|
|
74
|
+
// Truncate to 300 graphemes (Bluesky limit)
|
|
75
|
+
const text = truncateGraphemes(fullText, 300);
|
|
76
|
+
const wasTruncated = text !== fullText;
|
|
77
|
+
|
|
78
|
+
const post: BskyPost = {
|
|
79
|
+
$type: "app.bsky.feed.post",
|
|
80
|
+
text,
|
|
81
|
+
createdAt: new Date().toISOString(),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
if (langs && langs.length > 0) {
|
|
85
|
+
post.langs = langs.slice(0, 3); // Max 3 per spec
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Auto-detect URLs in text and build facets.
|
|
89
|
+
// If text was truncated, skip facets -- truncation may have cut
|
|
90
|
+
// a URL mid-string, producing a broken link facet.
|
|
91
|
+
if (!wasTruncated) {
|
|
92
|
+
const facets = buildFacets(text);
|
|
93
|
+
if (facets.length > 0) {
|
|
94
|
+
post.facets = facets;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Link card embed
|
|
99
|
+
post.embed = {
|
|
100
|
+
$type: "app.bsky.embed.external",
|
|
101
|
+
external: {
|
|
102
|
+
uri: url,
|
|
103
|
+
title,
|
|
104
|
+
description: truncateGraphemes(excerpt, 300),
|
|
105
|
+
...(thumbBlob ? { thumb: thumbBlob } : {}),
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
return post;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Rich text facets ────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Build rich text facets for URLs and hashtags in text.
|
|
116
|
+
*
|
|
117
|
+
* CRITICAL: Facet byte offsets use UTF-8 bytes, not JavaScript string indices.
|
|
118
|
+
*/
|
|
119
|
+
export function buildFacets(text: string): BskyFacet[] {
|
|
120
|
+
const encoder = new TextEncoder();
|
|
121
|
+
const facets: BskyFacet[] = [];
|
|
122
|
+
|
|
123
|
+
// Detect URLs
|
|
124
|
+
let match: RegExpExecArray | null;
|
|
125
|
+
URL_REGEX.lastIndex = 0;
|
|
126
|
+
while ((match = URL_REGEX.exec(text)) !== null) {
|
|
127
|
+
// Strip trailing punctuation that was captured by the greedy regex
|
|
128
|
+
const cleanUrl = match[0].replace(TRAILING_PUNCTUATION_RE, "");
|
|
129
|
+
const beforeBytes = encoder.encode(text.slice(0, match.index));
|
|
130
|
+
const matchBytes = encoder.encode(cleanUrl);
|
|
131
|
+
facets.push({
|
|
132
|
+
index: {
|
|
133
|
+
byteStart: beforeBytes.length,
|
|
134
|
+
byteEnd: beforeBytes.length + matchBytes.length,
|
|
135
|
+
},
|
|
136
|
+
features: [{ $type: "app.bsky.richtext.facet#link", uri: cleanUrl }],
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Detect hashtags
|
|
141
|
+
HASHTAG_REGEX.lastIndex = 0;
|
|
142
|
+
while ((match = HASHTAG_REGEX.exec(text)) !== null) {
|
|
143
|
+
const tag = match[1];
|
|
144
|
+
if (!tag) continue;
|
|
145
|
+
|
|
146
|
+
// Include the # in the byte range
|
|
147
|
+
const beforeBytes = encoder.encode(text.slice(0, match.index));
|
|
148
|
+
const matchBytes = encoder.encode(match[0]);
|
|
149
|
+
facets.push({
|
|
150
|
+
index: {
|
|
151
|
+
byteStart: beforeBytes.length,
|
|
152
|
+
byteEnd: beforeBytes.length + matchBytes.length,
|
|
153
|
+
},
|
|
154
|
+
features: [{ $type: "app.bsky.richtext.facet#tag", tag }],
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return facets;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Utilities ───────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Truncate a string to a maximum number of graphemes.
|
|
165
|
+
* Uses Intl.Segmenter for correct Unicode handling.
|
|
166
|
+
*/
|
|
167
|
+
function truncateGraphemes(text: string, maxGraphemes: number): string {
|
|
168
|
+
// Intl.Segmenter handles multi-codepoint graphemes (emoji, combining chars)
|
|
169
|
+
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
|
|
170
|
+
const segments = [...segmenter.segment(text)];
|
|
171
|
+
|
|
172
|
+
if (segments.length <= maxGraphemes) return text;
|
|
173
|
+
|
|
174
|
+
// Truncate and add ellipsis
|
|
175
|
+
return (
|
|
176
|
+
segments
|
|
177
|
+
.slice(0, maxGraphemes - 1)
|
|
178
|
+
.map((s) => s.segment)
|
|
179
|
+
.join("") + "\u2026"
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function stripTrailingSlash(url: string): string {
|
|
184
|
+
return url.endsWith("/") ? url.slice(0, -1) : url;
|
|
185
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AT Protocol / standard.site Plugin for EmDash CMS
|
|
3
|
+
*
|
|
4
|
+
* Syndicates published content to the AT Protocol network using the
|
|
5
|
+
* standard.site lexicons, with optional cross-posting to Bluesky.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Creates site.standard.publication record (one per site)
|
|
9
|
+
* - Creates site.standard.document records on publish
|
|
10
|
+
* - Optional Bluesky cross-post with link card
|
|
11
|
+
* - Automatic <link rel="site.standard.document"> injection via page:metadata
|
|
12
|
+
* - Sync status tracking in plugin storage
|
|
13
|
+
*
|
|
14
|
+
* Designed for sandboxed execution:
|
|
15
|
+
* - All HTTP via ctx.http.fetch()
|
|
16
|
+
* - Block Kit admin UI (no React components)
|
|
17
|
+
* - Capabilities: read:content, network:fetch:any
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { PluginDescriptor } from "emdash";
|
|
21
|
+
|
|
22
|
+
// ── Descriptor ──────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Create the AT Protocol plugin descriptor.
|
|
26
|
+
* Import this in your astro.config.mjs / live.config.ts.
|
|
27
|
+
*/
|
|
28
|
+
export function atprotoPlugin(): PluginDescriptor {
|
|
29
|
+
return {
|
|
30
|
+
id: "atproto",
|
|
31
|
+
version: "0.1.0",
|
|
32
|
+
format: "standard",
|
|
33
|
+
entrypoint: "@emdash-cms/plugin-atproto/sandbox",
|
|
34
|
+
capabilities: ["read:content", "network:fetch:any"],
|
|
35
|
+
storage: {
|
|
36
|
+
publications: { indexes: ["contentId", "platform", "publishedAt"] },
|
|
37
|
+
},
|
|
38
|
+
// Block Kit admin pages (no adminEntry needed -- sandboxed)
|
|
39
|
+
adminPages: [{ path: "/status", label: "AT Protocol", icon: "globe" }],
|
|
40
|
+
adminWidgets: [{ id: "sync-status", title: "AT Protocol", size: "third" }],
|
|
41
|
+
};
|
|
42
|
+
}
|