Skip to content

Commit 22e3a2c

Browse files
committed
fix: cover more formats and validate
1 parent 2d7563c commit 22e3a2c

3 files changed

Lines changed: 91 additions & 75 deletions

File tree

src/runtime/internal/preview/utils.ts

Lines changed: 32 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,7 @@ export const formatDate = (date: string | Date): string => {
8686
if (Number.isNaN(d.getTime())) {
8787
throw new TypeError(`Invalid date value: "${date}"`)
8888
}
89-
90-
const year = d.getUTCFullYear()
91-
const month = d.getUTCMonth() + 1
92-
const day = d.getUTCDate()
93-
94-
return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`
89+
return d.toISOString().slice(0, 10)
9590
}
9691

9792
/**
@@ -107,51 +102,52 @@ export const formatDateTime = (datetime: string | Date): string => {
107102
if (Number.isNaN(d.getTime())) {
108103
throw new TypeError(`Invalid datetime value: "${datetime}"`)
109104
}
110-
111-
const year = d.getUTCFullYear()
112-
const month = d.getUTCMonth() + 1
113-
const day = d.getUTCDate()
114-
const hours = d.getUTCHours()
115-
const minutes = d.getUTCMinutes()
116-
const seconds = d.getUTCSeconds()
117-
118-
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')}`
105+
return d.toISOString().slice(0, 19).replace('T', ' ')
119106
}
120107

108+
/** Match structured date/datetime inputs we can validate as civil UTC components. */
109+
const STRUCTURED = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/i
110+
121111
/**
122-
* Parse a date/datetime value as UTC.
123-
*
124-
* - Date objects are used as-is
125-
* - Space-separated datetimes (`YYYY-MM-DD HH:mm:ss[.sss]`) become ISO + Z
126-
* - Offset-less ISO datetimes (`YYYY-MM-DDTHH:mm:ss[.sss]`) get a Z suffix
127-
* - Date-only (`YYYY-MM-DD`) and values that already include Z/offset pass through
112+
* Parse as UTC. Offset-less values are treated as UTC.
113+
* Impossible civil dates (e.g. `2024-02-31`) are rejected via Date.UTC round-trip.
128114
*/
129115
function toUtcDate(value: string | Date): Date {
130116
if (value instanceof Date) {
131117
return value
132118
}
133119

134120
const input = String(value).trim()
135-
136-
// Already has an explicit offset or Z — Date parses correctly as absolute time
137-
if (/(?:z|[+-]\d{2}:?\d{2})$/i.test(input)) {
121+
const match = STRUCTURED.exec(input)
122+
if (!match) {
138123
return new Date(input)
139124
}
140125

141-
// Space-separated SQL-style datetime → ISO + Z
142-
const spaceSeparated = input.replace(
143-
/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/,
144-
'$1T$2$3Z',
145-
)
146-
if (spaceSeparated !== input) {
147-
return new Date(spaceSeparated)
126+
const year = Number(match[1])
127+
const month = Number(match[2])
128+
const day = Number(match[3])
129+
const hour = Number(match[4] || 0)
130+
const minute = Number(match[5] || 0)
131+
const second = Number(match[6] || 0)
132+
const offset = match[7]
133+
134+
// Round-trip through Date.UTC so Feb 31 / hour 25 stay invalid
135+
const utc = new Date(Date.UTC(year, month - 1, day, hour, minute, second))
136+
if (
137+
utc.getUTCFullYear() !== year
138+
|| utc.getUTCMonth() + 1 !== month
139+
|| utc.getUTCDate() !== day
140+
|| utc.getUTCHours() !== hour
141+
|| utc.getUTCMinutes() !== minute
142+
|| utc.getUTCSeconds() !== second
143+
) {
144+
return new Date(Number.NaN)
148145
}
149146

150-
// Offset-less ISO datetime (`2023-01-01T00:00:00`) → treat as UTC
151-
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(input)) {
152-
return new Date(`${input}Z`)
147+
// Explicit offset → absolute instant (civil parts already validated)
148+
if (offset && offset.toUpperCase() !== 'Z') {
149+
return new Date(input.includes('T') ? input : input.replace(' ', 'T'))
153150
}
154151

155-
// Date-only and everything else — Date-only is already UTC midnight per ES
156-
return new Date(input)
152+
return utc
157153
}

src/utils/content/transformers/utils.ts

Lines changed: 33 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,14 @@ export const defineTransformer = (transformer: ContentTransformer) => {
77
/**
88
* Format a date value as `YYYY-MM-DD` for SQL DATE columns.
99
*
10-
* Always uses UTC. Offset-less datetimes (e.g. `2023-01-01T00:00:00`,
11-
* `2023-01-01 00:00:00`) are treated as UTC rather than local time.
10+
* Always uses UTC. Offset-less datetimes are treated as UTC.
1211
*/
1312
export const formatDate = (date: string | Date): string => {
1413
const d = toUtcDate(date)
1514
if (Number.isNaN(d.getTime())) {
1615
throw new TypeError(`Invalid date value: "${date}"`)
1716
}
18-
19-
const year = d.getUTCFullYear()
20-
const month = d.getUTCMonth() + 1
21-
const day = d.getUTCDate()
22-
23-
return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`
17+
return d.toISOString().slice(0, 10)
2418
}
2519

2620
/**
@@ -33,51 +27,52 @@ export const formatDateTime = (datetime: string | Date): string => {
3327
if (Number.isNaN(d.getTime())) {
3428
throw new TypeError(`Invalid datetime value: "${datetime}"`)
3529
}
36-
37-
const year = d.getUTCFullYear()
38-
const month = d.getUTCMonth() + 1
39-
const day = d.getUTCDate()
40-
const hours = d.getUTCHours()
41-
const minutes = d.getUTCMinutes()
42-
const seconds = d.getUTCSeconds()
43-
44-
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')}`
30+
return d.toISOString().slice(0, 19).replace('T', ' ')
4531
}
4632

33+
/** Match structured date/datetime inputs we can validate as civil UTC components. */
34+
const STRUCTURED = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/i
35+
4736
/**
48-
* Parse a date/datetime value as UTC.
49-
*
50-
* - Date objects are used as-is
51-
* - Space-separated datetimes (`YYYY-MM-DD HH:mm:ss[.sss]`) become ISO + Z
52-
* - Offset-less ISO datetimes (`YYYY-MM-DDTHH:mm:ss[.sss]`) get a Z suffix
53-
* - Date-only (`YYYY-MM-DD`) and values that already include Z/offset pass through
37+
* Parse as UTC. Offset-less values are treated as UTC.
38+
* Impossible civil dates (e.g. `2024-02-31`) are rejected via Date.UTC round-trip.
5439
*/
5540
function toUtcDate(value: string | Date): Date {
5641
if (value instanceof Date) {
5742
return value
5843
}
5944

6045
const input = String(value).trim()
61-
62-
// Already has an explicit offset or Z — Date parses correctly as absolute time
63-
if (/(?:z|[+-]\d{2}:?\d{2})$/i.test(input)) {
46+
const match = STRUCTURED.exec(input)
47+
if (!match) {
6448
return new Date(input)
6549
}
6650

67-
// Space-separated SQL-style datetime → ISO + Z
68-
const spaceSeparated = input.replace(
69-
/^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})(\.\d+)?$/,
70-
'$1T$2$3Z',
71-
)
72-
if (spaceSeparated !== input) {
73-
return new Date(spaceSeparated)
51+
const year = Number(match[1])
52+
const month = Number(match[2])
53+
const day = Number(match[3])
54+
const hour = Number(match[4] || 0)
55+
const minute = Number(match[5] || 0)
56+
const second = Number(match[6] || 0)
57+
const offset = match[7]
58+
59+
// Round-trip through Date.UTC so Feb 31 / hour 25 stay invalid
60+
const utc = new Date(Date.UTC(year, month - 1, day, hour, minute, second))
61+
if (
62+
utc.getUTCFullYear() !== year
63+
|| utc.getUTCMonth() + 1 !== month
64+
|| utc.getUTCDate() !== day
65+
|| utc.getUTCHours() !== hour
66+
|| utc.getUTCMinutes() !== minute
67+
|| utc.getUTCSeconds() !== second
68+
) {
69+
return new Date(Number.NaN)
7470
}
7571

76-
// Offset-less ISO datetime (`2023-01-01T00:00:00`) → treat as UTC
77-
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(input)) {
78-
return new Date(`${input}Z`)
72+
// Explicit offset → absolute instant (civil parts already validated)
73+
if (offset && offset.toUpperCase() !== 'Z') {
74+
return new Date(input.includes('T') ? input : input.replace(' ', 'T'))
7975
}
8076

81-
// Date-only and everything else — Date-only is already UTC midnight per ES
82-
return new Date(input)
77+
return utc
8378
}

test/unit/formatDate.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,12 @@ describe('formatDate', () => {
3030
})
3131

3232
it('treats offset-less ISO datetimes as UTC', () => {
33-
// ES would parse this as local time; we force UTC
33+
// ES would parse these as local time; we force UTC
3434
expect(formatDate('2023-01-01T00:00:00')).toBe('2023-01-01')
3535
expect(formatDate('2022-12-31T23:30:00')).toBe('2022-12-31')
36+
// HH:mm form (no seconds)
37+
expect(formatDate('2023-01-01T00:00')).toBe('2023-01-01')
38+
expect(formatDate('2022-12-31T23:30')).toBe('2022-12-31')
3639
})
3740

3841
it('parses space-separated datetime as UTC', () => {
@@ -55,6 +58,15 @@ describe('formatDate', () => {
5558
expect(() => formatDate('not-a-date')).toThrow('Invalid date value')
5659
})
5760

61+
it('throws on impossible structured dates instead of normalizing them', () => {
62+
// Date would roll Feb 31 → Mar 2/3; we reject
63+
expect(() => formatDate('2024-02-31')).toThrow(TypeError)
64+
expect(() => formatDate('2024-02-31T12:00:00')).toThrow(TypeError)
65+
expect(() => formatDate('2024-02-31T12:00:00Z')).toThrow(TypeError)
66+
expect(() => formatDate('2024-13-01')).toThrow(TypeError)
67+
expect(() => formatDate('2024-04-31')).toThrow(TypeError)
68+
})
69+
5870
it('produces same output as the build-time copy', async () => {
5971
const buildTime = await import('../../src/utils/content/transformers/utils')
6072
const inputs = [
@@ -63,6 +75,7 @@ describe('formatDate', () => {
6375
'2024-12-31T23:59:59.000Z',
6476
'2023-01-01',
6577
'2023-01-01T00:00:00',
78+
'2023-01-01T00:00',
6679
'2022-06-15 14:30:00',
6780
'2023-01-01T00:30:00+05:30',
6881
]
@@ -97,6 +110,9 @@ describe('formatDateTime', () => {
97110
it('treats offset-less ISO datetimes as UTC', () => {
98111
expect(formatDateTime('2022-06-15T14:30:45')).toBe('2022-06-15 14:30:45')
99112
expect(formatDateTime('2022-12-31T23:00:00')).toBe('2022-12-31 23:00:00')
113+
// HH:mm form (no seconds)
114+
expect(formatDateTime('2022-06-15T14:30')).toBe('2022-06-15 14:30:00')
115+
expect(formatDateTime('2022-12-31T23:00')).toBe('2022-12-31 23:00:00')
100116
})
101117

102118
it('parses space-separated datetime as UTC', () => {
@@ -121,13 +137,22 @@ describe('formatDateTime', () => {
121137
expect(() => formatDateTime('garbage')).toThrow('Invalid datetime value')
122138
})
123139

140+
it('throws on impossible structured datetimes instead of normalizing them', () => {
141+
expect(() => formatDateTime('2024-02-31 12:00:00')).toThrow(TypeError)
142+
expect(() => formatDateTime('2024-02-31T12:00:00')).toThrow(TypeError)
143+
expect(() => formatDateTime('2024-02-31T12:00:00Z')).toThrow(TypeError)
144+
expect(() => formatDateTime('2024-01-01T25:00:00')).toThrow(TypeError)
145+
})
146+
124147
it('produces same output as the build-time copy', async () => {
125148
const buildTime = await import('../../src/utils/content/transformers/utils')
126149
const inputs = [
127150
'2022-06-15T14:30:45.000Z',
128151
'2023-01-01T00:00:00.000Z',
129152
'2022-06-15T14:30:45',
153+
'2022-06-15T14:30',
130154
'2022-06-15 14:30:45',
155+
'2022-06-15 14:30',
131156
'2022-06-15T14:30:45+02:00',
132157
]
133158
for (const input of inputs) {

0 commit comments

Comments
 (0)