- CV header with highlights and keeping-up summary replaces the intro and Now strip - "Projects I'm proudest of": up to six featured panels; screenshots muted until hover - "Also made" list; searching it also lists matching courses - Record section: recognition & volunteering, courses by topic - /work/learning/: searchable course list (plain script), in the sitemap - Top bar: Projects, Record, Writing, CV; no Bluesky link in the work footer - Highlights: only the placement takes the accent (owner, after the preview) Owner looked at the rendered preview on 2026-11-12 before this commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
121 lines
6.4 KiB
TypeScript
121 lines
6.4 KiB
TypeScript
import { beforeAll, describe, expect, test } from 'bun:test';
|
|
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { loadSources } from '../../src/sources/index.ts';
|
|
import { loadSiteData, type SiteData } from '../../src/site/data.ts';
|
|
import { FEEDS, feedEntries, sitePaths, xmlFiles } from '../../src/site/feeds.ts';
|
|
import { child, childrenNamed, parseXml } from './xml-check.ts';
|
|
|
|
const SITE = 'https://example.invalid';
|
|
let data: SiteData;
|
|
let noAddress: SiteData;
|
|
|
|
beforeAll(async () => {
|
|
const cacheDir = mkdtempSync(join(tmpdir(), 'site-cache-'));
|
|
const sources = await loadSources({ cacheDir, env: { SOURCES_FIXTURES: 'fixtures/sources' } });
|
|
writeFileSync(join(cacheDir, 'sources.json'), JSON.stringify(sources));
|
|
data = await loadSiteData('fixtures/content', cacheDir, '2026-01-15', undefined, `${SITE}/`);
|
|
noAddress = await loadSiteData('fixtures/content', cacheDir, '2026-01-15', undefined, '');
|
|
});
|
|
|
|
describe('site address in site data', () => {
|
|
test('comes from the parameter (SITE_URL by default), normalised; blank means none', () => {
|
|
expect(data.siteUrl).toBe(SITE);
|
|
expect(noAddress.siteUrl).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('sitePaths', () => {
|
|
test('every page the site builds, except the 404, once each', () => {
|
|
const paths = sitePaths(data);
|
|
expect(new Set(paths).size).toBe(paths.length);
|
|
expect(paths.slice(0, 6)).toEqual(['/', '/work/', '/off-the-clock/', '/off-the-clock/issues/', '/everything/', '/simple/']);
|
|
for (const p of data.projects) expect(paths).toContain(`/work/${p.id}/`);
|
|
for (const p of data.workPosts) expect(paths).toContain(`/work/writing/${p.slug}/`);
|
|
for (const i of data.items) expect(paths).toContain(`/off-the-clock/${i.id}/`);
|
|
for (const p of data.sources.posts.filter((x) => x.side === 'off-the-clock')) expect(paths).toContain(`/off-the-clock/${p.slug}/`);
|
|
for (const i of data.issues) expect(paths).toContain(`/off-the-clock/issues/${i.id}/`);
|
|
expect(paths).toContain('/work/learning/');
|
|
expect(paths.length).toBe(7 + data.projects.length + data.workPosts.length + data.personal.length + data.issues.length);
|
|
expect(paths.some((p) => p.includes('404'))).toBe(false);
|
|
expect(paths.some((p) => p.includes('draft'))).toBe(false);
|
|
});
|
|
|
|
test('/work/learning/ only when there are courses', () => {
|
|
expect(sitePaths({ ...data, courses: [] })).not.toContain('/work/learning/');
|
|
});
|
|
});
|
|
|
|
describe('feedEntries', () => {
|
|
test('courses are never feed entries', () => {
|
|
for (const name of ['all', 'work'] as const) expect(feedEntries(data, name).some((e) => e.path.includes('learning'))).toBe(false);
|
|
});
|
|
|
|
test('work: projects and work writing, newest first, with their summaries and levels', () => {
|
|
const entries = feedEntries(data, 'work');
|
|
expect(entries.length).toBe(data.projects.length + data.workPosts.length);
|
|
const dates = entries.map((e) => e.date);
|
|
expect([...dates].sort().reverse()).toEqual(dates);
|
|
const project = data.projects[0]!;
|
|
expect(entries).toContainEqual({ title: project.title, path: `/work/${project.id}/`, date: project.date, summary: project.summary, ai: project.ai });
|
|
const post = data.workPosts[0]!;
|
|
expect(entries).toContainEqual({ title: post.title, path: `/work/writing/${post.slug}/`, date: post.date, summary: post.excerpt, ai: post.ai });
|
|
expect(entries.some((e) => e.path.startsWith('/off-the-clock/'))).toBe(false);
|
|
});
|
|
|
|
test("off the clock: the wall's items and off-the-clock posts; an item's line is its summary", () => {
|
|
const entries = feedEntries(data, 'off-the-clock');
|
|
expect(entries.length).toBe(data.personal.length);
|
|
expect(entries.every((e) => e.path.startsWith('/off-the-clock/'))).toBe(true);
|
|
const withLine = data.items.find((i) => i.line)!;
|
|
expect(entries.find((e) => e.path === `/off-the-clock/${withLine.id}/`)).toEqual({
|
|
title: withLine.title, path: `/off-the-clock/${withLine.id}/`, date: withLine.date, summary: withLine.line, ai: withLine.ai,
|
|
});
|
|
const noLine = data.items.find((i) => !i.line && i.ai === null)!;
|
|
expect(entries.find((e) => e.path === `/off-the-clock/${noLine.id}/`)).toEqual({
|
|
title: noLine.title, path: `/off-the-clock/${noLine.id}/`, date: noLine.date, ai: null,
|
|
});
|
|
// Live cards (Trakt, Hardcover, ListenBrainz) are not entries.
|
|
const live = [data.sources.trakt.lastFilm?.title, ...data.sources.hardcover.reading.map((r) => r.title)].filter(Boolean);
|
|
for (const t of live) expect(entries.some((e) => e.title === t)).toBe(false);
|
|
});
|
|
|
|
test('all: both, and the same things /everything/ lists; never drafts', () => {
|
|
const all = feedEntries(data, 'all');
|
|
expect(all.length).toBe(data.everything.length);
|
|
expect(new Set(all.map((e) => e.path))).toEqual(new Set(data.everything.map((e) => e.url)));
|
|
expect(all.some((e) => /draft/i.test(e.path) || /draft/i.test(e.title))).toBe(false);
|
|
// Ties on the date are ordered by path, so the order never depends on load order.
|
|
for (let i = 1; i < all.length; i++) {
|
|
const [a, b] = [all[i - 1]!, all[i]!];
|
|
expect(a.date > b.date || (a.date === b.date && a.path < b.path)).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('xmlFiles', () => {
|
|
test('without a site address there are none', () => {
|
|
expect(xmlFiles(noAddress)).toEqual({});
|
|
});
|
|
|
|
test('the sitemap and three feeds, at their paths', () => {
|
|
const files = xmlFiles(data);
|
|
expect(Object.keys(files).sort()).toEqual(['feed', 'off-the-clock/feed', 'sitemap', 'work/feed']);
|
|
expect(FEEDS.all.path).toBe('/feed.xml');
|
|
expect(FEEDS.work.path).toBe('/work/feed.xml');
|
|
expect(FEEDS['off-the-clock'].path).toBe('/off-the-clock/feed.xml');
|
|
const sitemap = parseXml(files.sitemap!());
|
|
expect(childrenNamed(sitemap, 'url').map((u) => child(u, 'loc')!.text)).toEqual(sitePaths(data).map((p) => `${SITE}${p}`));
|
|
const work = parseXml(files['work/feed']!());
|
|
expect(child(work, 'title')!.text).toBe(`Work · ${data.site.name}`);
|
|
expect(child(work, 'id')!.text).toBe(`${SITE}/work/feed.xml`);
|
|
const first = childrenNamed(work, 'entry')[0]!;
|
|
const newest = feedEntries(data, 'work')[0]!;
|
|
expect(child(first, 'id')!.text).toBe(`${SITE}${newest.path}`);
|
|
expect(child(first, 'category')!.attrs.term).toBe(newest.ai!);
|
|
expect(child(parseXml(files.feed!()), 'title')!.text).toBe(data.site.name);
|
|
expect(child(parseXml(files['off-the-clock/feed']!()), 'title')!.text).toBe(`Off the clock · ${data.site.name}`);
|
|
});
|
|
});
|