@atproto/syntax 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/LICENSE.txt +1 -1
- package/dist/at-identifier.d.ts +1 -0
- package/dist/at-identifier.d.ts.map +1 -1
- package/dist/at-identifier.js +9 -0
- package/dist/at-identifier.js.map +1 -1
- package/dist/aturi.d.ts +8 -0
- package/dist/aturi.d.ts.map +1 -1
- package/dist/aturi.js +31 -40
- package/dist/aturi.js.map +1 -1
- package/dist/aturi_validation.d.ts +3 -2
- package/dist/aturi_validation.d.ts.map +1 -1
- package/dist/aturi_validation.js +13 -3
- package/dist/aturi_validation.js.map +1 -1
- package/dist/datetime.d.ts +128 -11
- package/dist/datetime.d.ts.map +1 -1
- package/dist/datetime.js +205 -79
- package/dist/datetime.js.map +1 -1
- package/dist/did.d.ts +3 -2
- package/dist/did.d.ts.map +1 -1
- package/dist/did.js +18 -13
- package/dist/did.js.map +1 -1
- package/dist/handle.d.ts +4 -3
- package/dist/handle.d.ts.map +1 -1
- package/dist/handle.js +9 -9
- package/dist/handle.js.map +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -22
- package/dist/index.js.map +1 -1
- package/dist/language.d.ts +18 -0
- package/dist/language.d.ts.map +1 -0
- package/dist/language.js +30 -0
- package/dist/language.js.map +1 -0
- package/dist/nsid.d.ts +4 -4
- package/dist/nsid.d.ts.map +1 -1
- package/dist/nsid.js +9 -11
- package/dist/nsid.js.map +1 -1
- package/dist/recordkey.d.ts +2 -2
- package/dist/recordkey.d.ts.map +1 -1
- package/dist/recordkey.js +10 -10
- package/dist/recordkey.js.map +1 -1
- package/dist/tid.d.ts +2 -2
- package/dist/tid.d.ts.map +1 -1
- package/dist/tid.js +5 -5
- package/dist/tid.js.map +1 -1
- package/dist/uri.d.ts +3 -0
- package/dist/uri.d.ts.map +1 -0
- package/dist/uri.js +7 -0
- package/dist/uri.js.map +1 -0
- package/package.json +7 -4
- package/src/at-identifier.ts +12 -1
- package/src/aturi.ts +30 -1
- package/src/aturi_validation.ts +20 -4
- package/src/datetime.ts +271 -92
- package/src/did.ts +25 -15
- package/src/handle.ts +17 -13
- package/src/index.ts +7 -5
- package/src/language.ts +39 -0
- package/src/nsid.ts +13 -7
- package/src/recordkey.ts +14 -12
- package/src/tid.ts +7 -5
- package/src/uri.ts +5 -0
- package/tests/aturi.test.ts +50 -2
- package/tests/datetime.test.ts +148 -61
- package/tests/did.test.ts +1 -0
- package/tests/handle.test.ts +1 -0
- package/tests/language.test.ts +88 -0
- package/tests/nsid.test.ts +1 -0
- package/tests/recordkey.test.ts +1 -0
- package/tests/tid.test.ts +1 -0
- package/tsconfig.build.json +5 -2
- package/tsconfig.build.tsbuildinfo +1 -1
- package/tsconfig.tests.json +6 -4
- package/vitest.config.ts +5 -0
- package/jest.config.js +0 -7
- package/tsconfig.tests.tsbuildinfo +0 -1
package/src/datetime.ts
CHANGED
|
@@ -1,125 +1,304 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Indicates a date or string is not a valid representation of a datetime
|
|
3
|
+
* according to the atproto
|
|
4
|
+
* {@link https://atproto.com/specs/lexicon#datetime specification}.
|
|
5
|
+
*/
|
|
6
|
+
export class InvalidDatetimeError extends Error {}
|
|
4
7
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* A subset of {@link DatetimeString} that represent valid datetime strings with
|
|
10
|
+
* the format: `YYYY-MM-DDTHH:mm:ss.sssZ`, as returned by `Date.toISOString()
|
|
11
|
+
* for dates between the years 0000 and 9999.
|
|
12
|
+
*
|
|
13
|
+
* @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString}
|
|
14
|
+
*/
|
|
15
|
+
export type ISODatetimeString =
|
|
16
|
+
// @TODO Switch to branded types for more accurate type safety.
|
|
17
|
+
`${string}-${string}-${string}T${string}:${string}:${string}.${string}Z`
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Represents a {@link Date} that can be safely stringified into a valid atproto
|
|
21
|
+
* {@link DatetimeString} using the {@link Date.toISOString toISOString()}
|
|
22
|
+
* method.
|
|
23
|
+
*/
|
|
24
|
+
export interface AtprotoDate extends Date {
|
|
25
|
+
toISOString(): ISODatetimeString
|
|
10
26
|
}
|
|
11
27
|
|
|
12
|
-
|
|
13
|
-
*
|
|
28
|
+
/**
|
|
29
|
+
* @see {@link AtprotoDate}
|
|
14
30
|
*/
|
|
15
|
-
export function
|
|
16
|
-
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
// must parse as ISO 8601; this also verifies semantics like month is not 13 or 00
|
|
20
|
-
if (isNaN(date.getTime())) {
|
|
21
|
-
throw new InvalidDatetimeError('datetime did not parse as ISO 8601')
|
|
22
|
-
}
|
|
23
|
-
if (date.toISOString().startsWith('-')) {
|
|
24
|
-
throw new InvalidDatetimeError('datetime normalized to a negative time')
|
|
25
|
-
}
|
|
26
|
-
// regex and other checks for RFC-3339
|
|
27
|
-
if (
|
|
28
|
-
!/^[0-9]{4}-[01][0-9]-[0-3][0-9]T[0-2][0-9]:[0-6][0-9]:[0-6][0-9](.[0-9]{1,20})?(Z|([+-][0-2][0-9]:[0-5][0-9]))$/.test(
|
|
29
|
-
dtStr,
|
|
30
|
-
)
|
|
31
|
-
) {
|
|
32
|
-
throw new InvalidDatetimeError("datetime didn't validate via regex")
|
|
33
|
-
}
|
|
34
|
-
if (dtStr.length > 64) {
|
|
35
|
-
throw new InvalidDatetimeError('datetime is too long (64 chars max)')
|
|
36
|
-
}
|
|
37
|
-
if (dtStr.endsWith('-00:00')) {
|
|
38
|
-
throw new InvalidDatetimeError(
|
|
39
|
-
'datetime can not use "-00:00" for UTC timezone',
|
|
40
|
-
)
|
|
41
|
-
}
|
|
42
|
-
if (dtStr.startsWith('000')) {
|
|
43
|
-
throw new InvalidDatetimeError('datetime so close to year zero not allowed')
|
|
31
|
+
export function assertAtprotoDate(date: Date): asserts date is AtprotoDate {
|
|
32
|
+
const res = parseDate(date)
|
|
33
|
+
if (!res.success) {
|
|
34
|
+
throw new InvalidDatetimeError(res.message)
|
|
44
35
|
}
|
|
45
36
|
}
|
|
46
37
|
|
|
47
|
-
|
|
38
|
+
/**
|
|
39
|
+
* @see {@link AtprotoDate}
|
|
48
40
|
*/
|
|
49
|
-
export function
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
41
|
+
export function asAtprotoDate(date: Date): AtprotoDate {
|
|
42
|
+
assertAtprotoDate(date)
|
|
43
|
+
return date
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @see {@link AtprotoDate}
|
|
48
|
+
*/
|
|
49
|
+
export function isAtprotoDate(date: Date): date is AtprotoDate {
|
|
50
|
+
return parseDate(date).success
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @see {@link AtprotoDate}
|
|
55
|
+
*/
|
|
56
|
+
export function ifAtprotoDate(date: Date): AtprotoDate | undefined {
|
|
57
|
+
return isAtprotoDate(date) ? date : undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Datetime strings in atproto data structures and API calls should meet the
|
|
62
|
+
* {@link https://ijmacd.github.io/rfc3339-iso8601/ intersecting} requirements
|
|
63
|
+
* of the RFC 3339, ISO 8601, and WHATWG HTML datetime standards.
|
|
64
|
+
*
|
|
65
|
+
* @note This literal template type is not accurate enough to ensure that a
|
|
66
|
+
* string is a valid atproto datetime. The {@link DatetimeString} validation
|
|
67
|
+
* functions ({@link assertDatetimeString}, {@link isDatetimeString}, etc)
|
|
68
|
+
* should be used to validate that a string meets the atproto datetime
|
|
69
|
+
* requirements, and the {@link toDatetimeString} function should be used to
|
|
70
|
+
* convert a {@link Date} object into a valid {@link DatetimeString} string.
|
|
71
|
+
*
|
|
72
|
+
* @example "2024-01-15T12:30:00Z"
|
|
73
|
+
* @example "2024-01-15T12:30:00.000Z"
|
|
74
|
+
* @example "2024-01-15T12:30:00+00:00"
|
|
75
|
+
* @example "2024-01-15T11:30:00-01:00"
|
|
76
|
+
* @see {@link https://atproto.com/specs/lexicon#datetime atproto Lexicon datetime format}
|
|
77
|
+
* @see {@link https://www.rfc-editor.org/rfc/rfc3339 RFC 3339}
|
|
78
|
+
* @see {@link https://www.iso.org/iso-8601-date-and-time-format.html ISO 8601}
|
|
79
|
+
*/
|
|
80
|
+
export type DatetimeString =
|
|
81
|
+
// @TODO Switch to branded types for more accurate type safety?
|
|
82
|
+
| `${string}-${string}-${string}T${string}:${string}:${string}Z`
|
|
83
|
+
| `${string}-${string}-${string}T${string}:${string}:${string}${'+' | '-'}${string}:${string}`
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Validates that a string is a valid {@link DatetimeString} format string,
|
|
87
|
+
* throwing an error if it is not.
|
|
88
|
+
*
|
|
89
|
+
* @throws InvalidDatetimeError if the input string does not meet the atproto 'datetime' format requirements.
|
|
90
|
+
* @see {@link DatetimeString}
|
|
91
|
+
*/
|
|
92
|
+
export function assertDatetimeString<I>(
|
|
93
|
+
input: I,
|
|
94
|
+
): asserts input is I & DatetimeString {
|
|
95
|
+
const result = parseString(input)
|
|
96
|
+
if (!result.success) {
|
|
97
|
+
throw new InvalidDatetimeError(result.message)
|
|
57
98
|
}
|
|
99
|
+
}
|
|
58
100
|
|
|
59
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Casts a string to a {@link DatetimeString} if it is a valid datetime format
|
|
103
|
+
* string, throwing an error if it is not.
|
|
104
|
+
*
|
|
105
|
+
* @throws InvalidDatetimeError if the input string does not meet the atproto 'datetime' format requirements.
|
|
106
|
+
* @see {@link DatetimeString}
|
|
107
|
+
*/
|
|
108
|
+
export function asDatetimeString<I>(input: I): I & DatetimeString {
|
|
109
|
+
assertDatetimeString(input)
|
|
110
|
+
return input
|
|
60
111
|
}
|
|
61
112
|
|
|
62
|
-
|
|
113
|
+
/**
|
|
114
|
+
* Checks if a string is a valid {@link DatetimeString} format string.
|
|
63
115
|
*
|
|
64
|
-
*
|
|
116
|
+
* @see {@link DatetimeString}
|
|
117
|
+
*/
|
|
118
|
+
export function isDatetimeString<I>(input: I): input is I & DatetimeString {
|
|
119
|
+
return parseString(input).success
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Returns the input if it is a valid {@link DatetimeString} format string, or
|
|
124
|
+
* `undefined` if it is not.
|
|
65
125
|
*
|
|
66
|
-
*
|
|
126
|
+
* @see {@link DatetimeString}
|
|
127
|
+
*/
|
|
128
|
+
export function ifDatetimeString<I>(
|
|
129
|
+
input: I,
|
|
130
|
+
): undefined | (I & DatetimeString) {
|
|
131
|
+
return isDatetimeString(input) ? input : undefined
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Returns the current date and time as a {@link DatetimeString}.
|
|
67
136
|
*
|
|
68
|
-
*
|
|
137
|
+
* @see {@link DatetimeString}
|
|
138
|
+
*/
|
|
139
|
+
export function currentDatetimeString(): DatetimeString {
|
|
140
|
+
return toDatetimeString(new Date())
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Converts any {@link Date} into a {@link DatetimeString} if possible, throwing
|
|
145
|
+
* an error if the date is not a valid atproto datetime.
|
|
69
146
|
*
|
|
70
|
-
*
|
|
147
|
+
* This is short-hand for `asAtprotoDate(date).toISOString()`.
|
|
148
|
+
*
|
|
149
|
+
* @throws InvalidDatetimeError if the input date is not a valid atproto datetime (eg, it is too far in the future or past, or it normalizes to a negative year).
|
|
150
|
+
* @see {@link DatetimeString}
|
|
71
151
|
*/
|
|
72
|
-
export function
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
152
|
+
export function toDatetimeString(date: Date): DatetimeString {
|
|
153
|
+
return asAtprotoDate(date).toISOString()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Takes a flexible datetime string and normalizes its representation.
|
|
158
|
+
*
|
|
159
|
+
* This function will work with any valid value that can be parsed as a date. It
|
|
160
|
+
* *additionally* is more flexible about accepting datetimes that are missing
|
|
161
|
+
* timezone information, and normalizing them to a valid atproto datetime.
|
|
162
|
+
*
|
|
163
|
+
* One use-case is a consistent, sortable string. Another is to work with older
|
|
164
|
+
* invalid createdAt datetimes.
|
|
165
|
+
*
|
|
166
|
+
* @returns ISODatetimeString - a valid atproto datetime with millisecond precision (3 sub-second digits) and UTC timezone with trailing 'Z' syntax.
|
|
167
|
+
* @throws InvalidDatetimeError - if the input string could not be parsed as a datetime, even with permissive parsing.
|
|
168
|
+
*/
|
|
169
|
+
export function normalizeDatetime(dtStr: string): ISODatetimeString {
|
|
170
|
+
// Parse the string as is
|
|
171
|
+
const date = new Date(dtStr)
|
|
172
|
+
if (isAtprotoDate(date)) {
|
|
173
|
+
return date.toISOString()
|
|
78
174
|
}
|
|
79
175
|
|
|
80
|
-
//
|
|
81
|
-
if (!/.*(([+-]\d\d:?\d\d)|[a-zA-Z])$/.test(dtStr)) {
|
|
82
|
-
const date = new Date(dtStr
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
if (isValidDatetime(tzStr)) {
|
|
86
|
-
return tzStr
|
|
87
|
-
}
|
|
176
|
+
// if dtStr is not a valid date, try parsing again with a timezone
|
|
177
|
+
if (isNaN(date.getTime()) && !/.*(([+-]\d\d:?\d\d)|[a-zA-Z])$/.test(dtStr)) {
|
|
178
|
+
const date = new Date(`${dtStr}Z`)
|
|
179
|
+
if (isAtprotoDate(date)) {
|
|
180
|
+
return date.toISOString()
|
|
88
181
|
}
|
|
89
182
|
}
|
|
90
183
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
throw new InvalidDatetimeError(
|
|
95
|
-
'datetime did not parse as any timestamp format',
|
|
96
|
-
)
|
|
97
|
-
}
|
|
98
|
-
const isoStr = date.toISOString()
|
|
99
|
-
if (isValidDatetime(isoStr)) {
|
|
100
|
-
return isoStr
|
|
101
|
-
} else {
|
|
102
|
-
throw new InvalidDatetimeError(
|
|
103
|
-
'datetime normalized to invalid timestamp string',
|
|
104
|
-
)
|
|
105
|
-
}
|
|
184
|
+
throw new InvalidDatetimeError(
|
|
185
|
+
'datetime did not parse as any timestamp format',
|
|
186
|
+
)
|
|
106
187
|
}
|
|
107
188
|
|
|
108
|
-
|
|
189
|
+
/**
|
|
190
|
+
* Variant of {@link normalizeDatetime} which always returns a valid datetime
|
|
191
|
+
* string.
|
|
192
|
+
*
|
|
193
|
+
* If a {@link InvalidDatetimeError} is encountered, returns the UNIX epoch time
|
|
194
|
+
* as a UTC datetime (`1970-01-01T00:00:00.000Z`).
|
|
109
195
|
*
|
|
110
|
-
*
|
|
196
|
+
* @see {@link normalizeDatetime}
|
|
111
197
|
*/
|
|
112
|
-
export
|
|
198
|
+
export function normalizeDatetimeAlways(dtStr: string): ISODatetimeString {
|
|
113
199
|
try {
|
|
114
200
|
return normalizeDatetime(dtStr)
|
|
115
201
|
} catch (err) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
202
|
+
return '1970-01-01T00:00:00.000Z'
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Legacy exports (should we deprecate these ?)
|
|
207
|
+
export {
|
|
208
|
+
assertDatetimeString as ensureValidDatetime,
|
|
209
|
+
isDatetimeString as isValidDatetime,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// -----------------------------------------------------------------------------
|
|
213
|
+
// ------------------------- Internal validation logic -------------------------
|
|
214
|
+
// -----------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
// Validation utils that allow avoiding try/catch for control flow (performance
|
|
217
|
+
// optimization). Other syntax formats should also use this pattern to avoid
|
|
218
|
+
// try/catch in their validation logic, at which point these utils can be moved
|
|
219
|
+
// to a common internal utils.
|
|
220
|
+
type FailureResult = { success: false; message: string }
|
|
221
|
+
const failure = (m: string): FailureResult => ({ success: false, message: m })
|
|
222
|
+
type SuccessResult<V> = { success: true; value: V }
|
|
223
|
+
const success = <V>(v: V): SuccessResult<V> => ({ success: true, value: v })
|
|
224
|
+
type Result<V> = FailureResult | SuccessResult<V>
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* @see {@link https://www.rfc-editor.org/rfc/rfc3339#section-5.6 Internet Date/Time Format}
|
|
228
|
+
*
|
|
229
|
+
* @example
|
|
230
|
+
* ```abnf
|
|
231
|
+
* date-fullyear = 4DIGIT
|
|
232
|
+
* date-month = 2DIGIT ; 01-12
|
|
233
|
+
* date-mday = 2DIGIT ; 01-28, 01-29, 01-30, 01-31 based on
|
|
234
|
+
* ; month/year
|
|
235
|
+
* time-hour = 2DIGIT ; 00-23
|
|
236
|
+
* time-minute = 2DIGIT ; 00-59
|
|
237
|
+
* time-second = 2DIGIT ; 00-58, 00-59, 00-60 based on leap second
|
|
238
|
+
* ; rules
|
|
239
|
+
* time-secfrac = "." 1*DIGIT
|
|
240
|
+
* time-numoffset = ("+" / "-") time-hour ":" time-minute
|
|
241
|
+
* time-offset = "Z" / time-numoffset
|
|
242
|
+
* partial-time = time-hour ":" time-minute ":" time-second
|
|
243
|
+
* [time-secfrac]
|
|
244
|
+
* full-date = date-fullyear "-" date-month "-" date-mday
|
|
245
|
+
* full-time = partial-time time-offset
|
|
246
|
+
* date-time = full-date "T" full-time
|
|
247
|
+
* ```
|
|
248
|
+
*/
|
|
249
|
+
const DATETIME_REGEX =
|
|
250
|
+
/^(?<full_year>[0-9]{4})-(?<date_month>0[1-9]|1[012])-(?<date_mday>[0-2][0-9]|3[01])T(?<time_hour>[0-1][0-9]|2[0-3]):(?<time_minute>[0-5][0-9]):(?<time_second>[0-5][0-9]|60)(?<time_secfrac>\.[0-9]+)?(?<time_offset>Z|(?<time_numoffset>[+-](?:[0-1][0-9]|2[0-3]):[0-5][0-9]))$/
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Validates that the input is a datetime string according to atproto Lexicon
|
|
254
|
+
* rules, and parses it into a Date object.
|
|
255
|
+
*/
|
|
256
|
+
function parseString(input: unknown): Result<AtprotoDate> {
|
|
257
|
+
// @NOTE Performing cheap tests first
|
|
258
|
+
if (typeof input !== 'string') {
|
|
259
|
+
return failure('datetime must be a string')
|
|
260
|
+
}
|
|
261
|
+
if (input.length > 64) {
|
|
262
|
+
return failure('datetime is too long (64 chars max)')
|
|
263
|
+
}
|
|
264
|
+
if (input.endsWith('-00:00')) {
|
|
265
|
+
return failure('datetime can not use "-00:00" for UTC timezone')
|
|
120
266
|
}
|
|
267
|
+
if (!DATETIME_REGEX.test(input)) {
|
|
268
|
+
return failure(
|
|
269
|
+
"datetime is not in a valid format (must match RFC 3339 & ISO 8601 with 'Z' or ±hh:mm timezone)",
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// must parse as ISO 8601; this also verifies semantics like leap seconds and
|
|
274
|
+
// correct number of days in month, which the regex does not check for
|
|
275
|
+
const date = new Date(input)
|
|
276
|
+
|
|
277
|
+
return parseDate(date)
|
|
121
278
|
}
|
|
122
279
|
|
|
123
|
-
|
|
280
|
+
/**
|
|
281
|
+
* Ensures that a Date object represents a valid datetime according to atproto
|
|
282
|
+
* Lexicon rules. This ensures that `date.toISOString()` will produce a valid
|
|
283
|
+
* datetime string that can be used where {@link DatetimeString} is expected.
|
|
124
284
|
*/
|
|
125
|
-
|
|
285
|
+
function parseDate(date: Date): Result<AtprotoDate> {
|
|
286
|
+
const fullYear = date.getUTCFullYear()
|
|
287
|
+
// Ensures that the date is valid. We could check isNaN(date.getTime()) here
|
|
288
|
+
// but since we'll check the year anyway, we just use that for the validity
|
|
289
|
+
// check since an invalid date will have NaN year.
|
|
290
|
+
if (Number.isNaN(fullYear)) {
|
|
291
|
+
return failure('datetime did not parse as ISO 8601')
|
|
292
|
+
}
|
|
293
|
+
// Ensure that the ISO string representation does not start with ±YYYYYY
|
|
294
|
+
if (fullYear < 0) {
|
|
295
|
+
return failure('datetime normalized to a negative time')
|
|
296
|
+
}
|
|
297
|
+
if (fullYear > 9999) {
|
|
298
|
+
return failure('datetime year is too far in the future')
|
|
299
|
+
}
|
|
300
|
+
if (fullYear < 10) {
|
|
301
|
+
return failure('datetime so close to year zero not allowed')
|
|
302
|
+
}
|
|
303
|
+
return success(date as AtprotoDate)
|
|
304
|
+
}
|
package/src/did.ts
CHANGED
|
@@ -14,19 +14,29 @@
|
|
|
14
14
|
|
|
15
15
|
export type DidString<M extends string = string> = `did:${M}:${string}`
|
|
16
16
|
|
|
17
|
-
export function ensureValidDid
|
|
18
|
-
|
|
17
|
+
export function ensureValidDid<I extends string>(
|
|
18
|
+
input: I,
|
|
19
|
+
): asserts input is I & DidString {
|
|
20
|
+
if (!input.startsWith('did:')) {
|
|
19
21
|
throw new InvalidDidError('DID requires "did:" prefix')
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
if (input.length > 2048) {
|
|
25
|
+
throw new InvalidDidError('DID is too long (2048 chars max)')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (input.endsWith(':') || input.endsWith('%')) {
|
|
29
|
+
throw new InvalidDidError('DID can not end with ":" or "%"')
|
|
30
|
+
}
|
|
31
|
+
|
|
22
32
|
// check that all chars are boring ASCII
|
|
23
|
-
if (!/^[a-zA-Z0-9._:%-]*$/.test(
|
|
33
|
+
if (!/^[a-zA-Z0-9._:%-]*$/.test(input)) {
|
|
24
34
|
throw new InvalidDidError(
|
|
25
35
|
'Disallowed characters in DID (ASCII letters, digits, and a couple other characters only)',
|
|
26
36
|
)
|
|
27
37
|
}
|
|
28
38
|
|
|
29
|
-
const { length, 1: method } =
|
|
39
|
+
const { length, 1: method } = input.split(':')
|
|
30
40
|
if (length < 3) {
|
|
31
41
|
throw new InvalidDidError(
|
|
32
42
|
'DID requires prefix, method, and method-specific content',
|
|
@@ -36,26 +46,26 @@ export function ensureValidDid(did: string): asserts did is DidString {
|
|
|
36
46
|
if (!/^[a-z]+$/.test(method)) {
|
|
37
47
|
throw new InvalidDidError('DID method must be lower-case letters')
|
|
38
48
|
}
|
|
39
|
-
|
|
40
|
-
if (did.endsWith(':') || did.endsWith('%')) {
|
|
41
|
-
throw new InvalidDidError('DID can not end with ":" or "%"')
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
if (did.length > 2 * 1024) {
|
|
45
|
-
throw new InvalidDidError('DID is too long (2048 chars max)')
|
|
46
|
-
}
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
|
|
51
|
+
const DID_REGEX = /^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$/
|
|
52
|
+
|
|
53
|
+
export function ensureValidDidRegex<I extends string>(
|
|
54
|
+
input: I,
|
|
55
|
+
): asserts input is I & DidString {
|
|
50
56
|
// simple regex to enforce most constraints via just regex and length.
|
|
51
57
|
// hand wrote this regex based on above constraints
|
|
52
|
-
if (
|
|
58
|
+
if (!DID_REGEX.test(input)) {
|
|
53
59
|
throw new InvalidDidError("DID didn't validate via regex")
|
|
54
60
|
}
|
|
55
61
|
|
|
56
|
-
if (
|
|
62
|
+
if (input.length > 2048) {
|
|
57
63
|
throw new InvalidDidError('DID is too long (2048 chars max)')
|
|
58
64
|
}
|
|
59
65
|
}
|
|
60
66
|
|
|
67
|
+
export function isValidDid<I extends string>(input: I): input is I & DidString {
|
|
68
|
+
return input.length <= 2048 && DID_REGEX.test(input)
|
|
69
|
+
}
|
|
70
|
+
|
|
61
71
|
export class InvalidDidError extends Error {}
|
package/src/handle.ts
CHANGED
|
@@ -39,20 +39,20 @@ export const DISALLOWED_TLDS = [
|
|
|
39
39
|
// - does not validate whether domain or TLD exists, or is a reserved or
|
|
40
40
|
// special TLD (eg, .onion or .local)
|
|
41
41
|
// - does not validate punycode
|
|
42
|
-
export function ensureValidHandle(
|
|
43
|
-
|
|
44
|
-
): asserts
|
|
42
|
+
export function ensureValidHandle<I extends string>(
|
|
43
|
+
input: I,
|
|
44
|
+
): asserts input is I & HandleString {
|
|
45
45
|
// check that all chars are boring ASCII
|
|
46
|
-
if (!/^[a-zA-Z0-9.-]*$/.test(
|
|
46
|
+
if (!/^[a-zA-Z0-9.-]*$/.test(input)) {
|
|
47
47
|
throw new InvalidHandleError(
|
|
48
48
|
'Disallowed characters in handle (ASCII letters, digits, dashes, periods only)',
|
|
49
49
|
)
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
if (
|
|
52
|
+
if (input.length > 253) {
|
|
53
53
|
throw new InvalidHandleError('Handle is too long (253 chars max)')
|
|
54
54
|
}
|
|
55
|
-
const labels =
|
|
55
|
+
const labels = input.split('.')
|
|
56
56
|
if (labels.length < 2) {
|
|
57
57
|
throw new InvalidHandleError('Handle domain needs at least two parts')
|
|
58
58
|
}
|
|
@@ -81,17 +81,19 @@ export function ensureValidHandle(
|
|
|
81
81
|
const HANDLE_REGEX =
|
|
82
82
|
/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
|
|
83
83
|
|
|
84
|
-
export function ensureValidHandleRegex(
|
|
85
|
-
|
|
86
|
-
): asserts
|
|
87
|
-
if (
|
|
84
|
+
export function ensureValidHandleRegex<I extends string>(
|
|
85
|
+
input: I,
|
|
86
|
+
): asserts input is I & HandleString {
|
|
87
|
+
if (input.length > 253) {
|
|
88
88
|
throw new InvalidHandleError('Handle is too long (253 chars max)')
|
|
89
89
|
}
|
|
90
|
-
if (!HANDLE_REGEX.test(
|
|
90
|
+
if (!HANDLE_REGEX.test(input)) {
|
|
91
91
|
throw new InvalidHandleError("Handle didn't validate via regex")
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
export function normalizeHandle(handle: HandleString): HandleString
|
|
96
|
+
export function normalizeHandle(handle: string): string
|
|
95
97
|
export function normalizeHandle(handle: string): string {
|
|
96
98
|
return handle.toLowerCase()
|
|
97
99
|
}
|
|
@@ -102,8 +104,10 @@ export function normalizeAndEnsureValidHandle(handle: string): HandleString {
|
|
|
102
104
|
return normalized
|
|
103
105
|
}
|
|
104
106
|
|
|
105
|
-
export function isValidHandle
|
|
106
|
-
|
|
107
|
+
export function isValidHandle<I extends string>(
|
|
108
|
+
input: I,
|
|
109
|
+
): input is I & HandleString {
|
|
110
|
+
return input.length <= 253 && HANDLE_REGEX.test(input)
|
|
107
111
|
}
|
|
108
112
|
|
|
109
113
|
export function isValidTld(handle: string): boolean {
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
export * from './
|
|
1
|
+
export * from './at-identifier.js'
|
|
2
|
+
export * from './aturi.js'
|
|
3
|
+
export * from './datetime.js'
|
|
2
4
|
export * from './did.js'
|
|
5
|
+
export * from './handle.js'
|
|
3
6
|
export * from './nsid.js'
|
|
4
|
-
export * from './
|
|
5
|
-
export * from './at-identifier.js'
|
|
6
|
-
export * from './tid.js'
|
|
7
|
+
export * from './language.js'
|
|
7
8
|
export * from './recordkey.js'
|
|
8
|
-
export * from './
|
|
9
|
+
export * from './tid.js'
|
|
10
|
+
export * from './uri.js'
|
package/src/language.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const BCP47_REGEXP =
|
|
2
|
+
/^((?<grandfathered>(en-GB-oed|i-ami|i-bnn|i-default|i-enochian|i-hak|i-klingon|i-lux|i-mingo|i-navajo|i-pwn|i-tao|i-tay|i-tsu|sgn-BE-FR|sgn-BE-NL|sgn-CH-DE)|(art-lojban|cel-gaulish|no-bok|no-nyn|zh-guoyu|zh-hakka|zh-min|zh-min-nan|zh-xiang))|((?<language>([A-Za-z]{2,3}(-(?<extlang>[A-Za-z]{3}(-[A-Za-z]{3}){0,2}))?)|[A-Za-z]{4}|[A-Za-z]{5,8})(-(?<script>[A-Za-z]{4}))?(-(?<region>[A-Za-z]{2}|[0-9]{3}))?(-(?<variant>[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(-(?<extension>[0-9A-WY-Za-wy-z](-[A-Za-z0-9]{2,8})+))*(-(?<privateUseA>x(-[A-Za-z0-9]{1,8})+))?)|(?<privateUseB>x(-[A-Za-z0-9]{1,8})+))$/
|
|
3
|
+
|
|
4
|
+
export type LanguageTag = {
|
|
5
|
+
grandfathered?: string
|
|
6
|
+
language?: string
|
|
7
|
+
extlang?: string
|
|
8
|
+
script?: string
|
|
9
|
+
region?: string
|
|
10
|
+
variant?: string
|
|
11
|
+
extension?: string
|
|
12
|
+
privateUse?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function parseLanguageString(input: string): LanguageTag | null {
|
|
16
|
+
const parsed = input.match(BCP47_REGEXP)
|
|
17
|
+
if (!parsed?.groups) return null
|
|
18
|
+
|
|
19
|
+
const { groups } = parsed
|
|
20
|
+
return {
|
|
21
|
+
grandfathered: groups.grandfathered,
|
|
22
|
+
language: groups.language,
|
|
23
|
+
extlang: groups.extlang,
|
|
24
|
+
script: groups.script,
|
|
25
|
+
region: groups.region,
|
|
26
|
+
variant: groups.variant,
|
|
27
|
+
extension: groups.extension,
|
|
28
|
+
privateUse: groups.privateUseA || groups.privateUseB,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validates well-formed BCP 47 syntax
|
|
34
|
+
*
|
|
35
|
+
* @see {@link https://www.rfc-editor.org/rfc/rfc5646.html#section-2.1}
|
|
36
|
+
*/
|
|
37
|
+
export function isValidLanguage(input: string): boolean {
|
|
38
|
+
return BCP47_REGEXP.test(input)
|
|
39
|
+
}
|
package/src/nsid.ts
CHANGED
|
@@ -48,20 +48,22 @@ export class NSID {
|
|
|
48
48
|
return this.segments
|
|
49
49
|
.slice(0, this.segments.length - 1)
|
|
50
50
|
.reverse()
|
|
51
|
-
.join('.')
|
|
51
|
+
.join('.') as `${string}.${string}`
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
get name() {
|
|
55
55
|
return this.segments.at(this.segments.length - 1)
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
toString() {
|
|
59
|
-
return this.segments.join('.')
|
|
58
|
+
toString(): NsidString {
|
|
59
|
+
return this.segments.join('.') as NsidString
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
export function ensureValidNsid
|
|
64
|
-
|
|
63
|
+
export function ensureValidNsid<I extends string>(
|
|
64
|
+
input: I,
|
|
65
|
+
): asserts input is I & NsidString {
|
|
66
|
+
const result = validateNsid(input)
|
|
65
67
|
if (!result.success) throw new InvalidNsidError(result.message)
|
|
66
68
|
}
|
|
67
69
|
|
|
@@ -71,10 +73,12 @@ export function parseNsid(nsid: string): string[] {
|
|
|
71
73
|
return result.value
|
|
72
74
|
}
|
|
73
75
|
|
|
74
|
-
export function isValidNsid
|
|
76
|
+
export function isValidNsid<I extends string>(
|
|
77
|
+
input: I,
|
|
78
|
+
): input is I & NsidString {
|
|
75
79
|
// Since the regex version is more performant for valid NSIDs, we use it when
|
|
76
80
|
// we don't care about error details.
|
|
77
|
-
return validateNsidRegex(
|
|
81
|
+
return validateNsidRegex(input).success
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
type ValidateResult<T> =
|
|
@@ -193,6 +197,8 @@ export function validateNsidRegex(value: string): ValidateResult<NsidString> {
|
|
|
193
197
|
}
|
|
194
198
|
|
|
195
199
|
if (
|
|
200
|
+
// Fast check for small values
|
|
201
|
+
value.length < 5 ||
|
|
196
202
|
!/^[a-zA-Z](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?:\.[a-zA-Z](?:[a-zA-Z0-9]{0,62})?)$/.test(
|
|
197
203
|
value,
|
|
198
204
|
)
|