Compare commits

...
2 Commits
Author SHA1 Message Date
venus c8cd5fd0d9 Merge branch 'feat-linked-articles' into dev 2026-10-20 16:49:50 -05:00
venus 7f12861511 feat: linked articles in header 2026-10-20 16:39:36 -05:00
5 changed files with 220 additions and 3 deletions
+5 -2
View File
@@ -3,6 +3,7 @@ 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 { validateLinkedArticles } from "./src/_utils/linked-articles.js";
export default function (eleventyConfig) {
// Build ignored local post fixtures without making them Git-tracked.
@@ -44,8 +45,10 @@ export default function (eleventyConfig) {
// 5. Custom Collections
// Pinned / featured prioritized, chronological blog posts collection (excludes drafts and hidden posts)
eleventyConfig.addCollection("posts", (collectionApi) => {
return collectionApi
.getFilteredByGlob("src/posts/*.md")
const allPosts = collectionApi.getFilteredByGlob("src/posts/*.md");
validateLinkedArticles(allPosts);
return allPosts
.filter((post) => isPostPublic(post))
.sort(postComparator);
});
+3 -1
View File
@@ -1,5 +1,6 @@
import { DateTime } from "luxon";
import markdownIt from "markdown-it";
import { resolveLinkedArticles } from "../_utils/linked-articles.js";
/**
* Custom Eleventy Filters for River Rooks Blog
@@ -108,7 +109,8 @@ export const filters = {
readingTime,
extractHeadings,
extractTerminalPreview,
removeTerminalPreview
removeTerminalPreview,
resolveLinkedArticles
};
export default filters;
+11
View File
@@ -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">
+48
View File
@@ -0,0 +1,48 @@
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 title of linkedArticles) {
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((title) => postsByTitle.get(title)).filter(Boolean);
};
+153
View File
@@ -0,0 +1,153 @@
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: 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);
},
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 }}"/
);
});