Skip to content
73 changes: 57 additions & 16 deletions src/runtime/internal/preview/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
77 changes: 62 additions & 15 deletions src/utils/content/transformers/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
147 changes: 120 additions & 27 deletions test/unit/formatDate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,66 +2,159 @@ 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', () => {
expect(() => formatDate('not-a-date')).toThrow(TypeError)
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))
}
})
})

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')
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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', () => {
expect(() => formatDateTime('garbage')).toThrow(TypeError)
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))
}
Expand Down
Loading