app/lib/blog-listing.mjs
112 lines and 14 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| picksFirst | 23 to 34 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:25> | 25 to 25 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| rank | 26 to 29 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| <anonymous:31> | 31 to 31 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:32> | 32 to 32 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| <anonymous:33> | 33 to 33 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| readPage | 41 to 45 | 5 | 100% | OkCRAP 5 | No data: no mutants in this code |
| pageForPosition | 51 to 53 | 1 | 33% | OkCRAP 1.3 | No data: no mutants in this code |
| pageCount | 61 to 63 | 1 | 33% | OkCRAP 1.3 | No data: no mutants in this code |
| splitFeatured | 74 to 79 | 4 | 100% | OkCRAP 4 | No data: no mutants in this code |
| <anonymous:76> | 76 to 76 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:78> | 78 to 78 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| startHere | 92 to 97 | 5 | 100% | OkCRAP 5 | No data: no mutants in this code |
| listingFacts | 108 to 122 | 7 | 7% | CriticalCRAP 46.8 | No data: no mutants in this code |
Imports
It imports no other module of this repo. Imported by app/components/taxonomy-listing.tsx, app/db/posts.ts, app/routes/admin.media._index.tsx, app/routes/blog._index.tsx, app/routes/blog.series.$series.tsx, app/routes/blog.tags.$tag.tsx.
Source
export const POSTS_PER_PAGE = 10;
/** The home page's Writing section is a taste, not a feed: the featured post and two more. */
export const HOME_CARDS = 3;
/**
* The essays that follow the featured post on the home page, in this order. `featured` marks one post
* (it leads /writing), so the rest of the home page's best-of is a hand-picked list. A pick that is
* not published drops out and the newest post fills its place; test/blog-listing.test.mjs holds each
* pick against a published file in content/posts.
*/
export const HOME_PICKS = ["policy-in-the-api-not-the-mcp", "observable-plot-inside-a-worker"];
/**
* The home page's picks first, in `picks` order, then everything else in the order given. Stable, so
* the rest stay newest first. Duplicates by slug are dropped, because the caller merges two queries.
*
* @template {{ slug: string }} T
* @param {T[]} rows none of them featured
* @param {readonly string[]} [picks]
* @returns {T[]}
*/
export function picksFirst(rows, picks = HOME_PICKS) {
const seen = new Set();
const unique = rows.filter((row) => !seen.has(row.slug) && seen.add(row.slug));
const rank = (/** @type {string} */ slug) => {
const i = picks.indexOf(slug);
return i === -1 ? picks.length : i;
};
return unique
.map((row, i) => ({ row, i }))
.sort((a, b) => rank(a.row.slug) - rank(b.row.slug) || a.i - b.i)
.map(({ row }) => row);
}
/**
* A `?page=` a listing can use: a positive whole number, else 1. `-3`, `1.5` and `abc` all read as 1.
*
* @param {string | null} raw
*/
export function readPage(raw) {
if (raw === null || !/^\d+$/.test(raw)) return 1;
const page = Number(raw);
return page > 0 && Number.isSafeInteger(page) ? page : 1;
}
/**
* @param {number} position 1-based
* @param {number} [perPage]
*/
export function pageForPosition(position, perPage = POSTS_PER_PAGE) {
return Math.floor((position - 1) / perPage) + 1;
}
/**
* An empty corpus still has one page, because /writing renders an empty state rather than a 404.
*
* @param {number} total
* @param {number} [perPage]
*/
export function pageCount(total, perPage = POSTS_PER_PAGE) {
return Math.max(1, Math.ceil(total / perPage));
}
/**
* Returns the list with the hero REMOVED, or it renders twice. Matched by slug, not identity,
* because a serialization boundary upstream would silently break identity.
*
* @template {{ slug: string, featured?: boolean }} T
* @param {T[]} posts the page of posts, in order
* @param {boolean} eligible whether this request may show a hero at all
* @returns {{ featured: T | null, posts: T[] }}
*/
export function splitFeatured(posts, eligible) {
if (!eligible) return { featured: null, posts };
const featured = posts.find((post) => post.featured) ?? null;
if (!featured) return { featured: null, posts };
return { featured, posts: posts.filter((post) => post.slug !== featured.slug) };
}
/**
* A pure function rather than SQL because the offline machine-readable check renders this section
* without a database. With no featured post the newest leads. `others` must exclude the featured
* post or it prints twice.
*
* @template {{ slug: string }} T
* @param {T[]} featuredRows at most one, the featured post
* @param {T[]} others in the order they follow the lead, none of them featured: `picksFirst` for the home page
* @param {number} [cards] how many the section shows in total, lead included
* @returns {{ featured: T | null, recent: T[] }}
*/
export function startHere(featuredRows, others, cards = HOME_CARDS) {
const lead = featuredRows[0] ?? others[0] ?? null;
if (lead === null) return { featured: null, recent: [] };
const recent = featuredRows[0] ? others.slice(0, cards - 1) : others.slice(1, cards);
return { featured: lead, recent };
}
/**
* The facts describe the FILTERED list, not the corpus: a tag view stating the whole blog's span
* would be true of something the page does not show. A null is not a zero; the row omits itself.
*
* @param {number} total posts in the whole filtered list, not on this page of it
* @param {{ firstYear: string | null, lastYear: string | null, minutes: number | null }} span
* @returns {(string | null)[]} three facts, in the row's order
*/
export function listingFacts(total, span) {
const years =
span.firstYear && span.lastYear
? span.firstYear === span.lastYear
? span.firstYear
: `${span.firstYear} to ${span.lastYear}`
: null;
// Hours only past two: "1 hour" rounded from 38 minutes would be wrong.
const reading = span.minutes
? span.minutes >= 120
? `${Math.round(span.minutes / 60)} hours of reading`
: `${span.minutes} minutes of reading`
: null;
return [`${total} post${total === 1 ? "" : "s"}`, years, reading];
}