diff --git a/eleventy.config.js b/eleventy.config.js index b358e08..b29b754 100644 --- a/eleventy.config.js +++ b/eleventy.config.js @@ -2,6 +2,7 @@ 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 } from "./src/_utils/sort-posts.js"; export default function (eleventyConfig) { // 1. Static Asset Passthrough Copy @@ -38,12 +39,12 @@ export default function (eleventyConfig) { } // 5. Custom Collections - // Chronological blog posts collection + // Pinned / featured prioritized, chronological blog posts collection eleventyConfig.addCollection("posts", (collectionApi) => { return collectionApi .getFilteredByGlob("src/posts/*.md") .filter((post) => !post.data.draft) - .sort((a, b) => b.date - a.date); + .sort(postComparator); }); // Dynamic Tag List Collection across all posts diff --git a/src/_utils/sort-posts.js b/src/_utils/sort-posts.js new file mode 100644 index 0000000..8909752 --- /dev/null +++ b/src/_utils/sort-posts.js @@ -0,0 +1,59 @@ +/** + * Safely extracts a numeric timestamp from an Eleventy item or its frontmatter data. + * @param {object} item Eleventy page / post object + * @returns {number} Unix timestamp in ms + */ +export function getPostTimestamp(item) { + if (!item || typeof item !== "object") return 0; + const rawDate = item.date ?? item.data?.date ?? item.data?.created; + if (rawDate instanceof Date) { + const t = rawDate.getTime(); + return Number.isNaN(t) ? 0 : t; + } + if (typeof rawDate === "string" || typeof rawDate === "number") { + const t = new Date(rawDate).getTime(); + return Number.isNaN(t) ? 0 : t; + } + return 0; +} + +/** + * Determines whether a post is flagged as pinned or featured. + * Explicit boolean true or truthy values are supported; + * missing, null, or false values return false. + * + * @param {object} item Eleventy page / post object + * @returns {boolean} + */ +export function isPostPinned(item) { + if (!item || typeof item !== "object") return false; + const data = item.data; + if (!data || typeof data !== "object") return false; + return Boolean(data.pinned || data.featured); +} + +/** + * Comparator for sorting blog posts: + * 1. Pinned or featured posts are prioritized at the top of the collection. + * 2. Within the same priority group (both pinned or both unpinned), posts + * are sorted chronologically in descending order (newest first). + * + * @param {object} a First post + * @param {object} b Second post + * @returns {number} Negative if a comes before b, positive if b comes before a, 0 if equal + */ +export function postComparator(a, b) { + const aPinned = isPostPinned(a); + const bPinned = isPostPinned(b); + + if (aPinned !== bPinned) { + return bPinned ? 1 : -1; + } + + const timeA = getPostTimestamp(a); + const timeB = getPostTimestamp(b); + + return timeB - timeA; +} + +export default postComparator; diff --git a/test/build-integrity.test.js b/test/build-integrity.test.js index 4bdd6d2..f6bde6d 100644 --- a/test/build-integrity.test.js +++ b/test/build-integrity.test.js @@ -45,7 +45,7 @@ test("Build & Link Integrity: Output inspection and link verification", () => { // 2. HTML Boilerplate Invariants assert.match(content, //i, `${relHtml} must contain `); assert.match(content, //i, `${relHtml} must link to main.css`); + assert.match(content, //i, `${relHtml} must link to main.css`); // 3. Internal Link Integrity (No 404s!) const internalLinks = [...content.matchAll(/href="(\/[^"#?]*)"/g)].map((m) => m[1]); diff --git a/test/post-sorting.test.js b/test/post-sorting.test.js new file mode 100644 index 0000000..44b1e10 --- /dev/null +++ b/test/post-sorting.test.js @@ -0,0 +1,104 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { postComparator, isPostPinned, getPostTimestamp } from "../src/_utils/sort-posts.js"; + +test("Post Sorting: isPostPinned detection", () => { + assert.equal(isPostPinned({ data: { pinned: true } }), true); + assert.equal(isPostPinned({ data: { featured: true } }), true); + assert.equal(isPostPinned({ data: { pinned: false } }), false); + assert.equal(isPostPinned({ data: { featured: false } }), false); + assert.equal(isPostPinned({ data: { pinned: null } }), false); + assert.equal(isPostPinned({ data: {} }), false); + assert.equal(isPostPinned({}), false); + assert.equal(isPostPinned(null), false); + assert.equal(isPostPinned(undefined), false); + assert.equal(isPostPinned("invalid"), false); +}); + +test("Post Sorting: getPostTimestamp extraction and fallback", () => { + const d = new Date("2026-09-01T00:00:00Z"); + assert.equal(getPostTimestamp({ date: d }), d.getTime()); + assert.equal(getPostTimestamp({ data: { date: "2026-09-01" } }), new Date("2026-09-01").getTime()); + assert.equal(getPostTimestamp({ data: { created: "2026-09-01" } }), new Date("2026-09-01").getTime()); + assert.equal(getPostTimestamp({ date: "not-a-date" }), 0); + assert.equal(getPostTimestamp({}), 0); + assert.equal(getPostTimestamp(null), 0); +}); + +test("Post Sorting: standard chronological sort (unpinned posts)", () => { + const postNew = { + data: { title: "Newer Post" }, + date: new Date("2026-09-10") + }; + const postOld = { + data: { title: "Older Post" }, + date: new Date("2026-08-01") + }; + + const sorted = [postOld, postNew].sort(postComparator); + assert.deepEqual(sorted.map((p) => p.data.title), ["Newer Post", "Older Post"]); +}); + +test("Post Sorting: pinned post forces top position over newer unpinned post", () => { + const newerUnpinned = { + data: { title: "Newer Unpinned", pinned: false }, + date: new Date("2026-09-20") + }; + const olderPinned = { + data: { title: "Older Pinned", pinned: true }, + date: new Date("2026-07-01") + }; + + const sorted = [newerUnpinned, olderPinned].sort(postComparator); + assert.equal(sorted[0].data.title, "Older Pinned"); + assert.equal(sorted[1].data.title, "Newer Unpinned"); +}); + +test("Post Sorting: featured post forces top position over newer unpinned post", () => { + const newerRegular = { + data: { title: "Fresh Article" }, + date: new Date("2026-09-22") + }; + const olderFeatured = { + data: { title: "Featured Pillar", featured: true }, + date: new Date("2026-06-15") + }; + + const sorted = [newerRegular, olderFeatured].sort(postComparator); + assert.equal(sorted[0].data.title, "Featured Pillar"); + assert.equal(sorted[1].data.title, "Fresh Article"); +}); + +test("Post Sorting: multiple pinned posts sort among themselves chronologically", () => { + const olderPinned = { + data: { title: "Older Pinned", pinned: true }, + date: new Date("2026-05-01") + }; + const newerPinned = { + data: { title: "Newer Pinned", featured: true }, + date: new Date("2026-08-15") + }; + const freshUnpinned = { + data: { title: "Fresh Regular" }, + date: new Date("2026-09-01") + }; + + const list = [olderPinned, freshUnpinned, newerPinned]; + const sorted = list.sort(postComparator); + + assert.deepEqual( + sorted.map((p) => p.data.title), + ["Newer Pinned", "Older Pinned", "Fresh Regular"] + ); +}); + +test("Post Sorting: adversarial robustness against missing data or equal dates", () => { + const postNoData = { date: new Date("2026-09-01") }; + const postMalformedDate = { data: { title: "Malformed", date: "invalid-date" } }; + const postIdenticalDate1 = { data: { title: "Post A" }, date: new Date("2026-09-01") }; + const postIdenticalDate2 = { data: { title: "Post B" }, date: new Date("2026-09-01") }; + + assert.doesNotThrow(() => { + [postNoData, postMalformedDate, postIdenticalDate1, postIdenticalDate2].sort(postComparator); + }); +});