Skip to content

Commit 2ba19b3

Browse files
committed
feat: add support for Date objects
1 parent a49105f commit 2ba19b3

File tree

6 files changed

+91
-10
lines changed

6 files changed

+91
-10
lines changed

spec.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,38 @@ The following object…
227227
a=9007199254740992n
228228
```
229229

230+
## Dates
231+
232+
Dates are stringified to their ISO 8601 string representation. If a date is at midnight UTC, the time component is omitted. If the year exceeds 9999, there may exist a `+` prefix, which needs to be percent-encoded.
233+
234+
The following objects…
235+
236+
```js
237+
{
238+
a: new Date('2024-10-27T00:00:00.000Z')
239+
}
240+
241+
{
242+
b: new Date('2024-10-27T12:34:56.789Z')
243+
}
244+
245+
{
246+
c: new Date('+10000-01-01T00:00:00.000Z')
247+
}
248+
```
249+
250+
…are encoded into the following query strings:
251+
252+
```
253+
a=2024-10-27
254+
255+
b=2024-10-27T12:34:56.789Z
256+
257+
c=%2B010000-01-01
258+
```
259+
260+
Unlike in strings, colons are not escaped in dates, since they're unlikely to be misread as a property name separator when judged at a glance.
261+
230262
## Everything Else
231263

232264
The remaining JSON types are merely stringified:

src/decode.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const enum ValueMode {
3333
Unknown,
3434
Array,
3535
Object,
36-
NumberOrBigint,
36+
Numeric,
3737
String,
3838
}
3939

@@ -68,15 +68,16 @@ function decodeValue(input: string, cursor = { pos: 0 }): DecodedValue {
6868
mode = ValueMode.Object
6969
break
7070

71+
case CharCode.Plus:
7172
case CharCode.Minus:
7273
mode = isDigit(input.charCodeAt(pos + 1))
73-
? ValueMode.NumberOrBigint
74+
? ValueMode.Numeric
7475
: ValueMode.String
7576
break
7677

7778
default:
7879
if (isDigit(charCode)) {
79-
mode = ValueMode.NumberOrBigint
80+
mode = ValueMode.Numeric
8081
} else {
8182
endPos = nested ? findEndPos(input, pos) : input.length
8283

@@ -125,14 +126,20 @@ function decodeValue(input: string, cursor = { pos: 0 }): DecodedValue {
125126
break
126127
}
127128

128-
case ValueMode.NumberOrBigint: {
129+
case ValueMode.Numeric: {
129130
pos = nested ? findEndPos(input, pos + 1) : input.length
130131
if (input.charCodeAt(pos - 1) === CharCode.LowerN) {
131132
result = BigInt(input.slice(startPos, pos - 1))
132133
} else {
133-
result = Number(input.slice(startPos, pos))
134+
const slice = input.slice(startPos, pos)
135+
result = Number(slice)
134136
if (Number.isNaN(result)) {
135-
throw new SyntaxError(`Invalid number at position ${startPos}`)
137+
result = new Date(slice)
138+
if (Number.isNaN(result.getTime())) {
139+
throw new SyntaxError(
140+
`Invalid number or date at position ${startPos}`
141+
)
142+
}
136143
}
137144
}
138145
break

src/encode.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isArray } from 'radashi'
1+
import { isArray, isDate } from 'radashi'
22
import { CharCode, isDigit } from './charCode.js'
33
import { CodableObject, CodableRecord, CodableValue } from './types.js'
44

@@ -98,6 +98,23 @@ function encodeValue(value: CodableValue): string {
9898
return encodeArray(value)
9999
}
100100
if (typeof value === 'object') {
101+
if (isDate(value)) {
102+
let iso = value.toISOString()
103+
// Remove the time component if it's midnight UTC.
104+
if (
105+
value.getUTCHours() === 0 &&
106+
value.getUTCMinutes() === 0 &&
107+
value.getUTCSeconds() === 0 &&
108+
value.getUTCMilliseconds() === 0
109+
) {
110+
iso = iso.slice(0, -14)
111+
}
112+
// We also need to encode the '+' sign, if present.
113+
if (iso.charCodeAt(0) === CharCode.Plus) {
114+
return '%2B' + iso.slice(1)
115+
}
116+
return iso
117+
}
101118
if (isRecord(value)) {
102119
return encodeObject(value)
103120
}
@@ -113,7 +130,8 @@ function isNumberLike(value: string): boolean {
113130
const charCode = value.charCodeAt(0)
114131
return (
115132
isDigit(charCode) ||
116-
(charCode === CharCode.Minus && isDigit(value.charCodeAt(1)))
133+
((charCode === CharCode.Minus || charCode === CharCode.Plus) &&
134+
isDigit(value.charCodeAt(1)))
117135
)
118136
}
119137

src/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
type JsonPrimitive = string | number | boolean | null
2-
type CodablePrimitive = JsonPrimitive | bigint | undefined
3-
type DecodedPrimitive = JsonPrimitive | bigint
2+
type CodablePrimitive = JsonPrimitive | bigint | Date | undefined
3+
type DecodedPrimitive = JsonPrimitive | bigint | Date
44

55
export type CodableObject = { toJSON(): CodableValue } | CodableRecord
66
export type CodableRecord = { [key: string]: CodableValue }

test/cases.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,10 @@ export const cases: Record<string, Case | Case[]> = {
132132
decoded: { a: '-123' },
133133
encoded: 'a=\\-123',
134134
},
135+
{
136+
decoded: { a: '+123' },
137+
encoded: 'a=\\%2B123',
138+
},
135139
{
136140
decoded: { a: '\\' },
137141
encoded: 'a=\\\\',
@@ -242,6 +246,24 @@ export const cases: Record<string, Case | Case[]> = {
242246
decoded: { null: null },
243247
encoded: 'null=null',
244248
},
249+
dates: [
250+
{
251+
decoded: { a: new Date('2024-10-27') },
252+
encoded: 'a=2024-10-27',
253+
},
254+
{
255+
decoded: { a: new Date('2024-10-27T12:34:56.789Z') },
256+
encoded: 'a=2024-10-27T12:34:56.789Z',
257+
},
258+
{
259+
decoded: { a: new Date('+100000-01-01T00:00:00.000Z') },
260+
encoded: 'a=%2B100000-01-01',
261+
},
262+
{
263+
decoded: { a: new Date('-100000-01-01T00:00:00.000Z') },
264+
encoded: 'a=-100000-01-01',
265+
},
266+
],
245267
'complex nested structures': {
246268
decoded: {
247269
user: {

test/decode.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ describe('json-qs', () => {
5555
'a={b}',
5656
'a={:}',
5757
'a=1.n',
58+
'a=2024-31-01',
5859
])
5960

6061
expect(results).toMatchInlineSnapshot(`
@@ -67,6 +68,7 @@ describe('json-qs', () => {
6768
"a={b}" => [SyntaxError: Failed to decode value for 'a' key: Unterminated key at position 2],
6869
"a={:}" => [SyntaxError: Failed to decode value for 'a' key: Unexpected end of string at position 2],
6970
"a=1.n" => [SyntaxError: Failed to decode value for 'a' key: Cannot convert 1. to a BigInt],
71+
"a=2024-31-01" => [SyntaxError: Failed to decode value for 'a' key: Invalid number or date at position 0],
7072
}
7173
`)
7274
})

0 commit comments

Comments
 (0)