- 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>
368 lines
18 KiB
TypeScript
368 lines
18 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
import { resolve } from 'node:path';
|
|
import { loadContent } from '../../src/library/load.ts';
|
|
import { baseFiles, item, project, writeContent } from './helpers.ts';
|
|
|
|
const FIXTURES = resolve(import.meta.dir, '../../fixtures');
|
|
|
|
async function load(files: Record<string, string | Buffer>) {
|
|
return loadContent(writeContent({ ...baseFiles, ...files }));
|
|
}
|
|
|
|
describe('loadContent', () => {
|
|
test('loads the fixture content', async () => {
|
|
const { content, errors } = await loadContent(`${FIXTURES}/content`);
|
|
expect(errors).toEqual([]);
|
|
expect(content!.projects).toHaveLength(9);
|
|
expect(content!.items).toHaveLength(14);
|
|
expect(content!.interests.map((i) => i.id)).toEqual([
|
|
'comics', 'games', 'music', 'film-tv', 'books', 'made-by-me', 'writing', 'life',
|
|
]);
|
|
expect(content!.projects.map((p) => p.id).slice(0, 2)).toEqual(['example-compute-portal', 'fixture-dashboard']);
|
|
const dates = content!.items.map((i) => i.date);
|
|
expect(dates).toEqual([...dates].sort().reverse());
|
|
expect(content!.items.find((i) => i.id === 'edge-long-title')!.title).toHaveLength(140);
|
|
expect(content!.site.issues.notes['2025-h2']).toContain('editor');
|
|
expect(content!.recognition).toHaveLength(6);
|
|
expect(content!.recognition.filter((r) => r.highlight)).toHaveLength(4);
|
|
expect(content!.courses).toHaveLength(11);
|
|
});
|
|
|
|
test('loads the sparse fixtures', async () => {
|
|
const { content, errors } = await loadContent(`${FIXTURES}/sparse`);
|
|
expect(errors).toEqual([]);
|
|
expect(content!.projects).toHaveLength(1);
|
|
expect(content!.items).toHaveLength(0);
|
|
expect(content!.recognition).toEqual([]);
|
|
expect(content!.courses).toEqual([]);
|
|
});
|
|
|
|
test('accepts contract 2', async () => {
|
|
const { content, errors } = await load({ 'site.yml': baseFiles['site.yml']!.replace('contract: 1', 'contract: 2') });
|
|
expect(errors).toEqual([]);
|
|
expect(content!.site.contract).toBe(2);
|
|
});
|
|
|
|
test('accepts an interest list, primary first', async () => {
|
|
const { content, errors } = await load({ 'items/a.md': item({ interest: ['comics', 'writing'] }) });
|
|
expect(errors).toEqual([]);
|
|
expect(content!.items[0]!.interests).toEqual(['comics', 'writing']);
|
|
expect(content!.items[0]!.interest).toBe('comics');
|
|
});
|
|
|
|
test('a single interest becomes a one-element list', async () => {
|
|
const { content } = await load({ 'items/a.md': item() });
|
|
expect(content!.items[0]!.interests).toEqual(['writing']);
|
|
expect(content!.items[0]!.interest).toBe('writing');
|
|
});
|
|
|
|
test('reports bad interest lists', async () => {
|
|
const { content, errors } = await load({
|
|
'items/a.md': item({ interest: [] }),
|
|
'items/b.md': item({ interest: ['comics', 'comics'] }),
|
|
'items/c.md': item({ interest: ['comics', 'nowhere'] }),
|
|
});
|
|
expect(content).toBeNull();
|
|
const msg = (f: string) => errors.filter((e) => e.file === f && e.field === 'interest').map((e) => e.message).join(' | ');
|
|
expect(msg('items/a.md')).toContain('must not be empty');
|
|
expect(msg('items/b.md')).toContain('repeats "comics"');
|
|
expect(msg('items/c.md')).toContain('unknown interest "nowhere"');
|
|
});
|
|
|
|
test('rejects unsupported contract', async () => {
|
|
const { content, errors } = await load({ 'site.yml': baseFiles['site.yml']!.replace('contract: 1', 'contract: 3') });
|
|
expect(content).toBeNull();
|
|
expect(errors).toContainEqual(expect.objectContaining({ file: 'site.yml', field: 'contract' }));
|
|
});
|
|
|
|
test("rejects ai on a share of someone else's work", async () => {
|
|
const { errors } = await load({ 'items/f.md': item({ kind: 'film', interest: 'comics', ai: 'by-hand' }) });
|
|
expect(errors).toContainEqual(expect.objectContaining({ file: 'items/f.md', field: 'ai' }));
|
|
});
|
|
|
|
test('own work defaults ai to unknown, shares get null', async () => {
|
|
const { content, errors } = await load({
|
|
'items/e.md': item({ kind: 'essay' }),
|
|
'items/f.md': item({ kind: 'film', interest: 'comics' }),
|
|
'items/a.md': item({ kind: 'art', credit: 'Someone' }),
|
|
'items/o.md': item({ kind: 'art' }),
|
|
});
|
|
expect(errors).toEqual([]);
|
|
const ai = Object.fromEntries(content!.items.map((i) => [i.id, i.ai]));
|
|
expect(ai).toEqual({ e: 'unknown', f: null, a: null, o: 'unknown' });
|
|
expect(content!.items.find((i) => i.id === 'a')!.ownWork).toBe(false);
|
|
});
|
|
|
|
test('projects default ai to unknown', async () => {
|
|
const { content } = await load({ 'projects/p.md': project() });
|
|
expect(content!.projects[0]!.ai).toBe('unknown');
|
|
});
|
|
|
|
test('applies size defaults per kind', async () => {
|
|
const kinds: Record<string, string> = { comic: 'l', essay: 'l', song: 'm', game: 'm', film: 'm', art: 'm', music: 'm', book: 's', life: 's' };
|
|
const files = Object.fromEntries(Object.keys(kinds).map((k) => [`items/${k}.md`, item({ kind: k })]));
|
|
files['items/override.md'] = item({ kind: 'book', size: 'l' });
|
|
const { content, errors } = await load(files);
|
|
expect(errors).toEqual([]);
|
|
const sizes = Object.fromEntries(content!.items.map((i) => [i.id, i.size]));
|
|
expect(sizes).toEqual({ ...kinds, override: 'l' });
|
|
});
|
|
|
|
test('reports unknown interest, related id and missing media file', async () => {
|
|
const { content, errors } = await load({
|
|
'projects/p.md': project({ related: ['nope'] }),
|
|
'items/a.md': item({ interest: 'nowhere' }),
|
|
'items/b.md': item({ media: [{ file: 'missing.png', alt: 'x' }] }),
|
|
});
|
|
expect(content).toBeNull();
|
|
expect(errors.map((e) => `${e.file}:${e.field}`).sort()).toEqual([
|
|
'items/a.md:interest', 'items/b.md:media[0].file', 'projects/p.md:related',
|
|
]);
|
|
});
|
|
|
|
test('years is optional, image is validated', async () => {
|
|
const ok = await load({
|
|
'projects/a.md': project({ years: undefined, image: { file: 'shot.png', alt: 'Screenshot' } }),
|
|
'media/shot.png': 'x',
|
|
});
|
|
expect(ok.errors).toEqual([]);
|
|
const a = ok.content!.projects[0]!;
|
|
expect(a.years).toBeUndefined();
|
|
expect(a.image).toEqual({ file: 'shot.png', alt: 'Screenshot' });
|
|
const bad = await load({
|
|
'projects/missing.md': project({ image: { file: 'nope.png', alt: 'x' } }),
|
|
'projects/ext.md': project({ image: { file: 'doc.pdf', alt: 'x' } }),
|
|
'projects/noalt.md': project({ image: { file: 'shot.png' } }),
|
|
'projects/shape.md': project({ image: 'shot.png' }),
|
|
'media/shot.png': 'x',
|
|
'media/doc.pdf': 'x',
|
|
});
|
|
expect(bad.errors.map((e) => `${e.file}:${e.field}`).sort()).toEqual([
|
|
'projects/ext.md:image.file', 'projects/missing.md:image.file', 'projects/noalt.md:image.alt', 'projects/shape.md:image',
|
|
]);
|
|
});
|
|
|
|
test('skips drafts', async () => {
|
|
const { content, errors } = await load({
|
|
'projects/d.md': project({ draft: true, group: 'bogus' }),
|
|
'items/d.md': item({ draft: true, kind: 'bogus' }),
|
|
'items/k.md': item(),
|
|
});
|
|
expect(errors).toEqual([]);
|
|
expect(content!.projects).toHaveLength(0);
|
|
expect(content!.items.map((i) => i.id)).toEqual(['k']);
|
|
});
|
|
|
|
test('rejects bad ids, dates and missing files', async () => {
|
|
const { errors } = await load({
|
|
'items/Bad_Id.md': item(),
|
|
'items/d.md': item({ date: '01/02/2025' }),
|
|
'interests.yml': '',
|
|
});
|
|
const keys = errors.map((e) => `${e.file}:${e.field}`);
|
|
expect(keys).toContain('items/Bad_Id.md:id');
|
|
expect(keys).toContain('items/d.md:date');
|
|
expect(keys).toContain('interests.yml:file');
|
|
});
|
|
|
|
test('media paths must be regular files inside media/', async () => {
|
|
const site = (cv: string) => baseFiles['site.yml']!.replace('linkedin: "https://example.invalid/l" }', `linkedin: "https://example.invalid/l", cv: "${cv}" }`);
|
|
const bad = await load({
|
|
'site.yml': site('../site.yml'),
|
|
'projects/up.md': project({ image: { file: '../media/shot.png', alt: 'x' } }),
|
|
'projects/abs.md': project({ image: { file: '/etc/x.png', alt: 'x' } }),
|
|
'projects/dir.md': project({ image: { file: 'sub.png', alt: 'x' } }),
|
|
'items/dot.md': item({ media: [{ file: '.', alt: 'x' }] }),
|
|
'items/back.md': item({ media: [{ file: '\\shot.png', alt: 'x' }] }),
|
|
'items/mid.md': item({ media: [{ file: 'sub.png/../shot.png', alt: 'x' }] }),
|
|
'media/shot.png': 'x',
|
|
'media/sub.png/inner.txt': 'x',
|
|
});
|
|
expect(bad.content).toBeNull();
|
|
expect(bad.errors.map((e) => `${e.file}:${e.field}`).sort()).toEqual([
|
|
'items/back.md:media[0].file', 'items/dot.md:media[0].file', 'items/mid.md:media[0].file',
|
|
'projects/abs.md:image.file', 'projects/dir.md:image.file', 'projects/up.md:image.file', 'site.yml:links.cv',
|
|
]);
|
|
const ok = await load({
|
|
'site.yml': site('cv/me.pdf'),
|
|
'items/a.md': item({ media: [{ file: 'art/a.png', alt: 'x' }] }),
|
|
'media/cv/me.pdf': 'x',
|
|
'media/art/a.png': 'x',
|
|
});
|
|
expect(ok.errors).toEqual([]);
|
|
});
|
|
|
|
test('a project link can point at a file in media/ instead of a URL', async () => {
|
|
const ok = await load({
|
|
'projects/p.md': project({ links: [{ label: 'Report', file: 'docs/report.pdf' }, { label: 'Source', url: 'https://example.invalid/s' }] }),
|
|
'media/docs/report.pdf': 'x',
|
|
});
|
|
expect(ok.errors).toEqual([]);
|
|
expect(ok.content!.projects[0]!.links).toEqual([
|
|
{ label: 'Report', url: '/media/docs/report.pdf' },
|
|
{ label: 'Source', url: 'https://example.invalid/s' },
|
|
]);
|
|
const bad = await load({
|
|
'projects/missing.md': project({ links: [{ label: 'Report', file: 'nope.pdf' }] }),
|
|
'projects/up.md': project({ links: [{ label: 'Report', file: '../site.yml' }] }),
|
|
'projects/both.md': project({ links: [{ label: 'Report', file: 'r.pdf', url: 'https://example.invalid/r' }] }),
|
|
'projects/neither.md': project({ links: [{ label: 'Report' }] }),
|
|
'media/r.pdf': 'x',
|
|
});
|
|
expect(bad.errors.map((e) => `${e.file}:${e.field}`).sort()).toEqual([
|
|
'projects/both.md:links[0]', 'projects/missing.md:links[0].file', 'projects/neither.md:links[0]', 'projects/up.md:links[0].file',
|
|
]);
|
|
});
|
|
|
|
test('dates must be real calendar days', async () => {
|
|
const { errors } = await load({
|
|
'items/feb.md': item({ date: '2025-02-30' }),
|
|
'items/month.md': item({ date: '2025-13-01' }),
|
|
'projects/p.md': project({ date: '2024-04-31' }),
|
|
'items/leap.md': item({ date: '2024-02-29' }),
|
|
});
|
|
expect(errors.map((e) => `${e.file}:${e.field}`).sort()).toEqual(['items/feb.md:date', 'items/month.md:date', 'projects/p.md:date']);
|
|
});
|
|
|
|
test('personal_intro is optional text, separate from intro', async () => {
|
|
const none = await load({});
|
|
expect(none.content!.site.personalIntro).toBeUndefined();
|
|
const set = await load({ 'site.yml': `${baseFiles['site.yml']}\npersonal_intro: Things I like.` });
|
|
expect(set.content!.site.personalIntro).toBe('Things I like.');
|
|
const bad = await load({ 'site.yml': `${baseFiles['site.yml']}\npersonal_intro: [a, b]` });
|
|
expect(bad.errors).toContainEqual(expect.objectContaining({ file: 'site.yml', field: 'personal_intro' }));
|
|
});
|
|
|
|
test('showcase ids are checked; featured needs a showcase or an image', async () => {
|
|
const { errors } = await load({
|
|
'projects/unknown.md': project({ showcase: 'dashboard' }),
|
|
'projects/bare.md': project({ featured: 1 }),
|
|
'projects/known.md': project({ featured: 2, showcase: 'config-editor' }),
|
|
'projects/pictured.md': project({ featured: 3, image: { file: 'shot.png', alt: 'Screenshot' } }),
|
|
'media/shot.png': 'x',
|
|
});
|
|
expect(errors.map((e) => `${e.file}:${e.field}`).sort()).toEqual(['projects/bare.md:showcase', 'projects/unknown.md:showcase']);
|
|
expect(errors.find((e) => e.file === 'projects/unknown.md')!.message).toContain('compute-queue, config-editor, peer-network, rowing-stroke');
|
|
});
|
|
|
|
test('images in Markdown bodies load lazily', async () => {
|
|
const { content } = await load({
|
|
'items/a.md': item({}, ''),
|
|
'projects/p.md': project({}, 'Text\n\n'),
|
|
});
|
|
expect(content!.items[0]!.bodyHtml).toContain('<img src="/media/a.png" alt="A page" loading="lazy" decoding="async">');
|
|
expect(content!.projects[0]!.bodyHtml).toMatch(/<img src="\/media\/b\.png" alt="Shot" title="Title" loading="lazy" decoding="async">/);
|
|
});
|
|
|
|
test('rejects an item id reserved for the issues page', async () => {
|
|
const { content, errors } = await load({ 'items/issues.md': item() });
|
|
expect(content).toBeNull();
|
|
expect(errors).toContainEqual({
|
|
file: 'items/issues.md',
|
|
field: 'id',
|
|
message: '"issues" is reserved for the half-yearly issues (/off-the-clock/issues/); rename the file',
|
|
});
|
|
});
|
|
|
|
test('sorts projects by featured then date, renders markdown', async () => {
|
|
const { content } = await load({
|
|
'projects/old.md': project({ date: '2020-01-01' }),
|
|
'projects/new.md': project({ date: '2024-01-01' }),
|
|
'projects/feat.md': project({ date: '2019-01-01', featured: 1, showcase: 'compute-queue' }),
|
|
}).then((r) => r);
|
|
expect(content!.projects.map((p) => p.id)).toEqual(['feat', 'new', 'old']);
|
|
const r = await load({ 'items/m.md': item({}, '**bold** and a table\n\n| a |\n|---|\n| b |') });
|
|
expect(r.content!.items[0]!.bodyHtml).toContain('<strong>bold</strong>');
|
|
expect(r.content!.items[0]!.bodyHtml).toContain('<table>');
|
|
});
|
|
});
|
|
|
|
describe('recognition.yml and courses.yml', () => {
|
|
const rec = [
|
|
'- { title: Hack One, kind: competition, result: winner, highlight: true, year: 2019 }',
|
|
'- { title: Helping Out, kind: volunteering, result: IT & website, url: "https://example.invalid/h", status: retired, detail: two years }',
|
|
'- { title: Hack Two, kind: competition, result: top 10%, year: 2021 }',
|
|
].join('\n');
|
|
const courses = [
|
|
'- { title: Course A, provider: Prov, kind: course, topic: Topic, completed: 2025-04-18, minutes: 90, certificate: true, keywords: [alpha, beta] }',
|
|
'- { title: Lab B, provider: Prov, kind: lab, topic: Topic, completed: 2025-02-01, minutes: 30, url: "https://example.invalid/b" }',
|
|
].join('\n');
|
|
|
|
test('both files load; recognition keeps file order', async () => {
|
|
const { content, errors } = await load({ 'recognition.yml': rec, 'courses.yml': courses });
|
|
expect(errors).toEqual([]);
|
|
expect(content!.recognition.map((r) => r.title)).toEqual(['Hack One', 'Helping Out', 'Hack Two']);
|
|
expect(content!.recognition[0]).toEqual({ title: 'Hack One', kind: 'competition', result: 'winner', highlight: true, year: '2019' });
|
|
expect(content!.recognition[1]).toEqual({
|
|
title: 'Helping Out', kind: 'volunteering', result: 'IT & website', highlight: false,
|
|
detail: 'two years', url: 'https://example.invalid/h', status: 'retired',
|
|
});
|
|
expect(content!.courses).toHaveLength(2);
|
|
expect(content!.courses[0]).toEqual({
|
|
title: 'Course A', provider: 'Prov', kind: 'course', topic: 'Topic', completed: '2025-04-18', minutes: 90,
|
|
certificate: true, keywords: ['alpha', 'beta'],
|
|
});
|
|
expect(content!.courses[1]).toEqual({
|
|
title: 'Lab B', provider: 'Prov', kind: 'lab', topic: 'Topic', completed: '2025-02-01', minutes: 30,
|
|
certificate: false, url: 'https://example.invalid/b', keywords: [],
|
|
});
|
|
});
|
|
|
|
test('missing files mean empty lists', async () => {
|
|
const { content, errors } = await load({});
|
|
expect(errors).toEqual([]);
|
|
expect(content!.recognition).toEqual([]);
|
|
expect(content!.courses).toEqual([]);
|
|
});
|
|
|
|
test('reports each problem in recognition.yml by field', async () => {
|
|
const { content, errors } = await load({
|
|
'recognition.yml': [
|
|
'- { kind: competition, result: x }',
|
|
'- { title: T, kind: medal, result: x }',
|
|
'- { title: T, kind: competition }',
|
|
'- { title: T, kind: competition, result: x, highlight: yes }',
|
|
'- { title: T, kind: competition, result: x, url: "ftp://nope" }',
|
|
'- { title: T, kind: competition, result: x, status: ongoing }',
|
|
'- { title: T, kind: volunteering, result: x, status: paused }',
|
|
'- { title: T, kind: competition, result: x, highlite: true }',
|
|
'- just text',
|
|
].join('\n'),
|
|
});
|
|
expect(content).toBeNull();
|
|
expect(errors.filter((e) => e.file === 'recognition.yml').map((e) => e.field)).toEqual([
|
|
'[0].title', '[1].kind', '[2].result', '[3].highlight', '[4].url', '[5].status', '[6].status', '[7].highlite', '[8]',
|
|
]);
|
|
});
|
|
|
|
test('reports each problem in courses.yml by field', async () => {
|
|
const ok = 'provider: P, kind: course, topic: T, completed: 2025-01-01, minutes: 10';
|
|
const { content, errors } = await load({
|
|
'courses.yml': [
|
|
`- { provider: P, kind: course, topic: T, completed: 2025-01-01, minutes: 10 }`,
|
|
`- { title: C, provider: P, kind: video, topic: T, completed: 2025-01-01, minutes: 10 }`,
|
|
`- { title: C, provider: P, kind: course, topic: T, completed: 2025-02-30, minutes: 10 }`,
|
|
`- { title: C, provider: P, kind: course, topic: T, completed: 2025-01-01, minutes: 0 }`,
|
|
`- { title: C, provider: P, kind: course, topic: T, completed: 2025-01-01, minutes: 1.5 }`,
|
|
`- { title: C, ${ok}, certificate: "yes" }`,
|
|
`- { title: C, ${ok}, url: "not a url" }`,
|
|
`- { title: C, ${ok}, keywords: [1, 2] }`,
|
|
`- { title: C, ${ok}, certifcate: true }`,
|
|
`- { title: C, provider: P, kind: course, completed: 2025-01-01, minutes: 10 }`,
|
|
`- [a, list]`,
|
|
].join('\n'),
|
|
});
|
|
expect(content).toBeNull();
|
|
expect(errors.filter((e) => e.file === 'courses.yml').map((e) => e.field)).toEqual([
|
|
'[0].title', '[1].kind', '[2].completed', '[3].minutes', '[4].minutes', '[5].certificate', '[6].url',
|
|
'[7].keywords', '[8].certifcate', '[9].topic', '[10]',
|
|
]);
|
|
});
|
|
|
|
test('a file that is not a list is an error', async () => {
|
|
const { errors } = await load({ 'recognition.yml': 'title: x', 'courses.yml': 'just text' });
|
|
expect(errors).toContainEqual(expect.objectContaining({ file: 'recognition.yml', field: 'file' }));
|
|
expect(errors).toContainEqual(expect.objectContaining({ file: 'courses.yml', field: 'file' }));
|
|
});
|
|
});
|