@rmdes/indiekit-syndicator-bluesky 1.0.5 → 1.0.7

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/index.js CHANGED
@@ -8,7 +8,8 @@ const defaults = {
8
8
  profileUrl: "https://bsky.app/profile",
9
9
  serviceUrl: "https://bsky.social",
10
10
  includePermalink: false,
11
- syndicateExternalLikes: true, // NEW: Enable syndication of external likes
11
+ syndicateExternalLikes: true, // Enable syndication of external likes
12
+ syndicateExternalReposts: true, // Enable syndication of external reposts
12
13
  checked: false,
13
14
  };
14
15
 
@@ -23,6 +24,7 @@ export default class BlueskySyndicator {
23
24
  * @param {string} [options.password] - Password
24
25
  * @param {boolean} [options.includePermalink] - Include permalink in status
25
26
  * @param {boolean} [options.syndicateExternalLikes] - Syndicate likes of external URLs as posts
27
+ * @param {boolean} [options.syndicateExternalReposts] - Syndicate reposts of external URLs as posts
26
28
  * @param {boolean} [options.checked] - Check syndicator in UI
27
29
  */
28
30
  constructor(options = {}) {
@@ -92,6 +94,7 @@ export default class BlueskySyndicator {
92
94
  serviceUrl: this.#serviceUrl,
93
95
  includePermalink: this.options.includePermalink,
94
96
  syndicateExternalLikes: this.options.syndicateExternalLikes,
97
+ syndicateExternalReposts: this.options.syndicateExternalReposts,
95
98
  });
96
99
 
97
100
  return await bluesky.post(properties, publication.me);
package/lib/bluesky.js CHANGED
@@ -6,6 +6,9 @@ import {
6
6
  getPostImage,
7
7
  getPostText,
8
8
  getLikePostText,
9
+ getRepostPostText,
10
+ getBookmarkPostText,
11
+ buildPostText,
9
12
  getPostParts,
10
13
  uriToPostUrl,
11
14
  fetchOpenGraphData,
@@ -22,6 +25,7 @@ export class Bluesky {
22
25
  * @param {string} options.serviceUrl - Service URL
23
26
  * @param {boolean} [options.includePermalink] - Include permalink in status
24
27
  * @param {boolean} [options.syndicateExternalLikes] - Syndicate likes of external URLs
28
+ * @param {boolean} [options.syndicateExternalReposts] - Syndicate reposts of external URLs
25
29
  */
26
30
  constructor(options) {
27
31
  this.identifier = options.identifier;
@@ -30,6 +34,7 @@ export class Bluesky {
30
34
  this.serviceUrl = options.serviceUrl;
31
35
  this.includePermalink = options.includePermalink || false;
32
36
  this.syndicateExternalLikes = options.syndicateExternalLikes !== false; // Default true
37
+ this.syndicateExternalReposts = options.syndicateExternalReposts !== false; // Default true
33
38
  }
34
39
 
35
40
  /**
@@ -330,7 +335,15 @@ export class Bluesky {
330
335
  if (isSameOrigin(repostUrl, this.profileUrl)) {
331
336
  return this.postRepost(repostUrl);
332
337
  }
333
- // Do not syndicate reposts of other URLs
338
+
339
+ // Syndicate reposts of external URLs as posts with link card
340
+ if (this.syndicateExternalReposts) {
341
+ const text = getRepostPostText(properties, repostUrl);
342
+ const richText = await createRichText(client, text);
343
+ const externalEmbed = await this.createExternalEmbed(repostUrl);
344
+ return this.postPost(richText, { images, externalEmbed });
345
+ }
346
+
334
347
  return;
335
348
  }
336
349
 
@@ -355,26 +368,28 @@ export class Bluesky {
355
368
  return;
356
369
  }
357
370
 
358
- // Handle bookmarks - similar to likes but with bookmark-of
371
+ // Handle bookmarks - OG card shows bookmarked URL, text has commentary + permalink
359
372
  const bookmarkOfUrl = properties["bookmark-of"];
360
373
  if (bookmarkOfUrl) {
361
- const text = getPostText(properties, this.includePermalink);
374
+ const text = getBookmarkPostText(properties, bookmarkOfUrl);
362
375
  const richText = await createRichText(client, text);
363
- // Create external embed for the bookmarked URL
364
376
  const externalEmbed = await this.createExternalEmbed(bookmarkOfUrl);
365
377
  return this.postPost(richText, { images, externalEmbed });
366
378
  }
367
379
 
368
- // Regular post - check for external URL in content to create link card
369
- const text = getPostText(properties, this.includePermalink);
380
+ // Regular post - determine external URL and build text accordingly
381
+ const externalUrl = getExternalUrl(properties);
382
+ const text = buildPostText(properties, { externalUrl });
370
383
  const richText = await createRichText(client, text);
371
384
 
372
- // If no images, try to create external embed from URLs in content
385
+ // Create OG embed:
386
+ // - External URL exists → use it as OG card (permalink is in text)
387
+ // - No external URL → use permalink as OG card
373
388
  let externalEmbed = null;
374
389
  if (!images?.length) {
375
- const externalUrl = getExternalUrl(properties);
376
- if (externalUrl) {
377
- externalEmbed = await this.createExternalEmbed(externalUrl);
390
+ const embedUrl = externalUrl || properties.url;
391
+ if (embedUrl) {
392
+ externalEmbed = await this.createExternalEmbed(embedUrl);
378
393
  }
379
394
  }
380
395
 
package/lib/utils.js CHANGED
@@ -180,7 +180,7 @@ export const uriToPostUrl = (profileUrl, uri) => {
180
180
  };
181
181
 
182
182
  /**
183
- * Get post text from given JF2 properties
183
+ * Get post text from given JF2 properties (legacy, used for likes/bookmarks)
184
184
  * @param {object} properties - JF2 properties
185
185
  * @param {boolean} [includePermalink] - Include permalink in post
186
186
  * @returns {string} Post text
@@ -210,7 +210,92 @@ export const getPostText = (properties, includePermalink) => {
210
210
  };
211
211
 
212
212
  /**
213
- * Get post text for a like of an external URL
213
+ * Extract plain text content from properties without appending URLs
214
+ * @param {object} properties - JF2 properties
215
+ * @returns {string} Plain text content
216
+ */
217
+ export function getContentText(properties) {
218
+ if (properties.name && properties.name !== "") {
219
+ return properties.name;
220
+ }
221
+ if (properties.content?.html) {
222
+ return htmlToText(properties.content.html, {
223
+ selectors: [
224
+ { selector: "a", options: { ignoreHref: true } },
225
+ { selector: "img", format: "skip" },
226
+ ],
227
+ wordwrap: false,
228
+ });
229
+ }
230
+ if (properties.content?.text) {
231
+ return properties.content.text;
232
+ }
233
+ return "";
234
+ }
235
+
236
+ /**
237
+ * Remove a URL from text, cleaning up surrounding whitespace and prefixes
238
+ * @param {string} text - Text containing the URL
239
+ * @param {string} url - URL to remove
240
+ * @returns {string} Cleaned text
241
+ */
242
+ export function removeUrlFromText(text, url) {
243
+ if (!text || !url) return text;
244
+ let result = text.replace(url, "");
245
+ // Clean up common prefixes left behind when URL was at the end
246
+ result = result.replace(/\s*(Réf|Ref|ref|via|Via|source|Source|link|Link|→|—)\s*$/im, "");
247
+ // Clean up excessive whitespace
248
+ result = result.replace(/\n{3,}/g, "\n\n").replace(/[ \t]{2,}/g, " ").trim();
249
+ return result;
250
+ }
251
+
252
+ /**
253
+ * Build post text for Bluesky syndication with proper URL and character handling.
254
+ *
255
+ * Two modes:
256
+ * 1. External URL exists → remove from text (shown as OG card), append permalink as text
257
+ * 2. No external URL → content only (permalink shown as OG card)
258
+ *
259
+ * @param {object} properties - JF2 properties
260
+ * @param {object} [options] - Options
261
+ * @param {string} [options.externalUrl] - External URL that will be used as OG card
262
+ * @returns {string} Post text fitting within 300 chars
263
+ */
264
+ export function buildPostText(properties, options = {}) {
265
+ const { externalUrl } = options;
266
+ const LIMIT = 300;
267
+
268
+ let text = getContentText(properties);
269
+
270
+ if (externalUrl) {
271
+ // External URL will be shown as OG card — remove from text to save space
272
+ text = removeUrlFromText(text, externalUrl);
273
+
274
+ // Always append permalink to original post
275
+ const permalink = properties.url;
276
+ if (permalink) {
277
+ const suffix = `\n\n${permalink}`;
278
+ const available = LIMIT - suffix.length;
279
+ if (text.length > available) {
280
+ text = text.slice(0, available - 1).trim() + "\u2026";
281
+ }
282
+ return (text + suffix).trim();
283
+ }
284
+ }
285
+
286
+ // No external URL — permalink will be shown as OG card
287
+ // No need to duplicate it in text
288
+ if (text.length > LIMIT) {
289
+ text = text.slice(0, LIMIT - 1).trim() + "\u2026";
290
+ }
291
+
292
+ return text.trim();
293
+ }
294
+
295
+ /**
296
+ * Get post text for a like of an external URL (Bluesky version).
297
+ * The external URL is shown as an OG card embed, so the text contains
298
+ * commentary + blog permalink (for webmentions), not the liked URL.
214
299
  * @param {object} properties - JF2 properties
215
300
  * @param {string} likedUrl - The URL being liked
216
301
  * @returns {string} Post text
@@ -225,20 +310,125 @@ export const getLikePostText = (properties, likedUrl) => {
225
310
  text = properties.content.text;
226
311
  }
227
312
 
228
- // If there's content, append the liked URL
229
- if (text) {
230
- // Check if the URL is already in the text
231
- if (!text.includes(likedUrl)) {
232
- text = `${text}\n\n❤️ ${likedUrl}`;
313
+ // Remove the liked URL from text if already present (OG card shows it)
314
+ if (text && likedUrl) {
315
+ text = text.replace(likedUrl, "").trim();
316
+ }
317
+
318
+ // Append blog permalink for webmentions
319
+ const permalink = properties.url;
320
+ if (permalink) {
321
+ if (text) {
322
+ text = `${text}\n\n${permalink}`;
323
+ } else {
324
+ text = `❤️ ${permalink}`;
233
325
  }
234
- } else {
235
- // No content, just post the liked URL with a heart
326
+ } else if (!text) {
327
+ // Fallback: no content, no permalink use liked URL
236
328
  text = `❤️ ${likedUrl}`;
237
329
  }
238
330
 
239
331
  // Truncate if needed (Bluesky limit is 300 chars)
240
332
  if (text.length > 300) {
241
- const suffix = `\n\n❤️ ${likedUrl}`;
333
+ const suffix = permalink ? `\n\n${permalink}` : `\n\n❤️ ${likedUrl}`;
334
+ const maxLen = 300 - suffix.length - 3;
335
+ const contentPart = text.replace(suffix, "").slice(0, maxLen).trim();
336
+ text = contentPart + "..." + suffix;
337
+ }
338
+
339
+ return text;
340
+ };
341
+
342
+ /**
343
+ * Get post text for a repost of an external URL (Bluesky version).
344
+ * The external URL is shown as an OG card embed, so the text contains
345
+ * commentary + blog permalink (for webmentions), not the reposted URL.
346
+ * @param {object} properties - JF2 properties
347
+ * @param {string} repostUrl - The URL being reposted
348
+ * @returns {string} Post text
349
+ */
350
+ export const getRepostPostText = (properties, repostUrl) => {
351
+ let text = "";
352
+
353
+ // Get the content/comment
354
+ if (properties.content?.html) {
355
+ text = htmlToStatusText(properties.content.html);
356
+ } else if (properties.content?.text) {
357
+ text = properties.content.text;
358
+ }
359
+
360
+ // Remove the reposted URL from text if already present (OG card shows it)
361
+ if (text && repostUrl) {
362
+ text = text.replace(repostUrl, "").trim();
363
+ }
364
+
365
+ // Append blog permalink for webmentions
366
+ const permalink = properties.url;
367
+ if (permalink) {
368
+ if (text) {
369
+ text = `${text}\n\n${permalink}`;
370
+ } else {
371
+ text = `🔁 ${permalink}`;
372
+ }
373
+ } else if (!text) {
374
+ // Fallback: no content, no permalink — use repost URL
375
+ text = `🔁 ${repostUrl}`;
376
+ }
377
+
378
+ // Truncate if needed (Bluesky limit is 300 chars)
379
+ if (text.length > 300) {
380
+ const suffix = permalink ? `\n\n${permalink}` : `\n\n🔁 ${repostUrl}`;
381
+ const maxLen = 300 - suffix.length - 3;
382
+ const contentPart = text.replace(suffix, "").slice(0, maxLen).trim();
383
+ text = contentPart + "..." + suffix;
384
+ }
385
+
386
+ return text;
387
+ };
388
+
389
+ /**
390
+ * Get post text for a bookmark of an external URL (Bluesky version).
391
+ * The external URL is shown as an OG card embed, so the text contains
392
+ * commentary + blog permalink (for webmentions), not the bookmarked URL.
393
+ * @param {object} properties - JF2 properties
394
+ * @param {string} bookmarkUrl - The URL being bookmarked
395
+ * @returns {string} Post text
396
+ */
397
+ export const getBookmarkPostText = (properties, bookmarkUrl) => {
398
+ let text = "";
399
+
400
+ // Get the content/comment
401
+ if (properties.name && properties.name !== "") {
402
+ text = properties.name;
403
+ } else if (properties.content?.html) {
404
+ text = htmlToStatusText(properties.content.html);
405
+ } else if (properties.content?.text) {
406
+ text = properties.content.text;
407
+ }
408
+
409
+ // Remove the bookmarked URL from text if already present (OG card shows it)
410
+ if (text && bookmarkUrl) {
411
+ text = text.replace(bookmarkUrl, "").trim();
412
+ }
413
+
414
+ // Append blog permalink for webmentions
415
+ const permalink = properties.url;
416
+ if (permalink) {
417
+ if (text) {
418
+ if (!text.includes(permalink)) {
419
+ text = `${text}\n\n${permalink}`;
420
+ }
421
+ } else {
422
+ text = `🔖 ${permalink}`;
423
+ }
424
+ } else if (!text) {
425
+ // Fallback: no content, no permalink — use bookmarked URL
426
+ text = `🔖 ${bookmarkUrl}`;
427
+ }
428
+
429
+ // Truncate if needed (Bluesky limit is 300 chars)
430
+ if (text.length > 300) {
431
+ const suffix = permalink ? `\n\n${permalink}` : `\n\n🔖 ${bookmarkUrl}`;
242
432
  const maxLen = 300 - suffix.length - 3;
243
433
  const contentPart = text.replace(suffix, "").slice(0, maxLen).trim();
244
434
  text = contentPart + "..." + suffix;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-syndicator-bluesky",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "Bluesky syndicator for Indiekit with external like support",
5
5
  "type": "module",
6
6
  "main": "index.js",