diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c8d9ed6e67..62c333b126 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,6 +38,9 @@ jobs: - name: Install Dependencies run: npm install working-directory: 'website' + - name: Run unit tests + run: npm test + working-directory: 'website' - name: Build the forge run: npm run build:nuxt:skip-images working-directory: 'website' diff --git a/nuxt/components/RelativeTime.vue b/nuxt/components/RelativeTime.vue new file mode 100644 index 0000000000..ff27d76fe4 --- /dev/null +++ b/nuxt/components/RelativeTime.vue @@ -0,0 +1,33 @@ + + + + + {{ value }} + diff --git a/nuxt/lib/relative-time.mjs b/nuxt/lib/relative-time.mjs new file mode 100644 index 0000000000..bb68341901 --- /dev/null +++ b/nuxt/lib/relative-time.mjs @@ -0,0 +1,54 @@ +// Formats the docs `updated:` stamp. Kept free of Nuxt and Vue imports so it can be +// unit tested with `node --test`. + +// Git writes commit dates as "2026-03-18 15:07:47 +0000". That is not a valid HTML +// datetime value, and engines are not required to parse it, so normalise it here +// rather than handing it to `new Date()` and hoping. +const GIT_DATE = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})(?:\s*([+-])(\d{2}):?(\d{2}))?$/ + +const MINUTE = 60 +const HOUR = MINUTE * 60 +const DAY = HOUR * 24 + +/** + * Normalise a git commit date to an ISO 8601 string, or return null if it is not one. + */ +export function toIso (raw) { + if (typeof raw !== 'string') return null + + const match = raw.trim().match(GIT_DATE) + if (!match) return null + + const [, date, time, sign, offsetHours, offsetMinutes] = match + const offset = sign ? `${sign}${offsetHours}:${offsetMinutes}` : 'Z' + + const iso = `${date}T${time}${offset}` + return Number.isNaN(Date.parse(iso)) ? null : iso +} + +/** + * Turn a git commit date into "3 days ago". Returns null when the input is unparseable, + * so the caller can fall back to showing the raw stamp. + * + * The unit is the largest one that still yields a count of at least 1, because "8 months + * ago" tells a reader more about how stale a page is than "241 days ago" does. + */ +export function formatRelative (raw, now = new Date()) { + const iso = toIso(raw) + if (!iso) return null + + const seconds = (Date.parse(iso) - now.getTime()) / 1000 + const absolute = Math.abs(seconds) + + if (absolute < MINUTE) return 'just now' + + const format = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) + + if (absolute < HOUR) return format.format(Math.round(seconds / MINUTE), 'minute') + if (absolute < DAY) return format.format(Math.round(seconds / HOUR), 'hour') + if (absolute < DAY * 7) return format.format(Math.round(seconds / DAY), 'day') + if (absolute < DAY * 30) return format.format(Math.round(seconds / (DAY * 7)), 'week') + if (absolute < DAY * 365) return format.format(Math.round(seconds / (DAY * 30)), 'month') + + return format.format(Math.round(seconds / (DAY * 365)), 'year') +} diff --git a/nuxt/lib/relative-time.test.mjs b/nuxt/lib/relative-time.test.mjs new file mode 100644 index 0000000000..299ac1a19c --- /dev/null +++ b/nuxt/lib/relative-time.test.mjs @@ -0,0 +1,56 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { formatRelative, toIso } from './relative-time.mjs' + +const now = new Date('2026-08-03T12:00:00Z') + +test('toIso normalises a git commit date', () => { + assert.equal(toIso('2026-03-18 15:07:47 +0000'), '2026-03-18T15:07:47+00:00') + assert.equal(toIso('2026-03-18 15:07:47 +0100'), '2026-03-18T15:07:47+01:00') + assert.equal(toIso('2026-03-18 15:07:47 -0500'), '2026-03-18T15:07:47-05:00') +}) + +test('toIso accepts a stamp with no offset', () => { + assert.equal(toIso('2026-03-18 15:07:47'), '2026-03-18T15:07:47Z') +}) + +test('toIso rejects anything it cannot read', () => { + assert.equal(toIso(''), null) + assert.equal(toIso('last Tuesday'), null) + assert.equal(toIso(undefined), null) + assert.equal(toIso('2026-13-45 99:99:99 +0000'), null) +}) + +test('formatRelative picks the largest unit that still counts at least one', () => { + const cases = [ + ['2026-08-03 11:59:30 +0000', 'just now'], + ['2026-08-03 11:30:00 +0000', '30 minutes ago'], + ['2026-08-03 04:00:00 +0000', '8 hours ago'], + ['2026-08-01 12:00:00 +0000', '2 days ago'], + ['2026-07-20 12:00:00 +0000', '2 weeks ago'], + ['2026-03-18 12:00:00 +0000', '5 months ago'], + ['2024-08-03 12:00:00 +0000', '2 years ago'], + ] + + for (const [raw, expected] of cases) { + assert.equal(formatRelative(raw, now), expected, raw) + } +}) + +test('formatRelative words a count of one the way a person would', () => { + assert.equal(formatRelative('2026-08-02 12:00:00 +0000', now), 'yesterday') + assert.equal(formatRelative('2026-07-27 12:00:00 +0000', now), 'last week') + assert.equal(formatRelative('2026-07-04 12:00:00 +0000', now), 'last month') + assert.equal(formatRelative('2025-07-10 12:00:00 +0000', now), 'last year') +}) + +test('formatRelative honours the offset rather than the wall clock', () => { + // Same instant, written in two zones: both are one hour ago. + assert.equal(formatRelative('2026-08-03 11:00:00 +0000', now), '1 hour ago') + assert.equal(formatRelative('2026-08-03 12:00:00 +0100', now), '1 hour ago') +}) + +test('formatRelative returns null on an unreadable stamp so the caller can fall back', () => { + assert.equal(formatRelative('not a date', now), null) +}) diff --git a/nuxt/pages/docs/[...slug].vue b/nuxt/pages/docs/[...slug].vue index 39203b3d16..5ea509a1e4 100644 --- a/nuxt/pages/docs/[...slug].vue +++ b/nuxt/pages/docs/[...slug].vue @@ -88,7 +88,7 @@ const breadcrumbs = computed(() => {