a tiny atproto lexicon validator
0

Configure Feed

Select the types of activity you want to include in your feed.

flex -> tinylex

+158 -132
+9 -9
README.md
··· 1 - # flex 1 + # tinylex 2 2 3 - flex is a tiny vanilla JavaScript library for validating [atproto lexicons](https://atproto.com/guides/lexicon). Given a set of lexicon schemata — the JSON documents that describe atproto record types — flex extracts static types and validates record data against them. It conforms to [Standard Schema](https://standardschema.dev/schema), so you can use it anywhere you'd use schema validators like Zod/Valibot/etc. 3 + tinylex is a tiny vanilla JavaScript library for validating [atproto lexicons](https://atproto.com/guides/lexicon). Given a set of lexicon schemata — the JSON documents that describe atproto record types — tinylex extracts static types and validates record data against them. It conforms to [Standard Schema](https://standardschema.dev/schema), so you can use it anywhere you'd use schema validators like Zod/Valibot/etc. 4 4 5 5 ## Installation 6 6 7 - flex is meant to be [vendored](https://htmx.org/essays/vendoring/); simply copy and paste `flex.js` and `flex-install` into your project! 7 + tinylex is meant to be [vendored](https://htmx.org/essays/vendoring/); simply copy and paste `tinylex.js` and `tinylex-install` into your project! 8 8 9 9 ## Usage 10 10 11 11 A `LexiconCatalog` holds your lexicon documents and resolves cross-references between them. You can handwrite or copy/paste lexicons inline or [fetch them by NSID](#fetching-lexicons) — either way, add them to the catalog, then call `schema(nsid)` to get a Standard Schema validator: 12 12 13 13 ```js 14 - import { LexiconCatalog } from "./flex.js"; 14 + import { LexiconCatalog } from "./tinylex.js"; 15 15 16 16 const lexicons = new LexiconCatalog().add({ 17 17 lexicon: 1, ··· 45 45 46 46 ## Fetching lexicons 47 47 48 - `flex-install` resolves lexicons by NSID over DNS (per the [lexicon resolution spec](https://atproto.com/specs/lexicon#lexicon-resolution)) and writes them to disk: 48 + `tinylex-install` resolves lexicons by NSID over DNS (per the [lexicon resolution spec](https://atproto.com/specs/lexicon#lexicon-resolution)) and writes them to disk: 49 49 50 50 ```sh 51 - ./flex-install app.bsky.feed.post app.bsky.feed.like 52 - ./flex-install app.bsky.actor.profile --out ./schemata 53 - ./flex-install app.bsky.feed.post --js 51 + ./tinylex-install app.bsky.feed.post app.bsky.feed.like 52 + ./tinylex-install app.bsky.actor.profile --out ./schemata 53 + ./tinylex-install app.bsky.feed.post --js 54 54 ``` 55 55 56 56 Output defaults to `./lexicons/<nsid>.json`. Pass `--js` to emit `.js` files wrapped in `export default /** @type {const} */ ({...})` so imports keep literal types (otherwise TypeScript widens the JSON and `LexiconCatalog.add()` can't infer narrow types for `result.value`). ··· 61 61 62 62 The [official guide to installing lexicons](https://atproto.com/guides/installing-lexicons) uses the [`@atproto/lex`](https://npmx.dev/package/@atproto/lex) package, which has 79 transitive dependencies and a requires a build step to actually use the lexicons in your code. [`@atcute/lex-cli`](https://npmx.dev/package/@atcute/lex-cli) is lighter weight, but requires a config file _and_ a build step. 63 63 64 - flex is ~650 lines of vanilla JavaScript with no dependencies. It lets you use the lexicon schema JSON as-is for validation and static typing. For convenience, it also includes a ~100 line install script that works with any JavaScript runtime to fetch lexicon documents based on their NSIDs. 64 + tinylex is ~650 lines of vanilla JavaScript with no dependencies. It lets you use the lexicon schema JSON as-is for validation and static typing. For convenience, it also includes a ~100 line install script that works with any JavaScript runtime to fetch lexicon documents based on their NSIDs.
-114
flex-install
··· 1 - #!/bin/sh 2 - ":" //# ; if command -v bun >/dev/null 2>&1; then exec bun "$0" "$@"; elif command -v deno >/dev/null 2>&1; then exec deno run -A "$0" "$@"; else exec node "$0" "$@"; fi 3 - 4 - // This Source Code Form is subject to the terms of the Mozilla Public 5 - // License, v. 2.0. If a copy of the MPL was not distributed with this 6 - // file, You can obtain one at https://mozilla.org/MPL/2.0/. 7 - // 8 - // Copyright (c) 2026 Jake Lazaroff https://tangled.org/jakelazaroff.com/flex 9 - 10 - import { writeFile, mkdir } from "node:fs/promises"; 11 - import { dirname, join } from "node:path"; 12 - 13 - async function resolveTxt(host) { 14 - const r = await fetch( 15 - `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(host)}&type=TXT`, 16 - { headers: { accept: "application/dns-json" } } 17 - ); 18 - if (!r.ok) throw new Error(`doh ${r.status}`); 19 - const { Answer = [] } = await r.json(); 20 - return Answer.filter(a => a.type === 16).map(a => { 21 - const chunks = []; 22 - const re = /"((?:[^"\\]|\\.)*)"/g; 23 - let m; 24 - while ((m = re.exec(a.data))) chunks.push(m[1].replace(/\\(.)/g, "$1")); 25 - return chunks; 26 - }); 27 - } 28 - 29 - const argv = typeof Deno !== "undefined" ? Deno.args : process.argv.slice(2); 30 - const nsids = []; 31 - let outDir = "lexicons"; 32 - let asJs = false; 33 - 34 - for (let i = 0; i < argv.length; i++) { 35 - const a = argv[i]; 36 - if (a === "--out" || a === "-o") outDir = argv[++i]; 37 - else if (a.startsWith("--out=")) outDir = a.slice(6); 38 - else if (a === "--js") asJs = true; 39 - else nsids.push(a); 40 - } 41 - if (!nsids.length) { 42 - console.error("usage: ./install <nsid>... [--out <dir>] [--js]"); 43 - process.exit(1); 44 - } 45 - 46 - function authorityDomain(nsid) { 47 - const segs = nsid.split("."); 48 - if (segs.length < 3 || segs.some(s => !/^[a-z][a-z0-9-]*$/i.test(s))) { 49 - throw new Error(`invalid nsid: ${nsid}`); 50 - } 51 - return segs.slice(0, -1).reverse().join("."); 52 - } 53 - 54 - async function resolveDid(nsid) { 55 - const host = `_lexicon.${authorityDomain(nsid)}`; 56 - const records = await resolveTxt(host); 57 - for (const chunks of records) { 58 - const m = chunks.join("").match(/^did=(.+)$/); 59 - if (m) return m[1]; 60 - } 61 - throw new Error(`no did= TXT at ${host}`); 62 - } 63 - 64 - async function resolvePds(did) { 65 - let doc; 66 - if (did.startsWith("did:plc:")) { 67 - const r = await fetch(`https://plc.directory/${did}`); 68 - if (!r.ok) throw new Error(`plc ${r.status}`); 69 - doc = await r.json(); 70 - } else if (did.startsWith("did:web:")) { 71 - const host = did.slice(8).replace(/:/g, "/"); 72 - const r = await fetch(`https://${host}/.well-known/did.json`); 73 - if (!r.ok) throw new Error(`did:web ${r.status}`); 74 - doc = await r.json(); 75 - } else { 76 - throw new Error(`unsupported did method: ${did}`); 77 - } 78 - const svc = (doc.service || []).find( 79 - s => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer" 80 - ); 81 - if (!svc) throw new Error("no atproto_pds service in did doc"); 82 - return svc.serviceEndpoint.replace(/\/$/, ""); 83 - } 84 - 85 - async function fetchLexicon(nsid) { 86 - const did = await resolveDid(nsid); 87 - const pds = await resolvePds(did); 88 - const url = `${pds}/xrpc/com.atproto.repo.getRecord` 89 - + `?repo=${encodeURIComponent(did)}` 90 - + `&collection=com.atproto.lexicon.schema` 91 - + `&rkey=${encodeURIComponent(nsid)}`; 92 - const r = await fetch(url); 93 - if (!r.ok) throw new Error(`getRecord ${r.status}`); 94 - const { value } = await r.json(); 95 - return value; 96 - } 97 - 98 - let failed = 0; 99 - for (const nsid of nsids) { 100 - try { 101 - const lex = await fetchLexicon(nsid); 102 - const out = join(outDir, `${nsid}.${asJs ? "js" : "json"}`); 103 - const body = asJs 104 - ? `export default /** @type {const} */ (${JSON.stringify(lex, null, 2)});\n` 105 - : JSON.stringify(lex, null, 2) + "\n"; 106 - await mkdir(dirname(out), { recursive: true }); 107 - await writeFile(out, body); 108 - console.log(`ok ${nsid} -> ${out}`); 109 - } catch (e) { 110 - console.error(`fail ${nsid} (${e.message})`); 111 - failed++; 112 - } 113 - } 114 - process.exit(failed ? 1 : 0);
+34 -8
flex.js tinylex.js
··· 2 2 // License, v. 2.0. If a copy of the MPL was not distributed with this 3 3 // file, You can obtain one at https://mozilla.org/MPL/2.0/. 4 4 // 5 - // Copyright (c) 2026 Jake Lazaroff https://tangled.org/jakelazaroff.com/flex 5 + // Copyright (c) 2026 Jake Lazaroff https://tangled.org/jakelazaroff.com/tinylex 6 6 7 7 // --------------------------------------------------------------------------- 8 8 // String format names (see FORMATS validators below) ··· 49 49 * @typedef {{ 50 50 * readonly "~standard": { 51 51 * readonly version: 1; 52 - * readonly vendor: "flex"; 52 + * readonly vendor: "tinylex"; 53 53 * readonly validate: (value: unknown) => Result<T>; 54 54 * readonly types?: { readonly input: unknown; readonly output: T }; 55 55 * }; ··· 276 276 * @returns {{ issues: ReadonlyArray<Issue> } | null} 277 277 */ 278 278 function checkRange(value, min, max, minLabel, maxLabel, prefix, path) { 279 - if (min !== undefined && value < min) return fail(`${prefix} ${value} < ${minLabel} ${min}`, path); 280 - if (max !== undefined && value > max) return fail(`${prefix} ${value} > ${maxLabel} ${max}`, path); 279 + if (min !== undefined && value < min) 280 + return fail(`${prefix} ${value} < ${minLabel} ${min}`, path); 281 + if (max !== undefined && value > max) 282 + return fail(`${prefix} ${value} > ${maxLabel} ${max}`, path); 281 283 return null; 282 284 } 283 285 ··· 340 342 return { 341 343 "~standard": { 342 344 version: 1, 343 - vendor: "flex", 345 + vendor: "tinylex", 344 346 validate: (value) => 345 347 /** @type {Result<ResolveRef<Ref, Catalog, never>>} */ ( 346 348 validateType(value, def, { resolveRef, nsid }, []) ··· 428 430 const ce = checkConstEnum(data, def, path); 429 431 if (ce) return ce; 430 432 const byteLen = TEXT_ENCODER.encode(data).length; 431 - const lenFail = checkRange(byteLen, def.minLength, def.maxLength, "minLength", "maxLength", "Byte length", path); 433 + const lenFail = checkRange( 434 + byteLen, 435 + def.minLength, 436 + def.maxLength, 437 + "minLength", 438 + "maxLength", 439 + "Byte length", 440 + path, 441 + ); 432 442 if (lenFail) return lenFail; 433 443 if (def.minGraphemes !== undefined || def.maxGraphemes !== undefined) { 434 444 const count = SEGMENTER ? [...SEGMENTER.segment(data)].length : [...data].length; 435 - const gFail = checkRange(count, def.minGraphemes, def.maxGraphemes, "minGraphemes", "maxGraphemes", "Grapheme count", path); 445 + const gFail = checkRange( 446 + count, 447 + def.minGraphemes, 448 + def.maxGraphemes, 449 + "minGraphemes", 450 + "maxGraphemes", 451 + "Grapheme count", 452 + path, 453 + ); 436 454 if (gFail) return gFail; 437 455 } 438 456 if (def.format !== undefined) { ··· 487 505 /** @type {Validator} */ 488 506 function validateArray(data, def, opts, path) { 489 507 if (!Array.isArray(data)) return fail(`Expected array, got ${typeof data}`, path); 490 - const lenFail = checkRange(data.length, def.minLength, def.maxLength, "minLength", "maxLength", "Length", path); 508 + const lenFail = checkRange( 509 + data.length, 510 + def.minLength, 511 + def.maxLength, 512 + "minLength", 513 + "maxLength", 514 + "Length", 515 + path, 516 + ); 491 517 if (lenFail) return lenFail; 492 518 if (!def.items) return fail(`Array schema missing "items"`, path); 493 519 const issues = data.flatMap(
+1 -1
test.js
··· 1 1 import { test } from "node:test"; 2 2 import assert from "node:assert/strict"; 3 - import { LexiconCatalog } from "./flex.js"; 3 + import { LexiconCatalog } from "./tinylex.js"; 4 4 5 5 /** @returns {(v: unknown) => boolean} */ 6 6 const validator = (/** @type {any} */ def) => {
+114
tinylex-install
··· 1 + #!/bin/sh 2 + ":" //# ; if command -v bun >/dev/null 2>&1; then exec bun "$0" "$@"; elif command -v deno >/dev/null 2>&1; then exec deno run -A "$0" "$@"; else exec node "$0" "$@"; fi 3 + 4 + // This Source Code Form is subject to the terms of the Mozilla Public 5 + // License, v. 2.0. If a copy of the MPL was not distributed with this 6 + // file, You can obtain one at https://mozilla.org/MPL/2.0/. 7 + // 8 + // Copyright (c) 2026 Jake Lazaroff https://tangled.org/jakelazaroff.com/tinylex 9 + 10 + import { writeFile, mkdir } from "node:fs/promises"; 11 + import { dirname, join } from "node:path"; 12 + 13 + async function resolveTxt(host) { 14 + const r = await fetch( 15 + `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(host)}&type=TXT`, 16 + { headers: { accept: "application/dns-json" } } 17 + ); 18 + if (!r.ok) throw new Error(`doh ${r.status}`); 19 + const { Answer = [] } = await r.json(); 20 + return Answer.filter(a => a.type === 16).map(a => { 21 + const chunks = []; 22 + const re = /"((?:[^"\\]|\\.)*)"/g; 23 + let m; 24 + while ((m = re.exec(a.data))) chunks.push(m[1].replace(/\\(.)/g, "$1")); 25 + return chunks; 26 + }); 27 + } 28 + 29 + const argv = typeof Deno !== "undefined" ? Deno.args : process.argv.slice(2); 30 + const nsids = []; 31 + let outDir = "lexicons"; 32 + let asJs = false; 33 + 34 + for (let i = 0; i < argv.length; i++) { 35 + const a = argv[i]; 36 + if (a === "--out" || a === "-o") outDir = argv[++i]; 37 + else if (a.startsWith("--out=")) outDir = a.slice(6); 38 + else if (a === "--js") asJs = true; 39 + else nsids.push(a); 40 + } 41 + if (!nsids.length) { 42 + console.error("usage: ./install <nsid>... [--out <dir>] [--js]"); 43 + process.exit(1); 44 + } 45 + 46 + function authorityDomain(nsid) { 47 + const segs = nsid.split("."); 48 + if (segs.length < 3 || segs.some(s => !/^[a-z][a-z0-9-]*$/i.test(s))) { 49 + throw new Error(`invalid nsid: ${nsid}`); 50 + } 51 + return segs.slice(0, -1).reverse().join("."); 52 + } 53 + 54 + async function resolveDid(nsid) { 55 + const host = `_lexicon.${authorityDomain(nsid)}`; 56 + const records = await resolveTxt(host); 57 + for (const chunks of records) { 58 + const m = chunks.join("").match(/^did=(.+)$/); 59 + if (m) return m[1]; 60 + } 61 + throw new Error(`no did= TXT at ${host}`); 62 + } 63 + 64 + async function resolvePds(did) { 65 + let doc; 66 + if (did.startsWith("did:plc:")) { 67 + const r = await fetch(`https://plc.directory/${did}`); 68 + if (!r.ok) throw new Error(`plc ${r.status}`); 69 + doc = await r.json(); 70 + } else if (did.startsWith("did:web:")) { 71 + const host = did.slice(8).replace(/:/g, "/"); 72 + const r = await fetch(`https://${host}/.well-known/did.json`); 73 + if (!r.ok) throw new Error(`did:web ${r.status}`); 74 + doc = await r.json(); 75 + } else { 76 + throw new Error(`unsupported did method: ${did}`); 77 + } 78 + const svc = (doc.service || []).find( 79 + s => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer" 80 + ); 81 + if (!svc) throw new Error("no atproto_pds service in did doc"); 82 + return svc.serviceEndpoint.replace(/\/$/, ""); 83 + } 84 + 85 + async function fetchLexicon(nsid) { 86 + const did = await resolveDid(nsid); 87 + const pds = await resolvePds(did); 88 + const url = `${pds}/xrpc/com.atproto.repo.getRecord` 89 + + `?repo=${encodeURIComponent(did)}` 90 + + `&collection=com.atproto.lexicon.schema` 91 + + `&rkey=${encodeURIComponent(nsid)}`; 92 + const r = await fetch(url); 93 + if (!r.ok) throw new Error(`getRecord ${r.status}`); 94 + const { value } = await r.json(); 95 + return value; 96 + } 97 + 98 + let failed = 0; 99 + for (const nsid of nsids) { 100 + try { 101 + const lex = await fetchLexicon(nsid); 102 + const out = join(outDir, `${nsid}.${asJs ? "js" : "json"}`); 103 + const body = asJs 104 + ? `export default /** @type {const} */ (${JSON.stringify(lex, null, 2)});\n` 105 + : JSON.stringify(lex, null, 2) + "\n"; 106 + await mkdir(dirname(out), { recursive: true }); 107 + await writeFile(out, body); 108 + console.log(`ok ${nsid} -> ${out}`); 109 + } catch (e) { 110 + console.error(`fail ${nsid} (${e.message})`); 111 + failed++; 112 + } 113 + } 114 + process.exit(failed ? 1 : 0);