@@ -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 */
1312export 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 */
5540function 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}
0 commit comments