From 0967d540cf5fc99b2ab7564e044615efbf5ea72f Mon Sep 17 00:00:00 2001 From: venus Date: Tue, 27 Oct 2026 17:33:34 -0500 Subject: [PATCH] feat: archive page --- eleventy.config.js | 18 +- src/_includes/partials/stats-sidebar.njk | 8 + src/_utils/sort-posts.js | 27 ++- src/archive.njk | 28 +++ test/archived-posts.test.js | 250 +++++++++++++++++++++++ 5 files changed, 327 insertions(+), 4 deletions(-) create mode 100644 src/archive.njk create mode 100644 test/archived-posts.test.js 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" %} + + +
+
+
+ + ARCHIVED DISPATCHES +
+ ORDER: CHRONOLOGICAL DESC • TOTAL: {{ collections.archivedPosts.length }} +
+ +
+ {% 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" + ); +});