diff --git a/eleventy.config.js b/eleventy.config.js
index 44d641b..440a998 100644
--- a/eleventy.config.js
+++ b/eleventy.config.js
@@ -2,7 +2,13 @@ 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) {
@@ -49,7 +55,7 @@ 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);
@@ -59,6 +65,14 @@ export default function (eleventyConfig) {
.sort(postComparator);
});
+ // 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) => {
const tagSet = new Set();
diff --git a/src/_includes/partials/stats-sidebar.njk b/src/_includes/partials/stats-sidebar.njk
index b2771d0..15fb7e9 100644
--- a/src/_includes/partials/stats-sidebar.njk
+++ b/src/_includes/partials/stats-sidebar.njk
@@ -14,6 +14,10 @@
TOTAL DISPATCHES:
{{ collections.posts.length }} PUBLISHED
+
+ ARCHIVED NOTES:
+ {{ (collections.archivedPosts or []).length }} ARCHIVED
+
TOPIC DOMAINS:
{{ collections.tagList.length }} CATEGORIES
@@ -27,6 +31,10 @@
{{ site.status }}
+
+
+ VIEW ARCHIVE ({{ (collections.archivedPosts or []).length }}) →
+
diff --git a/src/_utils/sort-posts.js b/src/_utils/sort-posts.js
index f0de9cc..27c3ac9 100644
--- a/src/_utils/sort-posts.js
+++ b/src/_utils/sort-posts.js
@@ -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;
diff --git a/src/archive.njk b/src/archive.njk
new file mode 100644
index 0000000..dd7af7f
--- /dev/null
+++ b/src/archive.njk
@@ -0,0 +1,28 @@
+---
+layout: layouts/base.njk
+title: "River Rooks | Archived Dispatches"
+---
+
+
+{% include "partials/hero-tile.njk" %}
+
+
+
+
+
+
+ {% for post in collections.archivedPosts %}
+ {% include "partials/post-card.njk" %}
+ {% else %}
+
+ No archived dispatches found in repository.
+
+ {% endfor %}
+
+
diff --git a/test/archived-posts.test.js b/test/archived-posts.test.js
new file mode 100644
index 0000000..6b9e801
--- /dev/null
+++ b/test/archived-posts.test.js
@@ -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(
+ /]*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(
+ /]*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"
+ );
+});