diff --git a/src/runtime/internal/preview/utils.ts b/src/runtime/internal/preview/utils.ts index 11dd0a336..cd7f1a5f0 100644 --- a/src/runtime/internal/preview/utils.ts +++ b/src/runtime/internal/preview/utils.ts @@ -77,17 +77,25 @@ export function parseSourceBase(source: CollectionSource) { * Importing it from the preview runtime causes a broken path in the * published package. * + * Uses UTC getters to avoid timezone-dependent date shifts on non-UTC + * CI runners or servers. + * * @see https://github.com/nuxt/content/issues/3742 */ -export const formatDate = (date: string): string => { - const d = new Date(date) +export const formatDate = (date: string | Date): string => { + const input = date instanceof Date ? date.toISOString() : String(date) + if (/^\d{4}-\d{2}-\d{2}$/.test(input)) { + return input + } + const normalized = input.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') + const d = new Date(normalized) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid date value: "${date}"`) } - const year = d.getFullYear() - const month = d.getMonth() + 1 - const day = d.getDate() + const year = d.getUTCFullYear() + const month = d.getUTCMonth() + 1 + const day = d.getUTCDate() return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}` } @@ -95,18 +103,28 @@ export const formatDate = (date: string): string => { /** * Format a date string as `YYYY-MM-DD HH:mm:ss` for SQL DATETIME columns. * + * Uses UTC getters to avoid timezone-dependent shifts. + * * @see {@link formatDate} for why this is duplicated here. * @see https://github.com/nuxt/content/issues/3742 */ -export const formatDateTime = (datetime: string): string => { - const d = new Date(datetime) +export const formatDateTime = (datetime: string | Date): string => { + const input = datetime instanceof Date ? datetime.toISOString() : String(datetime) + if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(input)) { + return input + } + const normalized = input.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') + const d = new Date(normalized) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid datetime value: "${datetime}"`) } - const hours = d.getHours() - const minutes = d.getMinutes() - const seconds = d.getSeconds() + const year = d.getUTCFullYear() + const month = d.getUTCMonth() + 1 + const day = d.getUTCDate() + const hours = d.getUTCHours() + const minutes = d.getUTCMinutes() + const seconds = d.getUTCSeconds() - return `${formatDate(datetime)} ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` + return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')} ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` } diff --git a/src/utils/content/transformers/utils.ts b/src/utils/content/transformers/utils.ts index 499b2dcda..bb2a496b0 100644 --- a/src/utils/content/transformers/utils.ts +++ b/src/utils/content/transformers/utils.ts @@ -4,28 +4,41 @@ export const defineTransformer = (transformer: ContentTransformer) => { return transformer } -export const formatDateTime = (datetime: string): string => { - const d = new Date(datetime) +export const formatDateTime = (datetime: string | Date): string => { + const input = datetime instanceof Date ? datetime.toISOString() : String(datetime) + if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(input)) { + return input + } + const normalized = input.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') + const d = new Date(normalized) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid datetime value: "${datetime}"`) } - const hours = d.getHours() - const minutes = d.getMinutes() - const seconds = d.getSeconds() + const year = d.getUTCFullYear() + const month = d.getUTCMonth() + 1 + const day = d.getUTCDate() + const hours = d.getUTCHours() + const minutes = d.getUTCMinutes() + const seconds = d.getUTCSeconds() - return `${formatDate(datetime)} ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` + return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')} ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` } -export const formatDate = (date: string): string => { - const d = new Date(date) +export const formatDate = (date: string | Date): string => { + const input = date instanceof Date ? date.toISOString() : String(date) + if (/^\d{4}-\d{2}-\d{2}$/.test(input)) { + return input + } + const normalized = input.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z') + const d = new Date(normalized) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid date value: "${date}"`) } - const year = d.getFullYear() - const month = d.getMonth() + 1 - const day = d.getDate() + const year = d.getUTCFullYear() + const month = d.getUTCMonth() + 1 + const day = d.getUTCDate() return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}` } diff --git a/test/unit/formatDate.test.ts b/test/unit/formatDate.test.ts index be1b03cae..461b0858e 100644 --- a/test/unit/formatDate.test.ts +++ b/test/unit/formatDate.test.ts @@ -2,27 +2,35 @@ import { describe, expect, it } from 'vitest' import { formatDate, formatDateTime } from '../../src/runtime/internal/preview/utils' describe('formatDate', () => { - it('formats a date string as YYYY-MM-DD', () => { - // formatDate uses local time (getFullYear/getMonth/getDate), so we - // construct expected values the same way to stay timezone-agnostic. - const input = '2022-06-15T12:00:00.000Z' - const d = new Date(input) - const expected = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` - expect(formatDate(input)).toBe(expected) + it('formats an ISO date string as YYYY-MM-DD using UTC', () => { + expect(formatDate('2022-06-15T12:00:00.000Z')).toBe('2022-06-15') + }) + + it('returns canonical date strings unchanged', () => { + expect(formatDate('2022-06-15')).toBe('2022-06-15') + expect(formatDate('2024-01-01')).toBe('2024-01-01') }) it('pads single-digit month and day', () => { - const input = '2022-01-05T12:00:00.000Z' - const result = formatDate(input) - // Format is always YYYY-MM-DD with zero-padded segments - expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/) - expect(result).toContain('-05') + expect(formatDate('2022-01-05T00:00:00.000Z')).toBe('2022-01-05') + }) + + it('handles end-of-year dates consistently in UTC', () => { + // 2022-12-31T23:00:00Z is still Dec 31 in UTC even if it's Jan 1 locally + expect(formatDate('2022-12-31T23:00:00.000Z')).toBe('2022-12-31') + }) + + it('handles dates near midnight boundary in UTC', () => { + // This is Jan 1 00:30 UTC — should be 2023-01-01, not 2022-12-31 + expect(formatDate('2023-01-01T00:30:00.000Z')).toBe('2023-01-01') + }) + + it('parses space-separated datetime as UTC', () => { + expect(formatDate('2022-06-15 14:30:00')).toBe('2022-06-15') }) - it('handles end-of-year dates', () => { - const input = '2022-12-31T12:00:00.000Z' - const result = formatDate(input) - expect(result).toMatch(/^\d{4}-12-31$/) + it('handles Date object input', () => { + expect(formatDate(new Date('2022-06-15T14:30:00.000Z'))).toBe('2022-06-15') }) it('throws on invalid date', () => { @@ -31,7 +39,6 @@ describe('formatDate', () => { }) it('produces same output as the build-time copy', async () => { - // Guard against the two copies drifting apart. const buildTime = await import('../../src/utils/content/transformers/utils') const inputs = ['2022-06-15T12:00:00.000Z', '2023-01-01T00:00:00.000Z', '2024-12-31T23:59:59.000Z'] for (const input of inputs) { @@ -41,17 +48,29 @@ describe('formatDate', () => { }) describe('formatDateTime', () => { - it('formats a datetime string as YYYY-MM-DD HH:mm:ss', () => { - const input = '2022-06-15T14:30:45.000Z' - const result = formatDateTime(input) - expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/) - // The date portion must match formatDate - expect(result.split(' ')[0]).toBe(formatDate(input)) + it('formats an ISO datetime string as YYYY-MM-DD HH:mm:ss using UTC', () => { + expect(formatDateTime('2022-06-15T14:30:45.000Z')).toBe('2022-06-15 14:30:45') + }) + + it('returns canonical datetime strings unchanged', () => { + expect(formatDateTime('2022-06-15 14:30:45')).toBe('2022-06-15 14:30:45') }) it('pads single-digit hours, minutes, and seconds', () => { - const result = formatDateTime('2022-01-01T01:02:03.000Z') - expect(result).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/) + expect(formatDateTime('2022-01-01T01:02:03.000Z')).toBe('2022-01-01 01:02:03') + }) + + it('uses UTC time components regardless of system timezone', () => { + // Midnight UTC should always produce 00:00:00 + expect(formatDateTime('2022-06-15T00:00:00.000Z')).toBe('2022-06-15 00:00:00') + // 23:59:59 UTC should always produce that time, not shift to next day + expect(formatDateTime('2022-12-31T23:59:59.000Z')).toBe('2022-12-31 23:59:59') + }) + + it('the date portion matches formatDate output', () => { + const input = '2022-06-15T14:30:45.000Z' + const result = formatDateTime(input) + expect(result.split(' ')[0]).toBe(formatDate(input)) }) it('throws on invalid datetime', () => {