@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.
Files changed (77) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LICENSE.txt +1 -1
  3. package/dist/at-identifier.d.ts +1 -0
  4. package/dist/at-identifier.d.ts.map +1 -1
  5. package/dist/at-identifier.js +9 -0
  6. package/dist/at-identifier.js.map +1 -1
  7. package/dist/aturi.d.ts +8 -0
  8. package/dist/aturi.d.ts.map +1 -1
  9. package/dist/aturi.js +31 -40
  10. package/dist/aturi.js.map +1 -1
  11. package/dist/aturi_validation.d.ts +3 -2
  12. package/dist/aturi_validation.d.ts.map +1 -1
  13. package/dist/aturi_validation.js +13 -3
  14. package/dist/aturi_validation.js.map +1 -1
  15. package/dist/datetime.d.ts +128 -11
  16. package/dist/datetime.d.ts.map +1 -1
  17. package/dist/datetime.js +205 -79
  18. package/dist/datetime.js.map +1 -1
  19. package/dist/did.d.ts +3 -2
  20. package/dist/did.d.ts.map +1 -1
  21. package/dist/did.js +18 -13
  22. package/dist/did.js.map +1 -1
  23. package/dist/handle.d.ts +4 -3
  24. package/dist/handle.d.ts.map +1 -1
  25. package/dist/handle.js +9 -9
  26. package/dist/handle.js.map +1 -1
  27. package/dist/index.d.ts +7 -5
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +11 -22
  30. package/dist/index.js.map +1 -1
  31. package/dist/language.d.ts +18 -0
  32. package/dist/language.d.ts.map +1 -0
  33. package/dist/language.js +30 -0
  34. package/dist/language.js.map +1 -0
  35. package/dist/nsid.d.ts +4 -4
  36. package/dist/nsid.d.ts.map +1 -1
  37. package/dist/nsid.js +9 -11
  38. package/dist/nsid.js.map +1 -1
  39. package/dist/recordkey.d.ts +2 -2
  40. package/dist/recordkey.d.ts.map +1 -1
  41. package/dist/recordkey.js +10 -10
  42. package/dist/recordkey.js.map +1 -1
  43. package/dist/tid.d.ts +2 -2
  44. package/dist/tid.d.ts.map +1 -1
  45. package/dist/tid.js +5 -5
  46. package/dist/tid.js.map +1 -1
  47. package/dist/uri.d.ts +3 -0
  48. package/dist/uri.d.ts.map +1 -0
  49. package/dist/uri.js +7 -0
  50. package/dist/uri.js.map +1 -0
  51. package/package.json +7 -4
  52. package/src/at-identifier.ts +12 -1
  53. package/src/aturi.ts +30 -1
  54. package/src/aturi_validation.ts +20 -4
  55. package/src/datetime.ts +271 -92
  56. package/src/did.ts +25 -15
  57. package/src/handle.ts +17 -13
  58. package/src/index.ts +7 -5
  59. package/src/language.ts +39 -0
  60. package/src/nsid.ts +13 -7
  61. package/src/recordkey.ts +14 -12
  62. package/src/tid.ts +7 -5
  63. package/src/uri.ts +5 -0
  64. package/tests/aturi.test.ts +50 -2
  65. package/tests/datetime.test.ts +148 -61
  66. package/tests/did.test.ts +1 -0
  67. package/tests/handle.test.ts +1 -0
  68. package/tests/language.test.ts +88 -0
  69. package/tests/nsid.test.ts +1 -0
  70. package/tests/recordkey.test.ts +1 -0
  71. package/tests/tid.test.ts +1 -0
  72. package/tsconfig.build.json +5 -2
  73. package/tsconfig.build.tsbuildinfo +1 -1
  74. package/tsconfig.tests.json +6 -4
  75. package/vitest.config.ts +5 -0
  76. package/jest.config.js +0 -7
  77. package/tsconfig.tests.tsbuildinfo +0 -1
package/src/datetime.ts CHANGED
@@ -1,125 +1,304 @@
1
- /** An ISO 8601 formatted datetime string (YYYY-MM-DDTHH:mm:ss.sssZ) */
2
- export type DatetimeString =
3
- `${string}-${string}-${string}T${string}:${string}:${string}${'Z' | `+${string}` | `-${string}`}`
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
- // Allow date.toISOString() to be used where datetime format is expected
6
- declare global {
7
- interface Date {
8
- toISOString(): `${string}-${string}-${string}T${string}:${string}:${string}Z`
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
- /* Validates datetime string against atproto Lexicon 'datetime' format.
13
- * Syntax is described at: https://atproto.com/specs/lexicon#datetime
28
+ /**
29
+ * @see {@link AtprotoDate}
14
30
  */
15
- export function ensureValidDatetime(
16
- dtStr: string,
17
- ): asserts dtStr is DatetimeString {
18
- const date = new Date(dtStr)
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
- /* Same logic as ensureValidDatetime(), but returns a boolean instead of throwing an exception.
38
+ /**
39
+ * @see {@link AtprotoDate}
48
40
  */
49
- export function isValidDatetime(dtStr: string): dtStr is DatetimeString {
50
- try {
51
- ensureValidDatetime(dtStr)
52
- } catch (err) {
53
- if (err instanceof InvalidDatetimeError) {
54
- return false
55
- }
56
- throw err
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
- return true
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
- /* Takes a flexible datetime string and normalizes representation.
113
+ /**
114
+ * Checks if a string is a valid {@link DatetimeString} format string.
63
115
  *
64
- * This function will work with any valid atproto datetime (eg, anything which isValidDatetime() is true for). It *additionally* is more flexible about accepting datetimes that don't comply to RFC 3339, or are missing timezone information, and normalizing them to a valid datetime.
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
- * One use-case is a consistent, sortable string. Another is to work with older invalid createdAt datetimes.
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
- * Successful output will be a valid atproto datetime with millisecond precision (3 sub-second digits) and UTC timezone with trailing 'Z' syntax. Throws `InvalidDatetimeError` if the input string could not be parsed as a datetime, even with permissive parsing.
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
- * Expected output format: YYYY-MM-DDTHH:mm:ss.sssZ
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 normalizeDatetime(dtStr: string): DatetimeString {
73
- if (isValidDatetime(dtStr)) {
74
- const outStr = new Date(dtStr).toISOString()
75
- if (isValidDatetime(outStr)) {
76
- return outStr
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
- // check if this permissive datetime is missing a timezone
81
- if (!/.*(([+-]\d\d:?\d\d)|[a-zA-Z])$/.test(dtStr)) {
82
- const date = new Date(dtStr + 'Z')
83
- if (!isNaN(date.getTime())) {
84
- const tzStr = date.toISOString()
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
- // finally try parsing as simple datetime
92
- const date = new Date(dtStr)
93
- if (isNaN(date.getTime())) {
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
- /* Variant of normalizeDatetime() which always returns a valid datetime strings.
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
- * If a InvalidDatetimeError is encountered, returns the UNIX epoch time as a UTC datetime (1970-01-01T00:00:00.000Z).
196
+ * @see {@link normalizeDatetime}
111
197
  */
112
- export const normalizeDatetimeAlways = (dtStr: string): DatetimeString => {
198
+ export function normalizeDatetimeAlways(dtStr: string): ISODatetimeString {
113
199
  try {
114
200
  return normalizeDatetime(dtStr)
115
201
  } catch (err) {
116
- if (err instanceof InvalidDatetimeError) {
117
- return new Date(0).toISOString()
118
- }
119
- throw err
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
- /* Indicates a datetime string did not pass full atproto Lexicon datetime string format checks.
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
- export class InvalidDatetimeError extends Error {}
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(did: string): asserts did is DidString {
18
- if (!did.startsWith('did:')) {
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(did)) {
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 } = did.split(':')
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
- export function ensureValidDidRegex(did: string): asserts did is DidString {
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 (!/^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$/.test(did)) {
58
+ if (!DID_REGEX.test(input)) {
53
59
  throw new InvalidDidError("DID didn't validate via regex")
54
60
  }
55
61
 
56
- if (did.length > 2 * 1024) {
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
- handle: string,
44
- ): asserts handle is HandleString {
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(handle)) {
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 (handle.length > 253) {
52
+ if (input.length > 253) {
53
53
  throw new InvalidHandleError('Handle is too long (253 chars max)')
54
54
  }
55
- const labels = handle.split('.')
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
- handle: string,
86
- ): asserts handle is HandleString {
87
- if (handle.length > 253) {
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(handle)) {
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(handle: string): handle is HandleString {
106
- return handle.length <= 253 && HANDLE_REGEX.test(handle)
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 './handle.js'
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 './aturi.js'
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 './datetime.js'
9
+ export * from './tid.js'
10
+ export * from './uri.js'
@@ -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(nsid: string): asserts nsid is NsidString {
64
- const result = validateNsid(nsid)
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(nsid: string): nsid is NsidString {
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(nsid).success
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
  )