UNPKG

@atproto/lex-cli

Version:

TypeScript codegen tool for atproto Lexicon schemas

541 lines 20 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.genComment = genComment; exports.genCommonImports = genCommonImports; exports.genImports = genImports; exports.genUserType = genUserType; exports.genToken = genToken; exports.genArray = genArray; exports.genPrimitiveOrBlob = genPrimitiveOrBlob; exports.genXrpcParams = genXrpcParams; exports.genXrpcInput = genXrpcInput; exports.genXrpcOutput = genXrpcOutput; exports.genRecord = genRecord; exports.stripScheme = stripScheme; exports.stripHash = stripHash; exports.getHash = getHash; exports.ipldToType = ipldToType; exports.primitiveOrBlobToType = primitiveOrBlobToType; exports.primitiveToType = primitiveToType; const posix_1 = require("node:path/posix"); const ts_morph_1 = require("ts-morph"); const util_1 = require("./util"); function genComment(commentable, def) { if (def.description) { commentable.addJsDoc({ description: def.description }); } return commentable; } function genCommonImports(file, baseNsid) { //= import {ValidationResult, BlobRef} from '@atproto/lexicon' file .addImportDeclaration({ moduleSpecifier: '@atproto/lexicon', }) .addNamedImports([ { name: 'ValidationResult', isTypeOnly: true }, { name: 'BlobRef' }, ]); //= import {CID} from 'multiformats/cid' file .addImportDeclaration({ moduleSpecifier: 'multiformats/cid', }) .addNamedImports([{ name: 'CID' }]); //= import { validate as _validate } from '../../lexicons.ts' file .addImportDeclaration({ moduleSpecifier: `${baseNsid .split('.') .map((_str) => '..') .join('/')}/lexicons`, }) .addNamedImports([{ name: 'validate', alias: '_validate' }]); //= import { type $Typed, is$typed as _is$typed, type OmitKey } from '../[...]/util.ts' file .addImportDeclaration({ moduleSpecifier: `${baseNsid .split('.') .map((_str) => '..') .join('/')}/util`, }) .addNamedImports([ { name: '$Typed', isTypeOnly: true }, { name: 'is$typed', alias: '_is$typed' }, { name: 'OmitKey', isTypeOnly: true }, ]); // tsc adds protection against circular imports, which hurts bundle size. // Since we know that lexicon.ts and util.ts do not depend on the file being // generated, we can safely bypass this protection. // Note that we are not using `import * as util from '../../util'` because // typescript will emit is own helpers for the import, which we want to avoid. file.addVariableStatement({ isExported: false, declarationKind: ts_morph_1.VariableDeclarationKind.Const, declarations: [ { name: 'is$typed', initializer: '_is$typed' }, { name: 'validate', initializer: '_validate' }, ], }); //= const id = "{baseNsid}" file.addVariableStatement({ isExported: false, // Do not export to allow tree-shaking declarationKind: ts_morph_1.VariableDeclarationKind.Const, declarations: [{ name: 'id', initializer: JSON.stringify(baseNsid) }], }); } function genImports(file, imports, baseNsid) { const startPath = '/' + baseNsid.split('.').slice(0, -1).join('/'); for (const nsid of imports) { const targetPath = '/' + nsid.split('.').join('/') + '.js'; let resolvedPath = (0, posix_1.relative)(startPath, targetPath); if (!resolvedPath.startsWith('.')) { resolvedPath = `./${resolvedPath}`; } file.addImportDeclaration({ isTypeOnly: true, moduleSpecifier: resolvedPath, namespaceImport: (0, util_1.toTitleCase)(nsid), }); } } function genUserType(file, imports, lexicons, lexUri) { const def = lexicons.getDefOrThrow(lexUri); switch (def.type) { case 'array': genArray(file, imports, lexUri, def); break; case 'token': genToken(file, lexUri, def); break; case 'object': { const ifaceName = (0, util_1.toTitleCase)(getHash(lexUri)); genObject(file, imports, lexUri, def, ifaceName, { typeProperty: true, }); genObjHelpers(file, lexUri, ifaceName, { requireTypeProperty: false, }); break; } case 'blob': case 'bytes': case 'cid-link': case 'boolean': case 'integer': case 'string': case 'unknown': genPrimitiveOrBlob(file, lexUri, def); break; default: throw new Error(`genLexUserType() called with wrong definition type (${def.type}) in ${lexUri}`); } } function genObject(file, imports, lexUri, def, ifaceName, { defaultsArePresent = true, allowUnknownProperties = false, typeProperty = false, } = {}) { const iface = file.addInterface({ name: ifaceName, isExported: true, }); genComment(iface, def); if (typeProperty) { const hash = getHash(lexUri); const baseNsid = stripScheme(stripHash(lexUri)); //= $type?: <uri> iface.addProperty({ name: typeProperty === 'required' ? `$type` : `$type?`, type: // Not using $Type here because it is less readable than a plain string // `$Type<${JSON.stringify(baseNsid)}, ${JSON.stringify(hash)}>` hash === 'main' ? JSON.stringify(`${baseNsid}`) : JSON.stringify(`${baseNsid}#${hash}`), }); } const nullableProps = new Set(def.nullable); if (def.properties) { for (const propKey in def.properties) { const propDef = def.properties[propKey]; const propNullable = nullableProps.has(propKey); const req = def.required?.includes(propKey) || (defaultsArePresent && 'default' in propDef && propDef.default !== undefined); if (propDef.type === 'ref' || propDef.type === 'union') { //= propName: External|External const types = propDef.type === 'union' ? propDef.refs.map((ref) => refToUnionType(ref, lexUri, imports)) : [refToType(propDef.ref, stripScheme(stripHash(lexUri)), imports)]; if (propDef.type === 'union' && !propDef.closed) { types.push('{ $type: string }'); } iface.addProperty({ name: `${propKey}${req ? '' : '?'}`, type: makeType(types, { nullable: propNullable }), }); continue; } else { if (propDef.type === 'array') { //= propName: type[] let propAst; if (propDef.items.type === 'ref') { propAst = iface.addProperty({ name: `${propKey}${req ? '' : '?'}`, type: makeType(refToType(propDef.items.ref, stripScheme(stripHash(lexUri)), imports), { nullable: propNullable, array: true, }), }); } else if (propDef.items.type === 'union') { const types = propDef.items.refs.map((ref) => refToUnionType(ref, lexUri, imports)); if (!propDef.items.closed) { types.push('{ $type: string }'); } propAst = iface.addProperty({ name: `${propKey}${req ? '' : '?'}`, type: makeType(types, { nullable: propNullable, array: true, }), }); } else { propAst = iface.addProperty({ name: `${propKey}${req ? '' : '?'}`, type: makeType(primitiveOrBlobToType(propDef.items), { nullable: propNullable, array: true, }), }); } genComment(propAst, propDef); } else { //= propName: type genComment(iface.addProperty({ name: `${propKey}${req ? '' : '?'}`, type: makeType(primitiveOrBlobToType(propDef), { nullable: propNullable, }), }), propDef); } } } if (allowUnknownProperties) { //= [k: string]: unknown iface.addIndexSignature({ keyName: 'k', keyType: 'string', returnType: 'unknown', }); } } } function genToken(file, lexUri, def) { //= /** <comment> */ //= export const <TOKEN> = `${id}#<token>` genComment(file.addVariableStatement({ isExported: true, declarationKind: ts_morph_1.VariableDeclarationKind.Const, declarations: [ { name: (0, util_1.toScreamingSnakeCase)(getHash(lexUri)), initializer: `\`\${id}#${getHash(lexUri)}\``, }, ], }), def); } function genArray(file, imports, lexUri, def) { if (def.items.type === 'ref') { file.addTypeAlias({ name: (0, util_1.toTitleCase)(getHash(lexUri)), type: `${refToType(def.items.ref, stripScheme(stripHash(lexUri)), imports)}[]`, isExported: true, }); } else if (def.items.type === 'union') { const types = def.items.refs.map((ref) => refToUnionType(ref, lexUri, imports)); if (!def.items.closed) { types.push('{ $type: string }'); } file.addTypeAlias({ name: (0, util_1.toTitleCase)(getHash(lexUri)), type: `(${types.join('|')})[]`, isExported: true, }); } else { genComment(file.addTypeAlias({ name: (0, util_1.toTitleCase)(getHash(lexUri)), type: `${primitiveOrBlobToType(def.items)}[]`, isExported: true, }), def); } } function genPrimitiveOrBlob(file, lexUri, def) { genComment(file.addTypeAlias({ name: (0, util_1.toTitleCase)(getHash(lexUri)), type: primitiveOrBlobToType(def), isExported: true, }), def); } function genXrpcParams(file, lexicons, lexUri, defaultsArePresent = true) { const def = lexicons.getDefOrThrow(lexUri, [ 'query', 'subscription', 'procedure', ]); // @NOTE We need to use a `type` here instead of an `interface` because we // need the generated type to be used as generic type parameter like this: // // type QueryParams = {} // Generated by this function // // type MyUtil<P extends xrpcServer.QueryParam> = (...) // type NsType = MyUtil<NS.QueryParams> // ERROR if `NS.QueryParams` is an `interface` // // Second line will fail if `NS.QueryParams` is an `interface` that does // not explicitly extend `xrpcServer.QueryParam`, or have a string index // signature that encompasses `xrpcServer.QueryParam`. //= export type QueryParams = {...} if (def.parameters) { genComment(file.addTypeAlias({ name: 'QueryParams', isExported: true, type: `{ ${Object.entries(def.parameters.properties) .map(([paramKey, paramDef]) => { const req = def.parameters.required?.includes(paramKey) || (defaultsArePresent && 'default' in paramDef && paramDef.default !== undefined); const jsDoc = paramDef.description ? `/** ${paramDef.description} */\n` : ''; return `${jsDoc}${paramKey}${req ? '' : '?'}: ${paramDef.type === 'array' ? primitiveToType(paramDef.items) + '[]' : primitiveToType(paramDef)}`; }) .join('\n')} }`, }), def.parameters); } else { file.addTypeAlias({ name: 'QueryParams', isExported: true, type: '{}', }); } } function genXrpcInput(file, imports, lexicons, lexUri, defaultsArePresent = true) { const def = lexicons.getDefOrThrow(lexUri, ['query', 'procedure']); if (def.type === 'procedure' && def.input?.schema) { if (def.input.schema.type === 'ref' || def.input.schema.type === 'union') { //= export type InputSchema = ... const types = def.input.schema.type === 'union' ? def.input.schema.refs.map((ref) => refToUnionType(ref, lexUri, imports)) : [ refToType(def.input.schema.ref, stripScheme(stripHash(lexUri)), imports), ]; if (def.input.schema.type === 'union' && !def.input.schema.closed) { types.push('{ $type: string }'); } file.addTypeAlias({ name: 'InputSchema', type: types.join('|'), isExported: true, }); } else { //= export interface InputSchema {...} genObject(file, imports, lexUri, def.input.schema, `InputSchema`, { defaultsArePresent, }); } } else if (def.type === 'procedure' && def.input?.encoding) { //= export type InputSchema = string | Uint8Array | Blob file.addTypeAlias({ isExported: true, name: 'InputSchema', type: 'string | Uint8Array | Blob', }); } else { //= export type InputSchema = undefined file.addTypeAlias({ isExported: true, name: 'InputSchema', type: 'undefined', }); } } function genXrpcOutput(file, imports, lexicons, lexUri, defaultsArePresent = true) { const def = lexicons.getDefOrThrow(lexUri, [ 'query', 'subscription', 'procedure', ]); const schema = def.type === 'subscription' ? def.message?.schema : def.output?.schema; if (schema) { if (schema.type === 'ref' || schema.type === 'union') { //= export type OutputSchema = ... const types = schema.type === 'union' ? schema.refs.map((ref) => refToUnionType(ref, lexUri, imports)) : [refToType(schema.ref, stripScheme(stripHash(lexUri)), imports)]; if (schema.type === 'union' && !schema.closed) { types.push('{ $type: string }'); } file.addTypeAlias({ name: 'OutputSchema', type: types.join('|'), isExported: true, }); } else { //= export interface OutputSchema {...} genObject(file, imports, lexUri, schema, `OutputSchema`, { defaultsArePresent, }); } } } function genRecord(file, imports, lexicons, lexUri) { const def = lexicons.getDefOrThrow(lexUri, ['record']); //= export interface Record {...} genObject(file, imports, lexUri, def.record, 'Record', { defaultsArePresent: true, allowUnknownProperties: true, typeProperty: 'required', }); //= export function isRecord(v: unknown): v is Record {...} genObjHelpers(file, lexUri, 'Record', { requireTypeProperty: true, }); } function genObjHelpers(file, lexUri, ifaceName, { requireTypeProperty, }) { const hash = getHash(lexUri); const hashVar = `hash${ifaceName}`; file.addVariableStatement({ isExported: false, declarationKind: ts_morph_1.VariableDeclarationKind.Const, declarations: [{ name: hashVar, initializer: JSON.stringify(hash) }], }); const isX = (0, util_1.toCamelCase)(`is-${ifaceName}`); //= export function is{X}<V>(v: V) {...} file .addFunction({ name: isX, typeParameters: [{ name: `V` }], parameters: [{ name: `v`, type: `V` }], isExported: true, }) .setBodyText(`return is$typed(v, id, ${hashVar})`); const validateX = (0, util_1.toCamelCase)(`validate-${ifaceName}`); //= export function validate{X}(v: unknown) {...} file .addFunction({ name: validateX, typeParameters: [{ name: `V` }], parameters: [{ name: `v`, type: `V` }], isExported: true, }) .setBodyText(`return validate<${ifaceName} & V>(v, id, ${hashVar}${requireTypeProperty ? ', true' : ''})`); } function stripScheme(uri) { if (uri.startsWith('lex:')) return uri.slice(4); return uri; } function stripHash(uri) { return uri.split('#')[0] || ''; } function getHash(uri) { return uri.split('#').pop() || ''; } function ipldToType(def) { if (def.type === 'bytes') { return 'Uint8Array'; } return 'CID'; } function refToUnionType(ref, lexUri, imports) { const baseNsid = stripScheme(stripHash(lexUri)); return `$Typed<${refToType(ref, baseNsid, imports)}>`; } function refToType(ref, baseNsid, imports) { // TODO: import external types! let [refBase, refHash] = ref.split('#'); refBase = stripScheme(refBase); if (!refHash) refHash = 'main'; // internal if (!refBase || baseNsid === refBase) { return (0, util_1.toTitleCase)(refHash); } // external imports.add(refBase); return `${(0, util_1.toTitleCase)(refBase)}.${(0, util_1.toTitleCase)(refHash)}`; } function primitiveOrBlobToType(def) { switch (def.type) { case 'blob': return 'BlobRef'; case 'bytes': return 'Uint8Array'; case 'cid-link': return 'CID'; default: return primitiveToType(def); } } function primitiveToType(def) { switch (def.type) { case 'string': if (def.knownValues?.length) { return `${def.knownValues .map((v) => JSON.stringify(v)) .join(' | ')} | (string & {})`; } else if (def.enum) { return def.enum.map((v) => JSON.stringify(v)).join(' | '); } else if (def.const) { return JSON.stringify(def.const); } return 'string'; case 'integer': if (def.enum) { return def.enum.map((v) => JSON.stringify(v)).join(' | '); } else if (def.const) { return JSON.stringify(def.const); } return 'number'; case 'boolean': if (def.const) { return JSON.stringify(def.const); } return 'boolean'; case 'unknown': // @TODO Should we use "object" here ? // the "Record" identifier from typescript get overwritten by the Record // interface created by lex-cli. return '{ [_ in string]: unknown }'; // Record<string, unknown> default: throw new Error(`Unexpected primitive type: ${JSON.stringify(def)}`); } } function makeType(_types, opts) { const types = [].concat(_types); if (opts?.nullable) types.push('null'); const arr = opts?.array ? '[]' : ''; if (types.length === 1) return `(${types[0]})${arr}`; if (arr) return `(${types.join(' | ')})${arr}`; return types.join(' | '); } //# sourceMappingURL=lex-gen.js.map