@rmdes/indiekit-syndicator-bluesky 1.0.1 → 1.0.3

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/lib/bluesky.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import { AtpAgent } from "@atproto/api";
2
- import { IndiekitError } from "@indiekit/error";
3
2
  import { getCanonicalUrl, isSameOrigin } from "@indiekit/util";
4
3
 
5
4
  import {
@@ -9,6 +8,8 @@ import {
9
8
  getLikePostText,
10
9
  getPostParts,
11
10
  uriToPostUrl,
11
+ fetchOpenGraphData,
12
+ getExternalUrl,
12
13
  } from "./utils.js";
13
14
 
14
15
  export class Bluesky {
@@ -121,26 +122,109 @@ export class Bluesky {
121
122
  return uriToPostUrl(this.profileUrl, quotePost.uri);
122
123
  }
123
124
 
125
+ /**
126
+ * Upload image from URL (for OG thumbnails)
127
+ * @param {string} imageUrl - URL of image to upload
128
+ * @returns {Promise<object|null>} Blob reference or null
129
+ */
130
+ async uploadImageFromUrl(imageUrl) {
131
+ if (!imageUrl) return null;
132
+
133
+ try {
134
+ const client = await this.#client();
135
+ const response = await fetch(imageUrl, {
136
+ headers: {
137
+ "User-Agent": "Mozilla/5.0 (compatible; IndiekitBot/1.0)",
138
+ },
139
+ redirect: "follow",
140
+ });
141
+
142
+ if (!response.ok) return null;
143
+
144
+ let blob = await response.blob();
145
+ let encoding = response.headers.get("Content-Type") || "image/jpeg";
146
+
147
+ // Compress if needed
148
+ if (encoding?.startsWith("image/")) {
149
+ const buffer = Buffer.from(await blob.arrayBuffer());
150
+ const image = await getPostImage(buffer, encoding);
151
+ blob = new Blob([new Uint8Array(image.buffer)], {
152
+ type: image.mimeType,
153
+ });
154
+ encoding = image.mimeType;
155
+ }
156
+
157
+ const uploadResponse = await client.com.atproto.repo.uploadBlob(blob, {
158
+ encoding,
159
+ });
160
+
161
+ return uploadResponse.data.blob;
162
+ } catch (error) {
163
+ console.error(`Failed to upload OG image: ${error.message}`);
164
+ return null;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Create external link embed
170
+ * @param {string} url - External URL
171
+ * @returns {Promise<object|null>} External embed or null
172
+ */
173
+ async createExternalEmbed(url) {
174
+ if (!url) return null;
175
+
176
+ try {
177
+ const ogData = await fetchOpenGraphData(url);
178
+ let thumb = null;
179
+
180
+ if (ogData.imageUrl) {
181
+ thumb = await this.uploadImageFromUrl(ogData.imageUrl);
182
+ }
183
+
184
+ return {
185
+ $type: "app.bsky.embed.external",
186
+ external: {
187
+ uri: url,
188
+ title: ogData.title || url,
189
+ description: ogData.description || "",
190
+ ...(thumb && { thumb }),
191
+ },
192
+ };
193
+ } catch (error) {
194
+ console.error(`Failed to create external embed: ${error.message}`);
195
+ return null;
196
+ }
197
+ }
198
+
124
199
  /**
125
200
  * Post a regular post
126
201
  * @param {object} richText - Rich text
127
- * @param {Array} [images] - Images
202
+ * @param {object} [options] - Post options
203
+ * @param {Array} [options.images] - Images
204
+ * @param {object} [options.externalEmbed] - External link embed
128
205
  * @returns {Promise<string>} Bluesky post URL
129
206
  */
130
- async postPost(richText, images) {
207
+ async postPost(richText, options = {}) {
131
208
  const client = await this.#client();
209
+ const { images, externalEmbed } = options;
210
+
211
+ // Determine embed type - images take priority over external
212
+ let embed = null;
213
+ if (images?.length > 0) {
214
+ embed = {
215
+ $type: "app.bsky.embed.images",
216
+ images,
217
+ };
218
+ } else if (externalEmbed) {
219
+ embed = externalEmbed;
220
+ }
132
221
 
133
222
  const postData = {
134
223
  $type: "app.bsky.feed.post",
135
224
  text: richText.text,
136
225
  facets: richText.facets,
137
226
  createdAt: new Date().toISOString(),
138
- ...(images?.length > 0 && {
139
- embed: {
140
- $type: "app.bsky.embed.images",
141
- images,
142
- },
143
- }),
227
+ ...(embed && { embed }),
144
228
  };
145
229
 
146
230
  const post = await client.post(postData);
@@ -166,7 +250,7 @@ export class Bluesky {
166
250
  const mediaResponse = await fetch(mediaUrl);
167
251
 
168
252
  if (!mediaResponse.ok) {
169
- throw await IndiekitError.fromFetch(mediaResponse);
253
+ throw new Error(`Failed to fetch media: ${mediaResponse.status} ${mediaResponse.statusText}`);
170
254
  }
171
255
 
172
256
  let blob = await mediaResponse.blob();
@@ -235,21 +319,43 @@ export class Bluesky {
235
319
  return this.postLike(likeOfUrl);
236
320
  }
237
321
 
238
- // NEW: Syndicate likes of external URLs as posts
322
+ // Syndicate likes of external URLs as posts with link card
239
323
  if (this.syndicateExternalLikes) {
240
324
  const text = getLikePostText(properties, likeOfUrl);
241
325
  const richText = await createRichText(client, text);
242
- return this.postPost(richText, images);
326
+ // Create external embed for the liked URL
327
+ const externalEmbed = await this.createExternalEmbed(likeOfUrl);
328
+ return this.postPost(richText, { images, externalEmbed });
243
329
  }
244
330
 
245
331
  // Don't syndicate if option is disabled
246
332
  return;
247
333
  }
248
334
 
249
- // Regular post
335
+ // Handle bookmarks - similar to likes but with bookmark-of
336
+ const bookmarkOfUrl = properties["bookmark-of"];
337
+ if (bookmarkOfUrl) {
338
+ const text = getPostText(properties, this.includePermalink);
339
+ const richText = await createRichText(client, text);
340
+ // Create external embed for the bookmarked URL
341
+ const externalEmbed = await this.createExternalEmbed(bookmarkOfUrl);
342
+ return this.postPost(richText, { images, externalEmbed });
343
+ }
344
+
345
+ // Regular post - check for external URL in content to create link card
250
346
  const text = getPostText(properties, this.includePermalink);
251
347
  const richText = await createRichText(client, text);
252
- return this.postPost(richText, images);
348
+
349
+ // If no images, try to create external embed from URLs in content
350
+ let externalEmbed = null;
351
+ if (!images?.length) {
352
+ const externalUrl = getExternalUrl(properties);
353
+ if (externalUrl) {
354
+ externalEmbed = await this.createExternalEmbed(externalUrl);
355
+ }
356
+ }
357
+
358
+ return this.postPost(richText, { images, externalEmbed });
253
359
  } catch (error) {
254
360
  throw new Error(error.message);
255
361
  }
package/lib/utils.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { RichText } from "@atproto/api";
2
2
  import { htmlToText } from "html-to-text";
3
3
  import sharp from "sharp";
4
+ import { JSDOM } from "jsdom";
4
5
 
5
6
  const AT_URI = /at:\/\/(?<did>did:[^/]+)\/(?<type>[^/]+)\/(?<rkey>[^/]+)/;
6
7
 
@@ -160,3 +161,95 @@ export const htmlToStatusText = (html) => {
160
161
  const statusText = lastHref ? `${text} ${lastHref}` : text;
161
162
  return statusText;
162
163
  };
164
+
165
+ /**
166
+ * Fetch OpenGraph metadata from a URL
167
+ * @param {string} url - URL to fetch OG data from
168
+ * @returns {Promise<{title: string, description: string, imageUrl: string|null}>} OG metadata
169
+ */
170
+ export async function fetchOpenGraphData(url) {
171
+ try {
172
+ const response = await fetch(url, {
173
+ headers: {
174
+ "User-Agent": "Mozilla/5.0 (compatible; IndiekitBot/1.0)",
175
+ Accept: "text/html,application/xhtml+xml",
176
+ },
177
+ redirect: "follow",
178
+ timeout: 10000,
179
+ });
180
+
181
+ if (!response.ok) {
182
+ return { title: url, description: "", imageUrl: null };
183
+ }
184
+
185
+ const html = await response.text();
186
+ const dom = new JSDOM(html);
187
+ const doc = dom.window.document;
188
+
189
+ // Get OG title, fallback to regular title
190
+ const ogTitle =
191
+ doc.querySelector('meta[property="og:title"]')?.getAttribute("content") ||
192
+ doc.querySelector("title")?.textContent ||
193
+ url;
194
+
195
+ // Get OG description, fallback to meta description
196
+ const ogDescription =
197
+ doc
198
+ .querySelector('meta[property="og:description"]')
199
+ ?.getAttribute("content") ||
200
+ doc.querySelector('meta[name="description"]')?.getAttribute("content") ||
201
+ "";
202
+
203
+ // Get OG image
204
+ let ogImage =
205
+ doc.querySelector('meta[property="og:image"]')?.getAttribute("content") ||
206
+ null;
207
+
208
+ // Handle relative URLs for image
209
+ if (ogImage && !ogImage.startsWith("http")) {
210
+ const baseUrl = new URL(url);
211
+ ogImage = new URL(ogImage, baseUrl.origin).href;
212
+ }
213
+
214
+ return {
215
+ title: ogTitle.slice(0, 300), // Bluesky title limit
216
+ description: ogDescription.slice(0, 1000), // Reasonable limit
217
+ imageUrl: ogImage,
218
+ };
219
+ } catch (error) {
220
+ console.error(`Failed to fetch OG data for ${url}:`, error.message);
221
+ return { title: url, description: "", imageUrl: null };
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Extract the primary URL from post properties
227
+ * @param {object} properties - JF2 properties
228
+ * @returns {string|null} Primary URL to create card for
229
+ */
230
+ export function getExternalUrl(properties) {
231
+ // For likes, use the liked URL
232
+ if (properties["like-of"]) {
233
+ return properties["like-of"];
234
+ }
235
+
236
+ // For bookmarks, use the bookmarked URL
237
+ if (properties["bookmark-of"]) {
238
+ return properties["bookmark-of"];
239
+ }
240
+
241
+ // For replies, use the in-reply-to URL
242
+ if (properties["in-reply-to"]) {
243
+ return properties["in-reply-to"];
244
+ }
245
+
246
+ // For regular posts with content, try to extract URL from content
247
+ if (properties.content?.html) {
248
+ const hrefs = [...properties.content.html.matchAll(/href="(https?:\/\/[^"]+)"/g)];
249
+ if (hrefs.length > 0) {
250
+ return hrefs.at(-1)[1];
251
+ }
252
+ }
253
+
254
+ return null;
255
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-syndicator-bluesky",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Bluesky syndicator for Indiekit with external like support",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -20,6 +20,8 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@atproto/api": "^0.14.0",
23
+ "html-to-text": "^9.0.0",
24
+ "jsdom": "^24.0.0",
23
25
  "sharp": "^0.33.0"
24
26
  },
25
27
  "peerDependencies": {