Compare commits
2
Commits
07de3b2782
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0967d540cf | ||
|
|
59f807c45b |
+16
-2
@@ -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();
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
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));
|
||||
|
||||
@@ -13,7 +22,9 @@ export const validateLinkedArticles = (posts) => {
|
||||
}
|
||||
|
||||
const linkedTitleSet = new Set();
|
||||
for (const title of linkedArticles) {
|
||||
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`
|
||||
@@ -44,5 +55,5 @@ 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((title) => postsByTitle.get(title)).filter(Boolean);
|
||||
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>
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
@@ -24,6 +24,26 @@ test("Linked articles: accepts an article's list when every name identifies anot
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
() => {
|
||||
|
||||
Reference in New Issue
Block a user