@rmdes/indiekit-endpoint-syndicate 1.0.0-beta.34 → 1.0.0-beta.36

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.
@@ -4,6 +4,7 @@ import { findBearerToken } from "../token.js";
4
4
  import {
5
5
  getAllPostData,
6
6
  getPostData,
7
+ isPostReady,
7
8
  syndicateToTargets,
8
9
  } from "../utils.js";
9
10
 
@@ -31,6 +32,21 @@ const syndicatePost = async ({
31
32
  bearerToken,
32
33
  force = false,
33
34
  }) => {
35
+ // Readiness gate: verify post and OG image are live before syndicating.
36
+ // Skip check in force mode (manual re-syndication from UI/backlog script).
37
+ const meUrl = typeof publication.me === "string" ? publication.me : publication.me?.href || publication.me?.toString?.() || "";
38
+ 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);
41
+ if (!readiness.ready) {
42
+ console.log(
43
+ `[syndication] Skipping ${postData.properties.url} — not yet built ` +
44
+ `(post: ${readiness.postStatus}, og: ${readiness.ogStatus})`
45
+ );
46
+ return { skipped: true, url: postData.properties.url, readiness };
47
+ }
48
+ }
49
+
34
50
  const { failedTargets, syndicatedUrls } = await syndicateToTargets(
35
51
  publication,
36
52
  postData.properties,
@@ -148,6 +164,7 @@ export const syndicateController = {
148
164
  const results = [];
149
165
  let successCount = 0;
150
166
  let failCount = 0;
167
+ let skippedCount = 0;
151
168
 
152
169
  for (const postData of allPostData) {
153
170
  try {
@@ -158,6 +175,18 @@ export const syndicateController = {
158
175
  bearerToken,
159
176
  });
160
177
 
178
+ // Post was skipped (not yet built)
179
+ if (result.skipped) {
180
+ results.push({
181
+ url: result.url,
182
+ success: true,
183
+ skipped: true,
184
+ reason: `Not yet built (post: ${result.readiness.postStatus}, og: ${result.readiness.ogStatus})`,
185
+ });
186
+ skippedCount++;
187
+ continue;
188
+ }
189
+
161
190
  results.push({
162
191
  url: result.url,
163
192
  success: true,
@@ -191,7 +220,7 @@ export const syndicateController = {
191
220
  }
192
221
 
193
222
  const description =
194
- `Processed ${allPostData.length} post(s): ${successCount} succeeded, ${failCount} failed`;
223
+ `Processed ${allPostData.length} post(s): ${successCount} succeeded, ${failCount} failed, ${skippedCount} not yet built`;
195
224
 
196
225
  console.log(`[syndication] ${description}`);
197
226
 
package/lib/utils.js CHANGED
@@ -217,3 +217,74 @@ export const syndicateToTargets = async (
217
217
  syndicatedUrls,
218
218
  };
219
219
  };
220
+
221
+ /**
222
+ * Derive OG image slug from a post URL path.
223
+ * Matches Eleventy theme's ogSlug filter logic.
224
+ *
225
+ * Date-based: /type/YYYY/MM/DD/slug → YYYY-MM-DD-slug
226
+ * Non-date: /about/ → about
227
+ *
228
+ * @param {string} postUrl - Full post URL
229
+ * @returns {string|null} OG slug or null
230
+ */
231
+ export function urlToOgSlug(postUrl) {
232
+ try {
233
+ const pathname = new URL(postUrl).pathname;
234
+ const segments = pathname.split("/").filter(Boolean);
235
+
236
+ // Date-based URL: /type/yyyy/MM/dd/slug → 5 segments
237
+ if (segments.length === 5) {
238
+ const [, year, month, day, slug] = segments;
239
+ if (/^\d{4}$/.test(year) && /^\d{2}$/.test(month) && /^\d{2}$/.test(day)) {
240
+ return `${year}-${month}-${day}-${slug}`;
241
+ }
242
+ }
243
+
244
+ // Fallback: last segment
245
+ return segments[segments.length - 1] || null;
246
+ } catch {
247
+ return null;
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Check if a post and its OG image are live on the public site.
253
+ * Uses HTTP HEAD requests with a 5-second timeout.
254
+ *
255
+ * @param {string} postUrl - Full public URL of the post
256
+ * @param {string} me - Publication URL (e.g., "https://rmendes.net")
257
+ * @returns {Promise<{ready: boolean, postStatus: number, ogStatus: number}>}
258
+ */
259
+ export async function isPostReady(postUrl, me) {
260
+ const ogSlug = urlToOgSlug(postUrl);
261
+ const meNorm = me?.replace(/\/$/, "");
262
+ const ogUrl = ogSlug && meNorm ? `${meNorm}/og/${ogSlug}.png` : null;
263
+
264
+ const headCheck = async (url) => {
265
+ try {
266
+ const controller = new AbortController();
267
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
268
+ const response = await fetch(url, {
269
+ method: "HEAD",
270
+ redirect: "follow",
271
+ signal: controller.signal,
272
+ });
273
+ clearTimeout(timeoutId);
274
+ return response.status;
275
+ } catch {
276
+ return 0;
277
+ }
278
+ };
279
+
280
+ const [postStatus, ogStatus] = await Promise.all([
281
+ headCheck(postUrl),
282
+ ogUrl ? headCheck(ogUrl) : Promise.resolve(200),
283
+ ]);
284
+
285
+ return {
286
+ ready: postStatus === 200 && ogStatus === 200,
287
+ postStatus,
288
+ ogStatus,
289
+ };
290
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-syndicate",
3
- "version": "1.0.0-beta.34",
3
+ "version": "1.0.0-beta.36",
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",