cerulea/lexicon
define atproto schemas in TypeScript
git clone https://git.t4t.associates/cerulea/lexicon
9ad63e0
main
1import type { StringFormat } from "../schema.ts" ; 2 3const LABEL = / ^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$ / ; 4const labels = ( parts :readonly string []) => 5parts . every (( part ) => part . length <= 63 && LABEL . test ( part )); 6 7export function isNsid ( value :string ) :boolean { 8const parts = value . split ( "." ); 9return value . length <= 317 && parts . length >= 3 && labels ( parts ) && 10/ ^[A-Za-z] / . test ( parts [ 0 ]) && / ^[A-Za-z][A-Za-z0-9]*$ / . test ( parts . at ( - 1 ) ?? "" ); 11} 12 13function isDid ( value :string ) :boolean { 14return value . length <= 2048 && 15/ ^did:[a-z]+:(?:[A-Za-z0-9._:-]|%[0-9A-Fa-f]{2})*(?:[A-Za-z0-9._-]|%[0-9A-Fa-f]{2})$ / . test ( 16value , 17); 18} 19 20function isHandle ( value :string ) :boolean { 21const parts = value . split ( "." ); 22return value . length <= 253 && parts . length >= 2 && labels ( parts ) && 23/ ^[A-Za-z] / . test ( parts . at ( - 1 ) ?? "" ); 24} 25 26function isAtUri ( value :string ) :boolean { 27if ( value . length > 8192 ) return false ; 28const match = / ^at:\/\/([^/?#]+)(?:\/([^/?#]+)(?:\/([^/?#]+))?)?$ / . exec ( value ); 29if ( ! match ) return false ; 30const [, authority , collection , key ] = match ; 31return ( isDid ( authority ) || isHandle ( authority )) && 32( collection === undefined || isNsid ( collection )) && 33( key === undefined || 34( key . length <= 512 && key !== "." && key !== ".." && / ^[A-Za-z0-9_~.:-]+$ / . test ( key ))); 35} 36 37function isUri ( value :string ) :boolean { 38if ( value . length > 8192 || / %(?![0-9A-Fa-f]{2}) / . test ( value )) return false ; 39const match = / ^[A-Za-z][A-Za-z0-9+.-]*:(?:\/\/([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?$ / . exec ( 40value , 41); 42if ( ! match ) return false ; 43const [, authority , path , query = "" , fragment = "" ] = match ; 44if ( 45! /^[A-Za-z0-9\-._~!$&'()*+,;=:@%/]*$ / . test ( path ) || 46! /^[A-Za-z0-9\-._~!$&'()*+,;=:@%/?]*$ / . test ( query ) || 47! /^[A-Za-z0-9\-._~!$&'()*+,;=:@%/?]*$ / . test ( fragment ) 48) return false ; 49if ( authority === undefined ) return true ; 50const host = 51/ ^(?:[A-Za-z0-9\-._~!$&'()*+,;=:%]*@)?(\[[^\]]+\]|[A-Za-z0-9\-._~!$&'()*+,;=%]*)(?::[0-9]*)?$ / 52. exec ( authority )?.[ 1 ]; 53if ( host === undefined ) return false ; 54if ( ! host . startsWith ( "[" )) return true ; 55if ( / ^\[v[0-9a-f]+\.[A-Za-z0-9\-._~!$&'()*+,;=:]+\]$ / i . test ( host )) return true ; 56try { 57new URL ( `http:// ${ host } /` ); 58return true ; 59} catch { 60return false ; 61} 62} 63 64function isDatetime ( value :string ) :boolean { 65const match = / ^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:\d{2})$ / 66. exec ( value ); 67if ( ! match ) return false ; 68const [, y , m , d , h , min , s , zone ] = match ; 69const year = Number ( y ), month = Number ( m ), day = Number ( d ); 70const leap = year % 4 === 0 && ( year % 100 !== 0 || year % 400 === 0 ); 71const days = [ 31 , leap ?29 :28 , 31 , 30 , 31 , 30 , 31 , 31 , 30 , 31 , 30 , 31 ]; 72if ( 73month < 1 || month > 12 || day < 1 || day > days [ month - 1 ] || 74Number ( h ) > 23 || Number ( min ) > 59 || Number ( s ) > 59 || zone === "-00:00" 75) return false ; 76if ( zone !== "Z" ) { 77const hours = Number ( zone . slice ( 1 , 3 )), minutes = Number ( zone . slice ( 4 )); 78if ( hours > 23 || minutes > 59 ) return false ; 79// Offsets must not move a year-zero date into the preceding year. 80if ( 81year === 0 && month === 1 && day === 1 && zone [ 0 ] === "+" && 82Number ( h ) * 60 + Number ( min ) < hours * 60 + minutes 83) return false ; 84} 85return true ; 86} 87 88function isLanguage ( value :string ) :boolean { 89// BCP 47 grandfathered tags cannot all be represented by the modern grammar. 90if ( 91/ ^(?:en-GB-oed|i-(?:ami|bnn|default|enochian|hak|klingon|lux|mingo|navajo|pwn|tao|tay|tsu)|sgn-(?:BE-FR|BE-NL|CH-DE)|art-lojban|cel-gaulish|no-bok|no-nyn|zh-(?:guoyu|hakka|min|min-nan|xiang))$ / i 92. test ( value ) 93) return true ; 94if ( / ^x(?:-[a-z0-9]{1,8})+$ / i . test ( value )) return true ; 95const match = 96/ ^([a-z]{2,3}(?:-[a-z]{3}){0,3}|[a-z]{4}|[a-z]{5,8})(?:-[a-z]{4})?(?:-(?:[a-z]{2}|[0-9]{3}))?((?:-(?:[a-z0-9]{5,8}|[0-9][a-z0-9]{3}))*)((?:-[0-9a-wy-z](?:-[a-z0-9]{2,8})+)*)(?:-x(?:-[a-z0-9]{1,8})+)?$ / i 97. exec ( value ); 98if ( ! match ) return false ; 99const [, , variantTags , extensionTags ] = match ; 100const variants = variantTags . toLowerCase (). split ( "-" ). filter ( Boolean ); 101const extensions = [ ...extensionTags . toLowerCase (). matchAll ( / -([0-9a-wy-z])(?=-) / g )]. map (( m ) => 102m [ 1 ] 103); 104return new Set ( variants ). size === variants . length && 105new Set ( extensions ). size === extensions . length ; 106} 107 108export function cidCodec ( value :string ) :0x55 | 0x71 | undefined { 109// AT Protocol's blessed form: base32 CIDv1, raw/dag-cbor, SHA-256. 110if ( value . length !== 59 || value [ 0 ] !== "b" ) return ; 111const bytes :number [] = []; 112let bits = 0 , accumulator = 0 ; 113for ( const char of value . slice ( 1 )) { 114const digit = "abcdefghijklmnopqrstuvwxyz234567" . indexOf ( char ); 115if ( digit < 0 ) return ; 116accumulator = ( accumulator << 5 ) | digit ; 117bits += 5 ; 118if ( bits >= 8 ) { 119bits -= 8 ; 120bytes . push (( accumulator >> bits ) & 255 ); 121accumulator &= ( 1 << bits ) - 1 ; 122} 123} 124if ( 125accumulator === 0 && bytes [ 0 ] === 1 && ( bytes [ 1 ] === 0x55 || bytes [ 1 ] === 0x71 ) && 126bytes [ 2 ] === 0x12 && bytes [ 3 ] === 32 127) return bytes [ 1 ]; 128} 129 130export function base64Length ( value :string ) :number | undefined { 131if ( ! /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}(?:==)?|[A-Za-z0-9+/]{3}=?)?$ / . test ( value )) return ; 132const raw = value . replace ( / =+$ / , "" ); 133const last = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" . indexOf ( 134raw . at ( - 1 ) ?? "A" , 135); 136const mask = raw . length % 4 === 2 ?15 :raw . length % 4 === 3 ?3 :0 ; 137if (( last & mask ) !== 0 ) return ; 138return Math . floor ( raw . length * 6 / 8 ); 139} 140 141export function matchesFormat ( format :StringFormat , value :string ) :boolean { 142switch ( format ) { 143case "did" : 144return isDid ( value ); 145case "handle" : 146return isHandle ( value ); 147case "at-identifier" : 148return isDid ( value ) || isHandle ( value ); 149case "at-uri" : 150return isAtUri ( value ); 151case "datetime" : 152return isDatetime ( value ); 153case "nsid" : 154return isNsid ( value ); 155case "tid" : 156return / ^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$ / . test ( value ); 157case "cid" : 158return cidCodec ( value ) !== undefined ; 159case "language" : 160return isLanguage ( value ); 161case "uri" : 162return isUri ( value ); 163} 164}