@rmdes/indiekit-endpoint-syndicate 1.0.0-beta.44 → 1.0.0-beta.46

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.
Files changed (2) hide show
  1. package/lib/utils.js +78 -69
  2. package/package.json +2 -2
package/lib/utils.js CHANGED
@@ -1,23 +1,18 @@
1
1
  /**
2
2
  * Get post data for a single post
3
+ * Get every post awaiting syndication
4
+ *
5
+ * A post is awaiting syndication while `mp-syndicate-to` remains: it is
6
+ * deleted once every target has returned a URL, and replaced with the
7
+ * targets that failed otherwise. Posts already syndicated to some of their
8
+ * targets are therefore still awaiting the rest, and `syndicateToTargets`
9
+ * skips the ones already done. Oldest first, so a backlog is syndicated in
10
+ * the order it was published.
3
11
  * @param {object} postsCollection - Posts database collection
4
- * @param {string} url - URL of existing post (optional)
5
- * @returns {Promise<object>} Post data for given URL else oldest post awaiting syndication
12
+ * @returns {Promise<Array<object>>} Post data, oldest first
6
13
  */
7
- export const getPostData = async (postsCollection, url) => {
8
- if (url) {
9
- return postsCollection.findOne({
10
- "properties.url": url,
11
- });
12
- }
13
-
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. Oldest first, so a backlog is syndicated in
19
- // the order it was published.
20
- const items = await postsCollection
14
+ export const getPostsAwaitingSyndication = async (postsCollection) => {
15
+ return postsCollection
21
16
  .find({
22
17
  "properties.mp-syndicate-to": {
23
18
  $exists: true,
@@ -27,10 +22,24 @@ export const getPostData = async (postsCollection, url) => {
27
22
  },
28
23
  })
29
24
  .sort({ "properties.published": 1 })
30
- .limit(1)
31
25
  .toArray();
26
+ };
32
27
 
33
- return items[0];
28
+ /**
29
+ * Get post data
30
+ * @param {object} postsCollection - Posts database collection
31
+ * @param {string} url - URL of existing post (optional)
32
+ * @returns {Promise<object>} Post data for given URL else oldest post awaiting syndication
33
+ */
34
+ export const getPostData = async (postsCollection, url) => {
35
+ if (url) {
36
+ return postsCollection.findOne({
37
+ "properties.url": url,
38
+ });
39
+ }
40
+
41
+ const [oldest] = await getPostsAwaitingSyndication(postsCollection);
42
+ return oldest;
34
43
  };
35
44
 
36
45
  /**
@@ -221,39 +230,6 @@ export const syndicateToTargets = async (
221
230
  };
222
231
  };
223
232
 
224
- /**
225
- * Derive OG image slug from a post URL path.
226
- * Matches Eleventy theme's ogSlug filter logic.
227
- *
228
- * Date-based: /type/YYYY/MM/DD/slug → YYYY-MM-DD-slug
229
- * Non-date: /about/ → about
230
- * @param {string} postUrl - Full post URL
231
- * @returns {string|null} OG slug or null
232
- */
233
- export function urlToOgSlug(postUrl) {
234
- try {
235
- const pathname = new URL(postUrl).pathname;
236
- const segments = pathname.split("/").filter(Boolean);
237
-
238
- // Date-based URL: /type/yyyy/MM/dd/slug → 5 segments
239
- if (segments.length === 5) {
240
- const [, year, month, day, slug] = segments;
241
- if (
242
- /^\d{4}$/.test(year) &&
243
- /^\d{2}$/.test(month) &&
244
- /^\d{2}$/.test(day)
245
- ) {
246
- return `${year}-${month}-${day}-${slug}`;
247
- }
248
- }
249
-
250
- // Fallback: last segment
251
- return segments.at(-1);
252
- } catch {
253
- return;
254
- }
255
- }
256
-
257
233
  /**
258
234
  * HEAD a URL and report its status, 0 on network error or timeout
259
235
  * @param {string} url - URL to check
@@ -276,30 +252,63 @@ const headCheck = async (url) => {
276
252
  };
277
253
 
278
254
  /**
279
- * Check if a post and its OG image are live on the public site.
280
- * Uses HTTP HEAD requests with a 5-second timeout.
255
+ * GET a page and return its status plus the og:image it declares.
256
+ * @param {string} url - Page URL
257
+ * @returns {Promise<{status: number, ogImage?: string}>}
258
+ */
259
+ const fetchOgImage = async (url) => {
260
+ try {
261
+ const controller = new AbortController();
262
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
263
+ const response = await fetch(url, {
264
+ redirect: "follow",
265
+ signal: controller.signal,
266
+ });
267
+ clearTimeout(timeoutId);
268
+ if (response.status !== 200) return { status: response.status };
269
+ const html = await response.text();
270
+ const match = html.match(/<meta\s+property="og:image"\s+content="([^"]+)"/);
271
+ return { status: 200, ogImage: match?.[1] };
272
+ } catch {
273
+ return { status: 0 };
274
+ }
275
+ };
276
+
277
+ /**
278
+ * Check if a post and its OG card are live on the public site.
279
+ *
280
+ * Reads the og:image the POST ITSELF declares rather than re-deriving its
281
+ * filename. The removed helper's own docstring said "Matches Eleventy theme's
282
+ * ogSlug filter logic" — a hand-copied convention from another repo, and on
283
+ * 2026-09-13 that convention changed: the theme added the post type to the
284
+ * cache key so a like and a reply sharing a basename stop colliding. This gate
285
+ * kept asking for the old type-less name, got 404 for cards that were
286
+ * demonstrably live, and silently blocked syndication for every new post.
287
+ *
288
+ * Reading the page removes the coupling: whatever the page says its card is, we
289
+ * check that. It also makes `skipOg` unnecessary in principle — a photo post
290
+ * declares its photo as og:image and that HEADs the same way — though the flag
291
+ * is kept for callers that still pass it.
292
+ *
293
+ * Costs one GET instead of one HEAD, for posts awaiting syndication only.
281
294
  * @param {string} postUrl - Full public URL of the post
282
- * @param {string} me - Publication URL (e.g., "https://rmendes.net")
295
+ * @param {string} me - Publication URL (no longer used to build the card URL; kept for the signature)
283
296
  * @param {object} [options] - Options
284
- * @param {boolean} [options.skipOg] - Skip OG image check (e.g., photo posts
285
- * where Eleventy uses the photo itself as og:image instead of generating /og/*.png)
297
+ * @param {boolean} [options.skipOg] - Don't wait on any og:image
286
298
  * @returns {Promise<{ready: boolean, postStatus: number, ogStatus: number}>}
287
299
  */
288
300
  export async function isPostReady(postUrl, me, options = {}) {
289
301
  const { skipOg = false } = options;
290
- const ogSlug = urlToOgSlug(postUrl);
291
- const meNorm = me?.replace(/\/$/, "");
292
- const ogUrl =
293
- !skipOg && ogSlug && meNorm ? `${meNorm}/og/${ogSlug}.png` : undefined;
302
+ const { status: postStatus, ogImage } = await fetchOgImage(postUrl);
294
303
 
295
- const [postStatus, ogStatus] = await Promise.all([
296
- headCheck(postUrl),
297
- ogUrl ? headCheck(ogUrl) : Promise.resolve(200),
298
- ]);
304
+ if (postStatus !== 200) {
305
+ return { ready: false, postStatus, ogStatus: 0 };
306
+ }
307
+ // No card declared (or explicitly skipped) → nothing to wait for.
308
+ if (skipOg || !ogImage) {
309
+ return { ready: true, postStatus, ogStatus: 200 };
310
+ }
299
311
 
300
- return {
301
- ready: postStatus === 200 && ogStatus === 200,
302
- postStatus,
303
- ogStatus,
304
- };
312
+ const ogStatus = await headCheck(ogImage);
313
+ return { ready: ogStatus === 200, postStatus, ogStatus };
305
314
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-syndicate",
3
- "version": "1.0.0-beta.44",
3
+ "version": "1.0.0-beta.46",
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",
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@indiekit/error": "^1.0.0-beta.28",
32
- "express": "^5.2.1",
32
+ "express": "^5.3.0",
33
33
  "jsonwebtoken": "^9.0.3"
34
34
  },
35
35
  "publishConfig": {