Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
33 changes: 25 additions & 8 deletions src/runtime/internal/preview/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,36 +77,53 @@ 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)
if (typeof date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(date)) {
return date
}
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 | 🟠 Major | ⚡ Quick win

Validate canonical-looking inputs before the passthrough return.

These fast paths now accept malformed-but-shaped values like 2024-02-31 and 2024-02-31 12:00:00 unchanged, so they never reach the TypeError path and can serialize invalid data into the SQL columns. Please keep the short-circuit, but only after verifying the matched components represent a real UTC date/datetime.

Also applies to: 113-115

🤖 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 `@src/runtime/internal/preview/utils.ts` around lines 86 - 88, The quick-return
branches that currently accept strings matching /^\d{4}-\d{2}-\d{2}$/ (and the
analogous datetime branch at 113-115) must validate that the captured
year/month/day (and hour/min/sec if present) form a real UTC date before
returning; modify the string fast-paths in utils.ts (the if checking typeof date
=== 'string' && /^\d{4}-\d{2}-\d{2}$/.test(date) and the similar datetime regex)
to extract numeric components, construct a UTC timestamp (e.g. via
Date.UTC(year, month-1, day, ...)) and verify that the resulting UTC date’s
year/month/day(/hour/min/sec) match the parsed components, and only then return
the original string; otherwise fall through to the existing TypeError/normal
path. Ensure you apply the same validation logic to both the date and datetime
short-circuits mentioned.

const normalized = typeof date === 'string'
? date.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z')
: date
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)
if (typeof datetime === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(datetime)) {
return datetime
}
const normalized = typeof datetime === 'string'
? datetime.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z')
: datetime
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 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')}`
}
28 changes: 20 additions & 8 deletions src/utils/content/transformers/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,39 @@ export const defineTransformer = (transformer: ContentTransformer) => {
}

export const formatDateTime = (datetime: string): string => {
const d = new Date(datetime)
if (typeof datetime === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(datetime)) {
return datetime
}
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 | 🟠 Major | ⚡ Quick win

Don't let the canonical fast path skip validity checks.

The new passthrough branches return any YYYY-MM-DD / YYYY-MM-DD HH:mm:ss string as-is, including impossible values like 2024-02-31. That weakens the serializer contract and lets invalid data through at build time as well. Validate the captured UTC components first, then return the original string if it is genuinely canonical.

Also applies to: 27-29

🤖 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 `@src/utils/content/transformers/utils.ts` around lines 8 - 10, The passthrough
branches currently return any YYYY-MM-DD or YYYY-MM-DD HH:mm:ss string without
checking component validity; update the branches that test the regexes (the
`typeof datetime === 'string' && /^\d{4}-\d{2}-\d{2} ...$/.test(datetime)` case
and the similar HH:mm:ss branch at 27-29) to extract year, month, day (and
hour/minute/second when present) from the regex capture groups and validate
ranges (month 1–12, day valid for month with leap-year rules for February, hour
0–23, minute/second 0–59) before returning the original `datetime` string; if
validation fails, fall through to the existing parsing/fallback logic instead of
returning the raw string.

const normalized = typeof datetime === 'string'
? datetime.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z')
: datetime
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 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')}`
}

export const formatDate = (date: string): string => {
const d = new Date(date)
if (typeof date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(date)) {
return date
}
const normalized = typeof date === 'string'
? date.replace(/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/, '$1T$2$3Z')
: date
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')}`
}
65 changes: 40 additions & 25 deletions test/unit/formatDate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,31 @@ 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', () => {
const input = '2022-12-31T12:00:00.000Z'
const result = formatDate(input)
expect(result).toMatch(/^\d{4}-12-31$/)
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('throws on invalid date', () => {
Expand All @@ -31,7 +35,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 +44,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