feat: adopt strict TDD framework and add adversarial test suite
- Update AGENTS.md and .agents/rules/blog-system.md with strict TDD mandate - Extract Eleventy custom filters into modular src/_filters/index.js for unit testing - Add npm test script using Node.js native test runner (node:test) - Create test/site-config.test.js for environment validation and fatal error assertions - Create test/filters.test.js for adversarial filter edge cases (dates, limits, reading time, headings) - Create test/frontmatter-schema.test.js enforcing post metadata contracts - Create test/design-rules.test.js enforcing strict anti-patterns (no glassmorphism, no shadows, max 4px radius) - Create test/build-integrity.test.js checking static output, zero 404 links, and TOC anchor targets
This commit is contained in:
@@ -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 `<img>` 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-51
@@ -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 <time datetime="...">
|
||||
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([23])\s+id="([^"]+)"[^>]*>(.*?)<\/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()
|
||||
});
|
||||
// 4. Custom Filters (imported from modular src/_filters/index.js)
|
||||
for (const [name, fn] of Object.entries(filters)) {
|
||||
eleventyConfig.addFilter(name, fn);
|
||||
}
|
||||
return headings;
|
||||
});
|
||||
|
||||
// 5. Custom Collections
|
||||
// Chronological blog posts collection
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
+5
-3
@@ -1,8 +1,9 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
// Load .env variables into process.env if present
|
||||
try {
|
||||
// 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");
|
||||
@@ -17,8 +18,9 @@ try {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e) {
|
||||
// Silent fallback
|
||||
}
|
||||
}
|
||||
|
||||
const siteDomain = process.env.SITE_DOMAIN;
|
||||
|
||||
@@ -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([23])\s+id="([^"]+)"[^>]*>(.*?)<\/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;
|
||||
@@ -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, /<!DOCTYPE html>/i, `${relHtml} must contain <!DOCTYPE html>`);
|
||||
assert.match(content, /<meta\s+name="viewport"/i, `${relHtml} must have viewport meta tag`);
|
||||
assert.match(content, /<link\s+rel="stylesheet"\s+href="\/assets\/css\/main\.css">/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}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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}'`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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 = "<div><span>word</span> <p>another</p></div>";
|
||||
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 = `
|
||||
<h1>Title To Ignore</h1>
|
||||
<h2 id="section-1">1.0 First Section</h2>
|
||||
<p>Some text</p>
|
||||
<h3 id="sub-1">1.1 Sub Section</h3>
|
||||
<h4 id="h4-ignore">Ignore H4</h4>
|
||||
<h2 id="section-2">2.0 Second Section with <code>code</code> and <span class="tag">span</span></h2>
|
||||
`;
|
||||
|
||||
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("<p>No headings here</p>"), []);
|
||||
});
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user