Compare commits
3
Commits
515e52b274
...
07de3b2782
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07de3b2782 | ||
|
|
c8cd5fd0d9 | ||
|
|
7f12861511 |
+11
-2
@@ -3,8 +3,15 @@ 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) {
|
||||
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);
|
||||
|
||||
@@ -44,8 +51,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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
{% include "partials/footer.njk" %}
|
||||
</div>
|
||||
|
||||
<script type="module" src="/assets/js/build-timer.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<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">
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,155 @@
|
||||
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);
|
||||
},
|
||||
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 }}"/
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user