@rmdes/indiekit-syndicator-bluesky 1.0.3 → 1.0.5

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
@@ -10,6 +10,7 @@ import {
10
10
  uriToPostUrl,
11
11
  fetchOpenGraphData,
12
12
  getExternalUrl,
13
+ generateDefaultOgImage,
13
14
  } from "./utils.js";
14
15
 
15
16
  export class Bluesky {
@@ -168,17 +169,39 @@ export class Bluesky {
168
169
  /**
169
170
  * Create external link embed
170
171
  * @param {string} url - External URL
172
+ * @param {object} [options] - Options
173
+ * @param {boolean} [options.generateDefaultImage] - Generate default image if no OG image
174
+ * @param {string} [options.siteName] - Site name for default image
171
175
  * @returns {Promise<object|null>} External embed or null
172
176
  */
173
- async createExternalEmbed(url) {
177
+ async createExternalEmbed(url, options = {}) {
174
178
  if (!url) return null;
175
179
 
180
+ const { generateDefaultImage = true, siteName } = options;
181
+
176
182
  try {
177
183
  const ogData = await fetchOpenGraphData(url);
178
184
  let thumb = null;
179
185
 
180
186
  if (ogData.imageUrl) {
187
+ // Use the OG image from the URL
181
188
  thumb = await this.uploadImageFromUrl(ogData.imageUrl);
189
+ } else if (generateDefaultImage && ogData.title && ogData.title !== url) {
190
+ // Generate a default image with the title
191
+ try {
192
+ const defaultImageBuffer = await generateDefaultOgImage(ogData.title, {
193
+ siteName: siteName || new URL(url).hostname,
194
+ });
195
+ const client = await this.#client();
196
+ const uploadResponse = await client.com.atproto.repo.uploadBlob(
197
+ new Blob([new Uint8Array(defaultImageBuffer)], { type: "image/png" }),
198
+ { encoding: "image/png" }
199
+ );
200
+ thumb = uploadResponse.data.blob;
201
+ } catch (imageError) {
202
+ console.error(`Failed to generate default OG image: ${imageError.message}`);
203
+ // Continue without thumb
204
+ }
182
205
  }
183
206
 
184
207
  return {
package/lib/utils.js CHANGED
@@ -3,8 +3,143 @@ import { htmlToText } from "html-to-text";
3
3
  import sharp from "sharp";
4
4
  import { JSDOM } from "jsdom";
5
5
 
6
+ /**
7
+ * Default OG image configuration
8
+ */
9
+ const DEFAULT_OG_CONFIG = {
10
+ width: 1200,
11
+ height: 630,
12
+ backgroundColor: "#1a1a2e", // Dark blue-purple
13
+ textColor: "#ffffff",
14
+ accentColor: "#e94560", // Coral/red accent
15
+ fontFamily: "sans-serif",
16
+ siteName: "rmendes.net",
17
+ };
18
+
6
19
  const AT_URI = /at:\/\/(?<did>did:[^/]+)\/(?<type>[^/]+)\/(?<rkey>[^/]+)/;
7
20
 
21
+ /**
22
+ * Escape XML special characters for SVG
23
+ * @param {string} text - Text to escape
24
+ * @returns {string} Escaped text
25
+ */
26
+ function escapeXml(text) {
27
+ return text
28
+ .replace(/&/g, "&amp;")
29
+ .replace(/</g, "&lt;")
30
+ .replace(/>/g, "&gt;")
31
+ .replace(/"/g, "&quot;")
32
+ .replace(/'/g, "&apos;");
33
+ }
34
+
35
+ /**
36
+ * Wrap text into lines that fit within a width
37
+ * @param {string} text - Text to wrap
38
+ * @param {number} maxCharsPerLine - Maximum characters per line
39
+ * @param {number} maxLines - Maximum number of lines
40
+ * @returns {string[]} Array of lines
41
+ */
42
+ function wrapText(text, maxCharsPerLine = 35, maxLines = 4) {
43
+ const words = text.split(/\s+/);
44
+ const lines = [];
45
+ let currentLine = "";
46
+
47
+ for (const word of words) {
48
+ if (lines.length >= maxLines) break;
49
+
50
+ const testLine = currentLine ? `${currentLine} ${word}` : word;
51
+
52
+ if (testLine.length <= maxCharsPerLine) {
53
+ currentLine = testLine;
54
+ } else {
55
+ if (currentLine) {
56
+ lines.push(currentLine);
57
+ currentLine = word;
58
+ } else {
59
+ // Word is longer than max, truncate it
60
+ lines.push(word.slice(0, maxCharsPerLine - 3) + "...");
61
+ currentLine = "";
62
+ }
63
+ }
64
+ }
65
+
66
+ if (currentLine && lines.length < maxLines) {
67
+ lines.push(currentLine);
68
+ }
69
+
70
+ // Add ellipsis to last line if we truncated
71
+ if (lines.length === maxLines && words.length > lines.join(" ").split(/\s+/).length) {
72
+ const lastLine = lines[maxLines - 1];
73
+ if (lastLine.length > maxCharsPerLine - 3) {
74
+ lines[maxLines - 1] = lastLine.slice(0, maxCharsPerLine - 3) + "...";
75
+ } else if (!lastLine.endsWith("...")) {
76
+ lines[maxLines - 1] = lastLine + "...";
77
+ }
78
+ }
79
+
80
+ return lines;
81
+ }
82
+
83
+ /**
84
+ * Generate a default OG image with title text
85
+ * @param {string} title - Title text to display
86
+ * @param {object} [options] - Configuration options
87
+ * @param {string} [options.siteName] - Site name to display
88
+ * @param {string} [options.backgroundColor] - Background color
89
+ * @param {string} [options.textColor] - Text color
90
+ * @param {string} [options.accentColor] - Accent color for decorations
91
+ * @returns {Promise<Buffer>} PNG image buffer
92
+ */
93
+ export async function generateDefaultOgImage(title, options = {}) {
94
+ const config = { ...DEFAULT_OG_CONFIG, ...options };
95
+ const { width, height, backgroundColor, textColor, accentColor, siteName } = config;
96
+
97
+ // Wrap title into multiple lines
98
+ const titleLines = wrapText(title, 35, 4);
99
+ const fontSize = titleLines.length > 2 ? 48 : 56;
100
+ const lineHeight = fontSize * 1.3;
101
+
102
+ // Calculate vertical position to center the text block
103
+ const textBlockHeight = titleLines.length * lineHeight;
104
+ const startY = (height - textBlockHeight) / 2 + fontSize * 0.8;
105
+
106
+ // Generate title text elements
107
+ const titleElements = titleLines
108
+ .map((line, i) => {
109
+ const y = startY + i * lineHeight;
110
+ return `<text x="${width / 2}" y="${y}" text-anchor="middle" font-size="${fontSize}" font-weight="bold" fill="${textColor}" font-family="${config.fontFamily}">${escapeXml(line)}</text>`;
111
+ })
112
+ .join("\n ");
113
+
114
+ // Create SVG
115
+ const svg = `<?xml version="1.0" encoding="UTF-8"?>
116
+ <svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
117
+ <!-- Background -->
118
+ <rect width="${width}" height="${height}" fill="${backgroundColor}"/>
119
+
120
+ <!-- Decorative elements -->
121
+ <rect x="0" y="0" width="${width}" height="8" fill="${accentColor}"/>
122
+ <rect x="0" y="${height - 80}" width="${width}" height="80" fill="${accentColor}" opacity="0.1"/>
123
+
124
+ <!-- Decorative circles -->
125
+ <circle cx="100" cy="100" r="200" fill="${accentColor}" opacity="0.05"/>
126
+ <circle cx="${width - 100}" cy="${height - 100}" r="150" fill="${accentColor}" opacity="0.05"/>
127
+
128
+ <!-- Title -->
129
+ ${titleElements}
130
+
131
+ <!-- Site name -->
132
+ <text x="${width / 2}" y="${height - 30}" text-anchor="middle" font-size="24" fill="${textColor}" opacity="0.7" font-family="${config.fontFamily}">${escapeXml(siteName)}</text>
133
+ </svg>`;
134
+
135
+ // Convert SVG to PNG using sharp
136
+ const pngBuffer = await sharp(Buffer.from(svg))
137
+ .png({ quality: 90 })
138
+ .toBuffer();
139
+
140
+ return pngBuffer;
141
+ }
142
+
8
143
  /**
9
144
  * Convert plain text to rich text
10
145
  * @param {import("@atproto/api").Agent} client - AT Protocol agent
@@ -62,8 +197,9 @@ export const getPostText = (properties, includePermalink) => {
62
197
  }
63
198
 
64
199
  // Truncate status if longer than 300 characters
200
+ // ALWAYS include permalink when truncating so readers can see full post
65
201
  if (text.length > 300) {
66
- const suffix = includePermalink ? `\n\n${properties.url}` : "";
202
+ const suffix = `\n\n${properties.url}`;
67
203
  const maxLen = 300 - suffix.length - 3;
68
204
  text = text.slice(0, maxLen).trim() + "..." + suffix;
69
205
  } else if (includePermalink && !text.includes(properties.url)) {
@@ -222,12 +358,30 @@ export async function fetchOpenGraphData(url) {
222
358
  }
223
359
  }
224
360
 
361
+ /**
362
+ * Extract URLs from text using a comprehensive regex
363
+ * @param {string} text - Text to search for URLs
364
+ * @returns {Array<string>} Array of URLs found
365
+ */
366
+ function extractUrlsFromText(text) {
367
+ if (!text) return [];
368
+ // Match URLs starting with http:// or https://
369
+ // This regex is more permissive to catch URLs in plain text
370
+ const urlRegex = /https?:\/\/[^\s<>"')\]]+/gi;
371
+ const matches = text.match(urlRegex) || [];
372
+ // Clean up trailing punctuation that might have been captured
373
+ return matches.map((url) =>
374
+ url.replace(/[.,;:!?)]+$/, "").replace(/\)+$/, "")
375
+ );
376
+ }
377
+
225
378
  /**
226
379
  * Extract the primary URL from post properties
227
380
  * @param {object} properties - JF2 properties
381
+ * @param {string} [ownDomain] - Own domain to deprioritize (e.g., "rmendes.net")
228
382
  * @returns {string|null} Primary URL to create card for
229
383
  */
230
- export function getExternalUrl(properties) {
384
+ export function getExternalUrl(properties, ownDomain) {
231
385
  // For likes, use the liked URL
232
386
  if (properties["like-of"]) {
233
387
  return properties["like-of"];
@@ -243,13 +397,53 @@ export function getExternalUrl(properties) {
243
397
  return properties["in-reply-to"];
244
398
  }
245
399
 
246
- // For regular posts with content, try to extract URL from content
400
+ // Collect all URLs from content
401
+ let urls = [];
402
+
403
+ // Extract from HTML href attributes (both single and double quotes)
247
404
  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];
405
+ const hrefMatches = [
406
+ ...properties.content.html.matchAll(/href=["'](https?:\/\/[^"']+)["']/gi),
407
+ ];
408
+ urls.push(...hrefMatches.map((m) => m[1]));
409
+ }
410
+
411
+ // Extract plain text URLs from HTML content (for URLs not in anchors)
412
+ if (properties.content?.html) {
413
+ const plainUrls = extractUrlsFromText(properties.content.html);
414
+ urls.push(...plainUrls);
415
+ }
416
+
417
+ // Extract from plain text content
418
+ if (properties.content?.text) {
419
+ const textUrls = extractUrlsFromText(properties.content.text);
420
+ urls.push(...textUrls);
421
+ }
422
+
423
+ // Deduplicate URLs
424
+ urls = [...new Set(urls)];
425
+
426
+ if (urls.length === 0) {
427
+ return null;
428
+ }
429
+
430
+ // If we have an ownDomain, try to find a URL that's NOT our own site first
431
+ // but if all URLs are our own site, still return one (for OG cards of own content)
432
+ if (ownDomain) {
433
+ const externalUrls = urls.filter((url) => {
434
+ try {
435
+ const hostname = new URL(url).hostname;
436
+ return !hostname.includes(ownDomain);
437
+ } catch {
438
+ return true;
439
+ }
440
+ });
441
+ // Prefer external URLs, but fall back to own domain URLs
442
+ if (externalUrls.length > 0) {
443
+ return externalUrls.at(-1);
251
444
  }
252
445
  }
253
446
 
254
- return null;
447
+ // Return the last URL found (most likely to be the main link)
448
+ return urls.at(-1);
255
449
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-syndicator-bluesky",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Bluesky syndicator for Indiekit with external like support",
5
5
  "type": "module",
6
6
  "main": "index.js",