fix(ingestion): trigger deterministic Eleventy builds on publish and unpublish

This commit is contained in:
venus
2026-09-14 01:24:07 -05:00
parent 417ec30bfd
commit 22f78da4cb
3 changed files with 65 additions and 17 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ services:
- "WATCH=${WATCH:-false}" - "WATCH=${WATCH:-false}"
volumes: volumes:
# Mount external markdown posts directly into the container # Mount external markdown posts directly into the container
- "./posts:/app/src/posts:ro" - "./posts:/app/src/posts"
ports: ports:
- "${HOST_PORT:-${PORT:-8080}}:${PORT:-8080}" - "${HOST_PORT:-${PORT:-8080}}:${PORT:-8080}"
networks: networks:
+54 -15
View File
@@ -74,6 +74,49 @@ function slugify(text) {
.replace(/^-+|-+$/g, ""); .replace(/^-+|-+$/g, "");
} }
let isBuilding = false;
let buildQueued = false;
/**
* Rebuilds the static site using Eleventy.
* Uses a build queue to prevent concurrent build races.
* @returns {Promise<void>}
*/
export function triggerEleventyBuild() {
if (isBuilding) {
buildQueued = true;
return Promise.resolve();
}
const root = process.cwd();
const hasConfig = fs.existsSync(path.join(root, "eleventy.config.js")) ||
fs.existsSync(path.join(root, ".eleventy.js")) ||
fs.existsSync(path.join(root, "node_modules/@11ty/eleventy"));
if (!hasConfig) {
return Promise.resolve();
}
isBuilding = true;
return new Promise((resolve) => {
exec("npx @11ty/eleventy", (err) => {
isBuilding = false;
if (err) {
console.error("[Ingestion API] Eleventy build error:", err);
} else {
console.log("[Ingestion API] Eleventy build completed successfully.");
}
if (buildQueued) {
buildQueued = false;
triggerEleventyBuild().then(resolve);
} else {
resolve();
}
});
});
}
/** /**
* Creates the HTTP server instance for serving static files and handling ingestion API * Creates the HTTP server instance for serving static files and handling ingestion API
* @param {string} [customSiteDir] Optional custom directory for _site * @param {string} [customSiteDir] Optional custom directory for _site
@@ -85,8 +128,9 @@ export function createServer(customSiteDir, customPostsDir, customAssetsDir) {
const siteDir = path.resolve(customSiteDir || path.join(process.cwd(), "_site")); const siteDir = path.resolve(customSiteDir || path.join(process.cwd(), "_site"));
const postsDir = path.resolve(customPostsDir || path.join(process.cwd(), "src/posts")); const postsDir = path.resolve(customPostsDir || path.join(process.cwd(), "src/posts"));
const assetsDir = path.resolve(customAssetsDir || path.join(process.cwd(), "src/assets/images/posts")); const assetsDir = path.resolve(customAssetsDir || path.join(process.cwd(), "src/assets/images/posts"));
const isCustomEnv = Boolean(customSiteDir || customPostsDir || customAssetsDir);
return http.createServer((req, res) => { return http.createServer(async (req, res) => {
// Standard security headers // Standard security headers
res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("X-Frame-Options", "DENY"); res.setHeader("X-Frame-Options", "DENY");
@@ -130,7 +174,7 @@ export function createServer(customSiteDir, customPostsDir, customAssetsDir) {
} }
}); });
req.on("end", () => { req.on("end", async () => {
let payload; let payload;
try { try {
payload = JSON.parse(body); payload = JSON.parse(body);
@@ -182,6 +226,11 @@ export function createServer(customSiteDir, customPostsDir, customAssetsDir) {
} }
} }
// Rebuild static site with Eleventy if in standard environment
if (!isCustomEnv) {
await triggerEleventyBuild();
}
res.writeHead(200, { "Content-Type": "application/json" }); res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ res.end(JSON.stringify({
status: "published", status: "published",
@@ -228,19 +277,9 @@ export function createServer(customSiteDir, customPostsDir, customAssetsDir) {
fs.unlinkSync(compiledHtml); fs.unlinkSync(compiledHtml);
} }
// 3. Rebuild static collections (index.html, dispatches, tags) if in full Eleventy environment // 3. Rebuild static collections (index.html, dispatches, tags) with Eleventy
try { if (!isCustomEnv) {
if (fs.existsSync(path.join(process.cwd(), ".eleventy.js"))) { await triggerEleventyBuild();
exec("npx @11ty/eleventy", (err) => {
if (err) {
console.error("[Ingestion API] Error rebuilding site after delete:", err);
} else {
console.log(`[Ingestion API] Site rebuild complete after unpublishing '${cleanSlug}'.`);
}
});
}
} catch (rebuildErr) {
console.error("[Ingestion API] Failed to trigger Eleventy rebuild:", rebuildErr);
} }
res.writeHead(200, { "Content-Type": "application/json" }); res.writeHead(200, { "Content-Type": "application/json" });
+10 -1
View File
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import http from "node:http"; import http from "node:http";
import { createServer } from "../server.js"; import { createServer, triggerEleventyBuild } from "../server.js";
const TEST_SECRET = "super-secret-test-token"; const TEST_SECRET = "super-secret-test-token";
@@ -274,3 +274,12 @@ test("Ingestion API: adversarial protection against path traversal in slug and D
fs.rmSync(tmpPosts, { recursive: true, force: true }); fs.rmSync(tmpPosts, { recursive: true, force: true });
} }
}); });
test("Ingestion API: triggerEleventyBuild builds site and handles concurrency", async () => {
assert.strictEqual(typeof triggerEleventyBuild, "function");
// Trigger two concurrent calls to assert queuing does not crash or throw
const [b1, b2] = await Promise.all([triggerEleventyBuild(), triggerEleventyBuild()]);
assert.strictEqual(b1, undefined);
assert.strictEqual(b2, undefined);
});