@rmdes/indiekit-endpoint-syndicate 1.0.0-beta.37 → 1.0.0-beta.39

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.
@@ -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
+ let batchRunning = false;
35
+
18
36
  /**
19
37
  * Syndicate a single post and update it via Micropub
20
38
  * @param {object} options - Options
@@ -34,10 +52,15 @@ const syndicatePost = async ({
34
52
  }) => {
35
53
  // Readiness gate: verify post and OG image are live before syndicating.
36
54
  // Skip check in force mode (manual re-syndication from UI/backlog script).
55
+ // Photo posts skip OG check — Eleventy deliberately skips OG generation
56
+ // for posts with photos (the photo itself serves as og:image in HTML).
37
57
  const meUrl = typeof publication.me === "string" ? publication.me : publication.me?.href || publication.me?.toString?.() || "";
58
+ const hasPhotos = Array.isArray(postData.properties?.photo) && postData.properties.photo.length > 0;
38
59
  if (!force && meUrl && postData.properties?.url) {
39
- console.log(`[syndication] Readiness gate: checking ${postData.properties.url} (me=${meUrl})`);
40
- const readiness = await isPostReady(postData.properties.url, meUrl);
60
+ console.log(`[syndication] Readiness gate: checking ${postData.properties.url} (me=${meUrl}, hasPhotos=${hasPhotos})`);
61
+ const readiness = hasPhotos
62
+ ? await isPostReady(postData.properties.url, meUrl, { skipOg: true })
63
+ : await isPostReady(postData.properties.url, meUrl);
41
64
  if (!readiness.ready) {
42
65
  console.log(
43
66
  `[syndication] Skipping ${postData.properties.url} — not yet built ` +
@@ -148,87 +171,98 @@ export const syndicateController = {
148
171
  }
149
172
 
150
173
  // --- Batch mode (no source_url — process ALL pending posts) ---
151
- const allPostData = await getAllPostData(postsCollection);
152
-
153
- if (!allPostData || allPostData.length === 0) {
174
+ if (batchRunning) {
175
+ console.log("[syndication] Batch already in progress, skipping");
154
176
  return response.json({
155
177
  success: "OK",
156
- success_description: "No posts awaiting syndication",
178
+ success_description: "Batch already in progress",
157
179
  });
158
180
  }
159
181
 
160
- console.log(
161
- `[syndication] Batch processing ${allPostData.length} post(s)`,
162
- );
182
+ batchRunning = true;
163
183
 
164
- const results = [];
165
- let successCount = 0;
166
- let failCount = 0;
167
- let skippedCount = 0;
168
-
169
- for (const postData of allPostData) {
170
- try {
171
- const result = await syndicatePost({
172
- application,
173
- publication,
174
- postData,
175
- bearerToken,
184
+ try {
185
+ const allPostData = await getAllPostData(postsCollection);
186
+
187
+ if (!allPostData || allPostData.length === 0) {
188
+ return response.json({
189
+ success: "OK",
190
+ success_description: "No posts awaiting syndication",
176
191
  });
192
+ }
193
+
194
+ console.log(
195
+ `[syndication] Batch processing ${allPostData.length} post(s)`,
196
+ );
197
+
198
+ const results = [];
199
+
200
+ for (const [index, postData] of allPostData.entries()) {
201
+ try {
202
+ const result = await syndicatePost({
203
+ application,
204
+ publication,
205
+ postData,
206
+ bearerToken,
207
+ });
208
+
209
+ // Post was skipped (not yet built)
210
+ if (result.skipped) {
211
+ results.push({
212
+ url: result.url,
213
+ success: true,
214
+ skipped: true,
215
+ reason: `Not yet built (post: ${result.readiness.postStatus}, og: ${result.readiness.ogStatus})`,
216
+ });
217
+ continue;
218
+ }
177
219
 
178
- // Post was skipped (not yet built)
179
- if (result.skipped) {
180
220
  results.push({
181
221
  url: result.url,
182
222
  success: true,
183
- skipped: true,
184
- reason: `Not yet built (post: ${result.readiness.postStatus}, og: ${result.readiness.ogStatus})`,
223
+ syndicatedUrls: result.syndicatedUrls,
224
+ ...(result.failedTargets && {
225
+ failedTargets: result.failedTargets,
226
+ }),
185
227
  });
186
- skippedCount++;
187
- continue;
188
- }
189
-
190
- results.push({
191
- url: result.url,
192
- success: true,
193
- syndicatedUrls: result.syndicatedUrls,
194
- ...(result.failedTargets && {
195
- failedTargets: result.failedTargets,
196
- }),
197
- });
198
228
 
199
- successCount++;
200
- console.log(
201
- `[syndication] Syndicated: ${result.url} (${result.syndicatedUrls.length} target(s))`,
202
- );
203
- } catch (error) {
204
- results.push({
205
- url: postData.properties?.url,
206
- success: false,
207
- error: error.message,
208
- });
229
+ console.log(
230
+ `[syndication] Syndicated: ${result.url} (${result.syndicatedUrls.length} target(s))`,
231
+ );
232
+ } catch (error) {
233
+ results.push({
234
+ url: postData.properties?.url,
235
+ success: false,
236
+ error: error.message,
237
+ });
209
238
 
210
- failCount++;
211
- console.error(
212
- `[syndication] Failed: ${postData.properties?.url} - ${error.message}`,
213
- );
214
- }
239
+ console.error(
240
+ `[syndication] Failed: ${postData.properties?.url} - ${error.message}`,
241
+ );
242
+ }
215
243
 
216
- // Rate limit delay between posts (2 seconds)
217
- if (allPostData.indexOf(postData) < allPostData.length - 1) {
218
- await delay(2000);
244
+ // Rate limit delay between posts (2 seconds)
245
+ if (index < allPostData.length - 1) {
246
+ await delay(2000);
247
+ }
219
248
  }
220
- }
221
249
 
222
- const description =
223
- `Processed ${allPostData.length} post(s): ${successCount} succeeded, ${failCount} failed, ${skippedCount} not yet built`;
250
+ const failed = results.filter((result) => !result.success).length;
251
+ const skipped = results.filter((result) => result.skipped).length;
252
+ const succeeded = results.length - failed - skipped;
253
+ const description =
254
+ `Processed ${allPostData.length} post(s): ${succeeded} succeeded, ${failed} failed, ${skipped} not yet built`;
224
255
 
225
- console.log(`[syndication] ${description}`);
256
+ console.log(`[syndication] ${description}`);
226
257
 
227
- return response.json({
228
- success: "OK",
229
- success_description: description,
230
- results,
231
- });
258
+ return response.json({
259
+ success: "OK",
260
+ success_description: description,
261
+ results,
262
+ });
263
+ } finally {
264
+ batchRunning = false;
265
+ }
232
266
  } catch (error) {
233
267
  next(error);
234
268
  }
package/lib/utils.js CHANGED
@@ -142,8 +142,12 @@ export const syndicateToTargets = async (
142
142
  }
143
143
  })
144
144
  .map((t) => t.info.uid);
145
+ } else {
146
+ // Fall back to targets marked as checked by default
147
+ syndicateTo = syndicationTargets
148
+ .filter((t) => t?.options?.checked)
149
+ .map((t) => t.info.uid);
145
150
  }
146
- // If no existing syndication URLs, don't default to all — leave empty
147
151
  }
148
152
 
149
153
  // BUG FIX: Was `Array.isArray` (always truthy, it's a function reference)
@@ -254,12 +258,16 @@ export function urlToOgSlug(postUrl) {
254
258
  *
255
259
  * @param {string} postUrl - Full public URL of the post
256
260
  * @param {string} me - Publication URL (e.g., "https://rmendes.net")
261
+ * @param {object} [options] - Options
262
+ * @param {boolean} [options.skipOg] - Skip OG image check (e.g., photo posts
263
+ * where Eleventy uses the photo itself as og:image instead of generating /og/*.png)
257
264
  * @returns {Promise<{ready: boolean, postStatus: number, ogStatus: number}>}
258
265
  */
259
- export async function isPostReady(postUrl, me) {
266
+ export async function isPostReady(postUrl, me, options = {}) {
267
+ const { skipOg = false } = options;
260
268
  const ogSlug = urlToOgSlug(postUrl);
261
269
  const meNorm = me?.replace(/\/$/, "");
262
- const ogUrl = ogSlug && meNorm ? `${meNorm}/og/${ogSlug}.png` : null;
270
+ const ogUrl = !skipOg && ogSlug && meNorm ? `${meNorm}/og/${ogSlug}.png` : null;
263
271
 
264
272
  const headCheck = async (url) => {
265
273
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-syndicate",
3
- "version": "1.0.0-beta.37",
3
+ "version": "1.0.0-beta.39",
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",
@@ -37,5 +37,8 @@
37
37
  },
38
38
  "publishConfig": {
39
39
  "access": "public"
40
+ },
41
+ "scripts": {
42
+ "test": "node --test test/*.js"
40
43
  }
41
44
  }