diff --git a/src/runtime/internal/preview/utils.ts b/src/runtime/internal/preview/utils.ts index 11dd0a336..0c1d3339a 100644 --- a/src/runtime/internal/preview/utils.ts +++ b/src/runtime/internal/preview/utils.ts @@ -70,43 +70,84 @@ export function parseSourceBase(source: CollectionSource) { } /** - * Format a date string as `YYYY-MM-DD` for SQL DATE columns. + * Format a date value as `YYYY-MM-DD` for SQL DATE columns. * * Duplicated from `src/utils/content/transformers/utils.ts` because that * file lives outside the `runtime/` subtree and is not emitted to dist. * Importing it from the preview runtime causes a broken path in the * published package. * + * Always uses UTC. Offset-less datetimes are treated as UTC. + * * @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 d = toUtcDate(date) 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() - - return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}` + return d.toISOString().slice(0, 10) } /** - * Format a date string as `YYYY-MM-DD HH:mm:ss` for SQL DATETIME columns. + * Format a datetime value as `YYYY-MM-DD HH:mm:ss` for SQL DATETIME columns. + * + * Always uses UTC. Offset-less datetimes are treated as UTC. * * @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 d = toUtcDate(datetime) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid datetime value: "${datetime}"`) } + return d.toISOString().slice(0, 19).replace('T', ' ') +} + +/** Match structured date/datetime inputs we can validate as civil UTC components. */ +const STRUCTURED = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/i + +/** + * Parse as UTC. Offset-less values are treated as UTC. + * Impossible civil dates (e.g. `2024-02-31`) are rejected via Date.UTC round-trip. + */ +function toUtcDate(value: string | Date): Date { + if (value instanceof Date) { + return value + } + + const input = String(value).trim() + const match = STRUCTURED.exec(input) + if (!match) { + return new Date(input) + } - const hours = d.getHours() - const minutes = d.getMinutes() - const seconds = d.getSeconds() + const year = Number(match[1]) + const month = Number(match[2]) + const day = Number(match[3]) + const hour = Number(match[4] || 0) + const minute = Number(match[5] || 0) + const second = Number(match[6] || 0) + const offset = match[7] + + // Round-trip through Date.UTC so Feb 31 / hour 25 stay invalid + const utc = new Date(Date.UTC(year, month - 1, day, hour, minute, second)) + if ( + utc.getUTCFullYear() !== year + || utc.getUTCMonth() + 1 !== month + || utc.getUTCDate() !== day + || utc.getUTCHours() !== hour + || utc.getUTCMinutes() !== minute + || utc.getUTCSeconds() !== second + ) { + return new Date(Number.NaN) + } + + // Explicit offset → absolute instant (civil parts already validated) + if (offset && offset.toUpperCase() !== 'Z') { + return new Date(input.includes('T') ? input : input.replace(' ', 'T')) + } - return `${formatDate(datetime)} ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` + return utc } diff --git a/src/utils/content/transformers/utils.ts b/src/utils/content/transformers/utils.ts index 499b2dcda..ff61b8423 100644 --- a/src/utils/content/transformers/utils.ts +++ b/src/utils/content/transformers/utils.ts @@ -4,28 +4,75 @@ export const defineTransformer = (transformer: ContentTransformer) => { return transformer } -export const formatDateTime = (datetime: string): string => { - const d = new Date(datetime) +/** + * Format a date value as `YYYY-MM-DD` for SQL DATE columns. + * + * Always uses UTC. Offset-less datetimes are treated as UTC. + */ +export const formatDate = (date: string | Date): string => { + const d = toUtcDate(date) + if (Number.isNaN(d.getTime())) { + throw new TypeError(`Invalid date value: "${date}"`) + } + return d.toISOString().slice(0, 10) +} + +/** + * Format a datetime value as `YYYY-MM-DD HH:mm:ss` for SQL DATETIME columns. + * + * Always uses UTC. Offset-less datetimes are treated as UTC. + */ +export const formatDateTime = (datetime: string | Date): string => { + const d = toUtcDate(datetime) if (Number.isNaN(d.getTime())) { throw new TypeError(`Invalid datetime value: "${datetime}"`) } + return d.toISOString().slice(0, 19).replace('T', ' ') +} - const hours = d.getHours() - const minutes = d.getMinutes() - const seconds = d.getSeconds() +/** Match structured date/datetime inputs we can validate as civil UTC components. */ +const STRUCTURED = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/i - return `${formatDate(datetime)} ${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` -} +/** + * Parse as UTC. Offset-less values are treated as UTC. + * Impossible civil dates (e.g. `2024-02-31`) are rejected via Date.UTC round-trip. + */ +function toUtcDate(value: string | Date): Date { + if (value instanceof Date) { + return value + } -export const formatDate = (date: string): string => { - const d = new Date(date) - if (Number.isNaN(d.getTime())) { - throw new TypeError(`Invalid date value: "${date}"`) + const input = String(value).trim() + const match = STRUCTURED.exec(input) + if (!match) { + return new Date(input) } - const year = d.getFullYear() - const month = d.getMonth() + 1 - const day = d.getDate() + const year = Number(match[1]) + const month = Number(match[2]) + const day = Number(match[3]) + const hour = Number(match[4] || 0) + const minute = Number(match[5] || 0) + const second = Number(match[6] || 0) + const offset = match[7] + + // Round-trip through Date.UTC so Feb 31 / hour 25 stay invalid + const utc = new Date(Date.UTC(year, month - 1, day, hour, minute, second)) + if ( + utc.getUTCFullYear() !== year + || utc.getUTCMonth() + 1 !== month + || utc.getUTCDate() !== day + || utc.getUTCHours() !== hour + || utc.getUTCMinutes() !== minute + || utc.getUTCSeconds() !== second + ) { + return new Date(Number.NaN) + } + + // Explicit offset → absolute instant (civil parts already validated) + if (offset && offset.toUpperCase() !== 'Z') { + return new Date(input.includes('T') ? input : input.replace(' ', 'T')) + } - return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}` + return utc } diff --git a/test/unit/formatDate.test.ts b/test/unit/formatDate.test.ts index be1b03cae..b7e927431 100644 --- a/test/unit/formatDate.test.ts +++ b/test/unit/formatDate.test.ts @@ -2,27 +2,55 @@ 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', () => { + // Still Dec 31 in UTC even when local TZ has crossed into Jan 1 + expect(formatDate('2022-12-31T23:00:00.000Z')).toBe('2022-12-31') + }) + + it('handles dates near midnight boundary in UTC', () => { + expect(formatDate('2023-01-01T00:30:00.000Z')).toBe('2023-01-01') + }) + + it('treats date-only strings as UTC calendar dates', () => { + expect(formatDate('2023-01-01')).toBe('2023-01-01') + expect(formatDate('2024-12-31')).toBe('2024-12-31') + }) + + it('treats offset-less ISO datetimes as UTC', () => { + // ES would parse these as local time; we force UTC + expect(formatDate('2023-01-01T00:00:00')).toBe('2023-01-01') + expect(formatDate('2022-12-31T23:30:00')).toBe('2022-12-31') + // HH:mm form (no seconds) + expect(formatDate('2023-01-01T00:00')).toBe('2023-01-01') + expect(formatDate('2022-12-31T23:30')).toBe('2022-12-31') + }) + + it('parses space-separated datetime as UTC', () => { + expect(formatDate('2022-06-15 14:30:00')).toBe('2022-06-15') + }) + + it('respects explicit non-UTC offsets', () => { + // 2023-01-01 00:30 in UTC+5:30 → 2022-12-31 19:00 UTC + expect(formatDate('2023-01-01T00:30:00+05:30')).toBe('2022-12-31') }) - 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 via UTC components', () => { + expect(formatDate(new Date('2022-06-15T14:30:00.000Z'))).toBe('2022-06-15') + // Near midnight UTC boundary + expect(formatDate(new Date('2022-12-31T23:00:00.000Z'))).toBe('2022-12-31') }) it('throws on invalid date', () => { @@ -30,10 +58,27 @@ describe('formatDate', () => { expect(() => formatDate('not-a-date')).toThrow('Invalid date value') }) + it('throws on impossible structured dates instead of normalizing them', () => { + // Date would roll Feb 31 → Mar 2/3; we reject + expect(() => formatDate('2024-02-31')).toThrow(TypeError) + expect(() => formatDate('2024-02-31T12:00:00')).toThrow(TypeError) + expect(() => formatDate('2024-02-31T12:00:00Z')).toThrow(TypeError) + expect(() => formatDate('2024-13-01')).toThrow(TypeError) + expect(() => formatDate('2024-04-31')).toThrow(TypeError) + }) + 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'] + const inputs = [ + '2022-06-15T12:00:00.000Z', + '2023-01-01T00:00:00.000Z', + '2024-12-31T23:59:59.000Z', + '2023-01-01', + '2023-01-01T00:00:00', + '2023-01-01T00:00', + '2022-06-15 14:30:00', + '2023-01-01T00:30:00+05:30', + ] for (const input of inputs) { expect(formatDate(input)).toBe(buildTime.formatDate(input)) } @@ -41,17 +86,50 @@ 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('handles Date object input', () => { + expect(formatDateTime(new Date('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', () => { + expect(formatDateTime('2022-06-15T00:00:00.000Z')).toBe('2022-06-15 00:00:00') + expect(formatDateTime('2022-12-31T23:59:59.000Z')).toBe('2022-12-31 23:59:59') + }) + + it('treats offset-less ISO datetimes as UTC', () => { + expect(formatDateTime('2022-06-15T14:30:45')).toBe('2022-06-15 14:30:45') + expect(formatDateTime('2022-12-31T23:00:00')).toBe('2022-12-31 23:00:00') + // HH:mm form (no seconds) + expect(formatDateTime('2022-06-15T14:30')).toBe('2022-06-15 14:30:00') + expect(formatDateTime('2022-12-31T23:00')).toBe('2022-12-31 23:00:00') + }) + + it('parses space-separated datetime as UTC', () => { + expect(formatDateTime('2022-06-15 14:30:45')).toBe('2022-06-15 14:30:45') + }) + + it('respects explicit non-UTC offsets', () => { + expect(formatDateTime('2022-06-15T14:30:45+02:00')).toBe('2022-06-15 12:30:45') + }) + + it('handles Date object input', () => { + expect(formatDateTime(new Date('2022-06-15T14:30:45.000Z'))).toBe('2022-06-15 14:30:45') + }) + + it('the date portion matches formatDate output', () => { + const input = '2022-06-15T14:30:45.000Z' + expect(formatDateTime(input).split(' ')[0]).toBe(formatDate(input)) }) it('throws on invalid datetime', () => { @@ -59,9 +137,24 @@ describe('formatDateTime', () => { expect(() => formatDateTime('garbage')).toThrow('Invalid datetime value') }) + it('throws on impossible structured datetimes instead of normalizing them', () => { + expect(() => formatDateTime('2024-02-31 12:00:00')).toThrow(TypeError) + expect(() => formatDateTime('2024-02-31T12:00:00')).toThrow(TypeError) + expect(() => formatDateTime('2024-02-31T12:00:00Z')).toThrow(TypeError) + expect(() => formatDateTime('2024-01-01T25:00:00')).toThrow(TypeError) + }) + it('produces same output as the build-time copy', async () => { const buildTime = await import('../../src/utils/content/transformers/utils') - const inputs = ['2022-06-15T14:30:45.000Z', '2023-01-01T00:00:00.000Z'] + const inputs = [ + '2022-06-15T14:30:45.000Z', + '2023-01-01T00:00:00.000Z', + '2022-06-15T14:30:45', + '2022-06-15T14:30', + '2022-06-15 14:30:45', + '2022-06-15 14:30', + '2022-06-15T14:30:45+02:00', + ] for (const input of inputs) { expect(formatDateTime(input)).toBe(buildTime.formatDateTime(input)) }