@rmdes/indiekit-syndicator-bluesky 1.0.0
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 +110 -0
- package/lib/bluesky.js +257 -0
- package/lib/utils.js +162 -0
- package/package.json +30 -0
package/index.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { IndiekitError } from "@indiekit/error";
|
|
3
|
+
import { Bluesky } from "./lib/bluesky.js";
|
|
4
|
+
|
|
5
|
+
const defaults = {
|
|
6
|
+
handle: "",
|
|
7
|
+
password: process.env.BLUESKY_PASSWORD,
|
|
8
|
+
profileUrl: "https://bsky.app/profile",
|
|
9
|
+
serviceUrl: "https://bsky.social",
|
|
10
|
+
includePermalink: false,
|
|
11
|
+
syndicateExternalLikes: true, // NEW: Enable syndication of external likes
|
|
12
|
+
checked: false,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export default class BlueskySyndicator {
|
|
16
|
+
name = "Bluesky syndicator";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {object} [options] - Plug-in options
|
|
20
|
+
* @param {string} [options.profileUrl] - Profile URL
|
|
21
|
+
* @param {string} [options.serviceUrl] - Service URL
|
|
22
|
+
* @param {string} [options.handle] - Handle
|
|
23
|
+
* @param {string} [options.password] - Password
|
|
24
|
+
* @param {boolean} [options.includePermalink] - Include permalink in status
|
|
25
|
+
* @param {boolean} [options.syndicateExternalLikes] - Syndicate likes of external URLs as posts
|
|
26
|
+
* @param {boolean} [options.checked] - Check syndicator in UI
|
|
27
|
+
*/
|
|
28
|
+
constructor(options = {}) {
|
|
29
|
+
this.options = { ...defaults, ...options };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get #profileUrl() {
|
|
33
|
+
return new URL(this.options.profileUrl).href;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get #serviceUrl() {
|
|
37
|
+
return new URL(this.options.serviceUrl).href;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get #user() {
|
|
41
|
+
return this.options?.handle
|
|
42
|
+
? `@${this.options.handle.replace("@", "")}`
|
|
43
|
+
: false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get environment() {
|
|
47
|
+
return ["BLUESKY_PASSWORD"];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get info() {
|
|
51
|
+
const userName = this.options?.handle?.replace("@", "");
|
|
52
|
+
const url = new URL(this.options.profileUrl).href + "/" + userName;
|
|
53
|
+
|
|
54
|
+
const info = {
|
|
55
|
+
checked: this.options.checked,
|
|
56
|
+
name: this.#user,
|
|
57
|
+
uid: url,
|
|
58
|
+
service: {
|
|
59
|
+
name: "Bluesky",
|
|
60
|
+
photo: "/assets/@indiekit-syndicator-bluesky/icon.svg",
|
|
61
|
+
url: this.#serviceUrl,
|
|
62
|
+
},
|
|
63
|
+
user: {
|
|
64
|
+
name: this.#user,
|
|
65
|
+
url,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
if (!this.#user) {
|
|
70
|
+
info.error = "User identifier required";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return info;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get prompts() {
|
|
77
|
+
return [
|
|
78
|
+
{
|
|
79
|
+
type: "text",
|
|
80
|
+
name: "handle",
|
|
81
|
+
message: "What is your Bluesky handle (without the @)?",
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async syndicate(properties, publication) {
|
|
87
|
+
try {
|
|
88
|
+
const bluesky = new Bluesky({
|
|
89
|
+
identifier: this.options?.handle,
|
|
90
|
+
password: this.options?.password,
|
|
91
|
+
profileUrl: this.#profileUrl,
|
|
92
|
+
serviceUrl: this.#serviceUrl,
|
|
93
|
+
includePermalink: this.options.includePermalink,
|
|
94
|
+
syndicateExternalLikes: this.options.syndicateExternalLikes,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return await bluesky.post(properties, publication.me);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
throw new IndiekitError(error.message, {
|
|
100
|
+
cause: error,
|
|
101
|
+
plugin: this.name,
|
|
102
|
+
status: error.statusCode,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
init(Indiekit) {
|
|
108
|
+
Indiekit.addSyndicator(this);
|
|
109
|
+
}
|
|
110
|
+
}
|
package/lib/bluesky.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { AtpAgent } from "@atproto/api";
|
|
2
|
+
import { IndiekitError } from "@indiekit/error";
|
|
3
|
+
import { getCanonicalUrl, isSameOrigin } from "@indiekit/util";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
createRichText,
|
|
7
|
+
getPostImage,
|
|
8
|
+
getPostText,
|
|
9
|
+
getLikePostText,
|
|
10
|
+
getPostParts,
|
|
11
|
+
uriToPostUrl,
|
|
12
|
+
} from "./utils.js";
|
|
13
|
+
|
|
14
|
+
export class Bluesky {
|
|
15
|
+
/**
|
|
16
|
+
* @param {object} options - Syndicator options
|
|
17
|
+
* @param {string} options.identifier - User identifier
|
|
18
|
+
* @param {string} options.password - Password
|
|
19
|
+
* @param {string} options.profileUrl - Profile URL
|
|
20
|
+
* @param {string} options.serviceUrl - Service URL
|
|
21
|
+
* @param {boolean} [options.includePermalink] - Include permalink in status
|
|
22
|
+
* @param {boolean} [options.syndicateExternalLikes] - Syndicate likes of external URLs
|
|
23
|
+
*/
|
|
24
|
+
constructor(options) {
|
|
25
|
+
this.identifier = options.identifier;
|
|
26
|
+
this.password = options.password;
|
|
27
|
+
this.profileUrl = options.profileUrl;
|
|
28
|
+
this.serviceUrl = options.serviceUrl;
|
|
29
|
+
this.includePermalink = options.includePermalink || false;
|
|
30
|
+
this.syndicateExternalLikes = options.syndicateExternalLikes !== false; // Default true
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Initialise AT Protocol client
|
|
35
|
+
* @access private
|
|
36
|
+
* @returns {Promise<AtpAgent>} AT Protocol agent
|
|
37
|
+
*/
|
|
38
|
+
async #client() {
|
|
39
|
+
const { identifier, password, serviceUrl } = this;
|
|
40
|
+
const agent = new AtpAgent({ service: serviceUrl });
|
|
41
|
+
await agent.login({ identifier, password });
|
|
42
|
+
return agent;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Get a post
|
|
47
|
+
* @param {string} postUrl - URL of post to like
|
|
48
|
+
* @returns {Promise<object>} Bluesky post record
|
|
49
|
+
*/
|
|
50
|
+
async getPost(postUrl) {
|
|
51
|
+
const client = await this.#client();
|
|
52
|
+
const postParts = getPostParts(postUrl);
|
|
53
|
+
return await client.getPost({
|
|
54
|
+
repo: postParts.did,
|
|
55
|
+
rkey: postParts.rkey,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Post a like
|
|
61
|
+
* @param {string} postUrl - URL of post to like
|
|
62
|
+
* @returns {Promise<string>} Bluesky post URL
|
|
63
|
+
*/
|
|
64
|
+
async postLike(postUrl) {
|
|
65
|
+
const client = await this.#client();
|
|
66
|
+
const post = await this.getPost(postUrl);
|
|
67
|
+
const like = await client.like(post.uri, post.cid);
|
|
68
|
+
return uriToPostUrl(this.profileUrl, like.uri);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Post a repost
|
|
73
|
+
* @param {string} postUrl - URL of post to repost
|
|
74
|
+
* @returns {Promise<string>} Bluesky post URL
|
|
75
|
+
*/
|
|
76
|
+
async postRepost(postUrl) {
|
|
77
|
+
const client = await this.#client();
|
|
78
|
+
const post = await this.getPost(postUrl);
|
|
79
|
+
const repost = await client.repost(post.uri, post.cid);
|
|
80
|
+
return uriToPostUrl(this.profileUrl, repost.uri);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Post a quote post
|
|
85
|
+
* @param {string} postUrl - URL of post to quote
|
|
86
|
+
* @param {object} richText - Rich text
|
|
87
|
+
* @param {Array} [images] - Images
|
|
88
|
+
* @returns {Promise<string>} Bluesky post URL
|
|
89
|
+
*/
|
|
90
|
+
async postQuotePost(postUrl, richText, images) {
|
|
91
|
+
const client = await this.#client();
|
|
92
|
+
const post = await this.getPost(postUrl);
|
|
93
|
+
|
|
94
|
+
const record = {
|
|
95
|
+
$type: "app.bsky.embed.record",
|
|
96
|
+
record: { uri: post.uri, cid: post.cid },
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const media = {
|
|
100
|
+
$type: "app.bsky.embed.images",
|
|
101
|
+
images,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const recordWithMedia = {
|
|
105
|
+
$type: "app.bsky.embed.recordWithMedia",
|
|
106
|
+
record,
|
|
107
|
+
media,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const embed = images?.length > 0 ? recordWithMedia : record;
|
|
111
|
+
|
|
112
|
+
const postData = {
|
|
113
|
+
$type: "app.bsky.feed.post",
|
|
114
|
+
text: richText.text,
|
|
115
|
+
facets: richText.facets,
|
|
116
|
+
createdAt: new Date().toISOString(),
|
|
117
|
+
embed,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const quotePost = await client.post(postData);
|
|
121
|
+
return uriToPostUrl(this.profileUrl, quotePost.uri);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Post a regular post
|
|
126
|
+
* @param {object} richText - Rich text
|
|
127
|
+
* @param {Array} [images] - Images
|
|
128
|
+
* @returns {Promise<string>} Bluesky post URL
|
|
129
|
+
*/
|
|
130
|
+
async postPost(richText, images) {
|
|
131
|
+
const client = await this.#client();
|
|
132
|
+
|
|
133
|
+
const postData = {
|
|
134
|
+
$type: "app.bsky.feed.post",
|
|
135
|
+
text: richText.text,
|
|
136
|
+
facets: richText.facets,
|
|
137
|
+
createdAt: new Date().toISOString(),
|
|
138
|
+
...(images?.length > 0 && {
|
|
139
|
+
embed: {
|
|
140
|
+
$type: "app.bsky.embed.images",
|
|
141
|
+
images,
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const post = await client.post(postData);
|
|
147
|
+
return uriToPostUrl(this.profileUrl, post.uri);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Upload media
|
|
152
|
+
* @param {object} media - JF2 media object
|
|
153
|
+
* @param {string} me - Publication URL
|
|
154
|
+
* @returns {Promise<object>} Blob reference for the uploaded media
|
|
155
|
+
*/
|
|
156
|
+
async uploadMedia(media, me) {
|
|
157
|
+
const client = await this.#client();
|
|
158
|
+
const { url } = media;
|
|
159
|
+
|
|
160
|
+
if (typeof url !== "string") {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const mediaUrl = getCanonicalUrl(url, me);
|
|
166
|
+
const mediaResponse = await fetch(mediaUrl);
|
|
167
|
+
|
|
168
|
+
if (!mediaResponse.ok) {
|
|
169
|
+
throw await IndiekitError.fromFetch(mediaResponse);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let blob = await mediaResponse.blob();
|
|
173
|
+
let encoding = mediaResponse.headers.get("Content-Type");
|
|
174
|
+
|
|
175
|
+
if (encoding?.startsWith("image/")) {
|
|
176
|
+
const buffer = Buffer.from(await blob.arrayBuffer());
|
|
177
|
+
const image = await getPostImage(buffer, encoding);
|
|
178
|
+
blob = new Blob([new Uint8Array(image.buffer)], {
|
|
179
|
+
type: image.mimeType,
|
|
180
|
+
});
|
|
181
|
+
encoding = image.mimeType;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const response = await client.com.atproto.repo.uploadBlob(blob, {
|
|
185
|
+
encoding,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return response.data.blob;
|
|
189
|
+
} catch (error) {
|
|
190
|
+
throw new Error(error.message);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Post to Bluesky
|
|
196
|
+
* @param {object} properties - JF2 properties
|
|
197
|
+
* @param {string} me - Publication URL
|
|
198
|
+
* @returns {Promise<string|boolean>} URL of syndicated status
|
|
199
|
+
*/
|
|
200
|
+
async post(properties, me) {
|
|
201
|
+
try {
|
|
202
|
+
const client = await this.#client();
|
|
203
|
+
|
|
204
|
+
// Upload photos
|
|
205
|
+
let images = [];
|
|
206
|
+
if (properties.photo) {
|
|
207
|
+
const photos = properties.photo.slice(0, 4);
|
|
208
|
+
const uploads = photos.map(async (photo) => ({
|
|
209
|
+
alt: photo.alt || "",
|
|
210
|
+
image: await this.uploadMedia(photo, me),
|
|
211
|
+
}));
|
|
212
|
+
images = await Promise.all(uploads);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Handle reposts
|
|
216
|
+
const repostUrl = properties["repost-of"];
|
|
217
|
+
if (repostUrl) {
|
|
218
|
+
if (isSameOrigin(repostUrl, this.profileUrl) && properties.content) {
|
|
219
|
+
const text = getPostText(properties, this.includePermalink);
|
|
220
|
+
const richText = await createRichText(client, text);
|
|
221
|
+
return this.postQuotePost(repostUrl, richText, images);
|
|
222
|
+
}
|
|
223
|
+
if (isSameOrigin(repostUrl, this.profileUrl)) {
|
|
224
|
+
return this.postRepost(repostUrl);
|
|
225
|
+
}
|
|
226
|
+
// Do not syndicate reposts of other URLs
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Handle likes
|
|
231
|
+
const likeOfUrl = properties["like-of"];
|
|
232
|
+
if (likeOfUrl) {
|
|
233
|
+
// Native Bluesky like for Bluesky URLs
|
|
234
|
+
if (isSameOrigin(likeOfUrl, this.profileUrl)) {
|
|
235
|
+
return this.postLike(likeOfUrl);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// NEW: Syndicate likes of external URLs as posts
|
|
239
|
+
if (this.syndicateExternalLikes) {
|
|
240
|
+
const text = getLikePostText(properties, likeOfUrl);
|
|
241
|
+
const richText = await createRichText(client, text);
|
|
242
|
+
return this.postPost(richText, images);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Don't syndicate if option is disabled
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Regular post
|
|
250
|
+
const text = getPostText(properties, this.includePermalink);
|
|
251
|
+
const richText = await createRichText(client, text);
|
|
252
|
+
return this.postPost(richText, images);
|
|
253
|
+
} catch (error) {
|
|
254
|
+
throw new Error(error.message);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
package/lib/utils.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { RichText } from "@atproto/api";
|
|
2
|
+
import { htmlToText } from "html-to-text";
|
|
3
|
+
import sharp from "sharp";
|
|
4
|
+
|
|
5
|
+
const AT_URI = /at:\/\/(?<did>did:[^/]+)\/(?<type>[^/]+)\/(?<rkey>[^/]+)/;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Convert plain text to rich text
|
|
9
|
+
* @param {import("@atproto/api").Agent} client - AT Protocol agent
|
|
10
|
+
* @param {string} text - Text to convert
|
|
11
|
+
* @returns {Promise<RichText>} Rich text
|
|
12
|
+
*/
|
|
13
|
+
export const createRichText = async (client, text) => {
|
|
14
|
+
const rt = new RichText({ text });
|
|
15
|
+
await rt.detectFacets(client);
|
|
16
|
+
return rt;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get post parts (UID and CID)
|
|
21
|
+
* @param {string} url - Post URL
|
|
22
|
+
* @returns {object} Parts
|
|
23
|
+
*/
|
|
24
|
+
export const getPostParts = (url) => {
|
|
25
|
+
const pathParts = new URL(url).pathname.split("/");
|
|
26
|
+
const did = pathParts[2];
|
|
27
|
+
const rkey = pathParts[4];
|
|
28
|
+
return { did, rkey };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Convert Bluesky URI to post URL
|
|
33
|
+
* @param {string} profileUrl - Profile URL
|
|
34
|
+
* @param {string} uri - Bluesky URI
|
|
35
|
+
* @returns {string|undefined} Post URL
|
|
36
|
+
*/
|
|
37
|
+
export const uriToPostUrl = (profileUrl, uri) => {
|
|
38
|
+
const match = uri.match(AT_URI);
|
|
39
|
+
if (match) {
|
|
40
|
+
let { did, rkey, type } = match.groups;
|
|
41
|
+
type = type.split(".").at(-1);
|
|
42
|
+
return `${profileUrl}/${did}/${type}/${rkey}`;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Get post text from given JF2 properties
|
|
48
|
+
* @param {object} properties - JF2 properties
|
|
49
|
+
* @param {boolean} [includePermalink] - Include permalink in post
|
|
50
|
+
* @returns {string} Post text
|
|
51
|
+
*/
|
|
52
|
+
export const getPostText = (properties, includePermalink) => {
|
|
53
|
+
let text = "";
|
|
54
|
+
|
|
55
|
+
if (properties.name && properties.name !== "") {
|
|
56
|
+
text = `${properties.name} ${properties.url}`;
|
|
57
|
+
} else if (properties.content?.html) {
|
|
58
|
+
text = htmlToStatusText(properties.content.html);
|
|
59
|
+
} else if (properties.content?.text) {
|
|
60
|
+
text = properties.content.text;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Truncate status if longer than 300 characters
|
|
64
|
+
if (text.length > 300) {
|
|
65
|
+
const suffix = includePermalink ? `\n\n${properties.url}` : "";
|
|
66
|
+
const maxLen = 300 - suffix.length - 3;
|
|
67
|
+
text = text.slice(0, maxLen).trim() + "..." + suffix;
|
|
68
|
+
} else if (includePermalink && !text.includes(properties.url)) {
|
|
69
|
+
text = `${text}\n\n${properties.url}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return text;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get post text for a like of an external URL
|
|
77
|
+
* @param {object} properties - JF2 properties
|
|
78
|
+
* @param {string} likedUrl - The URL being liked
|
|
79
|
+
* @returns {string} Post text
|
|
80
|
+
*/
|
|
81
|
+
export const getLikePostText = (properties, likedUrl) => {
|
|
82
|
+
let text = "";
|
|
83
|
+
|
|
84
|
+
// Get the content/comment
|
|
85
|
+
if (properties.content?.html) {
|
|
86
|
+
text = htmlToStatusText(properties.content.html);
|
|
87
|
+
} else if (properties.content?.text) {
|
|
88
|
+
text = properties.content.text;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// If there's content, append the liked URL
|
|
92
|
+
if (text) {
|
|
93
|
+
// Check if the URL is already in the text
|
|
94
|
+
if (!text.includes(likedUrl)) {
|
|
95
|
+
text = `${text}\n\n❤️ ${likedUrl}`;
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
// No content, just post the liked URL with a heart
|
|
99
|
+
text = `❤️ ${likedUrl}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Truncate if needed (Bluesky limit is 300 chars)
|
|
103
|
+
if (text.length > 300) {
|
|
104
|
+
const suffix = `\n\n❤️ ${likedUrl}`;
|
|
105
|
+
const maxLen = 300 - suffix.length - 3;
|
|
106
|
+
const contentPart = text.replace(suffix, "").slice(0, maxLen).trim();
|
|
107
|
+
text = contentPart + "..." + suffix;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return text;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Constrain image buffer to be under 1MB
|
|
115
|
+
* @param {Buffer} buffer - Image buffer
|
|
116
|
+
* @param {number} maxBytes - Maximum byte length
|
|
117
|
+
* @param {number} [quality] - Image quality
|
|
118
|
+
* @returns {Promise<Buffer>} Compressed image
|
|
119
|
+
*/
|
|
120
|
+
export async function constrainImage(buffer, maxBytes, quality = 90) {
|
|
121
|
+
const compressed = await sharp(buffer).jpeg({ quality }).toBuffer();
|
|
122
|
+
if (compressed.byteLength > maxBytes) {
|
|
123
|
+
return constrainImage(buffer, maxBytes, quality - 5);
|
|
124
|
+
}
|
|
125
|
+
return compressed;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Compress image buffer to be under 1MB for Bluesky
|
|
130
|
+
* @param {Buffer} buffer - Image buffer
|
|
131
|
+
* @param {string} mimeType - Original MIME type
|
|
132
|
+
* @returns {Promise<{buffer: Buffer, mimeType: string}>} Compressed image
|
|
133
|
+
*/
|
|
134
|
+
export async function getPostImage(buffer, mimeType) {
|
|
135
|
+
const MAX_SIZE = 1024 * 1024; // 1MB
|
|
136
|
+
if (buffer.length < MAX_SIZE) {
|
|
137
|
+
return { buffer, mimeType };
|
|
138
|
+
}
|
|
139
|
+
const compressed = await constrainImage(buffer, MAX_SIZE);
|
|
140
|
+
return { buffer: compressed, mimeType: "image/jpeg" };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Convert HTML to plain text, appending last link href if present
|
|
145
|
+
* @param {string} html - HTML
|
|
146
|
+
* @returns {string} Text
|
|
147
|
+
*/
|
|
148
|
+
export const htmlToStatusText = (html) => {
|
|
149
|
+
let hrefs = [...html.matchAll(/href="(https?:\/\/.+?)"/g)];
|
|
150
|
+
const lastHref = hrefs.length > 0 ? hrefs.at(-1)[1] : false;
|
|
151
|
+
|
|
152
|
+
const text = htmlToText(html, {
|
|
153
|
+
selectors: [
|
|
154
|
+
{ selector: "a", options: { ignoreHref: true } },
|
|
155
|
+
{ selector: "img", format: "skip" },
|
|
156
|
+
],
|
|
157
|
+
wordwrap: false,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const statusText = lastHref ? `${text} ${lastHref}` : text;
|
|
161
|
+
return statusText;
|
|
162
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rmdes/indiekit-syndicator-bluesky",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Bluesky syndicator for Indiekit with external like support",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": "./index.js",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"indiekit",
|
|
10
|
+
"indiekit-plugin",
|
|
11
|
+
"indieweb",
|
|
12
|
+
"bluesky",
|
|
13
|
+
"syndication"
|
|
14
|
+
],
|
|
15
|
+
"author": "rmdes",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/rmdes/indiekit-syndicator-bluesky"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@atproto/api": "^0.14.0",
|
|
23
|
+
"@indiekit/error": "^1.0.0-beta.25",
|
|
24
|
+
"@indiekit/util": "^1.0.0-beta.25",
|
|
25
|
+
"sharp": "^0.33.0"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@indiekit/indiekit": "1.x"
|
|
29
|
+
}
|
|
30
|
+
}
|