// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. // // Copyright (c) 2026 Jake Lazaroff https://tangled.org/jakelazaroff.com/tinylex // --------------------------------------------------------------------------- // String format names (see FORMATS validators below) // --------------------------------------------------------------------------- /** * @typedef {"at-identifier" | "at-uri" | "cid" | "datetime" | "did" | "handle" * | "nsid" | "tid" | "record-key" | "uri" | "language"} FormatName */ // --------------------------------------------------------------------------- // Structural atproto types // --------------------------------------------------------------------------- /** @typedef {{ readonly $bytes: string }} Bytes */ /** @typedef {{ readonly $link: string }} CidLink */ /** * @typedef {{ * readonly $type: "blob"; * readonly mimeType: string; * readonly size: number; * readonly ref: CidLink; * }} Blob */ // --------------------------------------------------------------------------- // Standard Schema v1 result types // --------------------------------------------------------------------------- /** * @typedef {{ * readonly message: string; * readonly path?: ReadonlyArray<{ readonly key: string | number }>; * }} Issue */ /** * @template T * @typedef {{ readonly value: T; readonly issues?: undefined } | { readonly issues: ReadonlyArray }} Result */ /** * @template T * @typedef {{ * readonly "~standard": { * readonly version: 1; * readonly vendor: "tinylex"; * readonly validate: (value: unknown) => Result; * readonly types?: { readonly input: unknown; readonly output: T }; * }; * }} StandardSchema */ // --------------------------------------------------------------------------- // Flat catalog map: "nsid#defName" | "nsid" (alias for "nsid#main") -> type // --------------------------------------------------------------------------- /** @typedef {Record} CatalogMap */ // --------------------------------------------------------------------------- // Ref resolution — direct key lookup into flat map // --------------------------------------------------------------------------- /** * @template {string} Ref * @template {CatalogMap} Catalog * @template {string} CurrentNsid * @typedef {Ref extends `#${infer Name}` * ? `${CurrentNsid}#${Name}` extends keyof Catalog ? Catalog[`${CurrentNsid}#${Name}`] : unknown * : Ref extends `${string}#${string}` * ? Ref extends keyof Catalog ? Catalog[Ref] : unknown * : `${Ref}#main` extends keyof Catalog * ? Catalog[`${Ref}#main`] * : Ref extends keyof Catalog ? Catalog[Ref] : unknown * } ResolveRef */ // --------------------------------------------------------------------------- // Core type inference // --------------------------------------------------------------------------- /** * @template Def * @typedef {Def extends { const: infer C extends boolean } ? C : boolean} InferBoolean */ /** * @template Def * @typedef {Def extends { const: infer C extends number } ? C * : Def extends { enum: ReadonlyArray } ? E * : number * } InferInteger */ /** * @template Def * @typedef {Def extends { const: infer C extends string } ? C * : Def extends { enum: ReadonlyArray } ? E * : string * } InferString */ /** * @template Def * @template {CatalogMap} Catalog * @template {string} CurrentNsid * @typedef {Def extends { type: "null" } ? null * : Def extends { type: "boolean" } ? InferBoolean * : Def extends { type: "integer" } ? InferInteger * : Def extends { type: "string" } ? InferString * : Def extends { type: "bytes" } ? Bytes * : Def extends { type: "cid-link" } ? CidLink * : Def extends { type: "blob" } ? Blob * : Def extends { type: "array"; items: infer Items } ? Infer[] * : Def extends { type: "object" | "params" } ? InferObject * : Def extends { type: "record"; record: infer R } ? InferObject & { readonly $type: CurrentNsid } * : Def extends { type: "ref"; ref: infer R extends string } ? ResolveRef * : Def extends { type: "union"; refs: ReadonlyArray } ? ResolveRef * : Def extends { type: "unknown" } ? Record * : unknown * } Infer */ // --------------------------------------------------------------------------- // Object inference // --------------------------------------------------------------------------- /** * @template Props * @template Req * @typedef {Req extends ReadonlyArray ? Extract : never} RequiredKeys */ /** * @template Props * @template Req * @typedef {Req extends ReadonlyArray ? Exclude : keyof Props} OptionalKeys */ /** * @template Nul * @typedef {Nul extends ReadonlyArray ? Nul[number] : never} NullableKeys */ /** * @template K * @template Props * @template Nul * @template {CatalogMap} Catalog * @template {string} CurrentNsid * @typedef {K extends keyof Props * ? K extends NullableKeys * ? Infer | null * : Infer * : never * } InferField */ /** * @template Def * @template {CatalogMap} Catalog * @template {string} CurrentNsid * @typedef {Def extends { properties: infer Props; required?: infer Req; nullable?: infer Nul } * ? { readonly [K in RequiredKeys]: InferField } * & { readonly [K in OptionalKeys]?: InferField } * : Record * } InferObject */ // --------------------------------------------------------------------------- // Flatten a lexicon file's defs into the catalog's flat map format: // "nsid#defName" -> InferredType // "nsid" -> InferredType (alias for #main, if present) // --------------------------------------------------------------------------- /** * @template {string} Nsid * @template Defs * @template {CatalogMap} Catalog * @typedef {{ readonly [K in keyof Defs as `${Nsid}#${string & K}`]: Infer } * & ("main" extends keyof Defs ? { readonly [N in Nsid]: Infer } : {}) * } FlattenDefs */ // --------------------------------------------------------------------------- // Internal runtime types // --------------------------------------------------------------------------- /** @typedef {any} SchemaDef */ /** * @typedef {Object} ValidateOptions * @property {(ref: string, currentNsid?: string) => { nsid: string, def: SchemaDef }} [resolveRef] * @property {string} [nsid] */ // --------------------------------------------------------------------------- // String format validators // --------------------------------------------------------------------------- /** Module-level helpers — shared by FORMATS entries and other validators. */ const isDid = (/** @type {string} */ v) => /^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$/.test(v); const isHandle = (/** @type {string} */ v) => v.length <= 253 && /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/.test(v); const isCid = (/** @type {string} */ v) => /^[a-zA-Z][a-zA-Z0-9+/=_-]+$/.test(v); /** @type {Record boolean>} */ const FORMATS = { did: isDid, handle: isHandle, "at-identifier": (v) => isDid(v) || isHandle(v), cid: isCid, nsid: (v) => v.length <= 317 && /^[a-zA-Z][a-zA-Z0-9-]*(\.[a-zA-Z0-9-]+){1,}\.[a-zA-Z][a-zA-Z0-9]*$/.test(v), "at-uri": (v) => /^at:\/\/[a-zA-Z0-9._:%-]+(\/[a-zA-Z0-9._~:@!$&'()*+,;=%-]*)*(\/[a-zA-Z0-9._~:@!$&'()*+,;=%-]*)?$/.test( v, ), datetime: (v) => /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):[0-5]\d:[0-5]\d(\.\d+)?(Z|[+-]\d{2}:\d{2})$/.test( v, ) && !v.endsWith("-00:00"), tid: (v) => /^[2-7a-z]{13}$/.test(v), "record-key": (v) => v.length > 0 && /^[a-zA-Z0-9_~.:-]{1,512}$/.test(v), uri: (v) => v.length <= 8192 && /^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(v), language: (v) => /^[a-zA-Z]{1,8}(-[a-zA-Z0-9]{1,8})*$/.test(v), }; // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- const TEXT_ENCODER = new TextEncoder(); const SEGMENTER = typeof Intl !== "undefined" && Intl.Segmenter ? new Intl.Segmenter() : null; /** @typedef {ReadonlyArray<{ key: string | number }>} Path */ /** * @param {Path} path * @param {string | number} key * @returns {Path} */ const push = (path, key) => [...path, { key }]; /** * @param {string} message * @param {Path} path * @returns {{ issues: ReadonlyArray }} */ const fail = (message, path) => ({ issues: [{ message, ...(path.length ? { path } : {}) }] }); /** @param {unknown} x */ const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x); /** * Bound check helper — returns a failure when `value` falls outside [min, max], * or null when in range (or both bounds are undefined). The min/max labels are * passed explicitly so callers can match the lexicon spec's exact key names * (e.g. "minimum"/"maximum" vs "minLength"/"maxLength"). * * @param {number} value * @param {number | undefined} min * @param {number | undefined} max * @param {string} minLabel Name of the min bound (e.g. "minLength"). * @param {string} maxLabel Name of the max bound (e.g. "maxLength"). * @param {string} prefix Prefix for the error message (e.g. "Byte length", "Value"). * @param {Path} path * @returns {{ issues: ReadonlyArray } | null} */ function checkRange(value, min, max, minLabel, maxLabel, prefix, path) { if (min !== undefined && value < min) return fail(`${prefix} ${value} < ${minLabel} ${min}`, path); if (max !== undefined && value > max) return fail(`${prefix} ${value} > ${maxLabel} ${max}`, path); return null; } // --------------------------------------------------------------------------- // Lexicon catalog — immutable builder // --------------------------------------------------------------------------- /** * @template {CatalogMap} [Catalog={}] */ export default class TinyLex { #files = new Map(); /** * @template {string} const Nsid * @template {Record} const Defs * @param {{ readonly id: Nsid; readonly defs: Defs; readonly [k: string]: unknown }} file * @returns {TinyLex>>} */ add(file) { this.#files.set(file.id, file); return /** @type {any} */ (this); } /** * @param {string} ref * @param {string} [currentNsid] * @returns {{ nsid: string, def: SchemaDef }} */ #resolve(ref, currentNsid) { let nsid, name; if (ref.startsWith("#")) { if (!currentNsid) throw new Error(`Cannot resolve local ref "${ref}" without a current NSID`); nsid = currentNsid; name = ref.slice(1); } else { const hash = ref.indexOf("#"); nsid = hash === -1 ? ref : ref.slice(0, hash); name = hash === -1 ? "main" : ref.slice(hash + 1); } const file = this.#files.get(nsid); if (!file) throw new Error(`Lexicon not found in catalog: "${nsid}"`); const def = file.defs[name]; if (!def) throw new Error(`Definition "${name}" not found in "${nsid}"`); return { nsid, def }; } /** * Create a Standard Schema v1 schema for a ref in this catalog. * https://standardschema.dev * * @template {string} Ref * @param {Ref} ref * @returns {StandardSchema>} */ schema(ref) { const { def, nsid } = this.#resolve(ref); const resolveRef = (/** @type {string} */ r, /** @type {string | undefined} */ n) => this.#resolve(r, n); return { "~standard": { version: 1, vendor: "tinylex", validate: (value) => /** @type {Result>} */ ( validateType(value, def, { resolveRef, nsid }, []) ), }, }; } /** @returns {string[]} */ nsids() { return [...this.#files.keys()]; } } // --------------------------------------------------------------------------- // Internal type dispatch — not exported // --------------------------------------------------------------------------- /** @typedef {(data: any, def: any, opts: ValidateOptions, path: Path) => Result} Validator */ /** * @param {unknown} data * @param {SchemaDef} def * @param {ValidateOptions} opts * @param {Path} path * @returns {Result} */ function validateType(data, def, opts, path) { const fn = VALIDATORS[def.type]; return fn ? fn(data, def, opts, path) : fail(`Unknown schema type: "${def.type}"`, path); } /** * Shared const/enum check. Returns failure on mismatch, null on success. * @param {any} data * @param {any} def * @param {Path} path * @returns {{ issues: ReadonlyArray } | null} */ function checkConstEnum(data, def, path) { if (def.const !== undefined && data !== def.const) return fail(`Expected const ${JSON.stringify(def.const)}`, path); if (def.enum !== undefined && !def.enum.includes(data)) return fail(`${JSON.stringify(data)} not in enum ${JSON.stringify(def.enum)}`, path); return null; } /** * Canonicalize a union ref to its absolute "nsid#name" form. * @param {string} ref * @param {string | undefined} currentNsid * @returns {string} */ function canonicalizeRef(ref, currentNsid) { if (ref.startsWith("#")) return `${currentNsid ?? ""}${ref}`; if (ref.includes("#")) return ref; return `${ref}#main`; } /** @type {Validator} */ const validateNull = (data, _def, _opts, path) => data === null ? { value: data } : fail("Expected null", path); /** @type {Validator} */ function validateBoolean(data, def, _opts, path) { if (typeof data !== "boolean") return fail(`Expected boolean, got ${typeof data}`, path); return checkConstEnum(data, def, path) ?? { value: data }; } /** @type {Validator} */ function validateInteger(data, def, _opts, path) { if (typeof data !== "number" || !Number.isInteger(data)) return fail(`Expected integer, got ${typeof data}`, path); return ( checkConstEnum(data, def, path) ?? checkRange(data, def.minimum, def.maximum, "minimum", "maximum", "Value", path) ?? { value: data, } ); } /** @type {Validator} */ function validateString(data, def, _opts, path) { if (typeof data !== "string") return fail(`Expected string, got ${typeof data}`, path); const ce = checkConstEnum(data, def, path); if (ce) return ce; const byteLen = TEXT_ENCODER.encode(data).length; const lenFail = checkRange( byteLen, def.minLength, def.maxLength, "minLength", "maxLength", "Byte length", path, ); if (lenFail) return lenFail; if (def.minGraphemes !== undefined || def.maxGraphemes !== undefined) { const count = SEGMENTER ? [...SEGMENTER.segment(data)].length : [...data].length; const gFail = checkRange( count, def.minGraphemes, def.maxGraphemes, "minGraphemes", "maxGraphemes", "Grapheme count", path, ); if (gFail) return gFail; } if (def.format !== undefined) { const fn = FORMATS[/** @type {FormatName} */ (def.format)]; if (!fn) return fail(`Unknown string format: "${def.format}"`, path); if (!fn(data)) return fail(`Does not match format "${def.format}": "${data}"`, path); } return { value: data }; } /** @type {Validator} */ function validateBytes(data, _def, _opts, path) { if (!isPlainObject(data) || typeof data.$bytes !== "string") return fail(`Expected {$bytes: ""}, got ${JSON.stringify(data)}`, path); try { atob(data.$bytes); } catch { return fail("Invalid base64 in $bytes", path); } return { value: data }; } /** @type {Validator} */ function validateCidLink(data, _def, _opts, path) { if (!isPlainObject(data) || typeof data.$link !== "string") return fail(`Expected {$link: ""}, got ${JSON.stringify(data)}`, path); if (!isCid(data.$link)) return fail(`Invalid CID: "${data.$link}"`, path); return { value: data }; } /** @type {Validator} */ function validateBlob(data, def, _opts, path) { if (!data || typeof data !== "object") return fail(`Expected blob object`, path); if (data.$type !== "blob") return fail(`Blob must have $type "blob", got "${data.$type}"`, path); if (typeof data.mimeType !== "string") return fail(`Blob must have string "mimeType"`, path); if (def.accept?.length) { const ok = def.accept.some((/** @type {string} */ p) => { if (p === "*/*") return true; if (p.endsWith("/*")) return data.mimeType.startsWith(p.slice(0, -1)); return p === data.mimeType; }); if (!ok) return fail(`mimeType "${data.mimeType}" not in [${def.accept.join(", ")}]`, path); } if (def.maxSize !== undefined) { if (typeof data.size !== "number") return fail(`Blob must have numeric "size"`, path); if (data.size > def.maxSize) return fail(`Blob size ${data.size} > maxSize ${def.maxSize}`, path); } return { value: data }; } /** @type {Validator} */ function validateArray(data, def, opts, path) { if (!Array.isArray(data)) return fail(`Expected array, got ${typeof data}`, path); const lenFail = checkRange( data.length, def.minLength, def.maxLength, "minLength", "maxLength", "Length", path, ); if (lenFail) return lenFail; if (!def.items) return fail(`Array schema missing "items"`, path); const issues = data.flatMap( (item, i) => validateType(item, def.items, opts, push(path, i)).issues ?? [], ); return issues.length ? { issues } : { value: data }; } /** * Handles both `object` and `params` — params skips `nullable` handling. * @type {Validator} */ function validateObject(data, def, opts, path) { const isParams = def.type === "params"; if (!isPlainObject(data)) { return fail( isParams ? `Expected params object` : `Expected object, got ${Array.isArray(data) ? "array" : typeof data}`, path, ); } const required = new Set(def.required ?? []); const nullable = isParams ? null : new Set(def.nullable ?? []); const noun = isParams ? "param" : "field"; const issues = []; for (const field of required) if (!(field in data)) issues.push({ message: `Missing required ${noun} "${field}"`, path }); for (const [field, fieldDef] of Object.entries(def.properties ?? {})) { if (!(field in data)) continue; if (nullable && data[field] === null) { if (!nullable.has(field)) issues.push({ message: `Field "${field}" is not nullable`, path: push(path, field) }); continue; } const result = validateType(data[field], fieldDef, opts, push(path, field)); if (result.issues) issues.push(...result.issues); } return issues.length ? { issues } : { value: data }; } /** @type {Validator} */ function validateRecord(data, def, opts, path) { if (!isPlainObject(data)) return fail(`Expected object, got ${Array.isArray(data) ? "array" : typeof data}`, path); if (opts.nsid) { if (typeof data.$type !== "string") return fail(`Record must have "$type"`, path); if (data.$type !== opts.nsid && data.$type !== `${opts.nsid}#main`) return fail(`$type "${data.$type}" does not match "${opts.nsid}"`, path); } return validateObject(data, def.record, opts, path); } /** @type {Validator} */ function validateRef(data, def, opts, path) { if (!def.ref) return fail(`ref schema missing "ref"`, path); if (!opts.resolveRef) return fail(`Cannot resolve ref "${def.ref}" without a catalog`, path); try { const { def: resolved, nsid } = opts.resolveRef(def.ref, opts.nsid); return validateType(data, resolved, { ...opts, nsid }, path); } catch (e) { return fail(e instanceof Error ? e.message : String(e), path); } } /** @type {Validator} */ function validateUnion(data, def, opts, path) { if (!isPlainObject(data)) return fail(`Union value must be an object`, path); const refs = def.refs ?? []; const closed = def.closed ?? false; if (closed && refs.length === 0) return fail(`Closed union with empty refs is invalid`, path); if (typeof data.$type !== "string") return fail(`Union value must include "$type"`, path); for (const ref of refs) { const full = canonicalizeRef(ref, opts.nsid); if (full !== data.$type && full !== `${data.$type}#main`) continue; if (opts.resolveRef) { try { const { def: resolved, nsid } = opts.resolveRef(ref, opts.nsid); return validateType(data, resolved, { ...opts, nsid }, path); } catch { /* catalog miss on open union */ } } return { value: data }; } return closed ? fail(`$type "${data.$type}" not in closed union: [${refs.join(", ")}]`, path) : { value: data }; } /** @type {Validator} */ function validateUnknown(data, _def, _opts, path) { if (!isPlainObject(data)) return fail(`unknown type must be an object`, path); if ("$link" in data) return fail(`unknown type cannot have $link at top level`, path); if ("$bytes" in data) return fail(`unknown type cannot have $bytes at top level`, path); return { value: data }; } /** @type {Validator} */ const validateToken = (_data, _def, _opts, path) => fail("token type has no data representation", path); /** @type {Record} */ const VALIDATORS = { null: validateNull, boolean: validateBoolean, integer: validateInteger, string: validateString, bytes: validateBytes, "cid-link": validateCidLink, blob: validateBlob, array: validateArray, object: validateObject, params: validateObject, ref: validateRef, union: validateUnion, unknown: validateUnknown, record: validateRecord, token: validateToken, };