fix(ingestion): trigger deterministic Eleventy builds on publish and unpublish
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user