cerulea/lexicon

define atproto schemas in TypeScript

git clone https://git.t4t.associates/cerulea/lexicon

Charlotte Somsyntax: drop gratuitous non-null assertions, share the label check9ad63e0

main
6.1 KiB164 linesraw
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[]) =>
5  parts.every((part) => part.length <= 63 && LABEL.test(part));
6
7export function isNsid(value: string): boolean {
8  const parts = value.split(".");
9  return 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 {
14  return 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(
16      value,
17    );
18}
19
20function isHandle(value: string): boolean {
21  const parts = value.split(".");
22  return value.length <= 253 && parts.length >= 2 && labels(parts) &&
23    /^[A-Za-z]/.test(parts.at(-1) ?? "");
24}
25
26function isAtUri(value: string): boolean {
27  if (value.length > 8192) return false;
28  const match = /^at:\/\/([^/?#]+)(?:\/([^/?#]+)(?:\/([^/?#]+))?)?$/.exec(value);
29  if (!match) return false;
30  const [, authority, collection, key] = match;
31  return (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 {
38  if (value.length > 8192 || /%(?![0-9A-Fa-f]{2})/.test(value)) return false;
39  const match = /^[A-Za-z][A-Za-z0-9+.-]*:(?:\/\/([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?$/.exec(
40    value,
41  );
42  if (!match) return false;
43  const [, authority, path, query = "", fragment = ""] = match;
44  if (
45    !/^[A-Za-z0-9\-._~!$&'()*+,;=:@%/]*$/.test(path) ||
46    !/^[A-Za-z0-9\-._~!$&'()*+,;=:@%/?]*$/.test(query) ||
47    !/^[A-Za-z0-9\-._~!$&'()*+,;=:@%/?]*$/.test(fragment)
48  ) return false;
49  if (authority === undefined) return true;
50  const host =
51    /^(?:[A-Za-z0-9\-._~!$&'()*+,;=:%]*@)?(\[[^\]]+\]|[A-Za-z0-9\-._~!$&'()*+,;=%]*)(?::[0-9]*)?$/
52      .exec(authority)?.[1];
53  if (host === undefined) return false;
54  if (!host.startsWith("[")) return true;
55  if (/^\[v[0-9a-f]+\.[A-Za-z0-9\-._~!$&'()*+,;=:]+\]$/i.test(host)) return true;
56  try {
57    new URL(`http://${host}/`);
58    return true;
59  } catch {
60    return false;
61  }
62}
63
64function isDatetime(value: string): boolean {
65  const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:\d{2})$/
66    .exec(value);
67  if (!match) return false;
68  const [, y, m, d, h, min, s, zone] = match;
69  const year = Number(y), month = Number(m), day = Number(d);
70  const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
71  const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
72  if (
73    month < 1 || month > 12 || day < 1 || day > days[month - 1] ||
74    Number(h) > 23 || Number(min) > 59 || Number(s) > 59 || zone === "-00:00"
75  ) return false;
76  if (zone !== "Z") {
77    const hours = Number(zone.slice(1, 3)), minutes = Number(zone.slice(4));
78    if (hours > 23 || minutes > 59) return false;
79    // Offsets must not move a year-zero date into the preceding year.
80    if (
81      year === 0 && month === 1 && day === 1 && zone[0] === "+" &&
82      Number(h) * 60 + Number(min) < hours * 60 + minutes
83    ) return false;
84  }
85  return true;
86}
87
88function isLanguage(value: string): boolean {
89  // BCP 47 grandfathered tags cannot all be represented by the modern grammar.
90  if (
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;
94  if (/^x(?:-[a-z0-9]{1,8})+$/i.test(value)) return true;
95  const 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);
98  if (!match) return false;
99  const [, , variantTags, extensionTags] = match;
100  const variants = variantTags.toLowerCase().split("-").filter(Boolean);
101  const extensions = [...extensionTags.toLowerCase().matchAll(/-([0-9a-wy-z])(?=-)/g)].map((m) =>
102    m[1]
103  );
104  return new Set(variants).size === variants.length &&
105    new 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.
110  if (value.length !== 59 || value[0] !== "b") return;
111  const bytes: number[] = [];
112  let bits = 0, accumulator = 0;
113  for (const char of value.slice(1)) {
114    const digit = "abcdefghijklmnopqrstuvwxyz234567".indexOf(char);
115    if (digit < 0) return;
116    accumulator = (accumulator << 5) | digit;
117    bits += 5;
118    if (bits >= 8) {
119      bits -= 8;
120      bytes.push((accumulator >> bits) & 255);
121      accumulator &= (1 << bits) - 1;
122    }
123  }
124  if (
125    accumulator === 0 && bytes[0] === 1 && (bytes[1] === 0x55 || bytes[1] === 0x71) &&
126    bytes[2] === 0x12 && bytes[3] === 32
127  ) return bytes[1];
128}
129
130export function base64Length(value: string): number | undefined {
131  if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}(?:==)?|[A-Za-z0-9+/]{3}=?)?$/.test(value)) return;
132  const raw = value.replace(/=+$/, "");
133  const last = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".indexOf(
134    raw.at(-1) ?? "A",
135  );
136  const mask = raw.length % 4 === 2 ? 15 : raw.length % 4 === 3 ? 3 : 0;
137  if ((last & mask) !== 0) return;
138  return Math.floor(raw.length * 6 / 8);
139}
140
141export function matchesFormat(format: StringFormat, value: string): boolean {
142  switch (format) {
143    case "did":
144      return isDid(value);
145    case "handle":
146      return isHandle(value);
147    case "at-identifier":
148      return isDid(value) || isHandle(value);
149    case "at-uri":
150      return isAtUri(value);
151    case "datetime":
152      return isDatetime(value);
153    case "nsid":
154      return isNsid(value);
155    case "tid":
156      return /^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/.test(value);
157    case "cid":
158      return cidCodec(value) !== undefined;
159    case "language":
160      return isLanguage(value);
161    case "uri":
162      return isUri(value);
163  }
164}