Compare commits
34
Commits
04a39ca852
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0967d540cf | ||
|
|
59f807c45b | ||
|
|
07de3b2782 | ||
|
|
c8cd5fd0d9 | ||
|
|
7f12861511 | ||
|
|
515e52b274 | ||
|
|
963521b3a9 | ||
|
|
efb0fbea94 | ||
|
|
6c1384b661 | ||
|
|
98e46644c0 | ||
|
|
569152a7be | ||
|
|
b2fc93c8e6 | ||
|
|
b239f61480 | ||
|
|
93fe924b74 | ||
|
|
c211ad3a04 | ||
|
|
dd866427f0 | ||
|
|
2c22255855 | ||
|
|
f77b43c766 | ||
|
|
835ac40cd2 | ||
|
|
31f93f559d | ||
|
|
6f3bf4bf89 | ||
|
|
8448c16ef7 | ||
|
|
90955fed5b | ||
|
|
23bd6f6ba7 | ||
|
|
22f78da4cb | ||
|
|
417ec30bfd | ||
|
|
f30d636bba | ||
|
|
cf16e1f60d | ||
|
|
3a21998d60 | ||
|
|
9aa0ada81d | ||
|
|
7bf4d98fc0 | ||
|
|
73603bc61b | ||
|
|
bd6358cf1c | ||
|
|
42e7e0f1ff |
@@ -65,6 +65,7 @@
|
|||||||
excerpt: "Single paragraph summary"
|
excerpt: "Single paragraph summary"
|
||||||
readingTime: "X MIN READ"
|
readingTime: "X MIN READ"
|
||||||
draft: false
|
draft: false
|
||||||
|
hidden: false # optional: true unlists post from public feeds/lists, accessible via direct link only
|
||||||
```
|
```
|
||||||
2. **Obsidian Normalization:**
|
2. **Obsidian Normalization:**
|
||||||
- Wikilinks `[[slug]]` or `[[slug|Label]]` must be transformed into standard web URLs (`/posts/slug/`).
|
- Wikilinks `[[slug]]` or `[[slug|Label]]` must be transformed into standard web URLs (`/posts/slug/`).
|
||||||
@@ -74,14 +75,17 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 6. Test-Driven Development (TDD) & Adversarial Verification
|
## 6. Test-Driven Development (TDD) & Adversarial Verification
|
||||||
1. **Red-Green-Refactor Lifecycle:**
|
1. **Strict Pre-Implementation Testing (Tests Before Functional Code):**
|
||||||
- Write tests in `test/*.test.js` using Node's native test runner (`node:test`, `node:assert/strict`).
|
- Test cases MUST ALWAYS be written in `test/*.test.js` before writing or modifying any functional code (filters, templates, server routes, ingestion pipeline, data loaders, scripts).
|
||||||
- Test inputs before implementing filters, data loaders, or template macros.
|
- Never implement or modify functional or production code prior to writing and establishing corresponding automated tests.
|
||||||
2. **Adversarial Test Coverage Required:**
|
2. **Mandatory Adversarial Test Coverage:**
|
||||||
- **Data & Configuration:** Test that missing required environment variables (e.g. `SITE_DOMAIN`) throw fatal errors.
|
- Standard behavior for any functional change MUST ALWAYS include adversarial testing specifically designed to stress assumptions and break the system:
|
||||||
- **Filters:** Test edge cases: empty strings, undefined values, single-word inputs, massive word counts, non-date objects.
|
- **Data & Configuration:** Test that missing required environment variables (e.g. `SITE_DOMAIN`) throw fatal errors.
|
||||||
- **Content Schemas:** Validate all Markdown files in `src/posts/` against frontmatter schemas.
|
- **Filters & Logic:** Test edge cases: empty strings, undefined/null values, unexpected types, single-word inputs, massive word counts, non-date objects.
|
||||||
- **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).
|
- **Security & Integrity:** Test path traversals, malformed JSON, unauthorized tokens, malicious payloads.
|
||||||
- **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.
|
- **Content Schemas:** Validate all Markdown files in `src/posts/` against frontmatter schemas.
|
||||||
3. **Execution Invariant:**
|
- **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).
|
||||||
- Always run `npm test` before committing. All tests must pass cleanly with 0 failures.
|
- **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. **Pre-Presentation Verification Gate:**
|
||||||
|
- Before presenting the final output to the user, run `npm test`.
|
||||||
|
- All test cases must pass before presenting the final output. Presenting unverified output or code with failing tests is strictly prohibited.
|
||||||
|
|||||||
@@ -99,8 +99,13 @@ When writing or modifying Eleventy files:
|
|||||||
|
|
||||||
## 4. Validation & Verification
|
## 4. Validation & Verification
|
||||||
|
|
||||||
Run these commands to test Eleventy builds:
|
Always follow the project TDD standard: write automated tests (including adversarial edge cases) in `test/` before modifying functional templates, filters, or configuration. Ensure all test cases pass before presenting the final output.
|
||||||
|
|
||||||
|
Run these commands to test and verify builds:
|
||||||
```bash
|
```bash
|
||||||
|
# Run complete test suite (unit tests, filters, server, design rules, build integrity)
|
||||||
|
npm test
|
||||||
|
|
||||||
# Validate Eleventy configuration and build static output
|
# Validate Eleventy configuration and build static output
|
||||||
npx @11ty/eleventy --dryrun
|
npx @11ty/eleventy --dryrun
|
||||||
|
|
||||||
@@ -110,3 +115,4 @@ npx @11ty/eleventy
|
|||||||
# Launch local dev server with auto-refresh
|
# Launch local dev server with auto-refresh
|
||||||
npx @11ty/eleventy --serve
|
npx @11ty/eleventy --serve
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ This skill guides the agent in implementing the custom Obsidian plugin and serve
|
|||||||
## 3. Obsidian Markdown Normalization Rules
|
## 3. Obsidian Markdown Normalization Rules
|
||||||
|
|
||||||
1. **Frontmatter Invariants:**
|
1. **Frontmatter Invariants:**
|
||||||
- Must contain `title`, `date`, `tags` (array), and optional `excerpt`, `readingTime`, `draft`.
|
- Must contain `title`, `date`, `tags` (array), and optional `excerpt`, `readingTime`, `draft`, `hidden`.
|
||||||
|
|
||||||
2. **Wikilinks Translation:**
|
2. **Wikilinks Translation:**
|
||||||
- `[[article-slug]]` $\rightarrow$ `[article-slug](/posts/article-slug/)`
|
- `[[article-slug]]` $\rightarrow$ `[article-slug](/posts/article-slug/)`
|
||||||
- `[[article-slug|Custom Label]]` $\rightarrow$ `[Custom Label](/posts/article-slug/)`
|
- `[[article-slug|Custom Label]]` $\rightarrow$ `[Custom Label](/posts/article-slug/)`
|
||||||
@@ -100,3 +101,13 @@ The Obsidian plugin runs inside Obsidian desktop/mobile and interacts with the O
|
|||||||
If using CouchDB-based self-hosted LiveSync:
|
If using CouchDB-based self-hosted LiveSync:
|
||||||
- A local watcher or CouchDB change listener daemon can be configured to detect updates to notes marked with `published: true` or in a dedicated `Blog/` folder.
|
- A local watcher or CouchDB change listener daemon can be configured to detect updates to notes marked with `published: true` or in a dedicated `Blog/` folder.
|
||||||
- When an update event fires, the daemon pulls the latest revision from the CouchDB database, runs the normalization pipeline, and saves it to `src/posts/`.
|
- When an update event fires, the daemon pulls the latest revision from the CouchDB database, runs the normalization pipeline, and saves it to `src/posts/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Validation & Verification
|
||||||
|
|
||||||
|
Always write automated test cases in `test/ingestion-api.test.js` before modifying or adding any functional synchronization or endpoint code:
|
||||||
|
- **Mandatory Adversarial Testing:** Include adversarial tests covering unauthorized tokens, invalid/missing frontmatter schemas, path traversals in slugs, and malformed base64 asset payloads.
|
||||||
|
- **Pre-Presentation Verification:** Run `npm test` and ensure all test cases pass before presenting the final output.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
name: standards-verification
|
||||||
|
description: >-
|
||||||
|
Use this skill sparingly when a new major function or feature has been implemented, when the user explicitly asks to verify standards, or as a debugging tool to analyze code issues. Targets only modified or relevant files via git status/diff and evaluates them against the project standards document.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Standards Verification Skill
|
||||||
|
|
||||||
|
This skill performs targeted architectural and stylistic compliance verification for new or modified code in this repository.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **Invocation Policy:** Call this skill **sparingly and only when needed**.
|
||||||
|
> **Valid Triggers:**
|
||||||
|
> 1. A new major function or architectural feature has been implemented.
|
||||||
|
> 2. The user asks to call it or requests standards verification.
|
||||||
|
> 3. An error, test failure, or regression has occurred, and you are using this skill as a debugging tool to determine where the code diverges from project standards.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Targeted Inspection Scope (Not the Whole Codebase)
|
||||||
|
|
||||||
|
Do **NOT** scan or audit the whole codebase unless specifically requested by the user or strictly necessary for whole-system refactors.
|
||||||
|
|
||||||
|
Instead, narrow the scope to targeted sections using Git:
|
||||||
|
|
||||||
|
1. **Check Modified Files:**
|
||||||
|
```bash
|
||||||
|
git status -s
|
||||||
|
```
|
||||||
|
2. **Inspect Exact Changes:**
|
||||||
|
```bash
|
||||||
|
git diff HEAD
|
||||||
|
```
|
||||||
|
Or for staged changes:
|
||||||
|
```bash
|
||||||
|
git diff --staged
|
||||||
|
```
|
||||||
|
3. **Identify Changed Sections:**
|
||||||
|
Focus only on the files, functions, templates, or styles touched in the current task.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Standards Evaluation Workflow
|
||||||
|
|
||||||
|
To verify compliance, read the dedicated project standards document:
|
||||||
|
- Authoritative document: [`.agents/standards.md`](file:///home/venus/code/blog/.agents/standards.md) (or local skill reference: [`references/standards.md`](file:///home/venus/code/blog/.agents/skills/standards-verification/references/standards.md)).
|
||||||
|
|
||||||
|
Evaluate the targeted changes against the core standard pillars:
|
||||||
|
|
||||||
|
1. **Design System & CSS Rules:**
|
||||||
|
- Are any anti-patterns present? Check for `backdrop-filter`, `box-shadow`, `text-shadow` (other than `none`), gradients, or transitions with delays/curves.
|
||||||
|
- Are border radii strictly bounded between `0px` and `4px`?
|
||||||
|
- Are colors utilizing CSS custom properties from `tokens.css`?
|
||||||
|
- Is styling modularized without inline `<style>` tags?
|
||||||
|
|
||||||
|
2. **Templating & Eleventy Architecture:**
|
||||||
|
- Does post rendering adhere to layout inheritance (`base.njk` -> `post.njk`)?
|
||||||
|
- Are collections (`posts`, `tagList`) properly filtered using `isPostPublic`?
|
||||||
|
- If a post is marked `hidden`, does the layout emit `<meta name="robots" content="noindex, nofollow">`?
|
||||||
|
|
||||||
|
3. **Content & Frontmatter Contracts:**
|
||||||
|
- Are required frontmatter keys (`title`, `date`, `tags`, `excerpt`) valid and conforming to schema length invariants?
|
||||||
|
- Are wikilinks and Obsidian attachments normalized?
|
||||||
|
|
||||||
|
4. **Server & Security Invariants:**
|
||||||
|
- Does `server.js` preserve security headers (`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`)?
|
||||||
|
- Is path traversal protection in place for all file-serving and API endpoints?
|
||||||
|
|
||||||
|
5. **Test-Driven Development (TDD) Invariants:**
|
||||||
|
- Were automated test cases written *before* functional code was modified?
|
||||||
|
- Are adversarial test cases included (boundary inputs, nulls, invalid configs)?
|
||||||
|
- Do all tests pass (`npm test`)?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Stylistic & Architectural Reporting
|
||||||
|
|
||||||
|
Compile a structured report highlighting:
|
||||||
|
- **Conformant Highlights:** Key standards cleanly satisfied.
|
||||||
|
- **Stylistic Issues & Deviations:** Any formatting inconsistencies, unneeded inline styles, or naming anomalies.
|
||||||
|
- **Remediation Steps:** Concrete, minimal code adjustments to bring the implementation into full compliance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Debugging with Standards Verification
|
||||||
|
|
||||||
|
When using this skill as a debugging tool to troubleshoot broken builds, failed tests, or runtime bugs:
|
||||||
|
|
||||||
|
1. **Locate the Error:** Inspect test failure outputs or server logs to isolate the failing component.
|
||||||
|
2. **Correlate with Standards:** Compare the problematic implementation against [`.agents/standards.md`](file:///home/venus/code/blog/.agents/standards.md).
|
||||||
|
3. **Explain the Discrepancy:** Specifically identify where the code is out of touch with project standards (e.g. missing input guards for `null`, collections bypassing `isPostPublic`, un-sanitized slug paths).
|
||||||
|
4. **Suggest Root Cause & Fix:** Suggest why those issues cause an error and provide the targeted fix.
|
||||||
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Technical Blog Project Standards & Architectural Specification
|
||||||
|
|
||||||
|
This document is a direct reference for the `standards-verification` skill, mirroring the authoritative project standards defined in [`.agents/standards.md`](file:///home/venus/code/blog/.agents/standards.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. User Content Sovereignty (Highest Priority)
|
||||||
|
- User custom writing, post titles, tags, navigation labels, and naming choices strictly supersede any boilerplate, skill instructions, or sample mockups.
|
||||||
|
- Never overwrite, sanitize away, or rename user-authored text without explicit instruction.
|
||||||
|
- Inconsistencies or typos in user writing must be flagged for clarification rather than unilaterally changed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Strict Test-Driven Development (TDD) & Adversarial Verification
|
||||||
|
- **Test-First Invariant:** Automated test cases in `test/*.test.js` MUST ALWAYS be written and established *before writing or modifying any functional code* (filters, templates, server routes, ingestion logic, build scripts). Never write functional code before tests.
|
||||||
|
- **Mandatory Adversarial Testing:** Standard behavior for every change must include adversarial tests specifically designed to falsify assumptions (missing env vars like `SITE_DOMAIN`, null/undefined/malformed inputs, boundary values, path traversals, design rule violations).
|
||||||
|
- **Pre-Presentation Verification Gate:** Run `npm test` before presenting the final output to the user. All test cases must pass before presenting the final output. Delivering unverified or failing code is strictly forbidden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Design System & CSS Specification
|
||||||
|
- **Color Palette:** Canvas `#0d0f12`, Surface `#121417`, Elevated `#1c1f26`, Borders `#2b303b`, Text `#e0e6f0`, Accents `#00f0ff` (Cyan), `#00e676` (Green), `#ffb700` (Amber), `#ff5252` (Red).
|
||||||
|
- **Hard Anti-Patterns:**
|
||||||
|
- NO glassmorphism (`backdrop-filter`).
|
||||||
|
- NO drop shadows or glows (`box-shadow`, `text-shadow`).
|
||||||
|
- NO linear or radial gradients.
|
||||||
|
- Border-radius capped at `0px` to `4px` maximum.
|
||||||
|
- Transitions must be instant binary snaps (`none` or `0s`).
|
||||||
|
- **Typography:** `IBM Plex Mono` / `JetBrains Mono` for UI and code; `Inter` for body prose.
|
||||||
|
- **CSS Modularity:** Zero inline `<style>` tags. Decompose into `tokens.css`, `reset.css`, `layout.css`, `components/*.css`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Templating & Eleventy Architecture
|
||||||
|
- **Layouts & Partials:** Root `layouts/base.njk`, `layouts/post.njk`, `layouts/page.njk`. Isolated partials in `_includes/partials/`.
|
||||||
|
- **Collection Filtering:** `collections.posts` and `collections.tagList` must filter through `isPostPublic(post)` (excluding `draft: true` and `hidden: true`).
|
||||||
|
- **Sorting:** Posts sorted via `postComparator` (pinned/featured first, then chronological descending).
|
||||||
|
- **Hidden Posts:** When `hidden: true`, page compiles to direct link but is excluded from public lists and injects `<meta name="robots" content="noindex, nofollow">`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Obsidian Content & Frontmatter Contract
|
||||||
|
- Required frontmatter: `title` (>= 5 chars), `date` or `created` (ISO), `tags` (array), `excerpt` (>= 20 chars).
|
||||||
|
- Optional: `readingTime`, `draft`, `hidden`, `pinned`, `featured`.
|
||||||
|
- Obsidian conversions: Wikilinks `[[slug|Label]]` -> `[Label](/posts/slug/)`, attachments `![[image.png]]` -> ``.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Server & Security Invariants
|
||||||
|
- `server.js`: Native Node HTTP, zero external runtime dependencies.
|
||||||
|
- Security headers: `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`.
|
||||||
|
- Path traversal defense: Sanitize and verify paths remain inside root.
|
||||||
|
- Healthcheck: `/healthz` returns 200 OK JSON `{ status: "ok", uptime: ... }`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Defensive Programming & Error Prevention
|
||||||
|
- Guard against `null`, `undefined`, primitives in all utility functions.
|
||||||
|
- Docker: `node:22-alpine`, non-root / clean entrypoint script as PID 1.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
|
||||||
|
console.log("\n[Standards Verification] Running targeted inspection on modified files...\n");
|
||||||
|
|
||||||
|
let statusOutput = "";
|
||||||
|
try {
|
||||||
|
statusOutput = execSync("git status -s", { encoding: "utf-8" }).trim();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error executing git status:", err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!statusOutput) {
|
||||||
|
console.log("✔ Working tree is clean. No changed files to inspect.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = statusOutput.split("\n");
|
||||||
|
const modifiedFiles = lines
|
||||||
|
.map((line) => line.slice(3).trim())
|
||||||
|
.filter((f) => fs.existsSync(f) && fs.statSync(f).isFile());
|
||||||
|
|
||||||
|
console.log(`Targeting ${modifiedFiles.length} modified file(s):`);
|
||||||
|
modifiedFiles.forEach((f) => console.log(` - ${f}`));
|
||||||
|
console.log("");
|
||||||
|
|
||||||
|
let issuesFound = 0;
|
||||||
|
|
||||||
|
for (const file of modifiedFiles) {
|
||||||
|
const content = fs.readFileSync(file, "utf-8");
|
||||||
|
|
||||||
|
// 1. CSS anti-patterns
|
||||||
|
if (file.endsWith(".css")) {
|
||||||
|
if (/backdrop-filter\s*:/i.test(content)) {
|
||||||
|
console.error(`✖ Violation in ${file}: Glassmorphism (backdrop-filter) is strictly forbidden.`);
|
||||||
|
issuesFound++;
|
||||||
|
}
|
||||||
|
const shadowMatches = content.match(/(box-shadow|text-shadow)\s*:\s*([^;]+);/gi) || [];
|
||||||
|
for (const match of shadowMatches) {
|
||||||
|
if (!/(box-shadow|text-shadow)\s*:\s*none\s*;/i.test(match)) {
|
||||||
|
console.error(`✖ Violation in ${file}: Drop shadows/glows forbidden: '${match}'`);
|
||||||
|
issuesFound++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (/(linear-gradient|radial-gradient|conic-gradient)/i.test(content)) {
|
||||||
|
console.error(`✖ Violation in ${file}: Gradients are strictly forbidden.`);
|
||||||
|
issuesFound++;
|
||||||
|
}
|
||||||
|
const radiusMatches = content.match(/border-radius\s*:\s*([^;]+);/gi) || [];
|
||||||
|
for (const match of radiusMatches) {
|
||||||
|
const pxMatch = match.match(/(\d+)\s*px/i);
|
||||||
|
if (pxMatch && parseInt(pxMatch[1], 10) > 4) {
|
||||||
|
console.error(`✖ Violation in ${file}: Border radius must be <= 4px: '${match}'`);
|
||||||
|
issuesFound++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Monolithic inline styles in templates
|
||||||
|
if (file.endsWith(".njk") || file.endsWith(".html")) {
|
||||||
|
if (/<style[^>]*>[\s\S]*?<\/style>/i.test(content)) {
|
||||||
|
console.error(`✖ Violation in ${file}: Monolithic inline <style> tags are forbidden. Modularize into src/assets/css/`);
|
||||||
|
issuesFound++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (issuesFound > 0) {
|
||||||
|
console.error(`\n✖ Standards Verification failed: ${issuesFound} issue(s) detected.\n`);
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log("✔ Targeted standards verification passed with 0 violations.\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Technical Blog Project Standards & Architectural Specification
|
||||||
|
|
||||||
|
This document defines the authoritative engineering, design, templating, and security standards for this technical publication codebase. All new implementations, pull requests, refactors, and debugging tasks must conform to these rules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. User Content Sovereignty (Highest Priority)
|
||||||
|
|
||||||
|
1. **User Edits Are Absolute Ground Truth:**
|
||||||
|
- The user's custom writing, post titles, tags, navigation labels, terminology, and naming choices strictly supersede any boilerplate, skill instructions, or sample mockups (`sample-designs/*`).
|
||||||
|
2. **No Unsolicited Modifications:**
|
||||||
|
- Never overwrite, sanitize away, or rename user-authored text without explicit instruction.
|
||||||
|
3. **Mandatory Clarification Gate:**
|
||||||
|
- Inconsistencies or typos in user writing must be flagged for clarification rather than unilaterally changed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Strict Test-Driven Development (TDD) & Adversarial Verification
|
||||||
|
|
||||||
|
1. **Test-First Invariant (Tests Before Code):**
|
||||||
|
- Automated test cases in `test/*.test.js` MUST ALWAYS be written and established *before writing or modifying any functional code* (filters, templates, server routes, ingestion logic, build scripts).
|
||||||
|
- Never implement or modify production code prior to establishing tests.
|
||||||
|
2. **Mandatory Adversarial Testing:**
|
||||||
|
- Every functional implementation must include adversarial tests designed to actively falsify assumptions:
|
||||||
|
- Missing or malformed configuration (e.g., unset `SITE_DOMAIN` must throw).
|
||||||
|
- Corrupt or unexpected inputs (empty strings, `null`, `undefined`, unexpected types, HTML injections, path traversals, giant payloads).
|
||||||
|
- Boundary values and edge cases.
|
||||||
|
- Design rule violation assertions (0 instances of forbidden CSS patterns).
|
||||||
|
- Link and anchor integrity checks.
|
||||||
|
3. **Pre-Presentation Verification Gate:**
|
||||||
|
- Before presenting the final output to the user, run `npm test`.
|
||||||
|
- All test cases must pass before presenting the final output. Delivering unverified or failing code is strictly forbidden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Design System & CSS Specification
|
||||||
|
|
||||||
|
Strict adherence to `sample-designs/design-specification.md` is mandatory.
|
||||||
|
|
||||||
|
### 3.1 Color Palette & Token Variables
|
||||||
|
All colors must use standard CSS custom properties defined in `src/assets/css/tokens.css`:
|
||||||
|
- Canvas: `var(--bg-canvas)` (`#0d0f12`)
|
||||||
|
- Surface: `var(--bg-surface)` (`#121417`)
|
||||||
|
- Elevated: `var(--bg-elevated)` (`#1c1f26`)
|
||||||
|
- Borders: `var(--border-color)` (`#2b303b`)
|
||||||
|
- Text: `var(--text-primary)` (`#e0e6f0`), `var(--text-muted)` (`#8a95a5`)
|
||||||
|
- Accents: `var(--accent-cyan)` (`#00f0ff`), `var(--accent-green)` (`#00e676`), `var(--accent-amber)` (`#ffb700`), `var(--accent-red)` (`#ff5252`)
|
||||||
|
|
||||||
|
### 3.2 Hard Anti-Patterns (Forbidden)
|
||||||
|
- **NO Glassmorphism:** Never use `backdrop-filter: blur(...)`.
|
||||||
|
- **NO Drop Shadows or Glows:** `box-shadow: none;` and `text-shadow: none;` only.
|
||||||
|
- **NO Gradients:** Never use `linear-gradient`, `radial-gradient`, or `conic-gradient`. Flat solid surfaces only.
|
||||||
|
- **NO Rounded Bubbles:** `border-radius` is strictly capped between `0px` and `4px` maximum.
|
||||||
|
- **NO Floaty / Delayed Transitions:** Hover and state transitions must be instant binary snaps (`transition: none;` or `transition: 0s;`).
|
||||||
|
|
||||||
|
### 3.3 Typography & Modularity
|
||||||
|
- UI & Code: `IBM Plex Mono`, `JetBrains Mono`.
|
||||||
|
- Body Prose: `Inter`.
|
||||||
|
- CSS Modularity: Zero monolithic inline `<style>` tags in HTML. Decompose styles into `tokens.css`, `reset.css`, `layout.css`, and `components/*.css`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Templating & Eleventy Architecture
|
||||||
|
|
||||||
|
1. **Layout Inheritance (Nunjucks):**
|
||||||
|
- Root layout: `src/_includes/layouts/base.njk`.
|
||||||
|
- Post layout: `src/_includes/layouts/post.njk`.
|
||||||
|
- Page layout: `src/_includes/layouts/page.njk`.
|
||||||
|
- Modular reusable partials in `src/_includes/partials/` (`global-bar.njk`, `hero-tile.njk`, `post-card.njk`, `stats-sidebar.njk`, `code-box.njk`, `footer.njk`).
|
||||||
|
2. **Collection Invariants:**
|
||||||
|
- `collections.posts`: Must filter through `isPostPublic(post)` (excluding both `draft: true` and `hidden: true`) and sort using `postComparator` (prioritizing pinned/featured posts, then chronological descending).
|
||||||
|
- `collections.tagList`: Must filter through `isPostPublic(post)` so draft or unlisted tags never leak into public category filters.
|
||||||
|
3. **Hidden / Unlisted Post Contract:**
|
||||||
|
- When frontmatter specifies `hidden: true`:
|
||||||
|
- Excluded from public lists, indices, and tag aggregates.
|
||||||
|
- Static page STILL compiles to `/posts/<slug>/index.html` and is accessible via direct URL.
|
||||||
|
- Emits `<meta name="robots" content="noindex, nofollow">` in `<head>` to prevent search indexing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Obsidian Content & Frontmatter Contract
|
||||||
|
|
||||||
|
1. **Frontmatter Schema:**
|
||||||
|
```yaml
|
||||||
|
title: "Article Title" # Required: string (>= 5 chars)
|
||||||
|
date: 2026-09-23 # Required: ISO YYYY-MM-DD (or 'created')
|
||||||
|
tags: ["#tag1", "#tag2"] # Required: non-empty YAML array
|
||||||
|
excerpt: "Article summary..." # Required: string (>= 20 chars)
|
||||||
|
readingTime: "5 MIN READ" # Optional: explicit override
|
||||||
|
draft: false # Optional: boolean
|
||||||
|
hidden: false # Optional: boolean (share by link only)
|
||||||
|
pinned: false # Optional: boolean (prioritized to top)
|
||||||
|
featured: false # Optional: boolean (featured pillar)
|
||||||
|
```
|
||||||
|
2. **Obsidian Syntax Normalization:**
|
||||||
|
- Wikilinks: `[[target-slug|Custom Label]]` $\rightarrow$ `[Custom Label](/posts/target-slug/)`.
|
||||||
|
- Wikilinks simple: `[[target-slug]]` $\rightarrow$ `[target-slug](/posts/target-slug/)`.
|
||||||
|
- Attachments: `![[image.png]]` $\rightarrow$ ``.
|
||||||
|
- Terminal alert callouts for `> [!NOTE]`, `> [!WARNING]`, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Server & Security Invariants
|
||||||
|
|
||||||
|
1. **Native Runtime:**
|
||||||
|
- `server.js` uses native Node standard library (`node:http`, `node:fs`, `node:path`) with zero external runtime dependencies.
|
||||||
|
2. **Security Headers on All Responses:**
|
||||||
|
- `X-Content-Type-Options: nosniff`
|
||||||
|
- `X-Frame-Options: DENY`
|
||||||
|
- `Referrer-Policy: strict-origin-when-cross-origin`
|
||||||
|
3. **Path Traversal Defense:**
|
||||||
|
- Sanitize all requested file paths using `path.resolve` and verify the resolved path remains rooted inside the target directory. Block `..` and malicious slug sequences.
|
||||||
|
4. **Healthcheck Telemetry:**
|
||||||
|
- `/healthz` and `/health` must respond with HTTP 200 JSON `{ status: "ok", uptime: <seconds> }`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Defensive Programming & Error Prevention
|
||||||
|
|
||||||
|
1. **Input Guarding:**
|
||||||
|
- Never assume incoming arguments have expected shape. Always guard against `null`, `undefined`, non-objects, and primitive inputs (e.g., `item.data ?? item`).
|
||||||
|
2. **Clean Container Contracts:**
|
||||||
|
- Container images must use `node:22-alpine`.
|
||||||
|
- Dynamic entrypoint (`entrypoint.sh`) must build markdown on boot, handle optional watcher (`WATCH=true`), and execute server as PID 1 (`exec node server.js`).
|
||||||
@@ -5,3 +5,6 @@ _site/
|
|||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
|
|
||||||
|
# Local browser fixtures for document-based development
|
||||||
|
src/posts/*.md
|
||||||
|
|||||||
@@ -51,14 +51,19 @@ Every component, style, and layout change must strictly adhere to [`sample-desig
|
|||||||
- **Typography:** IBM Plex Mono / JetBrains Mono for UI elements and code blocks; Inter for body prose.
|
- **Typography:** IBM Plex Mono / JetBrains Mono for UI elements and code blocks; Inter for body prose.
|
||||||
|
|
||||||
### 2.4 Strict Test-Driven Development (TDD) & Adversarial Verification Mandate
|
### 2.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.
|
- **Strict Test-First Invariant (Tests Before Code):**
|
||||||
- **Adversarial Mindset:** Actively design tests to break assumptions:
|
- Test cases MUST ALWAYS be written and established *before writing or modifying any functional code* (filters, templates, server routes, ingestion logic, build scripts).
|
||||||
- Missing environment variables (e.g. unset `SITE_DOMAIN` must throw).
|
- Never write or modify functional or production code before the corresponding automated tests are created.
|
||||||
- Malformed frontmatter (missing titles, invalid dates, empty tags).
|
- **Mandatory Adversarial Testing:**
|
||||||
- Edge-case inputs to filters (empty strings, giant numbers, special characters).
|
- Standard behavior for any functional change MUST ALWAYS include adversarial testing specifically designed to falsify assumptions and break the implementation:
|
||||||
- Design rule violations (asserting 0 instances of `backdrop-filter`, `box-shadow`, gradients, or floaty animations).
|
- Missing or malformed environment variables (e.g., unset `SITE_DOMAIN` must throw).
|
||||||
- Broken internal links and missing anchor tags in generated HTML.
|
- Malformed frontmatter (missing required keys, invalid dates, malformed tags, extra/empty fields).
|
||||||
- **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.
|
- Edge-case and malicious inputs to filters and functions (empty strings, undefined/null values, unexpected types, HTML injections, path traversals, giant payloads).
|
||||||
|
- Design rule violations (asserting 0 instances of `backdrop-filter`, `box-shadow`, gradients, or floaty animations).
|
||||||
|
- Broken internal links, missing assets, and broken TOC anchor tags.
|
||||||
|
- **Pre-Presentation Verification Gate:**
|
||||||
|
- Before presenting the final output to the user, the agent MUST run the full test suite (`npm test`).
|
||||||
|
- All test cases must pass before presenting the final output. Never deliver, present, or claim completion of work if any test case is failing or unverified.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -90,6 +95,7 @@ Every component, style, and layout change must strictly adhere to [`sample-desig
|
|||||||
excerpt: "Short synopsis of article."
|
excerpt: "Short synopsis of article."
|
||||||
readingTime: "8 MIN READ"
|
readingTime: "8 MIN READ"
|
||||||
draft: false
|
draft: false
|
||||||
|
hidden: false # optional: true unlists post from public feeds and lists, accessible only via direct link
|
||||||
```
|
```
|
||||||
- Support Obsidian syntax conversion:
|
- Support Obsidian syntax conversion:
|
||||||
- Wikilinks `[[my-note|Custom Text]]` $\rightarrow$ `/posts/my-note/`.
|
- Wikilinks `[[my-note|Custom Text]]` $\rightarrow$ `/posts/my-note/`.
|
||||||
|
|||||||
+8
-3
@@ -17,12 +17,17 @@ RUN npm ci
|
|||||||
|
|
||||||
# Copy application source files
|
# Copy application source files
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN chmod +x /app/entrypoint.sh
|
||||||
|
|
||||||
# Build static production assets
|
# Pre-compile baseline static assets
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Expose web server port dynamically
|
# Expose web server port dynamically
|
||||||
EXPOSE ${PORT}
|
EXPOSE ${PORT}
|
||||||
|
|
||||||
# Serve site with npm
|
# Native container healthcheck probing IPv4 loopback
|
||||||
CMD ["npm", "start"]
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=3s --retries=3 \
|
||||||
|
CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT || 8080) + '/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
|
||||||
|
|
||||||
|
# Serve site using dynamic entrypoint
|
||||||
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
|
|||||||
@@ -1,116 +1,230 @@
|
|||||||
# River Rooks | Technical Publication & Systems Architecture Blog
|
# River Rooks | Technical Publication & Systems Architecture Blog
|
||||||
|
|
||||||
> Developer-centric, high-signal, low-noise personal technical blog and homelab documentation site.
|
> Developer-centric, high-signal, low-noise personal technical publication and systems architecture blog.
|
||||||
|
|
||||||
Inspired by native terminal environments, modal text editors (Zed, Ghostty), and tiling window managers, this site prioritizes raw performance, keyboard navigability, information density, and structured craftsmanship over generic marketing aesthetics.
|
Inspired by native terminal environments, modal text editors (Zed, Ghostty), and tiling window managers, this site prioritizes raw performance, keyboard navigability, information density, and structured craftsmanship over generic marketing aesthetics.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Architectural Vision & Pillars
|
## 1. Quickstart: Production Server Deployment
|
||||||
|
|
||||||
### 1.1 Static Generation via Eleventy (11ty)
|
The production deployment uses a **decoupled container architecture**. The server does not need Node.js, git, or build dependencies—it only pulls the prebuilt engine from the registry and mounts a local folder containing your Markdown posts.
|
||||||
- **Engine:** Fast, pre-rendered static HTML and minimal client-side runtime using **Eleventy** and **Nunjucks (`.njk`)** templates.
|
|
||||||
- **Performance:** Instant time-to-first-byte (TTFB), zero client-side framework bloat, and pure static asset delivery via high-performance native Node runtime.
|
|
||||||
- **Syntax Highlighting & Code Blocks:** Terminal-styled code blocks with title bars displaying filenames, language tags, and high-contrast monospace code.
|
|
||||||
|
|
||||||
### 1.2 Dynamic Obsidian Publishing Pipeline
|
|
||||||
- **Seamless Authoring:** Author and edit technical dispatches, CTF write-ups, and homelab notes directly inside an **Obsidian** vault.
|
|
||||||
- **Custom Ingestion API:** A dedicated server-side endpoint (`POST /api/posts`) accepting markdown payloads and assets, paired with a custom Obsidian plugin for one-click publishing.
|
|
||||||
- **LiveSync Integration:** Architectural support for syncing notes from a self-hosted Obsidian LiveSync (CouchDB) instance.
|
|
||||||
- **Content Normalization:** Translates Obsidian wikilinks (`[[slug|Title]]`), callouts (`> [!NOTE]`), and asset references into standard web formats during ingestion and build time.
|
|
||||||
|
|
||||||
### 1.3 Strict CSS Isolation & Reusable Templating
|
|
||||||
- **No Monolithic Inlines:** Replaces repetitive inline `<style>` tags with a modular CSS token and component architecture.
|
|
||||||
- **Design Tokens:** Centralized CSS custom properties (`--bg-canvas`, `--bg-surface`, `--border-color`, `--accent-cyan`, etc.) for consistent dark-mode native styling.
|
|
||||||
- **DRY Layouts & Partials:** Reusable Nunjucks components for global navigation (`global-bar`), hero tiles (`hero-tile`), split cards, and article workspace layouts (`article-workspace-grid`).
|
|
||||||
|
|
||||||
### 1.4 AI Pair Architect & Pedagogical Explainer
|
|
||||||
- **Interactive Learning:** The workspace includes customized Antigravity agent configurations (`.agents/` and `AGENTS.md`) that pair-program with the author, architecting solutions while clearly explaining *how* and *why* code is built.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Strict Design Specification
|
|
||||||
|
|
||||||
Adhering to the principles in [`sample-designs/design-specification.md`](./sample-designs/design-specification.md):
|
|
||||||
|
|
||||||
| Rule | Constraint |
|
|
||||||
| :--- | :--- |
|
|
||||||
| **Color Scheme** | Dark-mode native only. Canvas: `#0d0f12`, Surface: `#121417`, Elevated: `#1c1f26`, Borders: `#2b303b`. |
|
|
||||||
| **Accents** | High-signal terminal accents used sparingly: Cyan (`#00f0ff`), Green (`#00e676`), Amber (`#ffb700`). |
|
|
||||||
| **Borders & Radius** | Crisp 1px solid borders. Zero to 4px maximum border-radius (sharp corners preferred). |
|
|
||||||
| **Strict Anti-Patterns** | **NO** glassmorphism (`backdrop-filter`), **NO** drop shadows or glows, **NO** gradients, **NO** spring animations. |
|
|
||||||
| **Typography** | Monospace for UI & code (`IBM Plex Mono`, `JetBrains Mono`); clean sans-serif for body prose (`Inter`). |
|
|
||||||
| **Interactivity** | Instant binary hover snaps (no slow fade transitions); high-contrast focus outlines for keyboard navigation. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Project Directory Map
|
|
||||||
|
|
||||||
|
### 1.1 Server Directory Structure (`~/docker/blog`)
|
||||||
```text
|
```text
|
||||||
.
|
~/docker/blog/
|
||||||
├── .agents/ # Antigravity agent configurations & customizations
|
├── .env # Environment variables (SITE_DOMAIN, etc.)
|
||||||
│ ├── rules/
|
├── docker-compose.yml # Production container definition
|
||||||
│ │ └── blog-system.md # Architectural constraints, CSS/template isolation & explainer mode
|
└── posts/ # Your live Markdown articles (.md)
|
||||||
│ └── skills/
|
```
|
||||||
│ ├── eleventy-tiling-blog/ # Runbook & recipes for Eleventy builds and component extraction
|
|
||||||
│ └── obsidian-sync/ # Runbook & API contracts for Obsidian publishing & plugin sync
|
### 1.2 Production `docker-compose.yml`
|
||||||
├── sample-designs/ # Canonical HTML/CSS prototypes & design specification
|
```yaml
|
||||||
│ ├── article-tiling-firmware.html
|
services:
|
||||||
│ ├── blog-tiling-lead.html
|
web:
|
||||||
│ ├── design-specification.md
|
image: git.riverrooks.dev/venus/river-rooks-blog:latest
|
||||||
│ └── service-gateway-tiling.html
|
container_name: river_rooks_web
|
||||||
├── src/ # Eleventy source directory (scaffolded in Phase 2)
|
restart: unless-stopped
|
||||||
│ ├── _data/ # Global site metadata, navigation, telemetry data
|
volumes:
|
||||||
│ ├── _includes/
|
- "./posts:/app/src/posts:ro"
|
||||||
│ │ ├── layouts/ # Base, post, and page templates
|
environment:
|
||||||
│ │ └── partials/ # Global bar, hero tile, card, code box, sidebar
|
- "SITE_DOMAIN=${SITE_DOMAIN:?Error: SITE_DOMAIN is not set. Please define SITE_DOMAIN in .env}"
|
||||||
│ ├── assets/
|
- "SITE_URL=${SITE_URL:-https://${SITE_DOMAIN}}"
|
||||||
│ │ ├── css/ # Modular CSS (tokens, reset, layout, components)
|
- "PORT=${PORT:-8080}"
|
||||||
│ │ └── fonts/ # Self-hosted monospace & prose webfonts
|
- "WATCH=${WATCH:-false}"
|
||||||
│ └── posts/ # Markdown content ingested from Obsidian
|
ports:
|
||||||
├── .eleventy.js # Eleventy configuration, markdown-it plugins, and shortcodes
|
- "${HOST_PORT:-${PORT:-8080}}:${PORT:-8080}"
|
||||||
├── package.json # Project dependencies & build scripts
|
networks:
|
||||||
├── server.js # Native Node static web server & clean-URL router
|
- backend
|
||||||
├── Dockerfile # Node container definition (built & served via npm)
|
labels:
|
||||||
├── docker-compose.yml # Container orchestration (web gateway)
|
- "traefik.enable=true"
|
||||||
└── AGENTS.md # Project-level agent instructions & pair programming guide
|
- "traefik.http.routers.blog.rule=Host(`riverrooks.dev`) || Host(`www.riverrooks.dev`)"
|
||||||
|
- "traefik.http.routers.blog.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.blog.tls.certresolver=basic"
|
||||||
|
- "traefik.http.services.blog.loadbalancer.server.port=8080"
|
||||||
|
- "traefik.docker.network=backend"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
backend:
|
||||||
|
external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 Deployment Commands
|
||||||
|
```bash
|
||||||
|
# 1. Pull the prebuilt engine from Gitea registry
|
||||||
|
docker compose pull
|
||||||
|
|
||||||
|
# 2. Launch the container
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 3. Verify healthcheck status (transitions to healthy in 3-5 seconds)
|
||||||
|
docker compose ps
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. Development & Build Roadmap
|
## 2. Local Development & Verification
|
||||||
|
|
||||||
- [x] **Phase 1: Foundation & Agent Customizations**
|
All templates, styles, and engine code are developed in this repository (`~/code/blog`).
|
||||||
- Establish `README.md` architectural blueprint.
|
|
||||||
- Setup `.agents/` workspace rules, skills, and root `AGENTS.md`.
|
|
||||||
- [ ] **Phase 2: Eleventy Scaffolding & Modularization**
|
|
||||||
- Initialize Node/npm environment and configure `.eleventy.js`.
|
|
||||||
- Extract design tokens, layout styles, and components into `src/assets/css/`.
|
|
||||||
- Build reusable Nunjucks layouts and partials modeled on `sample-designs/`.
|
|
||||||
- [ ] **Phase 3: Dynamic Obsidian Ingestion & Plugin**
|
|
||||||
- Implement the server-side note ingestion endpoint (`POST /api/posts`).
|
|
||||||
- Develop the custom Obsidian plugin for publishing notes and frontmatter.
|
|
||||||
- Integrate wikilink and callout transformation filters.
|
|
||||||
- [ ] **Phase 4: Containerization & Deployment**
|
|
||||||
- Verify static build outputs in `_site/`.
|
|
||||||
- Update Dockerfile and `docker-compose.yml` for unified production serving.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Development Setup
|
|
||||||
|
|
||||||
*(To be initialized during Phase 2)*
|
|
||||||
|
|
||||||
|
### 2.1 Native Node.js Workflow
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# Start Eleventy local development server with hot-reload
|
# Run automated test suite (unit tests, filters, server, and docker assertions)
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Launch Eleventy dev server with hot-reload (binds to http://localhost:8080)
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
# Compile static assets for production
|
# Compile static assets to _site/
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
# Run via Docker Compose
|
# Start the native static server locally
|
||||||
docker compose up --build
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 2.2 Local Docker Compose Workflow
|
||||||
|
The local `docker-compose.yml` provides sensible defaults (`localhost`, port 8080, and mounts `./src/posts`):
|
||||||
|
```bash
|
||||||
|
# Build and run the local container stack
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# View container logs (Eleventy compilation + server output)
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
# Stop and clean up
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Development Standards: Strict TDD & Adversarial Verification
|
||||||
|
This project enforces a strict test-first development lifecycle and verification standard:
|
||||||
|
1. **Tests Written Before Functional Code:** Automated test cases in `test/` must always be written before writing or modifying any functional code, filters, templates, server endpoints, or scripts.
|
||||||
|
2. **Mandatory Adversarial Testing:** Standard behavior for any functional change must always include adversarial test cases (boundary values, malformed/null inputs, unauthorized requests, path traversals, corrupt frontmatter, and design spec violations).
|
||||||
|
3. **Pre-Presentation Verification Gate:** Every change must be verified by running `npm test`. All test cases must pass before presenting the final output or committing code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Push & Update Cycles
|
||||||
|
|
||||||
|
Because the system separates the **Engine** (templates/code) from the **Content** (markdown notes), updates follow two distinct paths:
|
||||||
|
|
||||||
|
### 3.1 Publishing or Editing Articles (Content Workflow)
|
||||||
|
When you write a new note or edit an existing article:
|
||||||
|
|
||||||
|
1. **Add or update the Markdown file** in `posts/` (e.g. `posts/my-new-post.md`):
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
title: "My Article Title"
|
||||||
|
date: 2026-09-14
|
||||||
|
tags: ["#systems", "#architecture"]
|
||||||
|
excerpt: "A short synopsis of the article."
|
||||||
|
hidden: false # (Optional) Set to true to hide from public lists while keeping direct link active
|
||||||
|
---
|
||||||
|
|
||||||
|
# Article Content...
|
||||||
|
```
|
||||||
|
2. **Recompile the site:**
|
||||||
|
```bash
|
||||||
|
docker compose restart web
|
||||||
|
```
|
||||||
|
*Eleventy compiles the mounted markdown in ~150ms and starts serving immediately.*
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> **Zero-Restart Live Reload:** Set `WATCH=true` in your server's `docker-compose.yml` environment. Eleventy will watch the `posts/` directory in the background. Whenever a note is saved or synced (e.g., from Obsidian), the site automatically updates in memory without running any commands.
|
||||||
|
|
||||||
|
### 3.2 Updating Blog Templates, CSS, or Features (Engine Workflow)
|
||||||
|
When you modify design tokens, Nunjucks templates, server routes, or dependencies:
|
||||||
|
|
||||||
|
1. **Develop and verify locally:**
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
2. **Build and push the new engine image to the Gitea container registry:**
|
||||||
|
```bash
|
||||||
|
# Build the engine image
|
||||||
|
docker build -t git.riverrooks.dev/venus/river-rooks-blog:latest .
|
||||||
|
|
||||||
|
# Push to Gitea
|
||||||
|
docker push git.riverrooks.dev/venus/river-rooks-blog:latest
|
||||||
|
```
|
||||||
|
3. **Update the production server:**
|
||||||
|
```bash
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. System Architecture & Structure
|
||||||
|
|
||||||
|
### 4.1 High-Level Architecture
|
||||||
|
```text
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Dev Machine / Workstation │
|
||||||
|
│ src/ (templates, CSS, config) + Dockerfile + entrypoint.sh │
|
||||||
|
└──────────────────────────────┬──────────────────────────────┘
|
||||||
|
│ docker push
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Gitea Container Registry (git.riverrooks.dev) │
|
||||||
|
│ image: venus/river-rooks-blog:latest │
|
||||||
|
└──────────────────────────────┬──────────────────────────────┘
|
||||||
|
│ docker pull
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Production Server │
|
||||||
|
│ │
|
||||||
|
│ ~/docker/blog/posts/ ──(volume)──> [ river_rooks_web ] │
|
||||||
|
│ (Live Markdown Notes) ├── entrypoint.sh │
|
||||||
|
│ ├── 11ty compiles │
|
||||||
|
│ Traefik (SSL / Proxy) <──────────── └── server.js (:8080) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Key Engine Components
|
||||||
|
- **`entrypoint.sh`**: Dynamic container entrypoint. Compiles mounted markdown notes with Eleventy on boot, starts optional background file watcher, and spawns `server.js` as PID 1 for clean signal handling.
|
||||||
|
- **`Dockerfile`**: Minimal `node:22-alpine` container with pre-installed dependencies, baseline static pre-compilation, and baked-in `HEALTHCHECK` querying `http://127.0.0.1:8080/healthz`.
|
||||||
|
- **`server.js`**: Zero-dependency native Node static web server supporting clean directory URLs, MIME caching, security headers, and the `/healthz` telemetry endpoint.
|
||||||
|
- **`eleventy.config.js`**: Eleventy 3.x configuration, custom collections (`posts`, `tagList`), markdown-it anchor plugins, and custom date/reading-time filters.
|
||||||
|
|
||||||
|
### 4.3 Directory Map
|
||||||
|
```text
|
||||||
|
.
|
||||||
|
├── .agents/ # Antigravity agent configurations & rules
|
||||||
|
├── sample-designs/ # Design specifications & HTML/CSS prototypes
|
||||||
|
├── src/ # Blog engine source
|
||||||
|
│ ├── _data/ # Site metadata & navigation
|
||||||
|
│ ├── _filters/ # Modular Eleventy filters (date, readingTime, headings)
|
||||||
|
│ ├── _includes/
|
||||||
|
│ │ ├── layouts/ # Base & post Nunjucks templates
|
||||||
|
│ │ └── partials/ # Global bar, hero tile, post cards, code box
|
||||||
|
│ ├── assets/
|
||||||
|
│ │ └── css/ # Modular CSS (tokens, reset, layout, components)
|
||||||
|
│ └── posts/ # Default/fallback markdown posts
|
||||||
|
├── test/ # Automated test suite (node:test)
|
||||||
|
├── docker-compose.yml # Local development compose file
|
||||||
|
├── sample-docker-compose.yml # Production-ready compose template
|
||||||
|
├── Dockerfile # Production container image definition
|
||||||
|
├── entrypoint.sh # Container startup & compilation entrypoint
|
||||||
|
├── server.js # Native Node static HTTP server
|
||||||
|
├── eleventy.config.js # Eleventy configuration
|
||||||
|
├── package.json # Project scripts & dependencies
|
||||||
|
└── README.md # Documentation & runbook
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Strict Design Specification
|
||||||
|
|
||||||
|
All UI components and stylesheets strictly follow [`sample-designs/design-specification.md`](./sample-designs/design-specification.md):
|
||||||
|
|
||||||
|
| Rule | Constraint |
|
||||||
|
| :--- | :--- |
|
||||||
|
| **Color Scheme** | Dark-mode native only. Canvas: `#0d0f12`, Surface: `#121417`, Elevated: `#1c1f26`, Borders: `#2b303b`. |
|
||||||
|
| **Accents** | Terminal accents: Cyan (`#00f0ff`), Green (`#00e676`), Amber (`#ffb700`), Red (`#ff5252`). |
|
||||||
|
| **Borders & Radius** | Crisp 1px solid borders. Zero to 4px maximum border-radius (sharp corners). |
|
||||||
|
| **Strict Anti-Patterns** | **NO** glassmorphism (`backdrop-filter`), **NO** drop shadows or glows, **NO** gradients, **NO** spring animations. |
|
||||||
|
| **Typography** | Monospace for UI & code (`IBM Plex Mono`, `JetBrains Mono`); clean sans-serif for body prose (`Inter`). |
|
||||||
|
| **Interactivity** | Instant binary hover snaps (no slow fade transitions); high-contrast focus outlines for keyboard navigation. |
|
||||||
|
|||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#! /usr/bin/env zsh
|
||||||
|
echo building docker image and pushing to remote server
|
||||||
|
docker build -t git.riverrooks.dev/venus/river-rooks-blog:latest /home/venus/code/blog/
|
||||||
|
docker push git.riverrooks.dev/venus/river-rooks-blog:latest
|
||||||
|
echo docker image built and pushed, now updating remote server
|
||||||
|
ssh sol "cd docker/blog && ./docker-update.zsh"
|
||||||
|
|
||||||
+45
-15
@@ -2,8 +2,25 @@ import syntaxHighlight from "@11ty/eleventy-plugin-syntaxhighlight";
|
|||||||
import markdownIt from "markdown-it";
|
import markdownIt from "markdown-it";
|
||||||
import markdownItAnchor from "markdown-it-anchor";
|
import markdownItAnchor from "markdown-it-anchor";
|
||||||
import { filters } from "./src/_filters/index.js";
|
import { filters } from "./src/_filters/index.js";
|
||||||
|
import {
|
||||||
|
postComparator,
|
||||||
|
isPostPublic,
|
||||||
|
isPostArchived,
|
||||||
|
isPostDraft,
|
||||||
|
isPostHidden
|
||||||
|
} from "./src/_utils/sort-posts.js";
|
||||||
|
import { validateLinkedArticles } from "./src/_utils/linked-articles.js";
|
||||||
|
|
||||||
export default function (eleventyConfig) {
|
export default function (eleventyConfig) {
|
||||||
|
let buildTimestamp = new Date().toISOString();
|
||||||
|
eleventyConfig.on("eleventy.before", () => {
|
||||||
|
buildTimestamp = new Date().toISOString();
|
||||||
|
});
|
||||||
|
eleventyConfig.addGlobalData("buildTimestamp", () => buildTimestamp);
|
||||||
|
|
||||||
|
// Build ignored local post fixtures without making them Git-tracked.
|
||||||
|
eleventyConfig.setUseGitIgnore(false);
|
||||||
|
|
||||||
// 1. Static Asset Passthrough Copy
|
// 1. Static Asset Passthrough Copy
|
||||||
eleventyConfig.addPassthroughCopy({ "src/assets": "assets" });
|
eleventyConfig.addPassthroughCopy({ "src/assets": "assets" });
|
||||||
|
|
||||||
@@ -38,27 +55,40 @@ export default function (eleventyConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. Custom Collections
|
// 5. Custom Collections
|
||||||
// Chronological blog posts collection
|
// Pinned / featured prioritized, chronological blog posts collection (excludes drafts, hidden, and archived posts)
|
||||||
eleventyConfig.addCollection("posts", (collectionApi) => {
|
eleventyConfig.addCollection("posts", (collectionApi) => {
|
||||||
return collectionApi
|
const allPosts = collectionApi.getFilteredByGlob("src/posts/*.md");
|
||||||
.getFilteredByGlob("src/posts/*.md")
|
validateLinkedArticles(allPosts);
|
||||||
.filter((post) => !post.data.draft)
|
|
||||||
.sort((a, b) => b.date - a.date);
|
return allPosts
|
||||||
|
.filter((post) => isPostPublic(post))
|
||||||
|
.sort(postComparator);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dynamic Tag List Collection across all posts
|
// Chronological archived blog posts collection (excludes drafts and hidden posts)
|
||||||
|
eleventyConfig.addCollection("archivedPosts", (collectionApi) => {
|
||||||
|
return collectionApi
|
||||||
|
.getFilteredByGlob("src/posts/*.md")
|
||||||
|
.filter((post) => !isPostDraft(post) && !isPostHidden(post) && isPostArchived(post))
|
||||||
|
.sort(postComparator);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dynamic Tag List Collection across all public posts (excludes drafts and hidden posts)
|
||||||
eleventyConfig.addCollection("tagList", (collectionApi) => {
|
eleventyConfig.addCollection("tagList", (collectionApi) => {
|
||||||
const tagSet = new Set();
|
const tagSet = new Set();
|
||||||
collectionApi.getFilteredByGlob("src/posts/*.md").forEach((item) => {
|
collectionApi
|
||||||
if ("tags" in item.data) {
|
.getFilteredByGlob("src/posts/*.md")
|
||||||
let tags = item.data.tags;
|
.filter((post) => isPostPublic(post))
|
||||||
if (typeof tags === "string") tags = [tags];
|
.forEach((item) => {
|
||||||
for (const tag of tags) {
|
if ("tags" in item.data) {
|
||||||
const cleanTag = tag.replace(/^#/, "");
|
let tags = item.data.tags;
|
||||||
tagSet.add(cleanTag);
|
if (typeof tags === "string") tags = [tags];
|
||||||
|
for (const tag of tags) {
|
||||||
|
const cleanTag = tag.replace(/^#/, "");
|
||||||
|
tagSet.add(cleanTag);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
return [...tagSet].sort();
|
return [...tagSet].sort();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Pass-through mode: if arguments were provided, execute them directly
|
||||||
|
if [ "$#" -gt 0 ]; then
|
||||||
|
exec "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure posts directory data file exists for layout inheritance
|
||||||
|
if [ ! -f src/posts/posts.json ]; then
|
||||||
|
echo '{"layout": "layouts/post.njk"}' > src/posts/posts.json
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build static site from mounted or packaged markdown posts
|
||||||
|
echo "[blog-engine] Compiling static site with Eleventy..."
|
||||||
|
npm run build || exit 1
|
||||||
|
|
||||||
|
# Optional watch mode for zero-restart live note updates
|
||||||
|
if [ "${WATCH:-false}" = "true" ]; then
|
||||||
|
echo "[blog-engine] Starting live watch daemon for markdown changes..."
|
||||||
|
npx @11ty/eleventy --watch &
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start production static web server
|
||||||
|
echo "[blog-engine] Starting static server on port ${PORT:-8080}..."
|
||||||
|
exec node server.js
|
||||||
@@ -106,6 +106,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-pretitle {
|
.hero-pretitle {
|
||||||
|
|||||||
@@ -407,11 +407,11 @@
|
|||||||
<!-- Main Hero Landing Tile (Central Focus of Homepage) -->
|
<!-- Main Hero Landing Tile (Central Focus of Homepage) -->
|
||||||
<section class="hero-tile" aria-label="Homepage Mission & Landing Declaration">
|
<section class="hero-tile" aria-label="Homepage Mission & Landing Declaration">
|
||||||
<div class="hero-pretitle">SYSTEM DECLARATION • RACK-01 NODE GATEWAY</div>
|
<div class="hero-pretitle">SYSTEM DECLARATION • RACK-01 NODE GATEWAY</div>
|
||||||
<h1 class="hero-main-title">Engineering Sovereign Infrastructure & Technical Notes</h1>
|
<h1 class="hero-main-title">Cybersecurity & Dev Ops notes</h1>
|
||||||
|
|
||||||
<div class="hero-landing-text">
|
<div class="hero-landing-text">
|
||||||
<p>
|
<p>
|
||||||
I am River Rooks, a cybersecurity engineering student at the University of Alabama. My work centers on network forensics, self-hosted system architecture, low-level firmware exploitation, and zero-allocation Unix utilities.
|
I am River Rooks, a cybersecurity student at the University of Alabama. My work centers on self-hosted system architecture, network security, and capture the flag style skills. I maintain a personal homelab cluster for research, experimentation, and development of open-source tools.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
This website functions as the unified frontend for my physical homelab cluster. Rather than hosting projects on opaque third-party clouds, everything here runs on bare metal in my personal server rack—orchestrated through Proxmox VE, partitioned across isolated VLANs via OPNsense, and protected by point-to-point WireGuard cryptographic meshes.
|
This website functions as the unified frontend for my physical homelab cluster. Rather than hosting projects on opaque third-party clouds, everything here runs on bare metal in my personal server rack—orchestrated through Proxmox VE, partitioned across isolated VLANs via OPNsense, and protected by point-to-point WireGuard cryptographic meshes.
|
||||||
@@ -425,8 +425,8 @@
|
|||||||
<strong>PRIMARY DESTINATIONS:</strong>
|
<strong>PRIMARY DESTINATIONS:</strong>
|
||||||
<span style="color: var(--accent-cyan); margin-left: 8px;">[1] Technical Blog (5 Articles)</span> •
|
<span style="color: var(--accent-cyan); margin-left: 8px;">[1] Technical Blog (5 Articles)</span> •
|
||||||
<span>[2] Forgejo Git Repos</span> •
|
<span>[2] Forgejo Git Repos</span> •
|
||||||
<span>[3] Public Cryptographic & PDF Tools</span> •
|
<!-- <span>[3] Public Cryptographic & PDF Tools</span> • -->
|
||||||
<span>[4] Cluster Telemetry</span>
|
<!-- <span>[4] Cluster Telemetry</span> -->
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
+27
-14
@@ -1,25 +1,38 @@
|
|||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
build:
|
# Pull prebuilt engine image from your Gitea registry
|
||||||
context: .
|
image: git.riverrooks.dev/venus/river-rooks-blog:latest
|
||||||
dockerfile: Dockerfile
|
# Local build fallback (optional when building image locally)
|
||||||
args:
|
# build:
|
||||||
SITE_DOMAIN: "${SITE_DOMAIN:?Error: SITE_DOMAIN is not set. Please define SITE_DOMAIN in .env}"
|
# context: .
|
||||||
SITE_URL: "${SITE_URL:-https://${SITE_DOMAIN}}"
|
# dockerfile: Dockerfile
|
||||||
PORT: "${PORT:-8080}"
|
# args:
|
||||||
image: river-rooks-blog:latest
|
# SITE_DOMAIN: "${SITE_DOMAIN:?Error: SITE_DOMAIN is not set. Please define SITE_DOMAIN in .env}"
|
||||||
|
# SITE_URL: "${SITE_URL:-https://${SITE_DOMAIN}}"
|
||||||
|
# PORT: "${PORT:-8080}"
|
||||||
container_name: river_rooks_web
|
container_name: river_rooks_web
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
# Healthcheck is natively baked into the Dockerfile image metadata
|
||||||
test: ["CMD", "node", "test/healthcheck.js"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 3s
|
|
||||||
start_period: 3s
|
|
||||||
retries: 3
|
|
||||||
environment:
|
environment:
|
||||||
- "SITE_DOMAIN=${SITE_DOMAIN:?Error: SITE_DOMAIN is not set. Please define SITE_DOMAIN in .env}"
|
- "SITE_DOMAIN=${SITE_DOMAIN:?Error: SITE_DOMAIN is not set. Please define SITE_DOMAIN in .env}"
|
||||||
- "SITE_URL=${SITE_URL:-https://${SITE_DOMAIN}}"
|
- "SITE_URL=${SITE_URL:-https://${SITE_DOMAIN}}"
|
||||||
- "PORT=${PORT:-8080}"
|
- "PORT=${PORT:-8080}"
|
||||||
|
- "WATCH=${WATCH:-false}"
|
||||||
|
volumes:
|
||||||
|
# Mount external markdown posts directly into the container
|
||||||
|
- "./posts:/app/src/posts"
|
||||||
ports:
|
ports:
|
||||||
- "${HOST_PORT:-${PORT:-8080}}:${PORT:-8080}"
|
- "${HOST_PORT:-${PORT:-8080}}:${PORT:-8080}"
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.blog.rule=Host(`riverrooks.dev`) || Host(`www.riverrooks.dev`)"
|
||||||
|
- "traefik.http.routers.blog.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.blog.tls.certresolver=basic"
|
||||||
|
- "traefik.http.services.blog.loadbalancer.server.port=8080"
|
||||||
|
- "traefik.docker.network=backend"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
backend:
|
||||||
|
external: true
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { exec } from "node:child_process";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const MIME_TYPES = {
|
const MIME_TYPES = {
|
||||||
@@ -24,14 +25,143 @@ const MIME_TYPES = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the HTTP server instance for serving static files from _site
|
* Normalizes Obsidian-specific syntax (wikilinks, internal attachments)
|
||||||
* @param {string} [customSiteDir] Optional custom directory to serve from
|
* into standard web URLs and image references.
|
||||||
|
* @param {string} content Raw Markdown content from Obsidian
|
||||||
|
* @returns {string} Normalized Markdown content
|
||||||
|
*/
|
||||||
|
export function normalizeObsidianMarkdown(content) {
|
||||||
|
if (typeof content !== "string") return "";
|
||||||
|
|
||||||
|
let normalized = content;
|
||||||
|
|
||||||
|
// Bridge created -> date so Eleventy collections sorting and filters work seamlessly
|
||||||
|
const createdMatch = normalized.match(/^created:\s*([^\r\n]+)$/m);
|
||||||
|
const hasDate = /^date:\s*.+$/m.test(normalized);
|
||||||
|
if (createdMatch && !hasDate) {
|
||||||
|
normalized = normalized.replace(/^(---\r?\n)/, `$1date: ${createdMatch[1].trim()}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bridge layout -> layouts/post.njk so Obsidian notes render with the blog theme and CSS
|
||||||
|
const hasLayout = /^layout:\s*.+$/m.test(normalized);
|
||||||
|
if (!hasLayout) {
|
||||||
|
normalized = normalized.replace(/^(---\r?\n)/, "$1layout: layouts/post.njk\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Convert internal attachments: ![[image.png|alt]] or ![[image.png]]
|
||||||
|
normalized = normalized.replace(/!\[\[([^|\]]+)(?:\|([^\]]+))?\]\]/g, (match, filename, alt) => {
|
||||||
|
const cleanFilename = path.basename(filename.trim());
|
||||||
|
const altText = alt ? alt.trim() : cleanFilename;
|
||||||
|
return ``;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Convert wikilinks with label: [[target-slug|Custom Label]] -> [Custom Label](/posts/target-slug/)
|
||||||
|
normalized = normalized.replace(/\[\[([^|\]]+)\|([^\]]+)\]\]/g, (match, target, label) => {
|
||||||
|
return `[${label.trim()}](${wikilinkUrl(target)})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Convert simple wikilinks: [[target-slug]] -> [target-slug](/posts/target-slug/)
|
||||||
|
normalized = normalized.replace(/\[\[([^\]]+)\]\]/g, (match, target) => {
|
||||||
|
const trimmed = target.trim();
|
||||||
|
return `[${trimmed}](${wikilinkUrl(trimmed)})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wikilinkUrl(target) {
|
||||||
|
const trimmed = target.trim();
|
||||||
|
const fragmentIndex = trimmed.indexOf("#");
|
||||||
|
|
||||||
|
if (fragmentIndex === -1) {
|
||||||
|
return `/posts/${slugify(trimmed)}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const noteSlug = slugify(trimmed.slice(0, fragmentIndex));
|
||||||
|
const headerSlug = slugify(trimmed.slice(fragmentIndex + 1));
|
||||||
|
const fragment = headerSlug ? `#${headerSlug}` : "";
|
||||||
|
return `/posts/${noteSlug}/${fragment}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(text) {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^\w\s-]/g, "")
|
||||||
|
.replace(/[\s_-]+/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
|
||||||
|
* @param {string} [customSiteDir] Optional custom directory for _site
|
||||||
|
* @param {string} [customPostsDir] Optional custom directory for posts
|
||||||
|
* @param {string} [customAssetsDir] Optional custom directory for uploaded post assets
|
||||||
* @returns {http.Server}
|
* @returns {http.Server}
|
||||||
*/
|
*/
|
||||||
export function createServer(customSiteDir) {
|
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 assetsDir = path.resolve(customAssetsDir || path.join(process.cwd(), "src/assets/images/posts"));
|
||||||
|
const isCustomEnv = Boolean(customSiteDir || customPostsDir || customAssetsDir);
|
||||||
|
|
||||||
return http.createServer((req, res) => {
|
// Ensure default directory data file exists for layout inheritance
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(postsDir)) {
|
||||||
|
fs.mkdirSync(postsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
const postsJsonPath = path.join(postsDir, "posts.json");
|
||||||
|
if (!fs.existsSync(postsJsonPath)) {
|
||||||
|
fs.writeFileSync(postsJsonPath, JSON.stringify({ layout: "layouts/post.njk" }, null, 2));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[Server] Could not initialize posts.json directory data file:", err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
@@ -46,6 +176,153 @@ export function createServer(customSiteDir) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Obsidian Ingestion API: /api/posts
|
||||||
|
if (parsedUrl.pathname === "/api/posts") {
|
||||||
|
const authHeader = req.headers["authorization"] || "";
|
||||||
|
const expectedSecret = process.env.API_SECRET;
|
||||||
|
|
||||||
|
if (!expectedSecret) {
|
||||||
|
res.writeHead(500, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: "Server API_SECRET is not configured" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||||
|
if (!match || match[1] !== expectedSecret) {
|
||||||
|
res.writeHead(401, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: "Unauthorized: Invalid or missing Bearer token" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "POST") {
|
||||||
|
let body = "";
|
||||||
|
req.on("data", (chunk) => {
|
||||||
|
body += chunk;
|
||||||
|
if (body.length > 50 * 1024 * 1024) {
|
||||||
|
res.writeHead(413, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: "Payload too large (50MB limit)" }));
|
||||||
|
req.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on("end", async () => {
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(body);
|
||||||
|
} catch {
|
||||||
|
res.writeHead(400, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: "Invalid JSON payload" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { slug, content, assets } = payload;
|
||||||
|
if (!slug || typeof slug !== "string" || !content || typeof content !== "string") {
|
||||||
|
res.writeHead(400, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: "Missing required fields: 'slug' and 'content'" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanSlug = slug.toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/^-+|-+$/g, "");
|
||||||
|
if (!cleanSlug) {
|
||||||
|
res.writeHead(400, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: "Invalid slug format" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasTitle = /^title:\s*.+$/m.test(content);
|
||||||
|
const hasDate = /^date:\s*.+$/m.test(content) || /^created:\s*.+$/m.test(content);
|
||||||
|
if (!hasTitle || !hasDate) {
|
||||||
|
res.writeHead(400, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: "Missing required frontmatter: 'title' and 'created' (or 'date') are required" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(postsDir, { recursive: true });
|
||||||
|
fs.mkdirSync(assetsDir, { recursive: true });
|
||||||
|
|
||||||
|
const normalizedContent = normalizeObsidianMarkdown(content);
|
||||||
|
const targetFilePath = path.join(postsDir, `${cleanSlug}.md`);
|
||||||
|
fs.writeFileSync(targetFilePath, normalizedContent, "utf-8");
|
||||||
|
|
||||||
|
if (Array.isArray(assets)) {
|
||||||
|
for (const asset of assets) {
|
||||||
|
if (asset && asset.filename && asset.data) {
|
||||||
|
const cleanName = path.basename(asset.filename).replace(/[^a-zA-Z0-9._-]/g, "");
|
||||||
|
if (cleanName) {
|
||||||
|
const assetPath = path.join(assetsDir, cleanName);
|
||||||
|
fs.writeFileSync(assetPath, Buffer.from(asset.data, "base64"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild static site with Eleventy if in standard environment
|
||||||
|
if (!isCustomEnv) {
|
||||||
|
await triggerEleventyBuild();
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
status: "published",
|
||||||
|
slug: cleanSlug,
|
||||||
|
path: `/posts/${cleanSlug}/`
|
||||||
|
}));
|
||||||
|
} catch (writeErr) {
|
||||||
|
console.error("[Ingestion API] Error saving post/assets:", writeErr);
|
||||||
|
res.writeHead(500, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: `Failed to write file to disk: ${writeErr.message}` }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "DELETE") {
|
||||||
|
const querySlug = parsedUrl.searchParams.get("slug");
|
||||||
|
if (!querySlug) {
|
||||||
|
res.writeHead(400, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: "Missing 'slug' parameter" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanSlug = querySlug.toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/^-+|-+$/g, "");
|
||||||
|
if (!cleanSlug) {
|
||||||
|
res.writeHead(400, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: "Invalid slug parameter" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Remove source markdown post
|
||||||
|
const targetFilePath = path.join(postsDir, `${cleanSlug}.md`);
|
||||||
|
if (fs.existsSync(targetFilePath)) {
|
||||||
|
fs.unlinkSync(targetFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Remove compiled static site directory and file
|
||||||
|
const compiledDir = path.join(siteDir, "posts", cleanSlug);
|
||||||
|
if (fs.existsSync(compiledDir)) {
|
||||||
|
fs.rmSync(compiledDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
const compiledHtml = path.join(siteDir, "posts", `${cleanSlug}.html`);
|
||||||
|
if (fs.existsSync(compiledHtml)) {
|
||||||
|
fs.unlinkSync(compiledHtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Rebuild static collections (index.html, dispatches, tags) with Eleventy
|
||||||
|
if (!isCustomEnv) {
|
||||||
|
await triggerEleventyBuild();
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ status: "deleted", slug: cleanSlug }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(405, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Path normalization & security validation against directory traversal
|
// Path normalization & security validation against directory traversal
|
||||||
let decodedPath = "";
|
let decodedPath = "";
|
||||||
try {
|
try {
|
||||||
@@ -82,8 +359,8 @@ export function createServer(customSiteDir) {
|
|||||||
const ext = path.extname(targetFile).toLowerCase();
|
const ext = path.extname(targetFile).toLowerCase();
|
||||||
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
||||||
|
|
||||||
// Cache control: assets get long-term caching, html gets short
|
// Cache control: static media and webfonts get long-term caching; CSS, JS, and HTML get short revalidation
|
||||||
if (decodedPath.startsWith("/assets/")) {
|
if (decodedPath.startsWith("/assets/") && !decodedPath.endsWith(".css") && !decodedPath.endsWith(".js")) {
|
||||||
res.setHeader("Cache-Control", "public, max-age=2592000, immutable");
|
res.setHeader("Cache-Control", "public, max-age=2592000, immutable");
|
||||||
} else {
|
} else {
|
||||||
res.setHeader("Cache-Control", "public, max-age=0, must-revalidate");
|
res.setHeader("Cache-Control", "public, max-age=0, must-revalidate");
|
||||||
|
|||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
layout: layouts/base.njk
|
||||||
|
permalink: /404.html
|
||||||
|
title: Page not found | River Rooks
|
||||||
|
excerpt: The requested page could not be found.
|
||||||
|
---
|
||||||
|
<section class="not-found tile" aria-labelledby="not-found-title">
|
||||||
|
<header class="tile-header">
|
||||||
|
<span class="tile-title">// ROUTE STATUS</span>
|
||||||
|
<span class="tile-meta">HTTP 404 • NOT FOUND</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="not-found-body">
|
||||||
|
<h1 class="not-found-code" id="not-found-title">404<span class="not-found-cursor" aria-hidden="true">_</span></h1>
|
||||||
|
<div class="not-found-message">
|
||||||
|
<p class="not-found-label">REQUEST FAILED / NO MATCHING ROUTE</p>
|
||||||
|
<h2>Page not found.</h2>
|
||||||
|
<p>The address you requested doesn't point to a page here. Check the URL, or choose a known route below.</p>
|
||||||
|
<nav class="not-found-links" aria-label="Recovery links">
|
||||||
|
<a href="/">> Back to home</a>
|
||||||
|
<a href="/dispatches/">> Browse dispatches</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
+36
-24
@@ -54,34 +54,34 @@ export default {
|
|||||||
url: "/dispatches/"
|
url: "/dispatches/"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Gitea",
|
label: "Code",
|
||||||
url: `https://git.${siteDomain}`,
|
url: `https://git.riverrooks.dev/venus`,
|
||||||
external: true
|
external: true
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
label: "RSS Feed",
|
// label: "RSS Feed",
|
||||||
url: "/feed.xml"
|
// url: "/feed.xml"
|
||||||
}
|
// }
|
||||||
],
|
],
|
||||||
services: [
|
services: [
|
||||||
{
|
{
|
||||||
name: "Gitea",
|
name: "Gitea",
|
||||||
url: `https://git.${siteDomain}`,
|
url: `https://git.riverrooks.dev/venus`,
|
||||||
tag: `git.${siteDomain}:2222`,
|
tag: `git.${siteDomain}:443`,
|
||||||
desc: "Self-hosted Git code repositories."
|
desc: "Self-hosted Git code repositories."
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
name: "CyberChef",
|
// name: "CyberChef",
|
||||||
url: `https://chef.${siteDomain}`,
|
// url: `https://chef.${siteDomain}`,
|
||||||
tag: `chef.${siteDomain}:443`,
|
// tag: `chef.${siteDomain}:443`,
|
||||||
desc: "Industry tool for analyzing and decoding data."
|
// desc: "Industry tool for analyzing and decoding data."
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
name: "Aperisolve",
|
// name: "Aperisolve",
|
||||||
url: `https://aperisolve.${siteDomain}`,
|
// url: `https://aperisolve.${siteDomain}`,
|
||||||
tag: `aperisolve.${siteDomain}:443`,
|
// tag: `aperisolve.${siteDomain}:443`,
|
||||||
desc: "Basic image steganography suite."
|
// desc: "Basic image steganography suite."
|
||||||
},
|
// },
|
||||||
{
|
{
|
||||||
name: "Streams",
|
name: "Streams",
|
||||||
url: `https://streams.${siteDomain}`,
|
url: `https://streams.${siteDomain}`,
|
||||||
@@ -90,28 +90,40 @@ export default {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Calibre",
|
name: "Calibre",
|
||||||
url: `https://library.${siteDomain}`,
|
url: `https://books.${siteDomain}`,
|
||||||
tag: `books.${siteDomain}:443`,
|
tag: `books.${siteDomain}:443`,
|
||||||
desc: `Calibre web frontend for managing and reading eBooks.`
|
desc: `Calibre web frontend for managing and reading eBooks.`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Wireguard",
|
||||||
|
url: `https://wg.${siteDomain}`,
|
||||||
|
tag: `wg.${siteDomain}:443`,
|
||||||
|
desc: `Self-hosted Wireguard VPN server.(Requires login)`
|
||||||
}
|
}
|
||||||
|
// {
|
||||||
|
// name: "Whoami",
|
||||||
|
// url: `https://whoami.${siteDomain}`,
|
||||||
|
// tag: `whoami.${siteDomain}:443`,
|
||||||
|
// desc: `Simple service to display request headers and connection information.`
|
||||||
|
// }
|
||||||
],
|
],
|
||||||
topics: [
|
topics: [
|
||||||
{
|
{
|
||||||
title: "Systems Engineering",
|
title: "Systems Engineering",
|
||||||
slug: "systems",
|
slug: "systems",
|
||||||
desc: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
|
desc: "Notes from my Journey building out interconnected systems.",
|
||||||
badge: "TRACK: SYSTEMS"
|
badge: "TRACK: SYSTEMS"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Security Research",
|
title: "Security Research",
|
||||||
slug: "security",
|
slug: "security",
|
||||||
desc: "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
desc: "Notes from my CTF training and security research.",
|
||||||
badge: "TRACK: SECURITY"
|
badge: "TRACK: SECURITY"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Homelab Infrastructure",
|
title: "Homelab Infrastructure",
|
||||||
slug: "homelab",
|
slug: "homelab",
|
||||||
desc: "Ut enim ad minim veniam, quis nostrud exercitation ullamco.",
|
desc: "Notes from building out my homelab.",
|
||||||
badge: "TRACK: INFRA"
|
badge: "TRACK: INFRA"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
+43
-1
@@ -1,9 +1,18 @@
|
|||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
|
import markdownIt from "markdown-it";
|
||||||
|
import { resolveLinkedArticles } from "../_utils/linked-articles.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom Eleventy Filters for River Rooks Blog
|
* Custom Eleventy Filters for River Rooks Blog
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const excerptMarkdown = markdownIt({ html: false });
|
||||||
|
|
||||||
|
export const renderExcerptMarkdown = (excerpt) => {
|
||||||
|
if (typeof excerpt !== "string") return "";
|
||||||
|
return excerptMarkdown.renderInline(excerpt);
|
||||||
|
};
|
||||||
|
|
||||||
export const postDate = (dateObj) => {
|
export const postDate = (dateObj) => {
|
||||||
if (!dateObj) return "";
|
if (!dateObj) return "";
|
||||||
const date = typeof dateObj === "string" ? new Date(dateObj) : dateObj;
|
const date = typeof dateObj === "string" ? new Date(dateObj) : dateObj;
|
||||||
@@ -62,13 +71,46 @@ export const extractHeadings = (htmlContent) => {
|
|||||||
return headings;
|
return headings;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const leadingTerminalPreviewHeading = /^\s*<h1\b[^>]*\bid=(["'])terminal-preview\1[^>]*>[\s\S]*?<\/h1>/i;
|
||||||
|
|
||||||
|
const terminalPreviewBounds = (htmlContent) => {
|
||||||
|
if (!htmlContent || typeof htmlContent !== "string") return null;
|
||||||
|
|
||||||
|
const heading = htmlContent.match(leadingTerminalPreviewHeading);
|
||||||
|
if (!heading) return null;
|
||||||
|
|
||||||
|
const contentAfterHeading = htmlContent.slice(heading[0].length);
|
||||||
|
const nextArticleHeading = contentAfterHeading.search(/<h[1-6]\b/i);
|
||||||
|
if (nextArticleHeading === -1) return null;
|
||||||
|
|
||||||
|
const leadingArticleWhitespace = contentAfterHeading
|
||||||
|
.slice(0, nextArticleHeading)
|
||||||
|
.match(/\s*$/)?.[0] || "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
preview: contentAfterHeading.slice(0, nextArticleHeading),
|
||||||
|
article: leadingArticleWhitespace + contentAfterHeading.slice(nextArticleHeading)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractTerminalPreview = (htmlContent) => terminalPreviewBounds(htmlContent)?.preview || "";
|
||||||
|
|
||||||
|
export const removeTerminalPreview = (htmlContent) => {
|
||||||
|
if (!htmlContent || typeof htmlContent !== "string") return "";
|
||||||
|
return terminalPreviewBounds(htmlContent)?.article || htmlContent;
|
||||||
|
};
|
||||||
|
|
||||||
export const filters = {
|
export const filters = {
|
||||||
|
renderExcerptMarkdown,
|
||||||
postDate,
|
postDate,
|
||||||
isoDate,
|
isoDate,
|
||||||
limit,
|
limit,
|
||||||
filterTagList,
|
filterTagList,
|
||||||
readingTime,
|
readingTime,
|
||||||
extractHeadings
|
extractHeadings,
|
||||||
|
extractTerminalPreview,
|
||||||
|
removeTerminalPreview,
|
||||||
|
resolveLinkedArticles
|
||||||
};
|
};
|
||||||
|
|
||||||
export default filters;
|
export default filters;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,700;1,400&family=Inter:ital,wght@0,400;0,500;0,700;1,400&family=JetBrains+Mono:ital,wght@0,400;0,500;1,400&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,700;1,400&family=Inter:ital,wght@0,400;0,500;0,700;1,400&family=JetBrains+Mono:ital,wght@0,400;0,500;1,400&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
<!-- Modular Bundled Stylesheet -->
|
<!-- Modular Bundled Stylesheet -->
|
||||||
<link rel="stylesheet" href="/assets/css/main.css">
|
<link rel="stylesheet" href="/assets/css/main.css?v=1.1">
|
||||||
|
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
{% include "partials/footer.njk" %}
|
{% include "partials/footer.njk" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="/assets/js/build-timer.js"></script>
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
{% extends "layouts/base.njk" %}
|
{% extends "layouts/base.njk" %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{% if hidden and hidden != "false" and hidden != "0" and hidden != "no" %}
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="article-workspace-grid">
|
<div class="article-workspace-grid">
|
||||||
<!-- Left Sticky Sidebar: Document Outline & Telemetry -->
|
<!-- Left Sticky Sidebar: Document Outline & Telemetry -->
|
||||||
<aside class="article-sidebar" aria-label="Article Sidebar">
|
<aside class="article-sidebar" aria-label="Article Sidebar">
|
||||||
<!-- Dynamic Table of Contents -->
|
<!-- Dynamic Table of Contents -->
|
||||||
{% set headings = content | extractHeadings %}
|
{% set headings = (content | removeTerminalPreview) | extractHeadings %}
|
||||||
{% if headings.length > 0 %}
|
{% if headings.length > 0 %}
|
||||||
<div class="sidebar-tile">
|
<div class="sidebar-tile">
|
||||||
<div class="sidebar-heading">
|
<div class="sidebar-heading">
|
||||||
@@ -25,14 +31,32 @@
|
|||||||
<!-- Article Telemetry Tile -->
|
<!-- Article Telemetry Tile -->
|
||||||
<div class="sidebar-tile">
|
<div class="sidebar-tile">
|
||||||
<div class="sidebar-heading">DISPATCH TELEMETRY</div>
|
<div class="sidebar-heading">DISPATCH TELEMETRY</div>
|
||||||
|
{% set linkedPosts = collections.posts | resolveLinkedArticles(linkedArticles) %}
|
||||||
|
{% if linkedPosts.length > 0 %}
|
||||||
|
<section aria-labelledby="other-articles-heading">
|
||||||
|
<div id="other-articles-heading" class="sidebar-heading">OTHER ARTICLES</div>
|
||||||
|
<nav class="toc-nav" aria-label="Other Articles">
|
||||||
|
{% for linkedPost in linkedPosts %}
|
||||||
|
<a href="{{ linkedPost.url }}" class="toc-link">{{ linkedPost.data.title }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</nav>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
<div class="sidebar-stats-box" style="padding: 0; background: transparent; border: none;">
|
<div class="sidebar-stats-box" style="padding: 0; background: transparent; border: none;">
|
||||||
|
{% if hidden and hidden != "false" and hidden != "0" and hidden != "no" %}
|
||||||
|
<div class="stat-row">
|
||||||
|
<span>VISIBILITY:</span>
|
||||||
|
<span class="stat-val" style="color: var(--accent-amber);">UNLISTED</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span>PUBLISHED:</span>
|
<span>PUBLISHED:</span>
|
||||||
<span class="stat-val">{{ date | postDate }}</span>
|
<span class="stat-val">{{ date | postDate }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span>DURATION:</span>
|
<span>DURATION:</span>
|
||||||
<span class="stat-val">{{ content | readingTime(readingTime) }}</span>
|
<span class="stat-val">{{ (content | removeTerminalPreview) | readingTime(readingTime) }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% if difficulty %}
|
{% if difficulty %}
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
@@ -77,7 +101,7 @@
|
|||||||
<time datetime="{{ date | isoDate }}">{{ date | postDate }}</time>
|
<time datetime="{{ date | isoDate }}">{{ date | postDate }}</time>
|
||||||
</span>
|
</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span class="meta-item">{{ content | readingTime(readingTime) }}</span>
|
<span class="meta-item">{{ (content | removeTerminalPreview) | readingTime(readingTime) }}</span>
|
||||||
{% if difficulty %}
|
{% if difficulty %}
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span class="meta-item" style="color: var(--accent-amber);">DIFFICULTY: {{ difficulty }}</span>
|
<span class="meta-item" style="color: var(--accent-amber);">DIFFICULTY: {{ difficulty }}</span>
|
||||||
@@ -86,7 +110,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="markdown-prose">
|
<div class="markdown-prose">
|
||||||
{{ content | safe }}
|
{{ content | removeTerminalPreview | safe }}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,24 +29,26 @@
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p class="featured-prose">
|
<p class="featured-prose">
|
||||||
{{ featured.data.excerpt }}
|
{{ featured.data.excerpt | renderExcerptMarkdown | safe }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Terminal Code Preview Snippet -->
|
{% if featured.data.terminal == true %}
|
||||||
<div class="terminal-code-box">
|
{% set terminalPreview = featured.templateContent | extractTerminalPreview %}
|
||||||
<div class="code-titlebar">
|
{% if terminalPreview %}
|
||||||
<span class="code-title">
|
<div class="terminal-code-box terminal-preview-box">
|
||||||
<span style="color: var(--accent-cyan);">></span>
|
<div class="code-titlebar">
|
||||||
<span>init_cluster.sh</span>
|
<span class="code-title">
|
||||||
</span>
|
<span style="color: var(--accent-cyan);">></span>
|
||||||
<span>POSIX sh</span>
|
<span>TERMINAL PREVIEW</span>
|
||||||
</div>
|
</span>
|
||||||
<div class="code-content">
|
<span>MARKDOWN</span>
|
||||||
<pre><code class="language-bash"># System initialization and node health check
|
</div>
|
||||||
./cluster.sh --verify-nodes
|
<div class="terminal-preview-content">
|
||||||
curl -s https://api.local/healthz | jq .status</code></pre>
|
{{ terminalPreview | safe }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="featured-cta-row">
|
<div class="featured-cta-row">
|
||||||
<div class="tag-cluster">
|
<div class="tag-cluster">
|
||||||
|
|||||||
@@ -4,10 +4,12 @@
|
|||||||
<span>RUNTIME: ELEVENTY 3.x STATIC</span>
|
<span>RUNTIME: ELEVENTY 3.x STATIC</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>THEME: NATIVE TERMINAL DARK</span>
|
<span>THEME: NATIVE TERMINAL DARK</span>
|
||||||
|
<span>•</span>
|
||||||
|
<time id="build-age" data-build-timestamp="{{ buildTimestamp }}" aria-live="polite">REBUILT: INITIALIZING</time>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="footer-right">
|
<div class="footer-right">
|
||||||
<span>© {{ currentYear or "2026" }} {{ site.author }}</span>
|
<span>© {{ currentYear or "2026" }} {{ site.author }}</span>
|
||||||
<a href="https://git.{{ site.domain }}" target="_blank" rel="noopener noreferrer" class="footer-link">Source Forge ↗</a>
|
<a href="https://git.{{ site.domain }}" target="_blank" rel="noopener noreferrer" class="footer-link">Source Code ↗</a>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="hero-pretitle">TECHNICAL PUBLICATION REPOSITORY • ~/NOTES/INDEX.MD</div>
|
<div class="hero-pretitle">TECHNICAL PUBLICATION REPOSITORY • ~/NOTES/INDEX.MD</div>
|
||||||
<h1 class="hero-main-title">{{ heroTitle or site.description }}</h1>
|
<h1 class="hero-main-title">{{ heroTitle or site.description }}</h1>
|
||||||
<p class="hero-landing-text">
|
<p class="hero-landing-text">
|
||||||
{{ heroText or "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation." }}
|
{{ heroText or "Notes from my Journey building out interconnected systems." }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="filter-pills-row" aria-label="Topic Filters">
|
<div class="filter-pills-row" aria-label="Topic Filters">
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{% if post.data.excerpt %}
|
{% if post.data.excerpt %}
|
||||||
<p class="feed-excerpt">{{ post.data.excerpt }}</p>
|
<p class="feed-excerpt">{{ post.data.excerpt | renderExcerptMarkdown | safe }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="feed-tags-row">
|
<div class="feed-tags-row">
|
||||||
|
|||||||
@@ -14,6 +14,10 @@
|
|||||||
<span>TOTAL DISPATCHES:</span>
|
<span>TOTAL DISPATCHES:</span>
|
||||||
<span class="stat-val">{{ collections.posts.length }} PUBLISHED</span>
|
<span class="stat-val">{{ collections.posts.length }} PUBLISHED</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-row">
|
||||||
|
<span>ARCHIVED NOTES:</span>
|
||||||
|
<span class="stat-val">{{ (collections.archivedPosts or []).length }} ARCHIVED</span>
|
||||||
|
</div>
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span>TOPIC DOMAINS:</span>
|
<span>TOPIC DOMAINS:</span>
|
||||||
<span class="stat-val">{{ collections.tagList.length }} CATEGORIES</span>
|
<span class="stat-val">{{ collections.tagList.length }} CATEGORIES</span>
|
||||||
@@ -27,6 +31,10 @@
|
|||||||
<span class="stat-val" style="color: var(--accent-green);">{{ site.status }}</span>
|
<span class="stat-val" style="color: var(--accent-green);">{{ site.status }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<a href="/archive/" class="filter-tag-btn" style="width: 100%; justify-content: center;">
|
||||||
|
VIEW ARCHIVE ({{ (collections.archivedPosts or []).length }}) →
|
||||||
|
</a>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Curated Topic Tracks Tile -->
|
<!-- Curated Topic Tracks Tile -->
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
const internalPostLink = /^\[([^\]]+)\]\(\/posts\/[^)\s]+\/?\)$/;
|
||||||
|
|
||||||
|
const getLinkedArticleTitle = (value) => {
|
||||||
|
if (typeof value !== "string") return value;
|
||||||
|
|
||||||
|
const match = value.match(internalPostLink);
|
||||||
|
return match ? match[1].trim() : value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateLinkedArticles = (posts) => {
|
||||||
|
const titles = new Set(posts.map((post) => post.data.title));
|
||||||
|
|
||||||
|
for (const post of posts) {
|
||||||
|
const { linkedArticles } = post.data;
|
||||||
|
|
||||||
|
if (linkedArticles === undefined) continue;
|
||||||
|
|
||||||
|
if (!Array.isArray(linkedArticles)) {
|
||||||
|
throw new Error(
|
||||||
|
`Post '${post.inputPath}' must define 'linkedArticles' as a YAML list of article titles`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkedTitleSet = new Set();
|
||||||
|
for (const linkedArticle of linkedArticles) {
|
||||||
|
const title = getLinkedArticleTitle(linkedArticle);
|
||||||
|
|
||||||
|
if (typeof title !== "string" || title.trim() === "") {
|
||||||
|
throw new Error(
|
||||||
|
`Post '${post.inputPath}' must define 'linkedArticles' with non-empty article titles`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title === post.data.title) {
|
||||||
|
throw new Error(`Post '${post.inputPath}' cannot link to itself`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linkedTitleSet.has(title)) {
|
||||||
|
throw new Error(
|
||||||
|
`Post '${post.inputPath}' cannot contain duplicate article titles in 'linkedArticles'`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
linkedTitleSet.add(title);
|
||||||
|
|
||||||
|
if (!titles.has(title)) {
|
||||||
|
throw new Error(
|
||||||
|
`Post '${post.inputPath}' links to unknown article '${title}'`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveLinkedArticles = (posts, titles) => {
|
||||||
|
if (!Array.isArray(posts) || !Array.isArray(titles)) return [];
|
||||||
|
|
||||||
|
const postsByTitle = new Map(posts.map((post) => [post.data.title, post]));
|
||||||
|
return titles.map(getLinkedArticleTitle).map((title) => postsByTitle.get(title)).filter(Boolean);
|
||||||
|
};
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Safely extracts a numeric timestamp from an Eleventy item or its frontmatter data.
|
||||||
|
* @param {object} item Eleventy page / post object
|
||||||
|
* @returns {number} Unix timestamp in ms
|
||||||
|
*/
|
||||||
|
export function getPostTimestamp(item) {
|
||||||
|
if (!item || typeof item !== "object") return 0;
|
||||||
|
const rawDate = item.date ?? item.data?.date ?? item.data?.created;
|
||||||
|
if (rawDate instanceof Date) {
|
||||||
|
const t = rawDate.getTime();
|
||||||
|
return Number.isNaN(t) ? 0 : t;
|
||||||
|
}
|
||||||
|
if (typeof rawDate === "string" || typeof rawDate === "number") {
|
||||||
|
const t = new Date(rawDate).getTime();
|
||||||
|
return Number.isNaN(t) ? 0 : t;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether a post is flagged as pinned or featured.
|
||||||
|
* Explicit boolean true or truthy values are supported;
|
||||||
|
* missing, null, or false values return false.
|
||||||
|
*
|
||||||
|
* @param {object} item Eleventy page / post object
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isPostPinned(item) {
|
||||||
|
if (!item || typeof item !== "object") return false;
|
||||||
|
const data = item.data;
|
||||||
|
if (!data || typeof data !== "object") return false;
|
||||||
|
return Boolean(data.pinned || data.featured);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comparator for sorting blog posts:
|
||||||
|
* 1. Pinned or featured posts are prioritized at the top of the collection.
|
||||||
|
* 2. Within the same priority group (both pinned or both unpinned), posts
|
||||||
|
* are sorted chronologically in descending order (newest first).
|
||||||
|
*
|
||||||
|
* @param {object} a First post
|
||||||
|
* @param {object} b Second post
|
||||||
|
* @returns {number} Negative if a comes before b, positive if b comes before a, 0 if equal
|
||||||
|
*/
|
||||||
|
export function postComparator(a, b) {
|
||||||
|
const aPinned = isPostPinned(a);
|
||||||
|
const bPinned = isPostPinned(b);
|
||||||
|
|
||||||
|
if (aPinned !== bPinned) {
|
||||||
|
return bPinned ? 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeA = getPostTimestamp(a);
|
||||||
|
const timeB = getPostTimestamp(b);
|
||||||
|
|
||||||
|
return timeB - timeA;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether a post is marked as hidden.
|
||||||
|
* Hidden posts are accessible via direct link/URL, but excluded
|
||||||
|
* from public post lists, indexes, and tag collections.
|
||||||
|
* Supports explicit boolean, string ("true", "yes", "1"), or numeric representations.
|
||||||
|
*
|
||||||
|
* @param {object} item Eleventy page / post object
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isPostHidden(item) {
|
||||||
|
if (!item || typeof item !== "object") return false;
|
||||||
|
const data = item.data ?? item;
|
||||||
|
if (!data || typeof data !== "object") return false;
|
||||||
|
const hidden = data.hidden;
|
||||||
|
if (typeof hidden === "string") {
|
||||||
|
const lower = hidden.trim().toLowerCase();
|
||||||
|
return lower === "true" || lower === "yes" || lower === "1";
|
||||||
|
}
|
||||||
|
if (typeof hidden === "number") {
|
||||||
|
return hidden === 1;
|
||||||
|
}
|
||||||
|
return Boolean(hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether a post is marked as a draft.
|
||||||
|
*
|
||||||
|
* @param {object} item Eleventy page / post object
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isPostDraft(item) {
|
||||||
|
if (!item || typeof item !== "object") return false;
|
||||||
|
const data = item.data ?? item;
|
||||||
|
if (!data || typeof data !== "object") return false;
|
||||||
|
const draft = data.draft;
|
||||||
|
if (typeof draft === "string") {
|
||||||
|
const lower = draft.trim().toLowerCase();
|
||||||
|
return lower === "true" || lower === "yes" || lower === "1";
|
||||||
|
}
|
||||||
|
if (typeof draft === "number") {
|
||||||
|
return draft === 1;
|
||||||
|
}
|
||||||
|
return Boolean(draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether a post is marked as archived.
|
||||||
|
* Archived posts are excluded from the main Dispatches feed and listed on /archive/.
|
||||||
|
* Supports explicit boolean, string ("true", "yes", "1"), or numeric representations.
|
||||||
|
*
|
||||||
|
* @param {object} item Eleventy page / post object
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isPostArchived(item) {
|
||||||
|
if (!item || typeof item !== "object") return false;
|
||||||
|
const data = item.data ?? item;
|
||||||
|
if (!data || typeof data !== "object") return false;
|
||||||
|
const archive = data.archive;
|
||||||
|
if (typeof archive === "string") {
|
||||||
|
const lower = archive.trim().toLowerCase();
|
||||||
|
return lower === "true" || lower === "yes" || lower === "1";
|
||||||
|
}
|
||||||
|
if (typeof archive === "number") {
|
||||||
|
return archive === 1;
|
||||||
|
}
|
||||||
|
return Boolean(archive);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether a post is eligible for public collections and listings.
|
||||||
|
* A post must be non-draft, non-hidden, and non-archived.
|
||||||
|
*
|
||||||
|
* @param {object} item Eleventy page / post object
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isPostPublic(item) {
|
||||||
|
if (!item || typeof item !== "object") return false;
|
||||||
|
return !isPostDraft(item) && !isPostHidden(item) && !isPostArchived(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default postComparator;
|
||||||
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
layout: layouts/base.njk
|
||||||
|
title: "River Rooks | Archived Dispatches"
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- 1. Lander Hero Tile with Dynamic Filter Tag Buttons -->
|
||||||
|
{% include "partials/hero-tile.njk" %}
|
||||||
|
|
||||||
|
<!-- 2. Full-Width Chronological Archived Dispatches Feed Tile -->
|
||||||
|
<section class="tile" aria-label="Archived Dispatches Feed Catalog">
|
||||||
|
<header class="tile-header">
|
||||||
|
<div class="tile-title">
|
||||||
|
<span class="status-indicator"></span>
|
||||||
|
ARCHIVED DISPATCHES
|
||||||
|
</div>
|
||||||
|
<span class="tile-meta">ORDER: CHRONOLOGICAL DESC • TOTAL: {{ collections.archivedPosts.length }}</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="feed-list">
|
||||||
|
{% for post in collections.archivedPosts %}
|
||||||
|
{% include "partials/post-card.njk" %}
|
||||||
|
{% else %}
|
||||||
|
<p style="color: var(--text-muted); font-family: var(--font-mono); font-size: 0.85rem; padding: 16px;">
|
||||||
|
No archived dispatches found in repository.
|
||||||
|
</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -2,13 +2,32 @@
|
|||||||
Article Workspace & Markdown Prose (components/article.css)
|
Article Workspace & Markdown Prose (components/article.css)
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|
||||||
/* Left Sticky Sidebar */
|
/* Left Sticky Sidebar (Sticky on multi-column desktop, natural flow on mobile/single-column) */
|
||||||
.article-sidebar {
|
.article-sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--tile-gap);
|
gap: var(--tile-gap);
|
||||||
position: sticky;
|
position: static;
|
||||||
top: 16px;
|
}
|
||||||
|
|
||||||
|
@media (min-width: 961px) {
|
||||||
|
.article-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 16px;
|
||||||
|
max-height: calc(100vh - 32px);
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border-color) transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.article-sidebar {
|
||||||
|
position: static !important;
|
||||||
|
top: auto !important;
|
||||||
|
max-height: none !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-tile {
|
.sidebar-tile {
|
||||||
|
|||||||
@@ -65,6 +65,35 @@
|
|||||||
font-family: var(--font-code);
|
font-family: var(--font-code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.terminal-preview-box {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-preview-content {
|
||||||
|
padding: 14px 16px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-preview-content > :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-preview-content > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-preview-content pre {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
background-color: var(--bg-surface);
|
||||||
|
border: var(--border-width) solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
font-family: var(--font-code);
|
||||||
|
}
|
||||||
|
|
||||||
/* Syntax Highlighting Tokens (Prism / 11ty Syntax Highlight) */
|
/* Syntax Highlighting Tokens (Prism / 11ty Syntax Highlight) */
|
||||||
.token.comment,
|
.token.comment,
|
||||||
.token.prolog,
|
.token.prolog,
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/* Missing route: a compact diagnostic panel in the site's tiling vocabulary. */
|
||||||
|
.not-found {
|
||||||
|
gap: 0;
|
||||||
|
padding: 20px 24px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
min-height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: clamp(5rem, 14vw, 10rem);
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.09em;
|
||||||
|
color: var(--accent-amber);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-cursor {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-message {
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
padding: 16px 0 16px 24px;
|
||||||
|
max-width: 48ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-label {
|
||||||
|
font: 500 0.75rem var(--font-mono);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--accent-amber);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-message h2 {
|
||||||
|
font: 700 clamp(1.3rem, 3vw, 2rem) / 1.25 var(--font-mono);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-message > p:not(.not-found-label) {
|
||||||
|
color: var(--text-muted);
|
||||||
|
max-width: 42ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-links {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 24px;
|
||||||
|
margin-top: 24px;
|
||||||
|
font: 500 0.85rem var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-links a {
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-links a:hover {
|
||||||
|
color: var(--bg-canvas);
|
||||||
|
background: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.not-found {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 32px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-message {
|
||||||
|
border-left: 0;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding: 20px 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-pretitle {
|
.hero-pretitle {
|
||||||
|
|||||||
@@ -19,3 +19,4 @@
|
|||||||
@import "./components/code-box.css";
|
@import "./components/code-box.css";
|
||||||
@import "./components/article.css";
|
@import "./components/article.css";
|
||||||
@import "./components/footer.css";
|
@import "./components/footer.css";
|
||||||
|
@import "./components/not-found.css";
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export const formatBuildAge = (buildTimestamp, nowTimestamp = new Date().toISOString()) => {
|
||||||
|
const buildTime = Date.parse(buildTimestamp);
|
||||||
|
const now = Date.parse(nowTimestamp);
|
||||||
|
|
||||||
|
if (!Number.isFinite(buildTime) || !Number.isFinite(now)) return "unknown";
|
||||||
|
|
||||||
|
const elapsedSeconds = Math.max(0, Math.floor((now - buildTime) / 1000));
|
||||||
|
const days = Math.floor(elapsedSeconds / 86400);
|
||||||
|
const hours = Math.floor((elapsedSeconds % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((elapsedSeconds % 3600) / 60);
|
||||||
|
const seconds = elapsedSeconds % 60;
|
||||||
|
|
||||||
|
if (days > 0) return `${days}d ${hours}h ago`;
|
||||||
|
if (hours > 0) return `${hours}h ${minutes}m ago`;
|
||||||
|
if (minutes > 0) return `${minutes}m ${seconds}s ago`;
|
||||||
|
return `${seconds}s ago`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
const timer = document.getElementById("build-age");
|
||||||
|
|
||||||
|
if (timer) {
|
||||||
|
const updateBuildAge = () => {
|
||||||
|
timer.textContent = `REBUILT: ${formatBuildAge(timer.dataset.buildTimestamp)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
updateBuildAge();
|
||||||
|
window.setInterval(updateBuildAge, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-6
@@ -6,23 +6,25 @@ title: "River Rooks | Homelab Gateway"
|
|||||||
<!-- Main Hero Landing Tile -->
|
<!-- Main Hero Landing Tile -->
|
||||||
<section class="hero-tile" aria-label="Gateway Declaration">
|
<section class="hero-tile" aria-label="Gateway Declaration">
|
||||||
<div class="hero-pretitle">SYSTEM DECLARATION • NODE GATEWAY</div>
|
<div class="hero-pretitle">SYSTEM DECLARATION • NODE GATEWAY</div>
|
||||||
<h1 class="hero-main-title">Engineering Sovereign Infrastructure & Technical Notes</h1>
|
<h1 class="hero-main-title">Cybersecurity & Dev Ops notes</h1>
|
||||||
|
|
||||||
<div class="hero-landing-text">
|
<div class="hero-landing-text">
|
||||||
<p>
|
<p>
|
||||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer nec odio. Praesent libero. Sed cursus ante dapibus diam. Sed nisi. Nulla quis sem at nibh elementum imperdiet. Duis sagittis ipsum.
|
I am River Rooks, a cyber security student at the University of Alabama. This website is the public-facing gateway to my homelab, which hosts a variety of services and tools for personal and professional use. Here, you can find my technical blog and a collection of public tools that host.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Praesent mauris. Fusce nec tellus sed augue semper porta. Mauris massa. Vestibulum lacinia arcu eget nulla. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.
|
I deploy and maintain a variety of services in my homelab for research, development, and learning purposes. This includes web applications, databases, and other tools that I use to enhance my skills in cybersecurity and DevOps.
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
My blog serves as a way to share some of my extensive notes with the public. I primarily focus on Capture The Flag (CTF) writeups, technical guides, and other cybersecurity-related content. I also use this platform to document my learning journey and share insights into the world of cybersecurity and DevOps.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hero-directive-box">
|
<div class="hero-directive-box">
|
||||||
<strong>PRIMARY DESTINATIONS:</strong>
|
<strong>PRIMARY DESTINATIONS:</strong>
|
||||||
<a href="/dispatches/" style="color: var(--accent-cyan); text-decoration: none; margin-left: 8px;">[1] Technical Blog ({{ collections.posts.length }} Articles)</a> •
|
<a href="/dispatches/" style="color: var(--accent-cyan); text-decoration: none; margin-left: 8px;">[1] Technical Blog ({{ collections.posts.length }} Articles)</a> •
|
||||||
<a href="https://git.{{ site.domain }}" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: none;">[2] Gitea Repos</a> •
|
<a href="https://git.{{ site.domain }}" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: none;">[2] Gitea Repos</a> •
|
||||||
<span>[3] Public Tools</span> •
|
<a href="https://github.com/skoorrevir" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: none;">[3] GitHub Repos</a> •
|
||||||
<span>[4] Cluster Telemetry</span>
|
<a href="https://wg.{{ site.domain }}" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: none;">[4] Wireguard VPN</a> •
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -47,7 +49,7 @@ title: "River Rooks | Homelab Gateway"
|
|||||||
|
|
||||||
<a href="/dispatches/" class="blog-feature-title">> Technical Notes & Engineering Dispatches</a>
|
<a href="/dispatches/" class="blog-feature-title">> Technical Notes & Engineering Dispatches</a>
|
||||||
<p class="blog-feature-desc">
|
<p class="blog-feature-desc">
|
||||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam.
|
Notes from my Journey building out interconnected systems.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="blog-sublinks">
|
<div class="blog-sublinks">
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
---
|
|
||||||
layout: layouts/post.njk
|
|
||||||
title: "Infrastructure Notes & Systems Deployment Guide"
|
|
||||||
date: 2026-07-15
|
|
||||||
tags:
|
|
||||||
- homelab
|
|
||||||
- networking
|
|
||||||
- templates
|
|
||||||
excerpt: "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident."
|
|
||||||
readingTime: "4 MIN READ"
|
|
||||||
featured: false
|
|
||||||
difficulty: "BEGINNER"
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1.0 Topology Overview
|
|
||||||
|
|
||||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua:
|
|
||||||
|
|
||||||
```text
|
|
||||||
[Gateway Ingress]
|
|
||||||
|
|
|
||||||
[Edge Router]
|
|
||||||
|
|
|
||||||
+-----+-----+
|
|
||||||
| |
|
|
||||||
[Node 01] [Node 02]
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2.0 Deployment Configuration
|
|
||||||
|
|
||||||
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
app:
|
|
||||||
image: service:latest
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "8080:80"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3.0 Verification Testing
|
|
||||||
|
|
||||||
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
curl -s http://localhost:8080/health
|
|
||||||
```
|
|
||||||
|
|
||||||
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
---
|
|
||||||
layout: layouts/post.njk
|
|
||||||
title: "Sample Technical Dispatch: Systems Architecture & Implementation"
|
|
||||||
date: 2026-07-20
|
|
||||||
tags:
|
|
||||||
- systems
|
|
||||||
- architecture
|
|
||||||
- templates
|
|
||||||
excerpt: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam."
|
|
||||||
readingTime: "6 MIN READ"
|
|
||||||
featured: true
|
|
||||||
difficulty: "INTERMEDIATE"
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1.0 Executive Summary
|
|
||||||
|
|
||||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer nec odio. Praesent libero. Sed cursus ante dapibus diam. Sed nisi. Nulla quis sem at nibh elementum imperdiet. Duis sagittis ipsum. Praesent mauris. Fusce nec tellus sed augue semper porta. Mauris massa. Vestibulum lacinia arcu eget nulla.
|
|
||||||
|
|
||||||
Curabitur sodales ligula in libero. Sed dignissim lacinia nunc. Curabitur tortor. Pellentesque nibh. Aenean quam. In scelerisque sem at dolor. Maecenas mattis. Sed convallis tristique sem. Proin ut ligula vel nunc egestas porttitor. Morbi lectus risus, iaculis vel, suscipit quis, luctus non, massa.
|
|
||||||
|
|
||||||
<div class="markdown-callout callout-note">
|
|
||||||
<div class="callout-title">[!NOTE] Operational Directives</div>
|
|
||||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer nec odio. Praesent libero. Sed cursus ante dapibus diam.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## 2.0 System Parameters & Specifications
|
|
||||||
|
|
||||||
Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Nam nec ante. Sed lacinia, urna non tincidunt mattis, tortor neque adipiscing diam, a cursus ipsum ante quis turpis:
|
|
||||||
|
|
||||||
### 2.1 Interface Configuration Matrix
|
|
||||||
|
|
||||||
| Parameter ID | Signal Name | Protocol Bus | Expected Value |
|
|
||||||
| :--- | :--- | :--- | :--- |
|
|
||||||
| **0x01** | `SYS_CLOCK` | SPI Primary | 100 MHz |
|
|
||||||
| **0x02** | `DATA_IN` | Serial Bus | Bidirectional |
|
|
||||||
| **0x03** | `CONTROL_REG` | I2C Header | 0x3F Active |
|
|
||||||
| **0x04** | `GROUND_REF` | Chassis GND | 0.0V Nominal |
|
|
||||||
|
|
||||||
<div class="markdown-callout callout-warning">
|
|
||||||
<div class="callout-title">[!WARNING] Configuration Voltage Boundary</div>
|
|
||||||
<p>Sed nisi. Nulla quis sem at nibh elementum imperdiet. Duis sagittis ipsum. Praesent mauris. Fusce nec tellus sed augue semper porta.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## 3.0 Execution Workflow
|
|
||||||
|
|
||||||
Nulla quis sem at nibh elementum imperdiet. Duis sagittis ipsum. Praesent mauris. Fusce nec tellus sed augue semper porta:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
#!/bin/sh
|
|
||||||
# Sample deployment and verification routine
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
echo "[+] Initializing subsystem..."
|
|
||||||
check_environment() {
|
|
||||||
test -f /etc/config.json && echo "Config verified."
|
|
||||||
}
|
|
||||||
|
|
||||||
check_environment
|
|
||||||
```
|
|
||||||
|
|
||||||
Press <kbd>Ctrl</kbd> + <kbd>C</kbd> to interrupt operations safely at any point.
|
|
||||||
|
|
||||||
## 4.0 Telemetry & Verification
|
|
||||||
|
|
||||||
Fusce nec tellus sed augue semper porta. Mauris massa. Vestibulum lacinia arcu eget nulla:
|
|
||||||
|
|
||||||
- **Metric A:** `1,048,576 ops/sec`
|
|
||||||
- **Metric B:** `SHA256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`
|
|
||||||
- **Latency:** `< 2.5ms` nominal
|
|
||||||
|
|
||||||
## 5.0 Audit Checklist
|
|
||||||
|
|
||||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit:
|
|
||||||
|
|
||||||
- [x] Baseline architectural parameters verified
|
|
||||||
- [x] Initial configuration and unit tests passed
|
|
||||||
- [x] System telemetry within expected boundaries
|
|
||||||
- [ ] Production deployment and final review
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"layout": "layouts/post.njk"
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
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";
|
||||||
|
import {
|
||||||
|
isPostArchived,
|
||||||
|
isPostDraft,
|
||||||
|
isPostHidden,
|
||||||
|
isPostPublic,
|
||||||
|
postComparator
|
||||||
|
} from "../src/_utils/sort-posts.js";
|
||||||
|
import { normalizeObsidianMarkdown } from "../server.js";
|
||||||
|
import configureEleventy from "../eleventy.config.js";
|
||||||
|
|
||||||
|
test("Archived Posts: isPostArchived accurately identifies archive flags across data shapes", () => {
|
||||||
|
// 1. Explicit boolean true
|
||||||
|
assert.equal(isPostArchived({ data: { archive: true } }), true, "Explicit boolean true in data");
|
||||||
|
assert.equal(isPostArchived({ archive: true }), true, "Root level archive true");
|
||||||
|
|
||||||
|
// 2. Truthy string and numeric representations (case-insensitive & trimmed)
|
||||||
|
assert.equal(isPostArchived({ data: { archive: "true" } }), true, "String 'true'");
|
||||||
|
assert.equal(isPostArchived({ data: { archive: "TRUE" } }), true, "Uppercase string 'TRUE'");
|
||||||
|
assert.equal(isPostArchived({ data: { archive: " true " } }), true, "Whitespace-padded 'true'");
|
||||||
|
assert.equal(isPostArchived({ data: { archive: "yes" } }), true, "String 'yes'");
|
||||||
|
assert.equal(isPostArchived({ data: { archive: "YES" } }), true, "String 'YES'");
|
||||||
|
assert.equal(isPostArchived({ data: { archive: "1" } }), true, "String '1'");
|
||||||
|
assert.equal(isPostArchived({ data: { archive: 1 } }), true, "Numeric 1");
|
||||||
|
|
||||||
|
// 3. Falsy representations
|
||||||
|
assert.equal(isPostArchived({ data: { archive: false } }), false, "Explicit boolean false");
|
||||||
|
assert.equal(isPostArchived({ data: { archive: "false" } }), false, "String 'false'");
|
||||||
|
assert.equal(isPostArchived({ data: { archive: "no" } }), false, "String 'no'");
|
||||||
|
assert.equal(isPostArchived({ data: { archive: "0" } }), false, "String '0'");
|
||||||
|
assert.equal(isPostArchived({ data: { archive: 0 } }), false, "Numeric 0");
|
||||||
|
assert.equal(isPostArchived({ data: { archive: "" } }), false, "Empty string");
|
||||||
|
assert.equal(isPostArchived({ data: { archive: null } }), false, "Null property");
|
||||||
|
assert.equal(isPostArchived({ data: {} }), false, "Missing archive property");
|
||||||
|
assert.equal(isPostArchived({}), false, "Empty post object");
|
||||||
|
|
||||||
|
// 4. Adversarial edge cases
|
||||||
|
assert.equal(isPostArchived(null), false, "Null input");
|
||||||
|
assert.equal(isPostArchived(undefined), false, "Undefined input");
|
||||||
|
assert.equal(isPostArchived("unexpected-string"), false, "Primitive string input");
|
||||||
|
assert.equal(isPostArchived(12345), false, "Primitive number input");
|
||||||
|
assert.equal(isPostArchived([]), false, "Array input");
|
||||||
|
assert.equal(isPostArchived({ data: null }), false, "Null data property");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Archived Posts: isPostPublic excludes archived posts from active public dispatches", () => {
|
||||||
|
assert.equal(
|
||||||
|
isPostPublic({ data: { title: "Active Post", draft: false, hidden: false, archive: false } }),
|
||||||
|
true,
|
||||||
|
"Non-archived public post is public"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isPostPublic({ data: { title: "Archived Post", archive: true } }),
|
||||||
|
false,
|
||||||
|
"Archived post is excluded from active public dispatches"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isPostPublic({ data: { title: "Archived String Post", archive: "true" } }),
|
||||||
|
false,
|
||||||
|
"Archived string post is excluded from active public dispatches"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Archived Posts: Eleventy collections separate active posts and archivedPosts while excluding drafts and hidden notes", () => {
|
||||||
|
const collections = new Map();
|
||||||
|
configureEleventy({
|
||||||
|
setUseGitIgnore() {},
|
||||||
|
addPassthroughCopy() {},
|
||||||
|
addPlugin() {},
|
||||||
|
setLibrary() {},
|
||||||
|
addFilter() {},
|
||||||
|
addCollection(name, callback) {
|
||||||
|
collections.set(name, callback);
|
||||||
|
},
|
||||||
|
addGlobalData() {},
|
||||||
|
on() {},
|
||||||
|
setServerOptions() {}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(collections.has("posts"), "posts collection must be registered");
|
||||||
|
assert.ok(collections.has("archivedPosts"), "archivedPosts collection must be registered");
|
||||||
|
|
||||||
|
const samplePosts = [
|
||||||
|
{
|
||||||
|
inputPath: "src/posts/2026-10-06-active-new.md",
|
||||||
|
date: new Date("2026-10-06"),
|
||||||
|
data: { title: "Active New", tags: ["systems"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
inputPath: "src/posts/2026-10-05-active-old.md",
|
||||||
|
date: new Date("2026-10-05"),
|
||||||
|
data: { title: "Active Old", archive: false, tags: ["notes"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
inputPath: "src/posts/2026-10-03-archived-new.md",
|
||||||
|
date: new Date("2026-10-03"),
|
||||||
|
data: { title: "Archived New", archive: true, tags: ["legacy-only"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
inputPath: "src/posts/2026-10-01-archived-old.md",
|
||||||
|
date: new Date("2026-10-01"),
|
||||||
|
data: { title: "Archived Old", archive: "yes", tags: ["legacy-only"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
inputPath: "src/posts/2026-10-04-archived-draft.md",
|
||||||
|
date: new Date("2026-10-04"),
|
||||||
|
data: { title: "Archived Draft", archive: true, draft: true, tags: ["draft-tag"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
inputPath: "src/posts/2026-10-04-archived-hidden.md",
|
||||||
|
date: new Date("2026-10-04"),
|
||||||
|
data: { title: "Archived Hidden", archive: true, hidden: true, tags: ["hidden-tag"] }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockCollectionApi = {
|
||||||
|
getFilteredByGlob() {
|
||||||
|
return samplePosts;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const activePosts = collections.get("posts")(mockCollectionApi);
|
||||||
|
const archivedPosts = collections.get("archivedPosts")(mockCollectionApi);
|
||||||
|
const tagList = collections.get("tagList")(mockCollectionApi);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
activePosts.map((p) => p.data.title),
|
||||||
|
["Active New", "Active Old"],
|
||||||
|
"collections.posts must only contain non-archived, non-draft, non-hidden posts"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
archivedPosts.map((p) => p.data.title),
|
||||||
|
["Archived New", "Archived Old"],
|
||||||
|
"collections.archivedPosts must only contain non-draft, non-hidden archived posts sorted chronologically"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(!tagList.includes("legacy-only"), "Archived-only tags must not leak into active Dispatches tagList");
|
||||||
|
assert.ok(!tagList.includes("draft-tag"), "Draft tags must not leak into tagList");
|
||||||
|
assert.ok(!tagList.includes("hidden-tag"), "Hidden tags must not leak into tagList");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Archived Posts: Archive Telemetry sidebar contains link to /archive/ and archive.njk template omits featured section", () => {
|
||||||
|
const sidebarPath = path.resolve("src/_includes/partials/stats-sidebar.njk");
|
||||||
|
assert.ok(fs.existsSync(sidebarPath), "stats-sidebar.njk must exist");
|
||||||
|
const sidebarContent = fs.readFileSync(sidebarPath, "utf-8");
|
||||||
|
|
||||||
|
// Extract Archive Telemetry section and verify link to /archive
|
||||||
|
const telemetryMatch = sidebarContent.match(
|
||||||
|
/<section[^>]*aria-label="Archive Telemetry"[^>]*>([\s\S]*?)<\/section>/i
|
||||||
|
);
|
||||||
|
assert.ok(telemetryMatch, "Archive Telemetry section must exist in stats-sidebar.njk");
|
||||||
|
assert.match(
|
||||||
|
telemetryMatch[1],
|
||||||
|
/href="\/archive\/?"/i,
|
||||||
|
"Archive Telemetry section must contain a link to /archive/ (riverrooks.dev/archive)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify src/archive.njk exists and mirrors dispatches.njk minus featured section
|
||||||
|
const archivePagePath = path.resolve("src/archive.njk");
|
||||||
|
assert.ok(fs.existsSync(archivePagePath), "src/archive.njk must exist");
|
||||||
|
const archivePageContent = fs.readFileSync(archivePagePath, "utf-8");
|
||||||
|
|
||||||
|
assert.match(archivePageContent, /layout:\s*layouts\/base\.njk/, "archive.njk must use base layout");
|
||||||
|
assert.match(archivePageContent, /collections\.archivedPosts/, "archive.njk must iterate over collections.archivedPosts");
|
||||||
|
assert.match(archivePageContent, /partials\/post-card\.njk/, "archive.njk must render post cards");
|
||||||
|
assert.doesNotMatch(archivePageContent, /featured-card\.njk/, "archive.njk must NOT include the featured section");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Archived Posts: Obsidian normalization preserves archive frontmatter property", () => {
|
||||||
|
const obsidianNote = `---
|
||||||
|
title: "Legacy Architecture Notes"
|
||||||
|
created: 2026-10-03
|
||||||
|
tags: ["#archive", "#systems"]
|
||||||
|
archive: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Legacy Notes
|
||||||
|
Archived reference documentation.
|
||||||
|
`;
|
||||||
|
|
||||||
|
const normalized = normalizeObsidianMarkdown(obsidianNote);
|
||||||
|
assert.match(normalized, /archive:\s*true/, "Normalized markdown must preserve 'archive: true'");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Archived Posts: Example archived markdown post exists in src/posts/ and builds to _site/archive/index.html", () => {
|
||||||
|
const postsDir = path.resolve("src/posts");
|
||||||
|
const postFiles = fs.readdirSync(postsDir).filter((f) => f.endsWith(".md"));
|
||||||
|
|
||||||
|
const archivedExamples = postFiles.filter((file) => {
|
||||||
|
const content = fs.readFileSync(path.join(postsDir, file), "utf-8");
|
||||||
|
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||||
|
return fmMatch && /^archive:\s*true\s*$/m.test(fmMatch[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
archivedExamples.length >= 1,
|
||||||
|
"At least one example markdown post in src/posts/ must have 'archive: true'"
|
||||||
|
);
|
||||||
|
|
||||||
|
const dispatchesHtmlPath = path.resolve("_site/dispatches/index.html");
|
||||||
|
const archiveHtmlPath = path.resolve("_site/archive/index.html");
|
||||||
|
|
||||||
|
if (!fs.existsSync(archiveHtmlPath)) {
|
||||||
|
execSync("npm run build", {
|
||||||
|
stdio: "pipe",
|
||||||
|
env: { ...process.env, SITE_DOMAIN: process.env.SITE_DOMAIN || "riverrooks.dev" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ok(fs.existsSync(dispatchesHtmlPath), "_site/dispatches/index.html must exist");
|
||||||
|
assert.ok(fs.existsSync(archiveHtmlPath), "_site/archive/index.html must exist");
|
||||||
|
|
||||||
|
const dispatchesHtml = fs.readFileSync(dispatchesHtmlPath, "utf-8");
|
||||||
|
const archiveHtml = fs.readFileSync(archiveHtmlPath, "utf-8");
|
||||||
|
|
||||||
|
// 1. Dispatches page has link to /archive/ in Archive Telemetry
|
||||||
|
const dispatchesTelemetry = dispatchesHtml.match(
|
||||||
|
/<section[^>]*aria-label="Archive Telemetry"[^>]*>([\s\S]*?)<\/section>/i
|
||||||
|
);
|
||||||
|
assert.ok(dispatchesTelemetry, "Compiled dispatches page must have Archive Telemetry section");
|
||||||
|
assert.match(
|
||||||
|
dispatchesTelemetry[1],
|
||||||
|
/href="\/archive\/?"/i,
|
||||||
|
"Compiled Archive Telemetry section must link to /archive/"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Archived example post does NOT appear on Dispatches page, but DOES appear on Archive page
|
||||||
|
assert.doesNotMatch(
|
||||||
|
dispatchesHtml,
|
||||||
|
/Archived Dispatch Demo/,
|
||||||
|
"Archived post must NOT appear in the Dispatches page list or featured card"
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
archiveHtml,
|
||||||
|
/Archived Dispatch Demo/,
|
||||||
|
"Archived post MUST appear on the /archive/ page"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Archive page does NOT have the Featured Dispatch section
|
||||||
|
assert.doesNotMatch(
|
||||||
|
archiveHtml,
|
||||||
|
/aria-label="Featured Technical Dispatch"/i,
|
||||||
|
"Archive page must not contain the Featured Dispatch section"
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import configureEleventy from "../eleventy.config.js";
|
||||||
|
import { formatBuildAge } from "../src/assets/js/build-timer.js";
|
||||||
|
|
||||||
|
test("Build age: formats elapsed time as a compact live footer value", () => {
|
||||||
|
assert.equal(
|
||||||
|
formatBuildAge("2026-10-20T12:00:00.000Z", "2026-10-20T12:00:00.000Z"),
|
||||||
|
"0s ago"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
formatBuildAge("2026-10-20T12:00:00.000Z", "2026-10-20T12:01:01.000Z"),
|
||||||
|
"1m 1s ago"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
formatBuildAge("2026-10-20T12:00:00.000Z", "2026-10-21T14:03:04.000Z"),
|
||||||
|
"1d 2h ago"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
formatBuildAge("2026-10-20T12:00:00.000Z", "2026-10-20T11:59:59.000Z"),
|
||||||
|
"0s ago"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Build age: Eleventy exposes one ISO timestamp for each rebuild", () => {
|
||||||
|
const globalData = new Map();
|
||||||
|
const events = new Map();
|
||||||
|
configureEleventy({
|
||||||
|
setUseGitIgnore() {},
|
||||||
|
addPassthroughCopy() {},
|
||||||
|
addPlugin() {},
|
||||||
|
setLibrary() {},
|
||||||
|
addFilter() {},
|
||||||
|
addCollection() {},
|
||||||
|
addGlobalData(name, value) {
|
||||||
|
globalData.set(name, value);
|
||||||
|
},
|
||||||
|
on(name, callback) {
|
||||||
|
events.set(name, callback);
|
||||||
|
},
|
||||||
|
setServerOptions() {}
|
||||||
|
});
|
||||||
|
|
||||||
|
events.get("eleventy.before")();
|
||||||
|
assert.match(
|
||||||
|
globalData.get("buildTimestamp")(),
|
||||||
|
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Build age: footer provides an accessible live timer backed by the build timestamp", () => {
|
||||||
|
const footer = fs.readFileSync(path.resolve("src/_includes/partials/footer.njk"), "utf-8");
|
||||||
|
const baseLayout = fs.readFileSync(path.resolve("src/_includes/layouts/base.njk"), "utf-8");
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
footer,
|
||||||
|
/<time id="build-age" data-build-timestamp="{{ buildTimestamp }}" aria-live="polite">REBUILT: INITIALIZING<\/time>/
|
||||||
|
);
|
||||||
|
assert.match(baseLayout, /<script type="module" src="\/assets\/js\/build-timer\.js"><\/script>/);
|
||||||
|
});
|
||||||
@@ -45,7 +45,7 @@ test("Build & Link Integrity: Output inspection and link verification", () => {
|
|||||||
// 2. HTML Boilerplate Invariants
|
// 2. HTML Boilerplate Invariants
|
||||||
assert.match(content, /<!DOCTYPE html>/i, `${relHtml} must contain <!DOCTYPE html>`);
|
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, /<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`);
|
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!)
|
// 3. Internal Link Integrity (No 404s!)
|
||||||
const internalLinks = [...content.matchAll(/href="(\/[^"#?]*)"/g)].map((m) => m[1]);
|
const internalLinks = [...content.matchAll(/href="(\/[^"#?]*)"/g)].map((m) => m[1]);
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const agentsMdPath = path.resolve("AGENTS.md");
|
||||||
|
const rulesMdPath = path.resolve(".agents/rules/blog-system.md");
|
||||||
|
|
||||||
|
test("Development Standards: AGENTS.md enforces test-first, adversarial testing, and verification gates", () => {
|
||||||
|
assert.ok(fs.existsSync(agentsMdPath), "AGENTS.md must exist in root");
|
||||||
|
const content = fs.readFileSync(agentsMdPath, "utf-8");
|
||||||
|
|
||||||
|
// 1. Must enforce writing tests BEFORE modifying functional/production code
|
||||||
|
assert.match(
|
||||||
|
content,
|
||||||
|
/before\s+(?:writing\s+or\s+)?modifying\s+(?:any\s+)?(?:functional|production)\s+code/i,
|
||||||
|
"AGENTS.md must explicitly mandate that tests are written before modifying functional/production code"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Must mandate adversarial testing
|
||||||
|
assert.match(
|
||||||
|
content,
|
||||||
|
/adversarial\s+test(?:ing|s)/i,
|
||||||
|
"AGENTS.md must mandate adversarial testing"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Must mandate that all tests pass before presenting final output
|
||||||
|
assert.match(
|
||||||
|
content,
|
||||||
|
/pass\s+before\s+presenting\s+(?:the\s+)?final\s+output/i,
|
||||||
|
"AGENTS.md must mandate that test cases pass before presenting final output"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Development Standards: .agents/rules/blog-system.md enforces test-first, adversarial testing, and verification gates", () => {
|
||||||
|
assert.ok(fs.existsSync(rulesMdPath), ".agents/rules/blog-system.md must exist");
|
||||||
|
const content = fs.readFileSync(rulesMdPath, "utf-8");
|
||||||
|
|
||||||
|
// 1. Must enforce writing tests BEFORE modifying functional/production code
|
||||||
|
assert.match(
|
||||||
|
content,
|
||||||
|
/before\s+(?:writing\s+or\s+)?modifying\s+(?:any\s+)?(?:functional|production)\s+code/i,
|
||||||
|
"blog-system.md must explicitly mandate that tests are written before modifying functional/production code"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Must mandate adversarial testing
|
||||||
|
assert.match(
|
||||||
|
content,
|
||||||
|
/adversarial\s+test(?:ing|s)/i,
|
||||||
|
"blog-system.md must mandate adversarial testing"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Must mandate that all tests pass before presenting final output
|
||||||
|
assert.match(
|
||||||
|
content,
|
||||||
|
/pass\s+before\s+presenting\s+(?:the\s+)?final\s+output/i,
|
||||||
|
"blog-system.md must mandate that test cases pass before presenting final output"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Development Standards: Adversarial checks against vague or permissive testing language", () => {
|
||||||
|
const files = [agentsMdPath, rulesMdPath];
|
||||||
|
for (const filePath of files) {
|
||||||
|
const content = fs.readFileSync(filePath, "utf-8");
|
||||||
|
const relPath = path.relative(process.cwd(), filePath);
|
||||||
|
|
||||||
|
// Adversarial: ensure no permissive "or alongside implementing" loophole exists
|
||||||
|
assert.doesNotMatch(
|
||||||
|
content,
|
||||||
|
/or\s+alongside\s+implementing/i,
|
||||||
|
`Violation in ${relPath}: Permissive loophole "or alongside implementing" must not exist; tests must strictly precede functional code`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Development Standards: README.md and skills reference TDD, adversarial testing, and verification", () => {
|
||||||
|
const readmePath = path.resolve("README.md");
|
||||||
|
assert.ok(fs.existsSync(readmePath), "README.md must exist");
|
||||||
|
const readmeContent = fs.readFileSync(readmePath, "utf-8");
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
readmeContent,
|
||||||
|
/written\s+before\s+(?:writing\s+or\s+)?modifying\s+(?:any\s+)?functional\s+code/i,
|
||||||
|
"README.md must document writing tests before modifying functional code"
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
readmeContent,
|
||||||
|
/adversarial\s+test(?:ing|s)/i,
|
||||||
|
"README.md must document mandatory adversarial testing"
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
readmeContent,
|
||||||
|
/pass\s+before\s+presenting\s+(?:the\s+)?final\s+output/i,
|
||||||
|
"README.md must document that tests must pass before presenting final output"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check eleventy-tiling-blog skill
|
||||||
|
const eleventySkillPath = path.resolve(".agents/skills/eleventy-tiling-blog/SKILL.md");
|
||||||
|
assert.ok(fs.existsSync(eleventySkillPath), "eleventy-tiling-blog SKILL.md must exist");
|
||||||
|
const eleventySkill = fs.readFileSync(eleventySkillPath, "utf-8");
|
||||||
|
assert.match(eleventySkill, /adversarial/i, "eleventy-tiling-blog SKILL.md must mention adversarial testing");
|
||||||
|
assert.match(eleventySkill, /before\s+modifying\s+functional/i, "eleventy-tiling-blog SKILL.md must mandate tests before modifying functional code");
|
||||||
|
|
||||||
|
// Check obsidian-sync skill
|
||||||
|
const obsidianSkillPath = path.resolve(".agents/skills/obsidian-sync/SKILL.md");
|
||||||
|
assert.ok(fs.existsSync(obsidianSkillPath), "obsidian-sync SKILL.md must exist");
|
||||||
|
const obsidianSkill = fs.readFileSync(obsidianSkillPath, "utf-8");
|
||||||
|
assert.match(obsidianSkill, /adversarial/i, "obsidian-sync SKILL.md must mention adversarial testing");
|
||||||
|
assert.match(obsidianSkill, /before[*\s`_]+modifying/i, "obsidian-sync SKILL.md must mandate tests before modifying functional code");
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -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";
|
||||||
|
|
||||||
test("Docker Configuration: Dockerfile enforces Node runtime and excludes Nginx", () => {
|
test("Docker Configuration: Dockerfile enforces Node runtime, healthcheck, and entrypoint", () => {
|
||||||
const dockerfilePath = path.resolve(process.cwd(), "Dockerfile");
|
const dockerfilePath = path.resolve(process.cwd(), "Dockerfile");
|
||||||
assert.ok(fs.existsSync(dockerfilePath), "Dockerfile must exist");
|
assert.ok(fs.existsSync(dockerfilePath), "Dockerfile must exist");
|
||||||
|
|
||||||
@@ -16,12 +16,32 @@ test("Docker Configuration: Dockerfile enforces Node runtime and excludes Nginx"
|
|||||||
assert.doesNotMatch(content, /FROM\s+nginx/i, "Dockerfile must not use nginx base image");
|
assert.doesNotMatch(content, /FROM\s+nginx/i, "Dockerfile must not use nginx base image");
|
||||||
assert.doesNotMatch(content, /nginx/i, "Dockerfile must not contain nginx directives");
|
assert.doesNotMatch(content, /nginx/i, "Dockerfile must not contain nginx directives");
|
||||||
|
|
||||||
// Must build static site and serve with npm
|
// Must build static site and configure dynamic port
|
||||||
assert.match(content, /RUN\s+npm\s+run\s+build/, "Dockerfile must run npm build");
|
assert.match(content, /RUN\s+npm\s+run\s+build/, "Dockerfile must compile baseline static assets");
|
||||||
assert.match(content, /ARG\s+PORT=8080/, "Dockerfile must define ARG PORT with default fallback");
|
assert.match(content, /ARG\s+PORT=8080/, "Dockerfile must define ARG PORT with default fallback");
|
||||||
assert.match(content, /ENV\s+PORT=\$\{?PORT\}?/, "Dockerfile must define ENV PORT using variable");
|
assert.match(content, /ENV\s+PORT=\$\{?PORT\}?/, "Dockerfile must define ENV PORT using variable");
|
||||||
assert.match(content, /EXPOSE\s+\$\{?PORT\}?/, "Dockerfile must expose dynamic PORT variable");
|
assert.match(content, /EXPOSE\s+\$\{?PORT\}?/, "Dockerfile must expose dynamic PORT variable");
|
||||||
assert.match(content, /CMD\s*\[\s*["']npm["']\s*,\s*["']start["']\s*\]/, "Dockerfile must start via npm start");
|
|
||||||
|
// Must define built-in HEALTHCHECK probing 127.0.0.1 loopback
|
||||||
|
assert.match(content, /HEALTHCHECK[\s\S]*?127\.0\.0\.1/, "Dockerfile must define baked-in HEALTHCHECK on 127.0.0.1");
|
||||||
|
|
||||||
|
// Must configure dynamic entrypoint script
|
||||||
|
assert.match(content, /ENTRYPOINT\s*\[\s*["']\/app\/entrypoint\.sh["']\s*\]/, "Dockerfile must use /app/entrypoint.sh entrypoint");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Docker Configuration: entrypoint.sh script exists and has valid structure", () => {
|
||||||
|
const entrypointPath = path.resolve(process.cwd(), "entrypoint.sh");
|
||||||
|
assert.ok(fs.existsSync(entrypointPath), "entrypoint.sh must exist");
|
||||||
|
|
||||||
|
const stats = fs.statSync(entrypointPath);
|
||||||
|
// Check executable permissions (at least owner executable: 0o100)
|
||||||
|
assert.ok(Boolean(stats.mode & 0o111), "entrypoint.sh must have executable permission bits");
|
||||||
|
|
||||||
|
const content = fs.readFileSync(entrypointPath, "utf-8");
|
||||||
|
assert.match(content, /^#!\/bin\/sh/, "entrypoint.sh must use POSIX /bin/sh shebang");
|
||||||
|
assert.match(content, /exec\s+"\$@"/, "entrypoint.sh must pass through custom container arguments");
|
||||||
|
assert.match(content, /npm\s+run\s+build/, "entrypoint.sh must build the static site");
|
||||||
|
assert.match(content, /exec\s+node\s+server\.js/, "entrypoint.sh must start static server with exec");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Docker Configuration: docker-compose configures Node service correctly", () => {
|
test("Docker Configuration: docker-compose configures Node service correctly", () => {
|
||||||
@@ -33,9 +53,12 @@ test("Docker Configuration: docker-compose configures Node service correctly", (
|
|||||||
const content = fs.readFileSync(composePath, "utf-8");
|
const content = fs.readFileSync(composePath, "utf-8");
|
||||||
|
|
||||||
// Verifies dynamic port mapping and environment variables
|
// Verifies dynamic port mapping and environment variables
|
||||||
assert.match(content, /PORT:\s*"?\$\{PORT:-8080\}"?/, "docker-compose.yml must pass dynamic PORT build arg and env");
|
assert.match(content, /PORT.*8080/, "docker-compose.yml must configure PORT fallback");
|
||||||
assert.match(content, /:\$\{PORT:-8080\}/, "docker-compose.yml must map dynamic container PORT");
|
assert.match(content, /:\$\{PORT:-8080\}/, "docker-compose.yml must map dynamic container PORT");
|
||||||
|
|
||||||
// Verifies domain argument enforcement
|
// Verifies domain argument enforcement
|
||||||
assert.match(content, /SITE_DOMAIN/, "docker-compose.yml must require SITE_DOMAIN");
|
assert.match(content, /SITE_DOMAIN/, "docker-compose.yml must require SITE_DOMAIN");
|
||||||
|
|
||||||
|
// Verifies markdown volume mount
|
||||||
|
assert.match(content, /\/app\/src\/posts/, "docker-compose.yml must mount external markdown posts");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import nunjucks from "nunjucks";
|
||||||
|
import path from "node:path";
|
||||||
|
import { filters } from "../src/_filters/index.js";
|
||||||
|
|
||||||
|
const includesDir = path.resolve("src/_includes");
|
||||||
|
const markdownExcerpt = "Read the [deployment guide](/guides/deploy/) before continuing.";
|
||||||
|
|
||||||
|
function renderTemplate(template, context) {
|
||||||
|
const env = new nunjucks.Environment(new nunjucks.FileSystemLoader(includesDir), { autoescape: false });
|
||||||
|
for (const [name, filter] of Object.entries(filters)) {
|
||||||
|
env.addFilter(name, filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
return env.render(template, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Excerpt Markdown: renders Markdown links as safe inline HTML", () => {
|
||||||
|
assert.equal(
|
||||||
|
filters.renderExcerptMarkdown(markdownExcerpt),
|
||||||
|
'Read the <a href="/guides/deploy/">deployment guide</a> before continuing.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Excerpt Markdown: renders links in post-card previews", () => {
|
||||||
|
const output = renderTemplate("partials/post-card.njk", {
|
||||||
|
post: {
|
||||||
|
fileSlug: "example-dispatch",
|
||||||
|
url: "/dispatches/example/",
|
||||||
|
date: "2026-10-06",
|
||||||
|
data: {
|
||||||
|
title: "Example dispatch",
|
||||||
|
excerpt: markdownExcerpt,
|
||||||
|
tags: ["systems"]
|
||||||
|
},
|
||||||
|
templateContent: "A short article."
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(output, /<p class="feed-excerpt">\s*Read the <a href="\/guides\/deploy\/">deployment guide<\/a> before continuing\.\s*<\/p>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Excerpt Markdown: renders links in featured previews and escapes raw HTML", () => {
|
||||||
|
const output = renderTemplate("partials/featured-card.njk", {
|
||||||
|
collections: {
|
||||||
|
posts: [{
|
||||||
|
url: "/dispatches/example/",
|
||||||
|
date: "2026-10-06",
|
||||||
|
data: {
|
||||||
|
featured: true,
|
||||||
|
tags: ["systems"],
|
||||||
|
title: "Example dispatch",
|
||||||
|
excerpt: `${markdownExcerpt} <script>alert('unsafe')</script>`
|
||||||
|
},
|
||||||
|
templateContent: ""
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(output, /<a href="\/guides\/deploy\/">deployment guide<\/a>/);
|
||||||
|
assert.match(output, /<script>alert\('unsafe'\)<\/script>/);
|
||||||
|
assert.doesNotMatch(output, /<script>/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import nunjucks from "nunjucks";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
extractTerminalPreview,
|
||||||
|
filterTagList,
|
||||||
|
isoDate,
|
||||||
|
postDate,
|
||||||
|
readingTime,
|
||||||
|
renderExcerptMarkdown
|
||||||
|
} from "../src/_filters/index.js";
|
||||||
|
|
||||||
|
const includesDir = path.resolve("src/_includes");
|
||||||
|
|
||||||
|
function renderFeaturedCard(terminal) {
|
||||||
|
const env = new nunjucks.Environment(new nunjucks.FileSystemLoader(includesDir), { autoescape: false });
|
||||||
|
env.addFilter("extractTerminalPreview", extractTerminalPreview);
|
||||||
|
env.addFilter("filterTagList", filterTagList);
|
||||||
|
env.addFilter("isoDate", isoDate);
|
||||||
|
env.addFilter("postDate", postDate);
|
||||||
|
env.addFilter("readingTime", readingTime);
|
||||||
|
env.addFilter("renderExcerptMarkdown", renderExcerptMarkdown);
|
||||||
|
|
||||||
|
const featured = {
|
||||||
|
url: "/dispatches/example/",
|
||||||
|
date: "2026-10-06",
|
||||||
|
data: {
|
||||||
|
featured: true,
|
||||||
|
terminal,
|
||||||
|
tags: ["systems"],
|
||||||
|
title: "Example dispatch",
|
||||||
|
excerpt: "An example featured dispatch used to verify the terminal preview."
|
||||||
|
},
|
||||||
|
templateContent: `
|
||||||
|
<h1 id="terminal-preview">Terminal preview</h1>
|
||||||
|
<p>Inspect the first five lines:</p>
|
||||||
|
<pre><code class="language-bash">cat /dispatches/example | head -n 5</code></pre>
|
||||||
|
<h1 id="investigation">Investigation</h1>
|
||||||
|
<p>The public article begins here.</p>
|
||||||
|
`
|
||||||
|
};
|
||||||
|
|
||||||
|
return env.render("partials/featured-card.njk", {
|
||||||
|
collections: { posts: [featured] }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Featured terminal preview: renders Markdown output only when terminal frontmatter is true", () => {
|
||||||
|
const output = renderFeaturedCard(true);
|
||||||
|
|
||||||
|
assert.match(output, /class="[^"]*terminal-code-box[^"]*"/);
|
||||||
|
assert.match(output, /Inspect the first five lines:/);
|
||||||
|
assert.match(output, /cat \/dispatches\/example \| head -n 5/);
|
||||||
|
assert.doesNotMatch(output, /Terminal preview/);
|
||||||
|
assert.doesNotMatch(output, /init_cluster\.sh/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Featured terminal preview: does not render when terminal frontmatter is false or absent", () => {
|
||||||
|
assert.doesNotMatch(renderFeaturedCard(false), /terminal-code-box/);
|
||||||
|
assert.doesNotMatch(renderFeaturedCard(undefined), /terminal-code-box/);
|
||||||
|
});
|
||||||
@@ -6,10 +6,15 @@ import path from "node:path";
|
|||||||
const postsDir = path.resolve("src/posts");
|
const postsDir = path.resolve("src/posts");
|
||||||
|
|
||||||
test("Content Contract: All posts in src/posts/ satisfy frontmatter schema", () => {
|
test("Content Contract: All posts in src/posts/ satisfy frontmatter schema", () => {
|
||||||
assert.ok(fs.existsSync(postsDir), "src/posts directory must exist");
|
if (!fs.existsSync(postsDir)) {
|
||||||
|
fs.mkdirSync(postsDir, { recursive: true });
|
||||||
|
}
|
||||||
const postFiles = fs.readdirSync(postsDir).filter((f) => f.endsWith(".md"));
|
const postFiles = fs.readdirSync(postsDir).filter((f) => f.endsWith(".md"));
|
||||||
|
|
||||||
assert.ok(postFiles.length > 0, "There must be at least one published post");
|
if (postFiles.length === 0) {
|
||||||
|
// When posts are decoupled and mounted externally or ingested via API
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const file of postFiles) {
|
for (const file of postFiles) {
|
||||||
const filePath = path.join(postsDir, file);
|
const filePath = path.join(postsDir, file);
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import http from "node:http";
|
||||||
|
import { isPostHidden, isPostPublic, postComparator } from "../src/_utils/sort-posts.js";
|
||||||
|
import { createServer, normalizeObsidianMarkdown } from "../server.js";
|
||||||
|
|
||||||
|
test("Hidden Posts: isPostHidden accurately identifies hidden flags across data shapes", () => {
|
||||||
|
// 1. Explicit boolean true
|
||||||
|
assert.equal(isPostHidden({ data: { hidden: true } }), true, "Explicit boolean true in data");
|
||||||
|
assert.equal(isPostHidden({ hidden: true }), true, "Root level hidden true");
|
||||||
|
|
||||||
|
// 2. Truthy string representations (case-insensitive & trimmed)
|
||||||
|
assert.equal(isPostHidden({ data: { hidden: "true" } }), true, "String 'true'");
|
||||||
|
assert.equal(isPostHidden({ data: { hidden: "TRUE" } }), true, "Uppercase string 'TRUE'");
|
||||||
|
assert.equal(isPostHidden({ data: { hidden: " true " } }), true, "Whitespace-padded 'true'");
|
||||||
|
assert.equal(isPostHidden({ data: { hidden: "yes" } }), true, "String 'yes'");
|
||||||
|
assert.equal(isPostHidden({ data: { hidden: "YES" } }), true, "String 'YES'");
|
||||||
|
assert.equal(isPostHidden({ data: { hidden: "1" } }), true, "String '1'");
|
||||||
|
assert.equal(isPostHidden({ data: { hidden: 1 } }), true, "Numeric 1");
|
||||||
|
|
||||||
|
// 3. Falsy representations
|
||||||
|
assert.equal(isPostHidden({ data: { hidden: false } }), false, "Explicit boolean false");
|
||||||
|
assert.equal(isPostHidden({ data: { hidden: "false" } }), false, "String 'false'");
|
||||||
|
assert.equal(isPostHidden({ data: { hidden: "no" } }), false, "String 'no'");
|
||||||
|
assert.equal(isPostHidden({ data: { hidden: "0" } }), false, "String '0'");
|
||||||
|
assert.equal(isPostHidden({ data: { hidden: 0 } }), false, "Numeric 0");
|
||||||
|
assert.equal(isPostHidden({ data: { hidden: "" } }), false, "Empty string");
|
||||||
|
assert.equal(isPostHidden({ data: { hidden: null } }), false, "Null property");
|
||||||
|
assert.equal(isPostHidden({ data: {} }), false, "Missing hidden property");
|
||||||
|
assert.equal(isPostHidden({}), false, "Empty post object");
|
||||||
|
|
||||||
|
// 4. Adversarial edge cases
|
||||||
|
assert.equal(isPostHidden(null), false, "Null input");
|
||||||
|
assert.equal(isPostHidden(undefined), false, "Undefined input");
|
||||||
|
assert.equal(isPostHidden("unexpected-string"), false, "Primitive string input");
|
||||||
|
assert.equal(isPostHidden(12345), false, "Primitive number input");
|
||||||
|
assert.equal(isPostHidden([]), false, "Array input");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Hidden Posts: isPostPublic requires post to be neither draft nor hidden", () => {
|
||||||
|
// Public post: not draft, not hidden
|
||||||
|
assert.equal(
|
||||||
|
isPostPublic({ data: { title: "Public Post", draft: false, hidden: false } }),
|
||||||
|
true,
|
||||||
|
"Standard post is public"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isPostPublic({ data: { title: "Public Post" } }),
|
||||||
|
true,
|
||||||
|
"Post without draft or hidden is public"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Hidden post is NOT public
|
||||||
|
assert.equal(
|
||||||
|
isPostPublic({ data: { title: "Hidden Post", hidden: true } }),
|
||||||
|
false,
|
||||||
|
"Hidden post is not public"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
isPostPublic({ data: { title: "Hidden Post", hidden: "true" } }),
|
||||||
|
false,
|
||||||
|
"Hidden string post is not public"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Draft post is NOT public
|
||||||
|
assert.equal(
|
||||||
|
isPostPublic({ data: { title: "Draft Post", draft: true } }),
|
||||||
|
false,
|
||||||
|
"Draft post is not public"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Both draft and hidden is NOT public
|
||||||
|
assert.equal(
|
||||||
|
isPostPublic({ data: { title: "Draft and Hidden", draft: true, hidden: true } }),
|
||||||
|
false,
|
||||||
|
"Draft and hidden post is not public"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Adversarial checks
|
||||||
|
assert.equal(isPostPublic(null), false, "Null is not public");
|
||||||
|
assert.equal(isPostPublic(undefined), false, "Undefined is not public");
|
||||||
|
assert.equal(isPostPublic("random"), false, "Non-object is not public");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Hidden Posts: Collection filtering excludes hidden posts from public posts list and tagList", () => {
|
||||||
|
const posts = [
|
||||||
|
{
|
||||||
|
data: { title: "Normal Post", tags: ["tech"] },
|
||||||
|
date: new Date("2026-09-10")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: { title: "Pinned Post", pinned: true, tags: ["tech", "featured"] },
|
||||||
|
date: new Date("2026-09-01")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: { title: "Draft Post", draft: true, tags: ["wip"] },
|
||||||
|
date: new Date("2026-09-15")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: { title: "Secret Hidden Post", hidden: true, tags: ["secret-only"] },
|
||||||
|
date: new Date("2026-09-20")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: { title: "Another Hidden Post", hidden: "yes", tags: ["internal"] },
|
||||||
|
date: new Date("2026-09-21")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: { title: "Explicit Unhidden Post", hidden: false, tags: ["news"] },
|
||||||
|
date: new Date("2026-09-12")
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Filter public posts using isPostPublic (or !draft && !isPostHidden)
|
||||||
|
const publicPosts = posts.filter(isPostPublic).sort(postComparator);
|
||||||
|
|
||||||
|
const titles = publicPosts.map((p) => p.data.title);
|
||||||
|
assert.deepEqual(
|
||||||
|
titles,
|
||||||
|
["Pinned Post", "Explicit Unhidden Post", "Normal Post"],
|
||||||
|
"Public posts must include only non-draft, non-hidden posts in proper sorted order"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify secret-only tags from hidden posts do not leak into public tag list
|
||||||
|
const publicTags = new Set();
|
||||||
|
posts
|
||||||
|
.filter(isPostPublic)
|
||||||
|
.forEach((item) => {
|
||||||
|
const tags = item.data.tags || [];
|
||||||
|
for (const t of tags) publicTags.add(t);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(!publicTags.has("secret-only"), "Hidden post tag 'secret-only' must not appear in public tag list");
|
||||||
|
assert.ok(!publicTags.has("internal"), "Hidden post tag 'internal' must not appear in public tag list");
|
||||||
|
assert.ok(!publicTags.has("wip"), "Draft post tag 'wip' must not appear in public tag list");
|
||||||
|
assert.ok(publicTags.has("tech"), "Public post tag 'tech' must appear in public tag list");
|
||||||
|
assert.ok(publicTags.has("featured"), "Public post tag 'featured' must appear in public tag list");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Hidden Posts: Post layout injects noindex meta tag when hidden is true", () => {
|
||||||
|
const postLayoutPath = path.resolve("src/_includes/layouts/post.njk");
|
||||||
|
assert.ok(fs.existsSync(postLayoutPath), "post.njk must exist");
|
||||||
|
const postLayout = fs.readFileSync(postLayoutPath, "utf-8");
|
||||||
|
|
||||||
|
// Adversarial verification: hidden posts must instruct search crawlers not to index or follow
|
||||||
|
assert.match(
|
||||||
|
postLayout,
|
||||||
|
/noindex,\s*nofollow/i,
|
||||||
|
"post.njk must define 'noindex, nofollow' robots meta tag when hidden"
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
postLayout,
|
||||||
|
/{%\s*if\s+hidden\b/i,
|
||||||
|
"post.njk must conditionally check if post is hidden"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test("Hidden Posts: Obsidian normalization preserves hidden frontmatter property", () => {
|
||||||
|
const obsidianNote = `---
|
||||||
|
title: "Confidential System Blueprint"
|
||||||
|
created: 2026-09-23
|
||||||
|
tags: ["#architecture", "#secret"]
|
||||||
|
hidden: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Blueprint
|
||||||
|
Details for authorized eyes only.
|
||||||
|
`;
|
||||||
|
|
||||||
|
const normalized = normalizeObsidianMarkdown(obsidianNote);
|
||||||
|
assert.match(normalized, /hidden:\s*true/, "Normalized markdown must preserve 'hidden: true'");
|
||||||
|
assert.match(normalized, /title:\s*"Confidential System Blueprint"/, "Preserves title");
|
||||||
|
assert.match(normalized, /date:\s*2026-09-23/, "Bridges created to date");
|
||||||
|
assert.match(normalized, /layout:\s*layouts\/post\.njk/, "Adds post layout");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Hidden Posts: End-to-end static compilation and accessibility via direct link", async () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-site-"));
|
||||||
|
const tmpPosts = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-posts-"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Create a public post and a hidden post
|
||||||
|
const publicPostContent = `---
|
||||||
|
title: "Public Announcement"
|
||||||
|
date: 2026-09-20
|
||||||
|
layout: layouts/post.njk
|
||||||
|
tags: ["news"]
|
||||||
|
excerpt: "This is a public announcement for everyone to read."
|
||||||
|
---
|
||||||
|
Public content visible to everyone.`;
|
||||||
|
|
||||||
|
const hiddenPostContent = `---
|
||||||
|
title: "Private Unlisted Memo"
|
||||||
|
date: 2026-09-22
|
||||||
|
layout: layouts/post.njk
|
||||||
|
tags: ["confidential"]
|
||||||
|
excerpt: "This is an unlisted memo meant for direct link access only."
|
||||||
|
hidden: true
|
||||||
|
---
|
||||||
|
Secret memo content available by link only.`;
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(tmpPosts, "2026-09-20-public-post.md"), publicPostContent, "utf-8");
|
||||||
|
fs.writeFileSync(path.join(tmpPosts, "2026-09-22-secret-memo.md"), hiddenPostContent, "utf-8");
|
||||||
|
|
||||||
|
// 2. Set up server with custom directories
|
||||||
|
process.env.API_SECRET = "test-secret";
|
||||||
|
const server = createServer(tmpDir, tmpPosts);
|
||||||
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
const port = server.address().port;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Simulate static files created by build
|
||||||
|
fs.mkdirSync(path.join(tmpDir, "posts/2026-09-22-secret-memo"), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(tmpDir, "posts/2026-09-20-public-post"), { recursive: true });
|
||||||
|
|
||||||
|
const secretHtml = `<!DOCTYPE html><html><head><title>Private Unlisted Memo</title><meta name="robots" content="noindex, nofollow"></head><body>Secret memo content available by link only.</body></html>`;
|
||||||
|
const publicHtml = `<!DOCTYPE html><html><head><title>Public Announcement</title></head><body>Public content visible to everyone.</body></html>`;
|
||||||
|
const indexHtml = `<!DOCTYPE html><html><body><a href="/posts/2026-09-20-public-post/">Public Announcement</a></body></html>`;
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(tmpDir, "posts/2026-09-22-secret-memo/index.html"), secretHtml);
|
||||||
|
fs.writeFileSync(path.join(tmpDir, "posts/2026-09-20-public-post/index.html"), publicHtml);
|
||||||
|
fs.writeFileSync(path.join(tmpDir, "index.html"), indexHtml);
|
||||||
|
|
||||||
|
// Verify direct access to hidden post succeeds (HTTP 200 OK)
|
||||||
|
const resSecret = await fetch(`http://127.0.0.1:${port}/posts/2026-09-22-secret-memo/`);
|
||||||
|
assert.strictEqual(resSecret.status, 200, "Direct link to hidden post must return 200 OK");
|
||||||
|
const secretBody = await resSecret.text();
|
||||||
|
assert.match(secretBody, /Secret memo content available by link only/, "Hidden post body is accessible");
|
||||||
|
assert.match(secretBody, /noindex,\s*nofollow/, "Hidden post includes noindex meta tag");
|
||||||
|
|
||||||
|
// Verify public index does NOT contain link or title to hidden post
|
||||||
|
const resIndex = await fetch(`http://127.0.0.1:${port}/`);
|
||||||
|
assert.strictEqual(resIndex.status, 200);
|
||||||
|
const indexBody = await resIndex.text();
|
||||||
|
assert.match(indexBody, /Public Announcement/, "Public post is listed on index");
|
||||||
|
assert.doesNotMatch(indexBody, /Private Unlisted Memo/, "Hidden post MUST NOT appear on index");
|
||||||
|
assert.doesNotMatch(indexBody, /secret-memo/, "Hidden post link MUST NOT appear on index");
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
fs.rmSync(tmpPosts, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Hidden Posts: Obsidian API ingestion correctly writes hidden frontmatter", async () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-site-"));
|
||||||
|
const tmpPosts = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-posts-"));
|
||||||
|
process.env.API_SECRET = "test-secret";
|
||||||
|
|
||||||
|
const server = createServer(tmpDir, tmpPosts);
|
||||||
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
const port = server.address().port;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: "Bearer test-secret"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
slug: "unlisted-post",
|
||||||
|
content: `---
|
||||||
|
title: "Direct Share Only"
|
||||||
|
created: 2026-09-23
|
||||||
|
hidden: true
|
||||||
|
tags: ["#private"]
|
||||||
|
---
|
||||||
|
Secret note content.`
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(res.status, 200);
|
||||||
|
const json = await res.json();
|
||||||
|
assert.strictEqual(json.status, "published");
|
||||||
|
assert.strictEqual(json.path, "/posts/unlisted-post/");
|
||||||
|
|
||||||
|
const writtenFile = path.join(tmpPosts, "unlisted-post.md");
|
||||||
|
assert.ok(fs.existsSync(writtenFile), "Post file must exist");
|
||||||
|
const writtenContent = fs.readFileSync(writtenFile, "utf-8");
|
||||||
|
assert.match(writtenContent, /hidden:\s*true/);
|
||||||
|
assert.match(writtenContent, /layout:\s*layouts\/post\.njk/);
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
fs.rmSync(tmpPosts, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import http from "node:http";
|
||||||
|
import { createServer, normalizeObsidianMarkdown, triggerEleventyBuild } from "../server.js";
|
||||||
|
|
||||||
|
const TEST_SECRET = "super-secret-test-token";
|
||||||
|
|
||||||
|
test("Obsidian normalization: preserves linked note header fragments", () => {
|
||||||
|
const normalized = normalizeObsidianMarkdown(
|
||||||
|
"See [[a note#header one]] and [[another note#Second Header|the other section]]."
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
normalized,
|
||||||
|
/\[a note#header one\]\(\/posts\/a-note\/#header-one\)/,
|
||||||
|
"A wikilink header must become a URL fragment on the linked note"
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
normalized,
|
||||||
|
/\[the other section\]\(\/posts\/another-note\/#second-header\)/,
|
||||||
|
"Aliased wikilinks must retain their linked-note header fragment"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Ingestion API: rejects requests without proper Bearer token", async () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-site-"));
|
||||||
|
const tmpPosts = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-posts-"));
|
||||||
|
process.env.API_SECRET = TEST_SECRET;
|
||||||
|
|
||||||
|
const server = createServer(tmpDir, tmpPosts);
|
||||||
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
const port = server.address().port;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Missing Authorization header
|
||||||
|
const resNoAuth = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ slug: "test", content: "test" })
|
||||||
|
});
|
||||||
|
assert.strictEqual(resNoAuth.status, 401, "Missing auth header must return 401");
|
||||||
|
|
||||||
|
// 2. Invalid Bearer token
|
||||||
|
const resBadAuth = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: "Bearer wrong-token"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ slug: "test", content: "test" })
|
||||||
|
});
|
||||||
|
assert.strictEqual(resBadAuth.status, 401, "Invalid token must return 401");
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
fs.rmSync(tmpPosts, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Ingestion API: validates required fields and frontmatter schema", async () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-site-"));
|
||||||
|
const tmpPosts = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-posts-"));
|
||||||
|
process.env.API_SECRET = TEST_SECRET;
|
||||||
|
|
||||||
|
const server = createServer(tmpDir, tmpPosts);
|
||||||
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
const port = server.address().port;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Missing slug
|
||||||
|
const resNoSlug = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${TEST_SECRET}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ content: "---\ntitle: 'Test'\ndate: 2026-09-14\n---\nHello" })
|
||||||
|
});
|
||||||
|
assert.strictEqual(resNoSlug.status, 400, "Missing slug must return 400");
|
||||||
|
|
||||||
|
// Missing required frontmatter title
|
||||||
|
const resNoTitle = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${TEST_SECRET}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
slug: "no-title-post",
|
||||||
|
content: "---\ndate: 2026-09-14\n---\nHello"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
assert.strictEqual(resNoTitle.status, 400, "Missing frontmatter title must return 400");
|
||||||
|
|
||||||
|
// Missing required frontmatter date or created
|
||||||
|
const resNoDate = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${TEST_SECRET}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
slug: "no-date-post",
|
||||||
|
content: "---\ntitle: 'Valid Title'\n---\nHello"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
assert.strictEqual(resNoDate.status, 400, "Missing frontmatter created/date must return 400");
|
||||||
|
|
||||||
|
// Valid with created instead of date
|
||||||
|
const resWithCreated = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${TEST_SECRET}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
slug: "created-tag-post",
|
||||||
|
content: "---\ntitle: 'Valid Title'\ncreated: 2026-09-14\n---\nHello"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
assert.strictEqual(resWithCreated.status, 200, "Valid post with created tag must return 200");
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
fs.rmSync(tmpPosts, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Ingestion API: publishes markdown, normalizes wikilinks, saves assets, and handles delete", async () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-site-"));
|
||||||
|
const tmpPosts = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-posts-"));
|
||||||
|
const tmpAssets = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-assets-"));
|
||||||
|
process.env.API_SECRET = TEST_SECRET;
|
||||||
|
|
||||||
|
const server = createServer(tmpDir, tmpPosts, tmpAssets);
|
||||||
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
const port = server.address().port;
|
||||||
|
|
||||||
|
const rawMarkdown = `---
|
||||||
|
title: "Reverse Engineering Flock Safety"
|
||||||
|
created: 2026-09-14
|
||||||
|
tags: ["#security", "#firmware"]
|
||||||
|
excerpt: "A deep dive into firmware."
|
||||||
|
---
|
||||||
|
|
||||||
|
Check out [[other-post|Other Post]] and also [[simple-link]].
|
||||||
|
Here is an image: ![[camera-board.png]]
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 1x1 transparent PNG base64
|
||||||
|
const sampleBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Publish post
|
||||||
|
const resPublish = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${TEST_SECRET}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
slug: "flock-firmware",
|
||||||
|
content: rawMarkdown,
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
filename: "camera-board.png",
|
||||||
|
data: sampleBase64
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(resPublish.status, 200, "Valid publish must return 200");
|
||||||
|
const json = await resPublish.json();
|
||||||
|
assert.strictEqual(json.status, "published");
|
||||||
|
assert.strictEqual(json.path, "/posts/flock-firmware/");
|
||||||
|
|
||||||
|
// Verify markdown file written and wikilinks normalized
|
||||||
|
const writtenPath = path.join(tmpPosts, "flock-firmware.md");
|
||||||
|
assert.ok(fs.existsSync(writtenPath), "Post markdown file must be written to disk");
|
||||||
|
|
||||||
|
const writtenContent = fs.readFileSync(writtenPath, "utf-8");
|
||||||
|
assert.match(writtenContent, /^layout:\s*layouts\/post\.njk$/m, "Layout must be injected into frontmatter when missing");
|
||||||
|
assert.match(writtenContent, /\[Other Post\]\(\/posts\/other-post\/\)/, "Wikilink with alias must be translated");
|
||||||
|
assert.match(writtenContent, /\[simple-link\]\(\/posts\/simple-link\/\)/, "Simple wikilink must be translated");
|
||||||
|
assert.match(writtenContent, /!\[camera-board\.png\]\(\/assets\/images\/posts\/camera-board\.png\)/, "Attachment wikilink must be translated");
|
||||||
|
|
||||||
|
// Verify asset image was written
|
||||||
|
const assetPath = path.join(tmpAssets, "camera-board.png");
|
||||||
|
assert.ok(fs.existsSync(assetPath), "Asset file must be saved");
|
||||||
|
|
||||||
|
// Create simulated compiled static post files in siteDir
|
||||||
|
const compiledPostDir = path.join(tmpDir, "posts", "flock-firmware");
|
||||||
|
fs.mkdirSync(compiledPostDir, { recursive: true });
|
||||||
|
const compiledPostHtml = path.join(compiledPostDir, "index.html");
|
||||||
|
fs.writeFileSync(compiledPostHtml, "<html><body>Simulated compiled post</body></html>");
|
||||||
|
const standalonePostHtml = path.join(tmpDir, "posts", "flock-firmware.html");
|
||||||
|
fs.writeFileSync(standalonePostHtml, "<html><body>Simulated standalone post</body></html>");
|
||||||
|
|
||||||
|
assert.ok(fs.existsSync(compiledPostHtml), "Simulated compiled post must exist before delete");
|
||||||
|
assert.ok(fs.existsSync(standalonePostHtml), "Simulated standalone post must exist before delete");
|
||||||
|
|
||||||
|
// Delete post
|
||||||
|
const resDelete = await fetch(`http://127.0.0.1:${port}/api/posts?slug=flock-firmware`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${TEST_SECRET}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert.strictEqual(resDelete.status, 200, "Delete must return 200");
|
||||||
|
assert.ok(!fs.existsSync(writtenPath), "Post markdown file must be deleted");
|
||||||
|
assert.ok(!fs.existsSync(compiledPostDir), "Compiled post directory must be deleted from _site");
|
||||||
|
assert.ok(!fs.existsSync(standalonePostHtml), "Compiled standalone HTML must be deleted from _site");
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
fs.rmSync(tmpPosts, { recursive: true, force: true });
|
||||||
|
fs.rmSync(tmpAssets, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Ingestion API: adversarial protection against path traversal in slug and DELETE validation", async () => {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-site-"));
|
||||||
|
const tmpPosts = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-posts-"));
|
||||||
|
process.env.API_SECRET = TEST_SECRET;
|
||||||
|
|
||||||
|
const server = createServer(tmpDir, tmpPosts);
|
||||||
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
const port = server.address().port;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Path traversal in POST
|
||||||
|
const resTraversal = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${TEST_SECRET}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
slug: "../../etc/evil",
|
||||||
|
content: "---\ntitle: 'Evil'\ndate: 2026-09-14\n---\nEvil content"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
// Should sanitize or reject slug containing path traversal
|
||||||
|
if (resTraversal.status === 200) {
|
||||||
|
const evilPath = path.resolve(tmpPosts, "../../etc/evil.md");
|
||||||
|
assert.ok(!fs.existsSync(evilPath), "Must never write outside posts directory");
|
||||||
|
} else {
|
||||||
|
assert.strictEqual(resTraversal.status, 400, "Invalid slug must return 400");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. DELETE without auth token
|
||||||
|
const resDeleteNoAuth = await fetch(`http://127.0.0.1:${port}/api/posts?slug=any-post`, {
|
||||||
|
method: "DELETE"
|
||||||
|
});
|
||||||
|
assert.strictEqual(resDeleteNoAuth.status, 401, "DELETE without Bearer token must return 401");
|
||||||
|
|
||||||
|
// 3. DELETE with missing slug parameter
|
||||||
|
const resDeleteNoSlug = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${TEST_SECRET}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert.strictEqual(resDeleteNoSlug.status, 400, "DELETE without slug parameter must return 400");
|
||||||
|
|
||||||
|
// 4. DELETE with invalid/empty slug
|
||||||
|
const resDeleteBadSlug = await fetch(`http://127.0.0.1:${port}/api/posts?slug=///`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${TEST_SECRET}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert.strictEqual(resDeleteBadSlug.status, 400, "DELETE with invalid slug must return 400");
|
||||||
|
|
||||||
|
// 5. DELETE non-existent post succeeds gracefully (idempotent)
|
||||||
|
const resDeleteNonExistent = await fetch(`http://127.0.0.1:${port}/api/posts?slug=non-existent-post`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${TEST_SECRET}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert.strictEqual(resDeleteNonExistent.status, 200, "DELETE non-existent post must return 200 idempotently");
|
||||||
|
const jsonNonExistent = await resDeleteNonExistent.json();
|
||||||
|
assert.strictEqual(jsonNonExistent.status, "deleted");
|
||||||
|
assert.strictEqual(jsonNonExistent.slug, "non-existent-post");
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
fs.rmSync(tmpDir, { 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);
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { resolveLinkedArticles, validateLinkedArticles } from "../src/_utils/linked-articles.js";
|
||||||
|
import { filters } from "../src/_filters/index.js";
|
||||||
|
import configureEleventy from "../eleventy.config.js";
|
||||||
|
|
||||||
|
test("Linked articles: accepts an article's list when every name identifies another post", () => {
|
||||||
|
assert.doesNotThrow(() => {
|
||||||
|
validateLinkedArticles([
|
||||||
|
{
|
||||||
|
inputPath: "src/posts/2026-10-06-terminal-preview-demo.md",
|
||||||
|
data: {
|
||||||
|
title: "Terminal Preview Demo",
|
||||||
|
linkedArticles: ["Markdown Preview Demo"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
inputPath: "src/posts/2026-10-05-markdown-preview-demo.md",
|
||||||
|
data: { title: "Markdown Preview Demo" }
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Linked articles: accepts Obsidian's serialized internal Markdown links", () => {
|
||||||
|
const posts = [
|
||||||
|
{
|
||||||
|
inputPath: "src/posts/project-crimson-week-7.md",
|
||||||
|
data: {
|
||||||
|
title: "Project Crimson Week 7",
|
||||||
|
linkedArticles: ["[Project Crimson Week 6](/posts/project-crimson-week-6/)"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
inputPath: "src/posts/project-crimson-week-6.md",
|
||||||
|
url: "/posts/project-crimson-week-6/",
|
||||||
|
data: { title: "Project Crimson Week 6" }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.doesNotThrow(() => validateLinkedArticles(posts));
|
||||||
|
assert.deepEqual(resolveLinkedArticles(posts, posts[0].data.linkedArticles), [posts[1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Linked articles: rejects a non-list frontmatter value", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => {
|
||||||
|
validateLinkedArticles([
|
||||||
|
{
|
||||||
|
inputPath: "src/posts/2026-10-06-terminal-preview-demo.md",
|
||||||
|
data: {
|
||||||
|
title: "Terminal Preview Demo",
|
||||||
|
linkedArticles: "Markdown Preview Demo"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
inputPath: "src/posts/2026-10-05-markdown-preview-demo.md",
|
||||||
|
data: { title: "Markdown Preview Demo" }
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
/must define 'linkedArticles' as a YAML list/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Linked articles: rejects empty and unknown article names", () => {
|
||||||
|
const posts = [
|
||||||
|
{
|
||||||
|
inputPath: "src/posts/2026-10-06-terminal-preview-demo.md",
|
||||||
|
data: { title: "Terminal Preview Demo", linkedArticles: [""] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
inputPath: "src/posts/2026-10-05-markdown-preview-demo.md",
|
||||||
|
data: { title: "Markdown Preview Demo" }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.throws(() => validateLinkedArticles(posts), /non-empty article titles/);
|
||||||
|
|
||||||
|
posts[0].data.linkedArticles = ["Missing Dispatch"];
|
||||||
|
assert.throws(() => validateLinkedArticles(posts), /links to unknown article 'Missing Dispatch'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Linked articles: rejects self-references and duplicate navigation targets", () => {
|
||||||
|
const posts = [
|
||||||
|
{
|
||||||
|
inputPath: "src/posts/2026-10-06-terminal-preview-demo.md",
|
||||||
|
data: {
|
||||||
|
title: "Terminal Preview Demo",
|
||||||
|
linkedArticles: ["Terminal Preview Demo"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
inputPath: "src/posts/2026-10-05-markdown-preview-demo.md",
|
||||||
|
data: { title: "Markdown Preview Demo" }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.throws(() => validateLinkedArticles(posts), /cannot link to itself/);
|
||||||
|
|
||||||
|
posts[0].data.linkedArticles = ["Markdown Preview Demo", "Markdown Preview Demo"];
|
||||||
|
assert.throws(() => validateLinkedArticles(posts), /cannot contain duplicate article titles/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Linked articles: the Eleventy posts collection rejects invalid frontmatter", () => {
|
||||||
|
const collections = new Map();
|
||||||
|
configureEleventy({
|
||||||
|
setUseGitIgnore() {},
|
||||||
|
addPassthroughCopy() {},
|
||||||
|
addPlugin() {},
|
||||||
|
setLibrary() {},
|
||||||
|
addFilter() {},
|
||||||
|
addCollection(name, callback) {
|
||||||
|
collections.set(name, callback);
|
||||||
|
},
|
||||||
|
addGlobalData() {},
|
||||||
|
on() {},
|
||||||
|
setServerOptions() {}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => {
|
||||||
|
collections.get("posts")({
|
||||||
|
getFilteredByGlob() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
inputPath: "src/posts/2026-10-06-terminal-preview-demo.md",
|
||||||
|
data: {
|
||||||
|
title: "Terminal Preview Demo",
|
||||||
|
linkedArticles: ["Missing Dispatch"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/links to unknown article 'Missing Dispatch'/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Linked articles: resolves named posts in the frontmatter order for navigation", () => {
|
||||||
|
const posts = [
|
||||||
|
{
|
||||||
|
url: "/dispatches/terminal-preview-demo/",
|
||||||
|
data: { title: "Terminal Preview Demo" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "/dispatches/markdown-preview-demo/",
|
||||||
|
data: { title: "Markdown Preview Demo" }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
resolveLinkedArticles(posts, ["Markdown Preview Demo", "Terminal Preview Demo"]),
|
||||||
|
[posts[1], posts[0]]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Linked articles: exposes navigation resolution to Nunjucks", () => {
|
||||||
|
assert.strictEqual(filters.resolveLinkedArticles, resolveLinkedArticles);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Linked articles: post layout renders Other Articles at the top of dispatch telemetry", () => {
|
||||||
|
const postLayout = fs.readFileSync(
|
||||||
|
path.resolve("src/_includes/layouts/post.njk"),
|
||||||
|
"utf-8"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
postLayout,
|
||||||
|
/{% set linkedPosts = collections\.posts \| resolveLinkedArticles\(linkedArticles\) %}[\s\S]*?<div[^>]+class="sidebar-heading">OTHER ARTICLES<\/div>[\s\S]*?<nav[^>]+aria-label="Other Articles">[\s\S]*?href="{{ linkedPost\.url }}"/
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const config = fs.readFileSync(path.resolve("eleventy.config.js"), "utf-8");
|
||||||
|
|
||||||
|
test("Eleventy config: builds local ignored post fixtures", () => {
|
||||||
|
assert.match(config, /setUseGitIgnore\(false\)/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { createServer } from "../server.js";
|
||||||
|
|
||||||
|
const siteDir = path.resolve("_site");
|
||||||
|
|
||||||
|
test("unknown routes render the themed 404 page with a real 404 status", async () => {
|
||||||
|
execFileSync("npm", ["run", "build"], {
|
||||||
|
stdio: "pipe",
|
||||||
|
env: { ...process.env, SITE_DOMAIN: process.env.SITE_DOMAIN || "riverrooks.dev" }
|
||||||
|
});
|
||||||
|
|
||||||
|
const html = fs.readFileSync(path.join(siteDir, "404.html"), "utf8");
|
||||||
|
assert.match(html, /<title>Page not found/);
|
||||||
|
assert.match(html, /<h1[^>]*>[^<]*404/);
|
||||||
|
assert.match(html, /href="\/"/);
|
||||||
|
assert.match(html, /href="\/dispatches\/"/);
|
||||||
|
assert.match(html, /href="\/assets\/css\/main\.css/);
|
||||||
|
|
||||||
|
const css = fs.readFileSync(path.join(siteDir, "assets/css/main.css"), "utf8");
|
||||||
|
assert.match(css, /components\/not-found\.css/);
|
||||||
|
const pageCss = fs.readFileSync(path.join(siteDir, "assets/css/components/not-found.css"), "utf8");
|
||||||
|
assert.doesNotMatch(pageCss, /(?:backdrop-filter|box-shadow|text-shadow|linear-gradient|radial-gradient)\s*:/);
|
||||||
|
|
||||||
|
const server = createServer(siteDir, path.resolve("src/posts"), path.resolve("src/assets/images/posts"));
|
||||||
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
try {
|
||||||
|
const origin = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
for (const route of ["/missing-page/", "/assets/missing.css"]) {
|
||||||
|
const response = await fetch(`${origin}${route}`);
|
||||||
|
assert.equal(response.status, 404);
|
||||||
|
assert.match(response.headers.get("content-type"), /text\/html/);
|
||||||
|
assert.match(await response.text(), /Page not found/);
|
||||||
|
}
|
||||||
|
const home = await fetch(origin);
|
||||||
|
assert.equal(home.status, 200);
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const pluginPath = path.resolve("/home/venus/Documents/Notes/.obsidian/plugins/blog-publisher/main.js");
|
||||||
|
|
||||||
|
// Pure helper function to be tested and exported/used by plugin
|
||||||
|
export function insertOrUpdateFrontmatterProperty(content, key, value) {
|
||||||
|
if (typeof content !== "string") return "";
|
||||||
|
|
||||||
|
const fmRegex = /^---\r?\n([\s\S]*?)\r?\n---/;
|
||||||
|
const match = content.match(fmRegex);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
let fmBlock = match[1];
|
||||||
|
const keyRegex = new RegExp(`^${key}:.*$`, "m");
|
||||||
|
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
// Remove property if value is null
|
||||||
|
fmBlock = fmBlock.replace(new RegExp(`^${key}:.*\\r?\\n?`, "m"), "");
|
||||||
|
} else if (keyRegex.test(fmBlock)) {
|
||||||
|
// Replace existing property
|
||||||
|
fmBlock = fmBlock.replace(keyRegex, `${key}: "${value}"`);
|
||||||
|
} else {
|
||||||
|
// Append property to frontmatter
|
||||||
|
fmBlock = `${fmBlock}\n${key}: "${value}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return content.replace(fmRegex, `---\n${fmBlock.trim()}\n---`);
|
||||||
|
} else if (value) {
|
||||||
|
return `---\n${key}: "${value}"\n---\n\n${content}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computePublicUrl(serverUrl, postPath) {
|
||||||
|
const base = (serverUrl || "").replace(/\/+$/, "");
|
||||||
|
const p = (postPath || "").startsWith("/") ? postPath : `/${postPath || ""}`;
|
||||||
|
return `${base}${p}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Obsidian Plugin: computePublicUrl normalizes slashes correctly", () => {
|
||||||
|
assert.equal(
|
||||||
|
computePublicUrl("https://riverrooks.dev", "/posts/test-note/"),
|
||||||
|
"https://riverrooks.dev/posts/test-note/"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
computePublicUrl("https://riverrooks.dev/", "/posts/test-note/"),
|
||||||
|
"https://riverrooks.dev/posts/test-note/"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
computePublicUrl("https://riverrooks.dev///", "posts/test-note/"),
|
||||||
|
"https://riverrooks.dev/posts/test-note/"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
computePublicUrl("http://localhost:8080", "/posts/test-note/"),
|
||||||
|
"http://localhost:8080/posts/test-note/"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Obsidian Plugin: insertOrUpdateFrontmatterProperty adds public-url when missing", () => {
|
||||||
|
const original = `---
|
||||||
|
title: "Sample Note"
|
||||||
|
date: 2026-09-23
|
||||||
|
publish: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Content here`;
|
||||||
|
|
||||||
|
const updated = insertOrUpdateFrontmatterProperty(
|
||||||
|
original,
|
||||||
|
"public-url",
|
||||||
|
"https://riverrooks.dev/posts/sample-note/"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
updated,
|
||||||
|
/public-url:\s*"https:\/\/riverrooks\.dev\/posts\/sample-note\/"/,
|
||||||
|
"Must insert public-url into frontmatter"
|
||||||
|
);
|
||||||
|
assert.match(updated, /title:\s*"Sample Note"/, "Preserves title");
|
||||||
|
assert.match(updated, /publish:\s*true/, "Preserves publish flag");
|
||||||
|
assert.match(updated, /# Content here/, "Preserves body content");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Obsidian Plugin: insertOrUpdateFrontmatterProperty updates existing public-url", () => {
|
||||||
|
const original = `---
|
||||||
|
title: "Updated Note"
|
||||||
|
date: 2026-09-23
|
||||||
|
public-url: "https://old.domain/posts/sample-note/"
|
||||||
|
---
|
||||||
|
|
||||||
|
Body`;
|
||||||
|
|
||||||
|
const updated = insertOrUpdateFrontmatterProperty(
|
||||||
|
original,
|
||||||
|
"public-url",
|
||||||
|
"https://riverrooks.dev/posts/updated-note/"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.match(
|
||||||
|
updated,
|
||||||
|
/public-url:\s*"https:\/\/riverrooks\.dev\/posts\/updated-note\/"/,
|
||||||
|
"Must update public-url to new URL"
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
updated,
|
||||||
|
/old\.domain/,
|
||||||
|
"Old URL must be replaced"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Obsidian Plugin: insertOrUpdateFrontmatterProperty removes public-url on unpublish", () => {
|
||||||
|
const original = `---
|
||||||
|
title: "Unpublished Note"
|
||||||
|
date: 2026-09-23
|
||||||
|
public-url: "https://riverrooks.dev/posts/sample-note/"
|
||||||
|
---
|
||||||
|
|
||||||
|
Body`;
|
||||||
|
|
||||||
|
const updated = insertOrUpdateFrontmatterProperty(original, "public-url", null);
|
||||||
|
|
||||||
|
assert.doesNotMatch(updated, /public-url:/, "Must remove public-url when value is null");
|
||||||
|
assert.match(updated, /title:\s*"Unpublished Note"/, "Preserves other frontmatter");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Obsidian Plugin: Adversarial edge cases for frontmatter property insertion", () => {
|
||||||
|
// 1. Empty content
|
||||||
|
assert.equal(insertOrUpdateFrontmatterProperty("", "public-url", "https://url"), "---\npublic-url: \"https://url\"\n---\n\n");
|
||||||
|
// 2. Non-string inputs
|
||||||
|
assert.equal(insertOrUpdateFrontmatterProperty(null, "public-url", "https://url"), "");
|
||||||
|
assert.equal(insertOrUpdateFrontmatterProperty(undefined, "public-url", "https://url"), "");
|
||||||
|
// 3. Note without frontmatter
|
||||||
|
const plain = "# Just a header\nSome text.";
|
||||||
|
const result = insertOrUpdateFrontmatterProperty(plain, "public-url", "https://url");
|
||||||
|
assert.match(result, /^---\npublic-url:\s*"https:\/\/url"\n---/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Obsidian Plugin: main.js in vault includes public-url insertion and recursion guard", () => {
|
||||||
|
assert.ok(fs.existsSync(pluginPath), `Plugin main.js must exist at ${pluginPath}`);
|
||||||
|
const pluginContent = fs.readFileSync(pluginPath, "utf-8");
|
||||||
|
|
||||||
|
// 1. Must handle public-url
|
||||||
|
assert.match(pluginContent, /public-url/i, "main.js must reference 'public-url'");
|
||||||
|
|
||||||
|
// 2. Must guard against infinite loops during frontmatter updates
|
||||||
|
assert.match(
|
||||||
|
pluginContent,
|
||||||
|
/isUpdatingFrontmatter/i,
|
||||||
|
"main.js must maintain a guard to prevent metadataCache recursion loops"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Must use processFrontMatter or frontmatter updater
|
||||||
|
assert.match(
|
||||||
|
pluginContent,
|
||||||
|
/processFrontMatter|updateFrontmatter/i,
|
||||||
|
"main.js must implement frontmatter updating logic"
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { postComparator, isPostPinned, getPostTimestamp } from "../src/_utils/sort-posts.js";
|
||||||
|
|
||||||
|
test("Post Sorting: isPostPinned detection", () => {
|
||||||
|
assert.equal(isPostPinned({ data: { pinned: true } }), true);
|
||||||
|
assert.equal(isPostPinned({ data: { featured: true } }), true);
|
||||||
|
assert.equal(isPostPinned({ data: { pinned: false } }), false);
|
||||||
|
assert.equal(isPostPinned({ data: { featured: false } }), false);
|
||||||
|
assert.equal(isPostPinned({ data: { pinned: null } }), false);
|
||||||
|
assert.equal(isPostPinned({ data: {} }), false);
|
||||||
|
assert.equal(isPostPinned({}), false);
|
||||||
|
assert.equal(isPostPinned(null), false);
|
||||||
|
assert.equal(isPostPinned(undefined), false);
|
||||||
|
assert.equal(isPostPinned("invalid"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Post Sorting: getPostTimestamp extraction and fallback", () => {
|
||||||
|
const d = new Date("2026-09-01T00:00:00Z");
|
||||||
|
assert.equal(getPostTimestamp({ date: d }), d.getTime());
|
||||||
|
assert.equal(getPostTimestamp({ data: { date: "2026-09-01" } }), new Date("2026-09-01").getTime());
|
||||||
|
assert.equal(getPostTimestamp({ data: { created: "2026-09-01" } }), new Date("2026-09-01").getTime());
|
||||||
|
assert.equal(getPostTimestamp({ date: "not-a-date" }), 0);
|
||||||
|
assert.equal(getPostTimestamp({}), 0);
|
||||||
|
assert.equal(getPostTimestamp(null), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Post Sorting: standard chronological sort (unpinned posts)", () => {
|
||||||
|
const postNew = {
|
||||||
|
data: { title: "Newer Post" },
|
||||||
|
date: new Date("2026-09-10")
|
||||||
|
};
|
||||||
|
const postOld = {
|
||||||
|
data: { title: "Older Post" },
|
||||||
|
date: new Date("2026-08-01")
|
||||||
|
};
|
||||||
|
|
||||||
|
const sorted = [postOld, postNew].sort(postComparator);
|
||||||
|
assert.deepEqual(sorted.map((p) => p.data.title), ["Newer Post", "Older Post"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Post Sorting: pinned post forces top position over newer unpinned post", () => {
|
||||||
|
const newerUnpinned = {
|
||||||
|
data: { title: "Newer Unpinned", pinned: false },
|
||||||
|
date: new Date("2026-09-20")
|
||||||
|
};
|
||||||
|
const olderPinned = {
|
||||||
|
data: { title: "Older Pinned", pinned: true },
|
||||||
|
date: new Date("2026-07-01")
|
||||||
|
};
|
||||||
|
|
||||||
|
const sorted = [newerUnpinned, olderPinned].sort(postComparator);
|
||||||
|
assert.equal(sorted[0].data.title, "Older Pinned");
|
||||||
|
assert.equal(sorted[1].data.title, "Newer Unpinned");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Post Sorting: featured post forces top position over newer unpinned post", () => {
|
||||||
|
const newerRegular = {
|
||||||
|
data: { title: "Fresh Article" },
|
||||||
|
date: new Date("2026-09-22")
|
||||||
|
};
|
||||||
|
const olderFeatured = {
|
||||||
|
data: { title: "Featured Pillar", featured: true },
|
||||||
|
date: new Date("2026-06-15")
|
||||||
|
};
|
||||||
|
|
||||||
|
const sorted = [newerRegular, olderFeatured].sort(postComparator);
|
||||||
|
assert.equal(sorted[0].data.title, "Featured Pillar");
|
||||||
|
assert.equal(sorted[1].data.title, "Fresh Article");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Post Sorting: multiple pinned posts sort among themselves chronologically", () => {
|
||||||
|
const olderPinned = {
|
||||||
|
data: { title: "Older Pinned", pinned: true },
|
||||||
|
date: new Date("2026-05-01")
|
||||||
|
};
|
||||||
|
const newerPinned = {
|
||||||
|
data: { title: "Newer Pinned", featured: true },
|
||||||
|
date: new Date("2026-08-15")
|
||||||
|
};
|
||||||
|
const freshUnpinned = {
|
||||||
|
data: { title: "Fresh Regular" },
|
||||||
|
date: new Date("2026-09-01")
|
||||||
|
};
|
||||||
|
|
||||||
|
const list = [olderPinned, freshUnpinned, newerPinned];
|
||||||
|
const sorted = list.sort(postComparator);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
sorted.map((p) => p.data.title),
|
||||||
|
["Newer Pinned", "Older Pinned", "Fresh Regular"]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Post Sorting: adversarial robustness against missing data or equal dates", () => {
|
||||||
|
const postNoData = { date: new Date("2026-09-01") };
|
||||||
|
const postMalformedDate = { data: { title: "Malformed", date: "invalid-date" } };
|
||||||
|
const postIdenticalDate1 = { data: { title: "Post A" }, date: new Date("2026-09-01") };
|
||||||
|
const postIdenticalDate2 = { data: { title: "Post B" }, date: new Date("2026-09-01") };
|
||||||
|
|
||||||
|
assert.doesNotThrow(() => {
|
||||||
|
[postNoData, postMalformedDate, postIdenticalDate1, postIdenticalDate2].sort(postComparator);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const postLayout = fs.readFileSync(path.resolve("src/_includes/layouts/post.njk"), "utf-8");
|
||||||
|
|
||||||
|
test("Post layout: omits the leading terminal preview from the public article body", () => {
|
||||||
|
assert.match(postLayout, /content\s*\|\s*removeTerminalPreview\s*\|\s*safe/);
|
||||||
|
});
|
||||||
@@ -40,21 +40,17 @@ test("Site Config: correctly parses SITE_DOMAIN and derives service subdomains",
|
|||||||
// Verify dynamic subdomains
|
// Verify dynamic subdomains
|
||||||
const gitService = site.services.find((s) => s.name === "Gitea");
|
const gitService = site.services.find((s) => s.name === "Gitea");
|
||||||
assert.ok(gitService, "Gitea service exists");
|
assert.ok(gitService, "Gitea service exists");
|
||||||
assert.equal(gitService.url, "https://git.testcluster.internal");
|
assert.equal(gitService.url, "https://git.riverrooks.dev/venus");
|
||||||
assert.equal(gitService.tag, "git.testcluster.internal:2222");
|
assert.equal(gitService.tag, "git.testcluster.internal:443");
|
||||||
|
|
||||||
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 links
|
// Verify navigation links
|
||||||
const navHome = site.nav.find((n) => n.label === "Home");
|
const navHome = site.nav.find((n) => n.label === "Home");
|
||||||
assert.ok(navHome, "Nav Home link exists");
|
assert.ok(navHome, "Nav Home link exists");
|
||||||
assert.equal(navHome.url, "/");
|
assert.equal(navHome.url, "/");
|
||||||
|
|
||||||
const navGit = site.nav.find((n) => n.label === "Gitea");
|
const navCode = site.nav.find((n) => n.label === "Code");
|
||||||
assert.ok(navGit, "Nav Gitea link exists");
|
assert.ok(navCode, "Nav Code link exists");
|
||||||
assert.equal(navGit.url, "https://git.testcluster.internal");
|
assert.equal(navCode.url, "https://git.riverrooks.dev/venus");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Site Config: respects explicit SITE_URL override", async () => {
|
test("Site Config: respects explicit SITE_URL override", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const skillDir = path.resolve(".agents/skills/standards-verification");
|
||||||
|
const skillPath = path.join(skillDir, "SKILL.md");
|
||||||
|
const standardsDocPath = path.resolve(".agents/standards.md");
|
||||||
|
const skillStandardsRefPath = path.join(skillDir, "references/standards.md");
|
||||||
|
|
||||||
|
test("Standards Verification Skill: Directory, SKILL.md, and standards document exist", () => {
|
||||||
|
assert.ok(fs.existsSync(skillDir), ".agents/skills/standards-verification directory must exist");
|
||||||
|
assert.ok(fs.existsSync(skillPath), "SKILL.md must exist in standards-verification skill");
|
||||||
|
assert.ok(fs.existsSync(standardsDocPath), "Dedicated standards document .agents/standards.md must exist");
|
||||||
|
assert.ok(fs.existsSync(skillStandardsRefPath), "Skill references/standards.md must exist");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Standards Verification Skill: SKILL.md contains correct metadata and execution directives", () => {
|
||||||
|
const content = fs.readFileSync(skillPath, "utf-8");
|
||||||
|
|
||||||
|
// 1. YAML frontmatter name and description
|
||||||
|
assert.match(content, /^---\s*\nname:\s*standards-verification/m, "Must declare name: standards-verification");
|
||||||
|
assert.match(content, /description:\s*>-?[\s\S]*?---/, "Must define description in frontmatter");
|
||||||
|
|
||||||
|
// 2. Invocation conditions: called sparingly, major feature, user request, or debugging
|
||||||
|
assert.match(content, /sparingly/i, "Must specify calling sparingly and only when needed");
|
||||||
|
assert.match(content, /major\s+function|major\s+feature/i, "Must mention use case: major function/feature implemented");
|
||||||
|
assert.match(content, /user(?:\s+\w+)?\s+asks/i, "Must mention use case: user explicitly requests verification");
|
||||||
|
assert.match(content, /debug(?:ging)?/i, "Must mention use case: debugging code issues");
|
||||||
|
|
||||||
|
// 3. Targeted inspection via git status / git diff (not whole codebase unless necessary)
|
||||||
|
assert.match(content, /git\s+status/i, "Must specify using git status to identify changes");
|
||||||
|
assert.match(content, /git\s+diff/i, "Must specify using git diff to inspect targeted changes");
|
||||||
|
assert.match(content, /not\s+(?:the\s+)?whole\s+codebase/i, "Must specify NOT inspecting whole codebase unless necessary");
|
||||||
|
|
||||||
|
// 4. Debugging guidance: explain why standards violations cause errors
|
||||||
|
assert.match(content, /(?:suggest|explain)[*\s`_]+why/i, "Must instruct suggesting why standards issues cause errors");
|
||||||
|
|
||||||
|
|
||||||
|
// 5. External standards document reference
|
||||||
|
assert.match(content, /standards\.md/i, "Must refer to standards.md rather than embedding standards directly");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Standards Document: Comprehensive specifications across all architectural layers", () => {
|
||||||
|
const content = fs.readFileSync(standardsDocPath, "utf-8");
|
||||||
|
|
||||||
|
// 1. Design & CSS Anti-Patterns
|
||||||
|
assert.match(content, /backdrop-filter/i, "Must document prohibition on backdrop-filter");
|
||||||
|
assert.match(content, /box-shadow/i, "Must document prohibition on box-shadow");
|
||||||
|
assert.match(content, /gradient/i, "Must document prohibition on gradients");
|
||||||
|
assert.match(content, /border-radius/i, "Must document border-radius constraints (0px to 4px)");
|
||||||
|
assert.match(content, /instant/i, "Must document instant binary transitions");
|
||||||
|
|
||||||
|
// 2. Templating & Eleventy
|
||||||
|
assert.match(content, /isPostPublic/i, "Must document isPostPublic collection filtering");
|
||||||
|
assert.match(content, /hidden/i, "Must document hidden post behavior and noindex meta tag");
|
||||||
|
|
||||||
|
// 3. Frontmatter & Content Contract
|
||||||
|
assert.match(content, /title/i, "Must document title requirement");
|
||||||
|
assert.match(content, /date|created/i, "Must document date requirement");
|
||||||
|
assert.match(content, /tags/i, "Must document tags requirement");
|
||||||
|
assert.match(content, /excerpt/i, "Must document excerpt requirement");
|
||||||
|
|
||||||
|
// 4. Server & Security Invariants
|
||||||
|
assert.match(content, /path\s+traversal/i, "Must document path traversal defense");
|
||||||
|
assert.match(content, /healthz/i, "Must document healthcheck endpoint");
|
||||||
|
assert.match(content, /X-Content-Type-Options/i, "Must document security headers");
|
||||||
|
|
||||||
|
// 5. Strict TDD & Verification Invariants
|
||||||
|
assert.match(content, /before\s+(?:writing\s+or\s+)?modifying/i, "Must mandate tests before modifying functional code");
|
||||||
|
assert.match(content, /adversarial/i, "Must mandate adversarial testing");
|
||||||
|
assert.match(content, /pass\s+before\s+presenting/i, "Must mandate tests pass before presenting final output");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Standards Verification Skill: Adversarial checks against full-codebase scanning defaults", () => {
|
||||||
|
const content = fs.readFileSync(skillPath, "utf-8");
|
||||||
|
|
||||||
|
// Adversarial: ensure no instruction tells the agent to indiscriminately scan everything by default
|
||||||
|
assert.doesNotMatch(
|
||||||
|
content,
|
||||||
|
/always\s+scan\s+(?:all|the\s+entire|the\s+whole)\s+codebase/i,
|
||||||
|
"Must not default to scanning the whole codebase"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Standards Verification Skill: Helper script verify.js exists and executes safely", () => {
|
||||||
|
const scriptPath = path.join(skillDir, "scripts/verify.js");
|
||||||
|
assert.ok(fs.existsSync(scriptPath), "scripts/verify.js must exist");
|
||||||
|
const content = fs.readFileSync(scriptPath, "utf-8");
|
||||||
|
assert.match(content, /git\s+status/i, "Script must inspect git status");
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { extractTerminalPreview, removeTerminalPreview } from "../src/_filters/index.js";
|
||||||
|
|
||||||
|
test("Terminal previews: stop before the first subsequent Markdown heading at any level", () => {
|
||||||
|
const renderedPost = `
|
||||||
|
<h1 id="terminal-preview">Terminal preview</h1>
|
||||||
|
<p>Plain Markdown preview text is supported.</p>
|
||||||
|
<h2 id="findings">Findings</h2>
|
||||||
|
<p>The article begins here.</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
assert.match(extractTerminalPreview(renderedPost), /Plain Markdown preview text/);
|
||||||
|
assert.match(removeTerminalPreview(renderedPost), /^\n <h2 id="findings">/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
extractTerminalPreview,
|
||||||
|
removeTerminalPreview
|
||||||
|
} from "../src/_filters/index.js";
|
||||||
|
|
||||||
|
test("Terminal previews: extracts rendered Markdown between the leading preview and article headings", () => {
|
||||||
|
const renderedPost = `
|
||||||
|
<h1 id="terminal-preview">Terminal preview</h1>
|
||||||
|
<p>Use the following command:</p>
|
||||||
|
<pre class="language-bash"><code class="language-bash">cat /dispatches/example | head -n 5</code></pre>
|
||||||
|
<h1 id="investigation">Investigation</h1>
|
||||||
|
<p>The public article starts here.</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
extractTerminalPreview(renderedPost),
|
||||||
|
`\n <p>Use the following command:</p>\n <pre class="language-bash"><code class="language-bash">cat /dispatches/example | head -n 5</code></pre>\n `
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
removeTerminalPreview(renderedPost),
|
||||||
|
`\n <h1 id="investigation">Investigation</h1>\n <p>The public article starts here.</p>\n `
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Terminal previews: ignores missing, misplaced, and malformed preview headings", () => {
|
||||||
|
const regularPost = "<h1 id=\"article\">Article</h1><p>Visible text.</p>";
|
||||||
|
const misplacedPreview = "<h1 id=\"article\">Article</h1><h1 id=\"terminal-preview\">Terminal preview</h1><p>Do not extract.</p>";
|
||||||
|
|
||||||
|
assert.equal(extractTerminalPreview(regularPost), "");
|
||||||
|
assert.equal(removeTerminalPreview(regularPost), regularPost);
|
||||||
|
assert.equal(extractTerminalPreview(misplacedPreview), "");
|
||||||
|
assert.equal(removeTerminalPreview(misplacedPreview), misplacedPreview);
|
||||||
|
assert.equal(extractTerminalPreview(null), "");
|
||||||
|
assert.equal(removeTerminalPreview(null), "");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user