diff --git a/.agents/rules/blog-system.md b/.agents/rules/blog-system.md
index 42af3e9..c7e16dd 100644
--- a/.agents/rules/blog-system.md
+++ b/.agents/rules/blog-system.md
@@ -54,3 +54,18 @@
- Wikilinks `[[slug]]` or `[[slug|Label]]` must be transformed into standard web URLs (`/posts/slug/`).
- Callout blocks (`> [!NOTE]`, `> [!WARNING]`, etc.) must render as terminal-style alert callouts.
- Images must be processed into `src/assets/images/posts/` and linked via standard ` ` or Markdown image syntax.
+
+---
+
+## 5. Test-Driven Development (TDD) & Adversarial Verification
+1. **Red-Green-Refactor Lifecycle:**
+ - Write tests in `test/*.test.js` using Node's native test runner (`node:test`, `node:assert/strict`).
+ - Test inputs before implementing filters, data loaders, or template macros.
+2. **Adversarial Test Coverage Required:**
+ - **Data & Configuration:** Test that missing required environment variables (e.g. `SITE_DOMAIN`) throw fatal errors.
+ - **Filters:** Test edge cases: empty strings, undefined values, single-word inputs, massive word counts, non-date objects.
+ - **Content Schemas:** Validate all Markdown files in `src/posts/` against frontmatter schemas.
+ - **Design Spec Compliance:** Automate regex/AST assertions on `src/assets/css/` ensuring zero violations of the "Do Not Use" list (`backdrop-filter`, `box-shadow`, `text-shadow`, gradients, floaty transitions).
+ - **Link & Anchor Integrity:** Verify that every compiled page has zero broken internal links and that all Table of Contents anchor links resolve to valid heading IDs.
+3. **Execution Invariant:**
+ - Always run `npm test` before committing. All tests must pass cleanly with 0 failures.
diff --git a/AGENTS.md b/AGENTS.md
index d945b50..317c787 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -30,6 +30,16 @@ Every component, style, and layout change must strictly adhere to [`sample-desig
- **Interactivity:** Instant binary hover state transitions (no 300ms easing); high-contrast 1px/2px focus outlines for keyboard navigation.
- **Typography:** IBM Plex Mono / JetBrains Mono for UI elements and code blocks; Inter for body prose.
+### 1.4 Strict Test-Driven Development (TDD) & Adversarial Verification Mandate
+- **Test-First Development:** For all new features, custom filters, template helpers, frontmatter schemas, or configuration changes, write automated tests asserting expected behaviors and edge cases *before* or alongside implementing the code.
+- **Adversarial Mindset:** Actively design tests to break assumptions:
+ - Missing environment variables (e.g. unset `SITE_DOMAIN` must throw).
+ - Malformed frontmatter (missing titles, invalid dates, empty tags).
+ - Edge-case inputs to filters (empty strings, giant numbers, special characters).
+ - Design rule violations (asserting 0 instances of `backdrop-filter`, `box-shadow`, gradients, or floaty animations).
+ - Broken internal links and missing anchor tags in generated HTML.
+- **Verification Gate:** Every code change must be verified by running `npm test`. No feature or commit is considered complete unless 100% of the test suite passes.
+
---
## 2. Code Modularity & Isolation Guidelines
diff --git a/eleventy.config.js b/eleventy.config.js
index d8fa67d..2afa0cd 100644
--- a/eleventy.config.js
+++ b/eleventy.config.js
@@ -1,7 +1,7 @@
import syntaxHighlight from "@11ty/eleventy-plugin-syntaxhighlight";
import markdownIt from "markdown-it";
import markdownItAnchor from "markdown-it-anchor";
-import { DateTime } from "luxon";
+import { filters } from "./src/_filters/index.js";
export default function (eleventyConfig) {
// 1. Static Asset Passthrough Copy
@@ -32,57 +32,10 @@ export default function (eleventyConfig) {
eleventyConfig.setLibrary("md", markdownLib);
- // 4. Custom Filters
- // Format readable dates: SEP 12, 2026
- eleventyConfig.addFilter("postDate", (dateObj) => {
- if (!dateObj) return "";
- return DateTime.fromJSDate(new Date(dateObj), { zone: "utc" })
- .toFormat("LLL dd, yyyy")
- .toUpperCase();
- });
-
- // ISO date format for
- eleventyConfig.addFilter("isoDate", (dateObj) => {
- if (!dateObj) return "";
- return DateTime.fromJSDate(new Date(dateObj), { zone: "utc" }).toISODate();
- });
-
- // Limit array items
- eleventyConfig.addFilter("limit", (arr, limit) => {
- if (!Array.isArray(arr)) return [];
- return arr.slice(0, limit);
- });
-
- // Filter out internal system tags
- eleventyConfig.addFilter("filterTagList", (tags) => {
- const internalTags = ["all", "nav", "post", "posts"];
- return (tags || []).filter((tag) => !internalTags.includes(tag.toLowerCase()));
- });
-
- // Reading time estimator (defaults to 200 words/min if frontmatter lacks readingTime)
- eleventyConfig.addFilter("readingTime", (text, frontmatterTime) => {
- if (frontmatterTime) return frontmatterTime;
- if (!text) return "1 MIN READ";
- const wordCount = text.trim().split(/\s+/).length;
- const minutes = Math.ceil(wordCount / 200);
- return `${minutes} MIN READ`;
- });
-
- // Extract H2 and H3 headings with IDs for dynamic Table of Contents
- eleventyConfig.addFilter("extractHeadings", (htmlContent) => {
- if (!htmlContent) return [];
- const headingRegex = /]*>(.*?)<\/h\1>/gis;
- const headings = [];
- let match;
- while ((match = headingRegex.exec(htmlContent)) !== null) {
- headings.push({
- level: parseInt(match[1], 10),
- id: match[2],
- title: match[3].replace(/<[^>]+>/g, "").trim()
- });
- }
- return headings;
- });
+ // 4. Custom Filters (imported from modular src/_filters/index.js)
+ for (const [name, fn] of Object.entries(filters)) {
+ eleventyConfig.addFilter(name, fn);
+ }
// 5. Custom Collections
// Chronological blog posts collection
diff --git a/package.json b/package.json
index d18863a..a8a0e59 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
"scripts": {
"dev": "eleventy --serve --watch",
"build": "eleventy",
+ "test": "node --test 'test/**/*.test.js'",
"clean": "rm -rf _site",
"start": "npm run dev"
},
diff --git a/src/_data/site.js b/src/_data/site.js
index 335b7cd..174a9b2 100644
--- a/src/_data/site.js
+++ b/src/_data/site.js
@@ -1,24 +1,26 @@
import fs from "node:fs";
import path from "node:path";
-// Load .env variables into process.env if present
-try {
- const envPath = path.resolve(process.cwd(), ".env");
- if (fs.existsSync(envPath)) {
- const envContent = fs.readFileSync(envPath, "utf-8");
- for (const line of envContent.split("\n")) {
- const trimmed = line.trim();
- if (trimmed && !trimmed.startsWith("#") && trimmed.includes("=")) {
- const [key, ...vals] = trimmed.split("=");
- const value = vals.join("=").trim().replace(/^["']|["']$/g, "");
- if (!process.env[key.trim()]) {
- process.env[key.trim()] = value;
+// Load .env variables into process.env if SITE_DOMAIN is not already defined
+if (!process.env.SITE_DOMAIN) {
+ try {
+ const envPath = path.resolve(process.cwd(), ".env");
+ if (fs.existsSync(envPath)) {
+ const envContent = fs.readFileSync(envPath, "utf-8");
+ for (const line of envContent.split("\n")) {
+ const trimmed = line.trim();
+ if (trimmed && !trimmed.startsWith("#") && trimmed.includes("=")) {
+ const [key, ...vals] = trimmed.split("=");
+ const value = vals.join("=").trim().replace(/^["']|["']$/g, "");
+ if (!process.env[key.trim()]) {
+ process.env[key.trim()] = value;
+ }
}
}
}
+ } catch (e) {
+ // Silent fallback
}
-} catch (e) {
- // Silent fallback
}
const siteDomain = process.env.SITE_DOMAIN;
diff --git a/src/_filters/index.js b/src/_filters/index.js
new file mode 100644
index 0000000..53e3640
--- /dev/null
+++ b/src/_filters/index.js
@@ -0,0 +1,74 @@
+import { DateTime } from "luxon";
+
+/**
+ * Custom Eleventy Filters for River Rooks Blog
+ */
+
+export const postDate = (dateObj) => {
+ if (!dateObj) return "";
+ const date = typeof dateObj === "string" ? new Date(dateObj) : dateObj;
+ const dt = DateTime.fromJSDate(date, { zone: "utc" });
+ if (!dt.isValid) return "";
+ return dt.toFormat("LLL dd, yyyy").toUpperCase();
+};
+
+export const isoDate = (dateObj) => {
+ if (!dateObj) return "";
+ const date = typeof dateObj === "string" ? new Date(dateObj) : dateObj;
+ const dt = DateTime.fromJSDate(date, { zone: "utc" });
+ if (!dt.isValid) return "";
+ return dt.toISODate() || "";
+};
+
+export const limit = (arr, count) => {
+ if (!Array.isArray(arr)) return [];
+ if (count <= 0) return [];
+ return arr.slice(0, count);
+};
+
+export const filterTagList = (tags) => {
+ const internalTags = ["all", "nav", "post", "posts"];
+ if (!tags) return [];
+ const tagArray = Array.isArray(tags) ? tags : [tags];
+ return tagArray.filter((tag) => {
+ if (typeof tag !== "string") return false;
+ const clean = tag.replace(/^#/, "").toLowerCase();
+ return !internalTags.includes(clean);
+ });
+};
+
+export const readingTime = (text, frontmatterTime) => {
+ if (frontmatterTime) return frontmatterTime;
+ if (!text || typeof text !== "string") return "1 MIN READ";
+ const clean = text.replace(/<[^>]+>/g, "").trim();
+ if (!clean) return "1 MIN READ";
+ const words = clean.split(/\s+/).filter(Boolean).length;
+ const minutes = Math.max(1, Math.ceil(words / 200));
+ return `${minutes} MIN READ`;
+};
+
+export const extractHeadings = (htmlContent) => {
+ if (!htmlContent || typeof htmlContent !== "string") return [];
+ const headingRegex = /]*>(.*?)<\/h\1>/gis;
+ const headings = [];
+ let match;
+ while ((match = headingRegex.exec(htmlContent)) !== null) {
+ headings.push({
+ level: parseInt(match[1], 10),
+ id: match[2],
+ title: match[3].replace(/<[^>]+>/g, "").trim()
+ });
+ }
+ return headings;
+};
+
+export const filters = {
+ postDate,
+ isoDate,
+ limit,
+ filterTagList,
+ readingTime,
+ extractHeadings
+};
+
+export default filters;
diff --git a/test/build-integrity.test.js b/test/build-integrity.test.js
new file mode 100644
index 0000000..3d20b7f
--- /dev/null
+++ b/test/build-integrity.test.js
@@ -0,0 +1,83 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import { execSync } from "node:child_process";
+
+const siteDir = path.resolve("_site");
+
+function getAllHtmlFiles(dir) {
+ let results = [];
+ const list = fs.readdirSync(dir);
+ for (const file of list) {
+ const filePath = path.join(dir, file);
+ const stat = fs.statSync(filePath);
+ if (stat && stat.isDirectory()) {
+ results = results.concat(getAllHtmlFiles(filePath));
+ } else if (file.endsWith(".html")) {
+ results.push(filePath);
+ }
+ }
+ return results;
+}
+
+test("Build & Link Integrity: Output inspection and link verification", () => {
+ // Ensure fresh build exists
+ if (!fs.existsSync(siteDir)) {
+ execSync("npm run build", { stdio: "pipe" });
+ }
+
+ // 1. Core Pages Exist
+ assert.ok(fs.existsSync(path.join(siteDir, "index.html")), "Root _site/index.html must exist");
+ assert.ok(fs.existsSync(path.join(siteDir, "dispatches/index.html")), "_site/dispatches/index.html must exist");
+ assert.ok(fs.existsSync(path.join(siteDir, "assets/css/main.css")), "_site/assets/css/main.css must exist");
+
+ const htmlFiles = getAllHtmlFiles(siteDir);
+ assert.ok(htmlFiles.length >= 4, "Must compile all core pages and articles");
+
+ for (const htmlFile of htmlFiles) {
+ const relHtml = path.relative(siteDir, htmlFile);
+ const content = fs.readFileSync(htmlFile, "utf-8");
+
+ // 2. HTML Boilerplate Invariants
+ assert.match(content, //i, `${relHtml} must contain `);
+ assert.match(content, / /i, `${relHtml} must link to main.css`);
+
+ // 3. Internal Link Integrity (No 404s!)
+ const internalLinks = [...content.matchAll(/href="(\/[^"#?]*)"/g)].map((m) => m[1]);
+ for (const link of internalLinks) {
+ if (link.startsWith("/feed.xml")) continue; // dynamic RSS route
+ // Normalize link to file on disk: /dispatches/ -> _site/dispatches/index.html or _site/dispatches.html
+ let targetPath;
+ if (link.endsWith("/")) {
+ targetPath = path.join(siteDir, link, "index.html");
+ } else if (path.extname(link)) {
+ targetPath = path.join(siteDir, link);
+ } else {
+ targetPath = path.join(siteDir, `${link}.html`);
+ if (!fs.existsSync(targetPath)) {
+ targetPath = path.join(siteDir, link, "index.html");
+ }
+ }
+
+ assert.ok(
+ fs.existsSync(targetPath),
+ `Broken internal link in ${relHtml}: href="${link}" resolves to missing file: ${targetPath}`
+ );
+ }
+
+ // 4. Anchor Link Integrity (TOC anchor links match actual heading IDs)
+ const anchorLinks = [...content.matchAll(/href="#([^"]+)"/g)].map((m) => m[1]);
+ for (const anchor of anchorLinks) {
+ // Ignore skip-to-content or standard body anchors
+ if (anchor === "main-content") continue;
+ const expectedIdRegex = new RegExp(`id="${anchor}"`, "i");
+ assert.match(
+ content,
+ expectedIdRegex,
+ `Broken TOC anchor link in ${relHtml}: href="#${anchor}" has no corresponding id="${anchor}"`
+ );
+ }
+ }
+});
diff --git a/test/design-rules.test.js b/test/design-rules.test.js
new file mode 100644
index 0000000..eac00bf
--- /dev/null
+++ b/test/design-rules.test.js
@@ -0,0 +1,86 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+
+const cssDir = path.resolve("src/assets/css");
+
+function getAllCssFiles(dir) {
+ let results = [];
+ const list = fs.readdirSync(dir);
+ for (const file of list) {
+ const filePath = path.join(dir, file);
+ const stat = fs.statSync(filePath);
+ if (stat && stat.isDirectory()) {
+ results = results.concat(getAllCssFiles(filePath));
+ } else if (file.endsWith(".css")) {
+ results.push(filePath);
+ }
+ }
+ return results;
+}
+
+test("Design Specification: Strict Anti-Pattern Regression Testing", () => {
+ const cssFiles = getAllCssFiles(cssDir);
+ assert.ok(cssFiles.length >= 5, "Must inspect all modular CSS files");
+
+ for (const file of cssFiles) {
+ const relPath = path.relative(process.cwd(), file);
+ const content = fs.readFileSync(file, "utf-8");
+
+ // 1. Anti-Pattern: NO Glassmorphism (backdrop-filter)
+ assert.doesNotMatch(
+ content,
+ /backdrop-filter\s*:/i,
+ `Violation in ${relPath}: Glassmorphism (backdrop-filter) is strictly forbidden`
+ );
+
+ // 2. Anti-Pattern: NO Drop Shadows or Glows (box-shadow, text-shadow)
+ // Allowed: box-shadow: none; or text-shadow: none;
+ const shadowMatches = content.match(/(box-shadow|text-shadow)\s*:\s*([^;]+);/gi) || [];
+ for (const match of shadowMatches) {
+ assert.match(
+ match,
+ /(box-shadow|text-shadow)\s*:\s*none\s*;/i,
+ `Violation in ${relPath}: Drop shadows and glows are forbidden (found: '${match}')`
+ );
+ }
+
+ // 3. Anti-Pattern: NO Gradients (linear, radial, conic)
+ assert.doesNotMatch(
+ content,
+ /(linear-gradient|radial-gradient|conic-gradient)/i,
+ `Violation in ${relPath}: Gradients are strictly forbidden`
+ );
+
+ // 4. Anti-Pattern: NO Rounded Bubbles (border-radius capped at 4px max)
+ const radiusMatches = content.match(/border-radius\s*:\s*([^;]+);/gi) || [];
+ for (const match of radiusMatches) {
+ // Check for pixel values > 4px (e.g. 5px, 6px, 8px, 12px, 20px)
+ const pxMatch = match.match(/(\d+)\s*px/i);
+ if (pxMatch) {
+ const px = parseInt(pxMatch[1], 10);
+ assert.ok(
+ px <= 4,
+ `Violation in ${relPath}: Border radius must be 0px to 4px max, found: ${px}px in '${match}'`
+ );
+ }
+ // Check for percentages (e.g. 50%)
+ assert.doesNotMatch(
+ match,
+ /\d+%/,
+ `Violation in ${relPath}: Rounded pill/circular percentages forbidden in '${match}'`
+ );
+ }
+
+ // 5. Anti-Pattern: NO Float/Spring Transitions (Must be instant snaps)
+ const transitionMatches = content.match(/transition\s*:\s*([^;]+);/gi) || [];
+ for (const match of transitionMatches) {
+ assert.match(
+ match,
+ /transition\s*:\s*(none(\s*!important)?|0s\s*);/i,
+ `Violation in ${relPath}: Transitions must be instant (none or 0s), found: '${match}'`
+ );
+ }
+ }
+});
diff --git a/test/filters.test.js b/test/filters.test.js
new file mode 100644
index 0000000..8e2c7f8
--- /dev/null
+++ b/test/filters.test.js
@@ -0,0 +1,107 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ postDate,
+ isoDate,
+ limit,
+ filterTagList,
+ readingTime,
+ extractHeadings
+} from "../src/_filters/index.js";
+
+test("Filters - postDate: formats valid dates", () => {
+ assert.equal(postDate("2026-07-20"), "JUL 20, 2026");
+ assert.equal(postDate(new Date("2026-12-31T00:00:00Z")), "DEC 31, 2026");
+});
+
+test("Filters - postDate: handles adversarial / invalid inputs gracefully", () => {
+ assert.equal(postDate(null), "");
+ assert.equal(postDate(undefined), "");
+ assert.equal(postDate("invalid-date-string"), "");
+ assert.equal(postDate(""), "");
+});
+
+test("Filters - isoDate: formats ISO strings", () => {
+ assert.equal(isoDate("2026-07-20"), "2026-07-20");
+ assert.equal(isoDate(new Date("2026-05-15T00:00:00Z")), "2026-05-15");
+});
+
+test("Filters - isoDate: handles adversarial / invalid inputs gracefully", () => {
+ assert.equal(isoDate(null), "");
+ assert.equal(isoDate(undefined), "");
+ assert.equal(isoDate("not-a-date"), "");
+});
+
+test("Filters - limit: slices arrays correctly", () => {
+ assert.deepEqual(limit([1, 2, 3, 4, 5], 3), [1, 2, 3]);
+ assert.deepEqual(limit([1, 2], 5), [1, 2]);
+});
+
+test("Filters - limit: handles adversarial inputs (negative, 0, non-array)", () => {
+ assert.deepEqual(limit([1, 2, 3], 0), []);
+ assert.deepEqual(limit([1, 2, 3], -5), []);
+ assert.deepEqual(limit(null, 3), []);
+ assert.deepEqual(limit("not-an-array", 3), []);
+});
+
+test("Filters - filterTagList: removes internal system tags", () => {
+ const input = ["firmware", "posts", "security", "all", "nav", "post"];
+ assert.deepEqual(filterTagList(input), ["firmware", "security"]);
+});
+
+test("Filters - filterTagList: handles adversarial inputs (# prefix, casing, non-string)", () => {
+ const input = ["#firmware", "#POSTS", "ALL", "#homelab", 42, null, undefined];
+ assert.deepEqual(filterTagList(input), ["#firmware", "#homelab"]);
+ assert.deepEqual(filterTagList(null), []);
+ assert.deepEqual(filterTagList(undefined), []);
+});
+
+test("Filters - readingTime: calculates reading time from word count", () => {
+ // 199 words -> 1 min
+ const shortText = "word ".repeat(199);
+ assert.equal(readingTime(shortText), "1 MIN READ");
+
+ // 201 words -> 2 min
+ const mediumText = "word ".repeat(201);
+ assert.equal(readingTime(mediumText), "2 MIN READ");
+
+ // 1000 words -> 5 min
+ const longText = "word ".repeat(1000);
+ assert.equal(readingTime(longText), "5 MIN READ");
+});
+
+test("Filters - readingTime: respects explicit frontmatter override", () => {
+ assert.equal(readingTime("any text", "15 MIN READ"), "15 MIN READ");
+});
+
+test("Filters - readingTime: handles adversarial HTML tags without inflating word count", () => {
+ // HTML tags should be stripped before counting words
+ const htmlText = "";
+ assert.equal(readingTime(htmlText), "1 MIN READ");
+ assert.equal(readingTime(""), "1 MIN READ");
+ assert.equal(readingTime(null), "1 MIN READ");
+});
+
+test("Filters - extractHeadings: extracts H2 and H3 with IDs and titles", () => {
+ const html = `
+ Title To Ignore
+ 1.0 First Section
+ Some text
+ 1.1 Sub Section
+ Ignore H4
+ 2.0 Second Section with code and span
+ `;
+
+ const headings = extractHeadings(html);
+ assert.equal(headings.length, 3);
+ assert.deepEqual(headings[0], { level: 2, id: "section-1", title: "1.0 First Section" });
+ assert.deepEqual(headings[1], { level: 3, id: "sub-1", title: "1.1 Sub Section" });
+ assert.deepEqual(headings[2], { level: 2, id: "section-2", title: "2.0 Second Section with code and span" });
+});
+
+test("Filters - extractHeadings: handles adversarial HTML inputs gracefully", () => {
+ assert.deepEqual(extractHeadings(""), []);
+ assert.deepEqual(extractHeadings(null), []);
+ assert.deepEqual(extractHeadings(undefined), []);
+ assert.deepEqual(extractHeadings("No headings here
"), []);
+});
diff --git a/test/frontmatter-schema.test.js b/test/frontmatter-schema.test.js
new file mode 100644
index 0000000..cfd8504
--- /dev/null
+++ b/test/frontmatter-schema.test.js
@@ -0,0 +1,71 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+
+const postsDir = path.resolve("src/posts");
+
+test("Content Contract: All posts in src/posts/ satisfy frontmatter schema", () => {
+ assert.ok(fs.existsSync(postsDir), "src/posts directory must exist");
+ const postFiles = fs.readdirSync(postsDir).filter((f) => f.endsWith(".md"));
+
+ assert.ok(postFiles.length > 0, "There must be at least one published post");
+
+ for (const file of postFiles) {
+ const filePath = path.join(postsDir, file);
+ const content = fs.readFileSync(filePath, "utf-8");
+
+ // 1. Filename Convention: YYYY-MM-DD-slug.md
+ assert.match(
+ file,
+ /^\d{4}-\d{2}-\d{2}-[a-z0-9-]+\.md$/,
+ `Post filename '${file}' must follow 'YYYY-MM-DD-slug.md' naming standard`
+ );
+
+ // 2. YAML Frontmatter Boundary check
+ assert.match(
+ content,
+ /^---\n([\s\S]*?)\n---/,
+ `Post '${file}' must start with valid YAML frontmatter delimiters`
+ );
+
+ // Extract frontmatter block
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
+ const fmText = fmMatch[1];
+
+ // 3. Title Invariant
+ assert.match(
+ fmText,
+ /title:\s*"[^"]{5,}"/,
+ `Post '${file}' must contain a valid title of at least 5 characters`
+ );
+
+ // 4. Date Invariant (ISO Format)
+ assert.match(
+ fmText,
+ /date:\s*\d{4}-\d{2}-\d{2}/,
+ `Post '${file}' must define an ISO date (YYYY-MM-DD)`
+ );
+
+ // 5. Layout Invariant
+ assert.match(
+ fmText,
+ /layout:\s*layouts\/post\.njk/,
+ `Post '${file}' must specify 'layout: layouts/post.njk'`
+ );
+
+ // 6. Tags Invariant
+ assert.match(
+ fmText,
+ /tags:\s*\n(\s+-\s+[a-z0-9-]+\n)+/,
+ `Post '${file}' must specify a non-empty YAML tags list`
+ );
+
+ // 7. Excerpt Invariant
+ assert.match(
+ fmText,
+ /excerpt:\s*"[^"]{20,}"/,
+ `Post '${file}' must provide a substantive excerpt of at least 20 characters`
+ );
+ }
+});
diff --git a/test/site-config.test.js b/test/site-config.test.js
new file mode 100644
index 0000000..f48b6fd
--- /dev/null
+++ b/test/site-config.test.js
@@ -0,0 +1,64 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { execSync } from "node:child_process";
+
+test("Site Config: throws explicit error when SITE_DOMAIN is unset", () => {
+ // Run isolated Node process with empty environment to verify error throw
+ const cmd = `node -e '
+ process.env.SITE_DOMAIN = "";
+ delete process.env.SITE_DOMAIN;
+ // mock empty cwd without .env
+ process.chdir("/tmp");
+ import("${process.cwd()}/src/_data/site.js")
+ .then(() => process.exit(0))
+ .catch(err => {
+ if (err.message.includes("FATAL CONFIGURATION ERROR: SITE_DOMAIN")) {
+ process.exit(42);
+ }
+ process.exit(1);
+ });
+ '`;
+
+ try {
+ execSync(cmd, { stdio: "pipe" });
+ assert.fail("Should have thrown error when SITE_DOMAIN is missing");
+ } catch (err) {
+ assert.equal(err.status, 42, "Process exited with status 42 (expected error caught)");
+ }
+});
+
+test("Site Config: correctly parses SITE_DOMAIN and derives service subdomains", async () => {
+ process.env.SITE_DOMAIN = "testcluster.internal";
+ delete process.env.SITE_URL;
+
+ // Import site configuration dynamically
+ const { default: site } = await import(`../src/_data/site.js?t=${Date.now()}`);
+
+ assert.equal(site.domain, "testcluster.internal");
+ assert.equal(site.url, "https://testcluster.internal");
+
+ // Verify dynamic subdomains
+ const gitService = site.services.find((s) => s.name === "Forgejo Git");
+ assert.ok(gitService, "Forgejo Git service exists");
+ assert.equal(gitService.url, "https://git.testcluster.internal");
+ assert.equal(gitService.tag, "git.testcluster.internal:2222");
+
+ const chefService = site.services.find((s) => s.name === "CyberChef");
+ assert.ok(chefService, "CyberChef service exists");
+ assert.equal(chefService.url, "https://chef.testcluster.internal");
+
+ // Verify navigation Git Forge URL
+ const navGit = site.nav.find((n) => n.label === "Git Forge");
+ assert.ok(navGit, "Nav Git Forge link exists");
+ assert.equal(navGit.url, "https://git.testcluster.internal");
+});
+
+test("Site Config: respects explicit SITE_URL override", async () => {
+ process.env.SITE_DOMAIN = "riverrooks.dev";
+ process.env.SITE_URL = "http://localhost:8080";
+
+ const { default: site } = await import(`../src/_data/site.js?t=${Date.now()}`);
+
+ assert.equal(site.domain, "riverrooks.dev");
+ assert.equal(site.url, "http://localhost:8080");
+});