@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 +24 -1
- package/lib/utils.js +201 -7
- package/package.json +1 -1
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, "&")
|
|
29
|
+
.replace(/</g, "<")
|
|
30
|
+
.replace(/>/g, ">")
|
|
31
|
+
.replace(/"/g, """)
|
|
32
|
+
.replace(/'/g, "'");
|
|
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 =
|
|
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
|
-
//
|
|
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
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
447
|
+
// Return the last URL found (most likely to be the main link)
|
|
448
|
+
return urls.at(-1);
|
|
255
449
|
}
|