Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 29 additions & 11 deletions src/runtime/internal/preview/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,36 +77,54 @@ 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')}`
}

/**
* 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')}`
}
35 changes: 24 additions & 11 deletions src/utils/content/transformers/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')}`
}
69 changes: 44 additions & 25 deletions test/unit/formatDate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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) {
Expand All @@ -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')
})
Comment on lines 50 to 57
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add a Date-object test for formatDateTime.

formatDate is covered for Date input (Line 32), but formatDateTime isn’t. Since the API now accepts string | Date, this path should be tested explicitly.

Suggested test addition
 describe('formatDateTime', () => {
   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')
   })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/unit/formatDate.test.ts` around lines 50 - 57, Add a unit test that
covers the Date input path for formatDateTime by creating a Date instance (e.g.,
new Date('2022-06-15T14:30:45.000Z')) and asserting formatDateTime(date) returns
the expected canonical string "2022-06-15 14:30:45"; update the test suite in
the same file containing the existing formatDateTime tests so both string and
Date overloads are verified (referencing formatDateTime to locate the function).


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', () => {
Expand Down
Loading