@rmdes/indiekit-endpoint-syndicate 1.0.0-beta.38 → 1.0.0-beta.40
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/controllers/syndicate.js +104 -67
- package/lib/utils.js +54 -49
- package/package.json +8 -5
|
@@ -15,6 +15,24 @@ import {
|
|
|
15
15
|
*/
|
|
16
16
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Guards batch mode against overlapping runs.
|
|
20
|
+
*
|
|
21
|
+
* A post stays selectable until its Micropub update lands at the end of a
|
|
22
|
+
* batch, so a second trigger arriving mid-batch sees `mp-syndicate-to` still
|
|
23
|
+
* set and syndicates it again. Both triggers — the poller and the Eleventy
|
|
24
|
+
* post-build hook — reach the same process, so a module-level flag is enough.
|
|
25
|
+
* Anything skipped is picked up by the next poll.
|
|
26
|
+
*
|
|
27
|
+
* Batch mode only: the single-post force path (source_url) is deliberately
|
|
28
|
+
* unguarded so manual re-syndication always runs.
|
|
29
|
+
*
|
|
30
|
+
* ponytail: single-process guard. If Indiekit is ever run multi-process or
|
|
31
|
+
* multi-container, replace with an atomic per-post claim (findOneAndUpdate
|
|
32
|
+
* setting a claim timestamp, with a stale-claim timeout).
|
|
33
|
+
*/
|
|
34
|
+
const batch = { isRunning: false };
|
|
35
|
+
|
|
18
36
|
/**
|
|
19
37
|
* Syndicate a single post and update it via Micropub
|
|
20
38
|
* @param {object} options - Options
|
|
@@ -36,17 +54,24 @@ const syndicatePost = async ({
|
|
|
36
54
|
// Skip check in force mode (manual re-syndication from UI/backlog script).
|
|
37
55
|
// Photo posts skip OG check — Eleventy deliberately skips OG generation
|
|
38
56
|
// for posts with photos (the photo itself serves as og:image in HTML).
|
|
39
|
-
const meUrl =
|
|
40
|
-
|
|
57
|
+
const meUrl =
|
|
58
|
+
typeof publication.me === "string"
|
|
59
|
+
? publication.me
|
|
60
|
+
: publication.me?.href || publication.me?.toString?.() || "";
|
|
61
|
+
const hasPhotos =
|
|
62
|
+
Array.isArray(postData.properties?.photo) &&
|
|
63
|
+
postData.properties.photo.length > 0;
|
|
41
64
|
if (!force && meUrl && postData.properties?.url) {
|
|
42
|
-
console.log(
|
|
65
|
+
console.log(
|
|
66
|
+
`[syndication] Readiness gate: checking ${postData.properties.url} (me=${meUrl}, hasPhotos=${hasPhotos})`,
|
|
67
|
+
);
|
|
43
68
|
const readiness = hasPhotos
|
|
44
69
|
? await isPostReady(postData.properties.url, meUrl, { skipOg: true })
|
|
45
70
|
: await isPostReady(postData.properties.url, meUrl);
|
|
46
71
|
if (!readiness.ready) {
|
|
47
72
|
console.log(
|
|
48
73
|
`[syndication] Skipping ${postData.properties.url} — not yet built ` +
|
|
49
|
-
|
|
74
|
+
`(post: ${readiness.postStatus}, og: ${readiness.ogStatus})`,
|
|
50
75
|
);
|
|
51
76
|
return { skipped: true, url: postData.properties.url, readiness };
|
|
52
77
|
}
|
|
@@ -81,7 +106,9 @@ const syndicatePost = async ({
|
|
|
81
106
|
throw await IndiekitError.fromFetch(micropubResponse);
|
|
82
107
|
}
|
|
83
108
|
|
|
84
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
@type {object}
|
|
111
|
+
*/
|
|
85
112
|
const body = await micropubResponse.json();
|
|
86
113
|
|
|
87
114
|
return {
|
|
@@ -153,87 +180,97 @@ export const syndicateController = {
|
|
|
153
180
|
}
|
|
154
181
|
|
|
155
182
|
// --- Batch mode (no source_url — process ALL pending posts) ---
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if (!allPostData || allPostData.length === 0) {
|
|
183
|
+
if (batch.isRunning) {
|
|
184
|
+
console.log("[syndication] Batch already in progress, skipping");
|
|
159
185
|
return response.json({
|
|
160
186
|
success: "OK",
|
|
161
|
-
success_description: "
|
|
187
|
+
success_description: "Batch already in progress",
|
|
162
188
|
});
|
|
163
189
|
}
|
|
164
190
|
|
|
165
|
-
|
|
166
|
-
`[syndication] Batch processing ${allPostData.length} post(s)`,
|
|
167
|
-
);
|
|
191
|
+
batch.isRunning = true;
|
|
168
192
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const result = await syndicatePost({
|
|
177
|
-
application,
|
|
178
|
-
publication,
|
|
179
|
-
postData,
|
|
180
|
-
bearerToken,
|
|
193
|
+
try {
|
|
194
|
+
const allPostData = await getAllPostData(postsCollection);
|
|
195
|
+
|
|
196
|
+
if (!allPostData || allPostData.length === 0) {
|
|
197
|
+
return response.json({
|
|
198
|
+
success: "OK",
|
|
199
|
+
success_description: "No posts awaiting syndication",
|
|
181
200
|
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
console.log(
|
|
204
|
+
`[syndication] Batch processing ${allPostData.length} post(s)`,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const results = [];
|
|
208
|
+
|
|
209
|
+
for (const [index, postData] of allPostData.entries()) {
|
|
210
|
+
try {
|
|
211
|
+
const result = await syndicatePost({
|
|
212
|
+
application,
|
|
213
|
+
publication,
|
|
214
|
+
postData,
|
|
215
|
+
bearerToken,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Post was skipped (not yet built)
|
|
219
|
+
if (result.skipped) {
|
|
220
|
+
results.push({
|
|
221
|
+
url: result.url,
|
|
222
|
+
success: true,
|
|
223
|
+
skipped: true,
|
|
224
|
+
reason: `Not yet built (post: ${result.readiness.postStatus}, og: ${result.readiness.ogStatus})`,
|
|
225
|
+
});
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
182
228
|
|
|
183
|
-
// Post was skipped (not yet built)
|
|
184
|
-
if (result.skipped) {
|
|
185
229
|
results.push({
|
|
186
230
|
url: result.url,
|
|
187
231
|
success: true,
|
|
188
|
-
|
|
189
|
-
|
|
232
|
+
syndicatedUrls: result.syndicatedUrls,
|
|
233
|
+
...(result.failedTargets && {
|
|
234
|
+
failedTargets: result.failedTargets,
|
|
235
|
+
}),
|
|
190
236
|
});
|
|
191
|
-
skippedCount++;
|
|
192
|
-
continue;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
results.push({
|
|
196
|
-
url: result.url,
|
|
197
|
-
success: true,
|
|
198
|
-
syndicatedUrls: result.syndicatedUrls,
|
|
199
|
-
...(result.failedTargets && {
|
|
200
|
-
failedTargets: result.failedTargets,
|
|
201
|
-
}),
|
|
202
|
-
});
|
|
203
237
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
)
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
});
|
|
238
|
+
console.log(
|
|
239
|
+
`[syndication] Syndicated: ${result.url} (${result.syndicatedUrls.length} target(s))`,
|
|
240
|
+
);
|
|
241
|
+
} catch (error) {
|
|
242
|
+
results.push({
|
|
243
|
+
url: postData.properties?.url,
|
|
244
|
+
success: false,
|
|
245
|
+
error: error.message,
|
|
246
|
+
});
|
|
214
247
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
248
|
+
console.error(
|
|
249
|
+
`[syndication] Failed: ${postData.properties?.url} - ${error.message}`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
220
252
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
253
|
+
// Rate limit delay between posts (2 seconds)
|
|
254
|
+
if (index < allPostData.length - 1) {
|
|
255
|
+
await delay(2000);
|
|
256
|
+
}
|
|
224
257
|
}
|
|
225
|
-
}
|
|
226
258
|
|
|
227
|
-
|
|
228
|
-
|
|
259
|
+
const failed = results.filter((result) => !result.success).length;
|
|
260
|
+
const skipped = results.filter((result) => result.skipped).length;
|
|
261
|
+
const succeeded = results.length - failed - skipped;
|
|
262
|
+
const description = `Processed ${allPostData.length} post(s): ${succeeded} succeeded, ${failed} failed, ${skipped} not yet built`;
|
|
229
263
|
|
|
230
|
-
|
|
264
|
+
console.log(`[syndication] ${description}`);
|
|
231
265
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
266
|
+
return response.json({
|
|
267
|
+
success: "OK",
|
|
268
|
+
success_description: description,
|
|
269
|
+
results,
|
|
270
|
+
});
|
|
271
|
+
} finally {
|
|
272
|
+
batch.isRunning = false;
|
|
273
|
+
}
|
|
237
274
|
} catch (error) {
|
|
238
275
|
next(error);
|
|
239
276
|
}
|
package/lib/utils.js
CHANGED
|
@@ -5,36 +5,31 @@
|
|
|
5
5
|
* @returns {Promise<object>} Post data for given URL else most recent pending post
|
|
6
6
|
*/
|
|
7
7
|
export const getPostData = async (postsCollection, url) => {
|
|
8
|
-
let postData = {};
|
|
9
|
-
|
|
10
8
|
if (url) {
|
|
11
|
-
|
|
12
|
-
postData = await postsCollection.findOne({
|
|
9
|
+
return postsCollection.findOne({
|
|
13
10
|
"properties.url": url,
|
|
14
11
|
});
|
|
15
|
-
} else {
|
|
16
|
-
// Get most recent published post awaiting syndication
|
|
17
|
-
const items = await postsCollection
|
|
18
|
-
.find({
|
|
19
|
-
"properties.mp-syndicate-to": {
|
|
20
|
-
$exists: true,
|
|
21
|
-
},
|
|
22
|
-
// BUG FIX: Removed "properties.syndication": { $exists: false }
|
|
23
|
-
// That filter skipped partially syndicated posts (e.g., posted to
|
|
24
|
-
// Mastodon but not yet to Bluesky). syndicateToTargets() already
|
|
25
|
-
// calls hasSyndicationUrl() to skip completed targets.
|
|
26
|
-
"properties.post-status": {
|
|
27
|
-
$ne: "draft",
|
|
28
|
-
},
|
|
29
|
-
})
|
|
30
|
-
// eslint-disable-next-line unicorn/no-array-sort
|
|
31
|
-
.sort({ "properties.published": -1 })
|
|
32
|
-
.limit(1)
|
|
33
|
-
.toArray();
|
|
34
|
-
postData = items[0];
|
|
35
12
|
}
|
|
36
13
|
|
|
37
|
-
|
|
14
|
+
// A post is awaiting syndication while `mp-syndicate-to` remains: it is
|
|
15
|
+
// deleted once every target has returned a URL, and replaced with the
|
|
16
|
+
// targets that failed otherwise. Posts already syndicated to some of their
|
|
17
|
+
// targets are therefore still awaiting the rest, and `syndicateToTargets`
|
|
18
|
+
// skips the ones already done.
|
|
19
|
+
const items = await postsCollection
|
|
20
|
+
.find({
|
|
21
|
+
"properties.mp-syndicate-to": {
|
|
22
|
+
$exists: true,
|
|
23
|
+
},
|
|
24
|
+
"properties.post-status": {
|
|
25
|
+
$ne: "draft",
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
.sort({ "properties.published": -1 })
|
|
29
|
+
.limit(1)
|
|
30
|
+
.toArray();
|
|
31
|
+
|
|
32
|
+
return items[0];
|
|
38
33
|
};
|
|
39
34
|
|
|
40
35
|
/**
|
|
@@ -53,7 +48,7 @@ export const getAllPostData = async (postsCollection) => {
|
|
|
53
48
|
$ne: "draft",
|
|
54
49
|
},
|
|
55
50
|
})
|
|
56
|
-
|
|
51
|
+
|
|
57
52
|
.sort({ "properties.published": -1 })
|
|
58
53
|
.toArray();
|
|
59
54
|
|
|
@@ -136,7 +131,9 @@ export const syndicateToTargets = async (
|
|
|
136
131
|
syndicateTo = syndicationTargets
|
|
137
132
|
.filter((t) => {
|
|
138
133
|
try {
|
|
139
|
-
return
|
|
134
|
+
return (
|
|
135
|
+
t?.info?.uid && existingOrigins.has(new URL(t.info.uid).origin)
|
|
136
|
+
);
|
|
140
137
|
} catch {
|
|
141
138
|
return false;
|
|
142
139
|
}
|
|
@@ -228,7 +225,6 @@ export const syndicateToTargets = async (
|
|
|
228
225
|
*
|
|
229
226
|
* Date-based: /type/YYYY/MM/DD/slug → YYYY-MM-DD-slug
|
|
230
227
|
* Non-date: /about/ → about
|
|
231
|
-
*
|
|
232
228
|
* @param {string} postUrl - Full post URL
|
|
233
229
|
* @returns {string|null} OG slug or null
|
|
234
230
|
*/
|
|
@@ -240,22 +236,46 @@ export function urlToOgSlug(postUrl) {
|
|
|
240
236
|
// Date-based URL: /type/yyyy/MM/dd/slug → 5 segments
|
|
241
237
|
if (segments.length === 5) {
|
|
242
238
|
const [, year, month, day, slug] = segments;
|
|
243
|
-
if (
|
|
239
|
+
if (
|
|
240
|
+
/^\d{4}$/.test(year) &&
|
|
241
|
+
/^\d{2}$/.test(month) &&
|
|
242
|
+
/^\d{2}$/.test(day)
|
|
243
|
+
) {
|
|
244
244
|
return `${year}-${month}-${day}-${slug}`;
|
|
245
245
|
}
|
|
246
246
|
}
|
|
247
247
|
|
|
248
248
|
// Fallback: last segment
|
|
249
|
-
return segments
|
|
249
|
+
return segments.at(-1);
|
|
250
250
|
} catch {
|
|
251
|
-
return
|
|
251
|
+
return;
|
|
252
252
|
}
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
/**
|
|
256
|
+
* HEAD a URL and report its status, 0 on network error or timeout
|
|
257
|
+
* @param {string} url - URL to check
|
|
258
|
+
* @returns {Promise<number>} HTTP status
|
|
259
|
+
*/
|
|
260
|
+
const headCheck = async (url) => {
|
|
261
|
+
try {
|
|
262
|
+
const controller = new AbortController();
|
|
263
|
+
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
264
|
+
const response = await fetch(url, {
|
|
265
|
+
method: "HEAD",
|
|
266
|
+
redirect: "follow",
|
|
267
|
+
signal: controller.signal,
|
|
268
|
+
});
|
|
269
|
+
clearTimeout(timeoutId);
|
|
270
|
+
return response.status;
|
|
271
|
+
} catch {
|
|
272
|
+
return 0;
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
255
276
|
/**
|
|
256
277
|
* Check if a post and its OG image are live on the public site.
|
|
257
278
|
* Uses HTTP HEAD requests with a 5-second timeout.
|
|
258
|
-
*
|
|
259
279
|
* @param {string} postUrl - Full public URL of the post
|
|
260
280
|
* @param {string} me - Publication URL (e.g., "https://rmendes.net")
|
|
261
281
|
* @param {object} [options] - Options
|
|
@@ -267,23 +287,8 @@ export async function isPostReady(postUrl, me, options = {}) {
|
|
|
267
287
|
const { skipOg = false } = options;
|
|
268
288
|
const ogSlug = urlToOgSlug(postUrl);
|
|
269
289
|
const meNorm = me?.replace(/\/$/, "");
|
|
270
|
-
const ogUrl =
|
|
271
|
-
|
|
272
|
-
const headCheck = async (url) => {
|
|
273
|
-
try {
|
|
274
|
-
const controller = new AbortController();
|
|
275
|
-
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
276
|
-
const response = await fetch(url, {
|
|
277
|
-
method: "HEAD",
|
|
278
|
-
redirect: "follow",
|
|
279
|
-
signal: controller.signal,
|
|
280
|
-
});
|
|
281
|
-
clearTimeout(timeoutId);
|
|
282
|
-
return response.status;
|
|
283
|
-
} catch {
|
|
284
|
-
return 0;
|
|
285
|
-
}
|
|
286
|
-
};
|
|
290
|
+
const ogUrl =
|
|
291
|
+
!skipOg && ogSlug && meNorm ? `${meNorm}/og/${ogSlug}.png` : undefined;
|
|
287
292
|
|
|
288
293
|
const [postStatus, ogStatus] = await Promise.all([
|
|
289
294
|
headCheck(postUrl),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmdes/indiekit-endpoint-syndicate",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.40",
|
|
4
4
|
"description": "Syndication endpoint for Indiekit. Fork of @indiekit/endpoint-syndicate with batch syndication support and bug fixes.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"indiekit",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"engines": {
|
|
18
|
-
"node": ">=
|
|
18
|
+
"node": ">=24.17"
|
|
19
19
|
},
|
|
20
20
|
"type": "module",
|
|
21
21
|
"main": "index.js",
|
|
@@ -28,14 +28,17 @@
|
|
|
28
28
|
"url": "https://github.com/rmdes/indiekit-endpoint-syndicate.git"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@indiekit/error": "^1.0.0-beta.
|
|
32
|
-
"express": "^5.
|
|
33
|
-
"jsonwebtoken": "^9.0.
|
|
31
|
+
"@indiekit/error": "^1.0.0-beta.28",
|
|
32
|
+
"express": "^5.2.1",
|
|
33
|
+
"jsonwebtoken": "^9.0.3"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
36
|
"@indiekit/indiekit": ">=1.0.0-beta.25"
|
|
37
37
|
},
|
|
38
38
|
"publishConfig": {
|
|
39
39
|
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"test": "node --test test/*.js"
|
|
40
43
|
}
|
|
41
44
|
}
|