Compare commits
13
Commits
93fe924b74
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0967d540cf | ||
|
|
59f807c45b | ||
|
|
07de3b2782 | ||
|
|
c8cd5fd0d9 | ||
|
|
7f12861511 | ||
|
|
515e52b274 | ||
|
|
963521b3a9 | ||
|
|
efb0fbea94 | ||
|
|
6c1384b661 | ||
|
|
98e46644c0 | ||
|
|
569152a7be | ||
|
|
b2fc93c8e6 | ||
|
|
b239f61480 |
@@ -5,3 +5,6 @@ _site/
|
||||
*.log
|
||||
.env
|
||||
docker-compose.yml
|
||||
|
||||
# Local browser fixtures for document-based development
|
||||
src/posts/*.md
|
||||
|
||||
+29
-4
@@ -2,9 +2,25 @@ import syntaxHighlight from "@11ty/eleventy-plugin-syntaxhighlight";
|
||||
import markdownIt from "markdown-it";
|
||||
import markdownItAnchor from "markdown-it-anchor";
|
||||
import { filters } from "./src/_filters/index.js";
|
||||
import { postComparator, isPostPublic } from "./src/_utils/sort-posts.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) {
|
||||
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
|
||||
eleventyConfig.addPassthroughCopy({ "src/assets": "assets" });
|
||||
|
||||
@@ -39,11 +55,21 @@ export default function (eleventyConfig) {
|
||||
}
|
||||
|
||||
// 5. Custom Collections
|
||||
// Pinned / featured prioritized, chronological blog posts collection (excludes drafts and hidden posts)
|
||||
// Pinned / featured prioritized, chronological blog posts collection (excludes drafts, hidden, and archived posts)
|
||||
eleventyConfig.addCollection("posts", (collectionApi) => {
|
||||
const allPosts = collectionApi.getFilteredByGlob("src/posts/*.md");
|
||||
validateLinkedArticles(allPosts);
|
||||
|
||||
return allPosts
|
||||
.filter((post) => isPostPublic(post))
|
||||
.sort(postComparator);
|
||||
});
|
||||
|
||||
// Chronological archived blog posts collection (excludes drafts and hidden posts)
|
||||
eleventyConfig.addCollection("archivedPosts", (collectionApi) => {
|
||||
return collectionApi
|
||||
.getFilteredByGlob("src/posts/*.md")
|
||||
.filter((post) => isPostPublic(post))
|
||||
.filter((post) => !isPostDraft(post) && !isPostHidden(post) && isPostArchived(post))
|
||||
.sort(postComparator);
|
||||
});
|
||||
|
||||
@@ -66,7 +92,6 @@ export default function (eleventyConfig) {
|
||||
return [...tagSet].sort();
|
||||
});
|
||||
|
||||
|
||||
// 6. Dev Server Options (dynamic port binding)
|
||||
eleventyConfig.setServerOptions({
|
||||
port: parseInt(process.env.PORT || "8080", 10)
|
||||
|
||||
@@ -425,8 +425,8 @@
|
||||
<strong>PRIMARY DESTINATIONS:</strong>
|
||||
<span style="color: var(--accent-cyan); margin-left: 8px;">[1] Technical Blog (5 Articles)</span> •
|
||||
<span>[2] Forgejo Git Repos</span> •
|
||||
<span>[3] Public Cryptographic & PDF Tools</span> •
|
||||
<span>[4] Cluster Telemetry</span>
|
||||
<!-- <span>[3] Public Cryptographic & PDF Tools</span> • -->
|
||||
<!-- <span>[4] Cluster Telemetry</span> -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -57,20 +57,32 @@ export function normalizeObsidianMarkdown(content) {
|
||||
|
||||
// 2. Convert wikilinks with label: [[target-slug|Custom Label]] -> [Custom Label](/posts/target-slug/)
|
||||
normalized = normalized.replace(/\[\[([^|\]]+)\|([^\]]+)\]\]/g, (match, target, label) => {
|
||||
const slug = slugify(target);
|
||||
return `[${label.trim()}](/posts/${slug}/)`;
|
||||
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();
|
||||
const slug = slugify(trimmed);
|
||||
return `[${trimmed}](/posts/${slug}/)`;
|
||||
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()
|
||||
|
||||
+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>
|
||||
+28
-22
@@ -58,30 +58,30 @@ export default {
|
||||
url: `https://git.riverrooks.dev/venus`,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: "RSS Feed",
|
||||
url: "/feed.xml"
|
||||
}
|
||||
// {
|
||||
// label: "RSS Feed",
|
||||
// url: "/feed.xml"
|
||||
// }
|
||||
],
|
||||
services: [
|
||||
{
|
||||
name: "Gitea",
|
||||
url: `https://git.riverrooks.dev/venus`,
|
||||
tag: `git.${siteDomain}:2222`,
|
||||
tag: `git.${siteDomain}:443`,
|
||||
desc: "Self-hosted Git code repositories."
|
||||
},
|
||||
{
|
||||
name: "CyberChef",
|
||||
url: `https://chef.${siteDomain}`,
|
||||
tag: `chef.${siteDomain}:443`,
|
||||
desc: "Industry tool for analyzing and decoding data."
|
||||
},
|
||||
{
|
||||
name: "Aperisolve",
|
||||
url: `https://aperisolve.${siteDomain}`,
|
||||
tag: `aperisolve.${siteDomain}:443`,
|
||||
desc: "Basic image steganography suite."
|
||||
},
|
||||
// {
|
||||
// name: "CyberChef",
|
||||
// url: `https://chef.${siteDomain}`,
|
||||
// tag: `chef.${siteDomain}:443`,
|
||||
// desc: "Industry tool for analyzing and decoding data."
|
||||
// },
|
||||
// {
|
||||
// name: "Aperisolve",
|
||||
// url: `https://aperisolve.${siteDomain}`,
|
||||
// tag: `aperisolve.${siteDomain}:443`,
|
||||
// desc: "Basic image steganography suite."
|
||||
// },
|
||||
{
|
||||
name: "Streams",
|
||||
url: `https://streams.${siteDomain}`,
|
||||
@@ -90,16 +90,22 @@ export default {
|
||||
},
|
||||
{
|
||||
name: "Calibre",
|
||||
url: `https://library.${siteDomain}`,
|
||||
url: `https://books.${siteDomain}`,
|
||||
tag: `books.${siteDomain}:443`,
|
||||
desc: `Calibre web frontend for managing and reading eBooks.`
|
||||
},
|
||||
{
|
||||
name: "Whoami",
|
||||
url: `https://whoami.${siteDomain}`,
|
||||
tag: `whoami.${siteDomain}:443`,
|
||||
desc: `Simple service to display request headers and connection information.`
|
||||
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: [
|
||||
{
|
||||
|
||||
+43
-1
@@ -1,9 +1,18 @@
|
||||
import { DateTime } from "luxon";
|
||||
import markdownIt from "markdown-it";
|
||||
import { resolveLinkedArticles } from "../_utils/linked-articles.js";
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
if (!dateObj) return "";
|
||||
const date = typeof dateObj === "string" ? new Date(dateObj) : dateObj;
|
||||
@@ -62,13 +71,46 @@ export const extractHeadings = (htmlContent) => {
|
||||
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 = {
|
||||
renderExcerptMarkdown,
|
||||
postDate,
|
||||
isoDate,
|
||||
limit,
|
||||
filterTagList,
|
||||
readingTime,
|
||||
extractHeadings
|
||||
extractHeadings,
|
||||
extractTerminalPreview,
|
||||
removeTerminalPreview,
|
||||
resolveLinkedArticles
|
||||
};
|
||||
|
||||
export default filters;
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
{% include "partials/footer.njk" %}
|
||||
</div>
|
||||
|
||||
<script type="module" src="/assets/js/build-timer.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<!-- Left Sticky Sidebar: Document Outline & Telemetry -->
|
||||
<aside class="article-sidebar" aria-label="Article Sidebar">
|
||||
<!-- Dynamic Table of Contents -->
|
||||
{% set headings = content | extractHeadings %}
|
||||
{% set headings = (content | removeTerminalPreview) | extractHeadings %}
|
||||
{% if headings.length > 0 %}
|
||||
<div class="sidebar-tile">
|
||||
<div class="sidebar-heading">
|
||||
@@ -31,6 +31,17 @@
|
||||
<!-- Article Telemetry Tile -->
|
||||
<div class="sidebar-tile">
|
||||
<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;">
|
||||
{% if hidden and hidden != "false" and hidden != "0" and hidden != "no" %}
|
||||
<div class="stat-row">
|
||||
@@ -45,7 +56,7 @@
|
||||
|
||||
<div class="stat-row">
|
||||
<span>DURATION:</span>
|
||||
<span class="stat-val">{{ content | readingTime(readingTime) }}</span>
|
||||
<span class="stat-val">{{ (content | removeTerminalPreview) | readingTime(readingTime) }}</span>
|
||||
</div>
|
||||
{% if difficulty %}
|
||||
<div class="stat-row">
|
||||
@@ -90,7 +101,7 @@
|
||||
<time datetime="{{ date | isoDate }}">{{ date | postDate }}</time>
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span class="meta-item">{{ content | readingTime(readingTime) }}</span>
|
||||
<span class="meta-item">{{ (content | removeTerminalPreview) | readingTime(readingTime) }}</span>
|
||||
{% if difficulty %}
|
||||
<span>•</span>
|
||||
<span class="meta-item" style="color: var(--accent-amber);">DIFFICULTY: {{ difficulty }}</span>
|
||||
@@ -99,7 +110,7 @@
|
||||
</header>
|
||||
|
||||
<div class="markdown-prose">
|
||||
{{ content | safe }}
|
||||
{{ content | removeTerminalPreview | safe }}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -29,24 +29,26 @@
|
||||
</h2>
|
||||
|
||||
<p class="featured-prose">
|
||||
{{ featured.data.excerpt }}
|
||||
{{ featured.data.excerpt | renderExcerptMarkdown | safe }}
|
||||
</p>
|
||||
|
||||
<!-- Terminal Code Preview Snippet -->
|
||||
<div class="terminal-code-box">
|
||||
<div class="code-titlebar">
|
||||
<span class="code-title">
|
||||
<span style="color: var(--accent-cyan);">></span>
|
||||
<span>init_cluster.sh</span>
|
||||
</span>
|
||||
<span>POSIX sh</span>
|
||||
</div>
|
||||
<div class="code-content">
|
||||
<pre><code class="language-bash"># System initialization and node health check
|
||||
./cluster.sh --verify-nodes
|
||||
curl -s https://api.local/healthz | jq .status</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
{% if featured.data.terminal == true %}
|
||||
{% set terminalPreview = featured.templateContent | extractTerminalPreview %}
|
||||
{% if terminalPreview %}
|
||||
<div class="terminal-code-box terminal-preview-box">
|
||||
<div class="code-titlebar">
|
||||
<span class="code-title">
|
||||
<span style="color: var(--accent-cyan);">></span>
|
||||
<span>TERMINAL PREVIEW</span>
|
||||
</span>
|
||||
<span>MARKDOWN</span>
|
||||
</div>
|
||||
<div class="terminal-preview-content">
|
||||
{{ terminalPreview | safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<div class="featured-cta-row">
|
||||
<div class="tag-cluster">
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
<span>RUNTIME: ELEVENTY 3.x STATIC</span>
|
||||
<span>•</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 class="footer-right">
|
||||
<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>
|
||||
</footer>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</h3>
|
||||
|
||||
{% if post.data.excerpt %}
|
||||
<p class="feed-excerpt">{{ post.data.excerpt }}</p>
|
||||
<p class="feed-excerpt">{{ post.data.excerpt | renderExcerptMarkdown | safe }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="feed-tags-row">
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
<span>TOTAL DISPATCHES:</span>
|
||||
<span class="stat-val">{{ collections.posts.length }} PUBLISHED</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span>ARCHIVED NOTES:</span>
|
||||
<span class="stat-val">{{ (collections.archivedPosts or []).length }} ARCHIVED</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span>TOPIC DOMAINS:</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/archive/" class="filter-tag-btn" style="width: 100%; justify-content: center;">
|
||||
VIEW ARCHIVE ({{ (collections.archivedPosts or []).length }}) →
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<!-- 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);
|
||||
};
|
||||
@@ -101,16 +101,39 @@ export function isPostDraft(item) {
|
||||
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 and non-hidden.
|
||||
* 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);
|
||||
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>
|
||||
@@ -65,6 +65,35 @@
|
||||
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) */
|
||||
.token.comment,
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -19,3 +19,4 @@
|
||||
@import "./components/code-box.css";
|
||||
@import "./components/article.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);
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -10,7 +10,7 @@ title: "River Rooks | Homelab Gateway"
|
||||
|
||||
<div class="hero-landing-text">
|
||||
<p>
|
||||
I am River Rooks, a cybersecurity student and 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.
|
||||
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>
|
||||
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.
|
||||
@@ -23,8 +23,8 @@ title: "River Rooks | Homelab Gateway"
|
||||
<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="https://git.{{ site.domain }}" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: none;">[2] Gitea Repos</a> •
|
||||
<span>[3] Public Tools</span> •
|
||||
<span>[4] Cluster Telemetry</span>
|
||||
<a href="https://github.com/skoorrevir" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: none;">[3] GitHub Repos</a> •
|
||||
<a href="https://wg.{{ site.domain }}" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: none;">[4] Wireguard VPN</a> •
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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>/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -3,10 +3,27 @@ import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import http from "node:http";
|
||||
import { createServer, triggerEleventyBuild } from "../server.js";
|
||||
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-"));
|
||||
|
||||
@@ -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,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/);
|
||||
});
|
||||
@@ -41,11 +41,7 @@ test("Site Config: correctly parses SITE_DOMAIN and derives service subdomains",
|
||||
const gitService = site.services.find((s) => s.name === "Gitea");
|
||||
assert.ok(gitService, "Gitea service exists");
|
||||
assert.equal(gitService.url, "https://git.riverrooks.dev/venus");
|
||||
assert.equal(gitService.tag, "git.testcluster.internal:2222");
|
||||
|
||||
const chefService = site.services.find((s) => s.name === "CyberChef");
|
||||
assert.ok(chefService, "CyberChef service exists");
|
||||
assert.equal(chefService.url, "https://chef.testcluster.internal");
|
||||
assert.equal(gitService.tag, "git.testcluster.internal:443");
|
||||
|
||||
// Verify navigation links
|
||||
const navHome = site.nav.find((n) => n.label === "Home");
|
||||
|
||||
@@ -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