diff --git a/scripts/data/blog.mjs b/scripts/data/blog.mjs index 9c90fd4a..7370f66f 100644 --- a/scripts/data/blog.mjs +++ b/scripts/data/blog.mjs @@ -11,6 +11,74 @@ const OUTPUT = join(ROOT, 'generated', 'blog.json'); const titleFromBody = body => body.match(/^#\s+(.+)$/m)?.[1].trim() ?? null; +class InvalidBlogDateError extends Error { + constructor(file, display) { + super( + `Invalid blog date in ${join('pages', 'blog', 'posts', file)}: ${display} ` + + '(expected an ISO 8601 date or timestamp)' + ); + this.name = 'InvalidBlogDateError'; + } +} + +const daysInMonth = (year, month) => { + if (month === 2) { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28; + } + + return [31, 0, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] ?? 0; +}; + +const normalizeDate = (value, file, source) => { + const rawDate = source + .match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1] + ?.match(/^date:\s*(.*?)\s*$/m)?.[1]; + const calendar = rawDate?.match( + /^['"]?(\d{4})-(\d{1,2})-(\d{1,2})(?:(?:[Tt]|[ \t]+)(\d{1,2}):(\d{2})(?::(\d{2}))?)?/ + ); + + if ( + calendar && + (Number(calendar[2]) < 1 || + Number(calendar[2]) > 12 || + Number(calendar[3]) < 1 || + Number(calendar[3]) > + daysInMonth(Number(calendar[1]), Number(calendar[2])) || + (calendar[4] !== undefined && + (Number(calendar[4]) > 24 || + (Number(calendar[4]) === 24 && + (Number(calendar[5]) !== 0 || + (calendar[6] !== undefined && Number(calendar[6]) !== 0))))) || + (calendar[5] !== undefined && Number(calendar[5]) > 59) || + (calendar[6] !== undefined && Number(calendar[6]) > 59)) + ) { + const display = JSON.stringify(rawDate.trim()); + throw new InvalidBlogDateError(file, display); + } + + const date = + value instanceof Date + ? value + : typeof value === 'string' && value.trim() + ? new Date( + /^\d{4}-\d{1,2}-\d{1,2}(?:[Tt]|[ \t]+)\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test( + value.trim() + ) + ? `${value.trim().replace(/[ \t]+/, 'T')}Z` + : value + ) + : null; + + if (!date || Number.isNaN(date.getTime())) { + const displayValue = rawDate?.trim() || value; + const display = + displayValue === undefined ? '' : JSON.stringify(displayValue); + throw new InvalidBlogDateError(file, display); + } + + return date.toISOString(); +}; + const readPosts = async () => { const entries = await readdir(POSTS_DIR); const files = entries.filter(name => name.endsWith('.md')); @@ -18,15 +86,14 @@ const readPosts = async () => { const posts = await Promise.all( files.map(async file => { const slug = file.replace(/\.md$/, ''); - const { data, content } = matter( - await readFile(join(POSTS_DIR, file), 'utf8') - ); + const source = await readFile(join(POSTS_DIR, file), 'utf8'); + const { data, content } = matter(source); return { slug, title: titleFromBody(content) ?? slug, authors: data.authors?.split(',').map(s => s.trim()), - date: new Date(data.date).toISOString(), + date: normalizeDate(data.date, file, source), category: data.category ?? null, image: data.image ?? null, ...(data.description && { description: data.description }), diff --git a/tests/data/blog.test.mjs b/tests/data/blog.test.mjs new file mode 100644 index 00000000..4e8ab856 --- /dev/null +++ b/tests/data/blog.test.mjs @@ -0,0 +1,151 @@ +import { strict as assert } from 'node:assert'; +import { execFile } from 'node:child_process'; +import { + copyFile, + mkdtemp, + mkdir, + readFile, + rm, + writeFile, +} from 'node:fs/promises'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +const execFileAsync = promisify(execFile); +const blogScript = fileURLToPath( + new URL('../../scripts/data/blog.mjs', import.meta.url) +); + +const createFixture = async content => { + const root = await mkdtemp(join('tests', 'data', '.blog-')); + await mkdir(join(root, 'scripts', 'data'), { recursive: true }); + await mkdir(join(root, 'pages', 'blog', 'posts'), { recursive: true }); + await mkdir(join(root, 'generated'), { recursive: true }); + await copyFile(blogScript, join(root, 'scripts', 'data', 'blog.mjs')); + await writeFile(join(root, 'pages', 'blog', 'posts', 'example.md'), content); + return root; +}; + +const runFixture = (root, timeZone = 'UTC') => + execFileAsync(process.execPath, ['scripts/data/blog.mjs'], { + cwd: root, + env: { ...process.env, TZ: timeZone }, + }); + +test('generates blog data for valid dates', async t => { + const root = await createFixture("---\ndate: '2026-09-22'\n---\n# Test\n"); + t.after(() => rm(root, { recursive: true, force: true })); + + await runFixture(root); + const posts = JSON.parse( + await readFile(join(root, 'generated', 'blog.json'), 'utf8') + ); + + assert.equal(posts[0].date, '2026-09-22T00:00:00.000Z'); +}); + +test('normalizes offset-less quoted date-times as UTC', async t => { + const root = await createFixture( + "---\ndate: '2026-09-22 10:00'\n---\n# Test\n" + ); + t.after(() => rm(root, { recursive: true, force: true })); + + const dates = []; + for (const timeZone of ['UTC', 'America/Los_Angeles']) { + await runFixture(root, timeZone); + const posts = JSON.parse( + await readFile(join(root, 'generated', 'blog.json'), 'utf8') + ); + dates.push(posts[0].date); + } + + assert.deepEqual(dates, [ + '2026-09-22T10:00:00.000Z', + '2026-09-22T10:00:00.000Z', + ]); +}); + +test('reports the file for an invalid blog date', async t => { + const root = await createFixture('---\ndate: not-a-date\n---\n# Test\n'); + t.after(() => rm(root, { recursive: true, force: true })); + + await assert.rejects(runFixture(root), error => { + assert.match(error.stderr, /InvalidBlogDateError: Invalid blog date/); + assert.match(error.stderr, /pages[\\/]blog[\\/]posts[\\/]example\.md/); + assert.match(error.stderr, /not-a-date/); + return true; + }); +}); + +test('rejects nonexistent calendar dates', async t => { + const root = await createFixture('---\ndate: 2026-02-30\n---\n# Test\n'); + t.after(() => rm(root, { recursive: true, force: true })); + + await assert.rejects(runFixture(root), error => { + assert.match(error.stderr, /Invalid blog date/); + assert.match(error.stderr, /2026-02-30/); + return true; + }); +}); + +test('rejects one-digit dates that roll over', async t => { + const root = await createFixture( + '---\ndate: 2024-2-30 10:00:00\n---\n# Test\n' + ); + t.after(() => rm(root, { recursive: true, force: true })); + + await assert.rejects(runFixture(root), error => { + assert.match(error.stderr, /Invalid blog date/); + assert.match(error.stderr, /2024-2-30/); + return true; + }); +}); + +test('rejects out-of-range time components', async t => { + const root = await createFixture( + '---\ndate: 2024-01-01 25:60:60\n---\n# Test\n' + ); + t.after(() => rm(root, { recursive: true, force: true })); + + await assert.rejects(runFixture(root), error => { + assert.match(error.stderr, /Invalid blog date/); + assert.match(error.stderr, /25:60:60/); + return true; + }); +}); + +test('accepts ISO end-of-day timestamps', async t => { + for (const value of ['2026-09-22T24:00:00Z', '2026-09-22 24:00']) { + const root = await createFixture(`---\ndate: '${value}'\n---\n# Test\n`); + t.after(() => rm(root, { recursive: true, force: true })); + + await runFixture(root); + } +}); + +test('rejects nonzero end-of-day time components', async t => { + const root = await createFixture( + "---\ndate: '2026-09-22T24:00:01Z'\n---\n# Test\n" + ); + t.after(() => rm(root, { recursive: true, force: true })); + + await assert.rejects(runFixture(root), error => { + assert.match(error.stderr, /Invalid blog date/); + assert.match(error.stderr, /24:00:01/); + return true; + }); +}); + +test('reports the file for a missing blog date', async t => { + const root = await createFixture('---\ntitle: Test\n---\n# Test\n'); + t.after(() => rm(root, { recursive: true, force: true })); + + await assert.rejects(runFixture(root), error => { + assert.match(error.stderr, /Invalid blog date/); + assert.match(error.stderr, /example\.md/); + assert.match(error.stderr, //); + return true; + }); +});