a collection of lightweight TypeScript packages for AT Protocol, the protocol powering Bluesky
at trunk 958 lines 24 kB view raw
1import { dirname as getDirname, relative as getRelativePath } from 'node:path/posix'; 2 3import type { 4 LexDefinableField, 5 LexiconDoc, 6 LexObject, 7 LexRecord, 8 LexRefVariant, 9 LexUnknown, 10 LexUserType, 11 LexXrpcBody, 12 LexXrpcParameters, 13 LexXrpcProcedure, 14 LexXrpcQuery, 15 LexXrpcSubscription, 16} from '@atcute/lexicon-doc'; 17import { formatLexiconRef, parseLexiconRef, type ParsedLexiconRef } from '@atcute/lexicon-doc'; 18 19import * as prettier from 'prettier'; 20 21export interface SourceFile { 22 filename: string; 23 code: string; 24} 25 26export interface ImportMapping { 27 nsid: string[]; 28 imports: string | ((nsid: string) => { type: 'named' | 'namespace'; from: string }); 29} 30 31export interface LexiconApiOptions { 32 documents: LexiconDoc[]; 33 mappings: ImportMapping[]; 34 modules: { 35 importSuffix: string; 36 }; 37 prettier: { 38 cwd: string; 39 }; 40} 41 42export interface LexiconApiResult { 43 files: SourceFile[]; 44} 45 46type DocumentMap = Map<string, LexiconDoc>; 47type ImportSet = Set<string>; 48 49type Literal = string | number | boolean; 50 51const lit: (val: Literal | Literal[]) => string = JSON.stringify; 52 53const resolvePath = (from: ParsedLexiconRef, ref: string): ParsedLexiconRef => { 54 return parseLexiconRef(ref, from.nsid); 55}; 56 57const resolveExternalImport = (nsid: string, mappings: ImportMapping[]): ImportMapping | undefined => { 58 return mappings.find((mapping) => { 59 return mapping.nsid.some((pattern) => { 60 if (pattern.endsWith('.*')) { 61 return nsid.startsWith(pattern.slice(0, -1)); 62 } 63 64 return nsid === pattern; 65 }); 66 }); 67}; 68 69const PURE = `/*#__PURE__*/`; 70 71export const generateLexiconApi = async (opts: LexiconApiOptions): Promise<LexiconApiResult> => { 72 const importExt = opts.modules?.importSuffix; 73 74 const documents = opts.documents.toSorted((a, b) => { 75 if (a.id < b.id) { 76 return -1; 77 } 78 if (a.id > b.id) { 79 return 1; 80 } 81 82 return 0; 83 }); 84 85 const map: DocumentMap = new Map(documents.map((doc) => [doc.id, doc])); 86 const files: SourceFile[] = []; 87 const generatedIds = new Set<string>(); 88 89 for (const doc of documents) { 90 const filename = `types/${doc.id.replaceAll('.', '/')}.ts`; 91 const file = { 92 imports: '', 93 rawschemas: '', 94 schemadefs: '', 95 schemas: '', 96 interfaces: '', 97 sinterfaces: '', 98 exports: '', 99 ambients: '', 100 }; 101 102 file.imports += `import type {} from '@atcute/lexicons';\n`; 103 file.imports += `import * as v from '@atcute/lexicons/validations';\n`; 104 105 const imports = new Set<string>(); 106 107 const sortedDefIds = Object.keys(doc.defs).toSorted((a, b) => { 108 if (a < b) { 109 return -1; 110 } 111 if (a > b) { 112 return 1; 113 } 114 115 return 0; 116 }); 117 118 for (const defId of sortedDefIds) { 119 const def = doc.defs[defId]; 120 const path: ParsedLexiconRef = { nsid: doc.id, defId }; 121 122 const camelcased = toCamelCase(defId); 123 const varname = `${camelcased}Schema`; 124 125 let result: string; 126 switch (def.type) { 127 case 'query': { 128 result = generateXrpcQuery(imports, path, def); 129 130 file.imports += `import type {} from '@atcute/lexicons/ambient';\n`; 131 132 file.ambients += `declare module '@atcute/lexicons/ambient' {\n`; 133 file.ambients += ` interface XRPCQueries {\n`; 134 file.ambients += ` ${lit(formatLexiconRef(path))}: ${camelcased}Schema;\n`; 135 file.ambients += ` }\n`; 136 file.ambients += `}`; 137 break; 138 } 139 case 'procedure': { 140 result = generateXrpcProcedure(imports, path, def); 141 142 file.imports += `import type {} from '@atcute/lexicons/ambient';\n`; 143 144 file.ambients += `declare module '@atcute/lexicons/ambient' {\n`; 145 file.ambients += ` interface XRPCProcedures {\n`; 146 file.ambients += ` ${lit(formatLexiconRef(path))}: ${camelcased}Schema;\n`; 147 file.ambients += ` }\n`; 148 file.ambients += `}`; 149 break; 150 } 151 case 'subscription': { 152 result = generateXrpcSubscription(imports, path, def); 153 154 file.imports += `import type {} from '@atcute/lexicons/ambient';\n`; 155 156 file.ambients += `declare module '@atcute/lexicons/ambient' {\n`; 157 file.ambients += ` interface XRPCSubscriptions {\n`; 158 file.ambients += ` ${lit(formatLexiconRef(path))}: ${camelcased}Schema;\n`; 159 file.ambients += ` }\n`; 160 file.ambients += `}`; 161 break; 162 } 163 case 'object': { 164 result = generateObject(imports, path, def); 165 break; 166 } 167 case 'record': { 168 result = generateRecord(imports, path, def); 169 170 file.imports += `import type {} from '@atcute/lexicons/ambient';\n`; 171 172 file.ambients += `declare module '@atcute/lexicons/ambient' {\n`; 173 file.ambients += ` interface Records {\n`; 174 file.ambients += ` ${lit(formatLexiconRef(path))}: ${camelcased}Schema;\n`; 175 file.ambients += ` }\n`; 176 file.ambients += `}`; 177 break; 178 } 179 case 'token': { 180 result = `${PURE} v.literal(${lit(formatLexiconRef(path))})`; 181 break; 182 } 183 case 'permission-set': { 184 // skip permission sets 185 continue; 186 } 187 default: { 188 result = generateType(imports, path, def); 189 break; 190 } 191 } 192 193 file.rawschemas += `const _${varname} = ${result};\n`; 194 195 file.schemadefs += `type ${camelcased}$schematype = typeof _${varname};\n`; 196 197 file.schemas += `export interface ${camelcased}Schema extends ${camelcased}$schematype {}\n`; 198 199 file.exports += `export const ${varname} = _${varname} as ${camelcased}Schema;\n`; 200 201 switch (def.type) { 202 case 'query': { 203 if (def.parameters) { 204 file.sinterfaces += `export interface $params extends v.InferInput<${camelcased}Schema['params']> {}\n`; 205 } else { 206 file.sinterfaces += `export interface $params {}\n`; 207 } 208 209 if (def.output?.schema) { 210 if (def.output?.schema.type === 'object') { 211 file.sinterfaces += `export interface $output extends v.InferXRPCBodyInput<${camelcased}Schema['output']> {}\n`; 212 } else { 213 file.sinterfaces += `export type $output = v.InferXRPCBodyInput<${camelcased}Schema['output']>;\n`; 214 } 215 } else if (def.output) { 216 file.sinterfaces += `export type $output = v.InferXRPCBodyInput<${camelcased}Schema['output']>;\n`; 217 } 218 219 break; 220 } 221 case 'procedure': { 222 if (def.parameters) { 223 file.sinterfaces += `export interface $params extends v.InferInput<${camelcased}Schema['params']> {}\n`; 224 } else { 225 file.sinterfaces += `export interface $params {}\n`; 226 } 227 228 if (def.input?.schema) { 229 if (def.input?.schema.type === 'object') { 230 file.sinterfaces += `export interface $input extends v.InferXRPCBodyInput<${camelcased}Schema['input']> {}\n`; 231 } else { 232 file.sinterfaces += `export type $input = v.InferXRPCBodyInput<${camelcased}Schema['input']>;\n`; 233 } 234 } else if (def.input) { 235 file.sinterfaces += `export type $input = v.InferXRPCBodyInput<${camelcased}Schema['input']>;\n`; 236 } 237 238 if (def.output?.schema) { 239 if (def.output?.schema.type === 'object') { 240 file.sinterfaces += `export interface $output extends v.InferXRPCBodyInput<${camelcased}Schema['output']> {}\n`; 241 } else { 242 file.sinterfaces += `export type $output = v.InferXRPCBodyInput<${camelcased}Schema['output']>;\n`; 243 } 244 } else if (def.output) { 245 file.sinterfaces += `export type $output = v.InferXRPCBodyInput<${camelcased}Schema['output']>;\n`; 246 } 247 248 break; 249 } 250 case 'subscription': { 251 if (def.parameters) { 252 file.sinterfaces += `export interface $params extends v.InferInput<${camelcased}Schema['params']> {}\n`; 253 } else { 254 file.sinterfaces += `export interface $params {}\n`; 255 } 256 257 if (def.message?.schema) { 258 file.sinterfaces += `export type $message = v.InferInput<${camelcased}Schema['message']>;\n`; 259 } 260 261 break; 262 } 263 264 case 'array': 265 case 'object': 266 case 'record': 267 case 'unknown': { 268 file.interfaces += `export interface ${toTitleCase(defId)} extends v.InferInput<typeof ${varname}> {}\n`; 269 break; 270 } 271 case 'blob': 272 case 'boolean': 273 case 'bytes': 274 case 'cid-link': 275 case 'integer': 276 case 'string': 277 case 'token': { 278 file.interfaces += `export type ${toTitleCase(defId)} = v.InferInput<typeof ${varname}>;\n`; 279 break; 280 } 281 } 282 } 283 284 { 285 const dirname = getDirname(filename); 286 287 const sortedImports = [...imports].toSorted((a, b) => { 288 if (a < b) { 289 return -1; 290 } 291 if (a > b) { 292 return 1; 293 } 294 295 return 0; 296 }); 297 298 for (const ns of sortedImports) { 299 const local = map.get(ns); 300 301 if (local) { 302 const target = `types/${ns.replaceAll('.', '/')}${importExt}`; 303 304 let relative = getRelativePath(dirname, target); 305 if (!relative.startsWith('.')) { 306 relative = `./${relative}`; 307 } 308 309 file.imports += `import * as ${toTitleCase(ns)} from ${lit(relative)};\n`; 310 continue; 311 } 312 313 const external = resolveExternalImport(ns, opts.mappings); 314 315 if (external) { 316 if (typeof external.imports === 'function') { 317 const res = external.imports(ns); 318 319 if (res.type === 'named') { 320 file.imports += `import { ${toTitleCase(ns)} } from ${lit(res.from)};\n`; 321 } else if (res.type === 'namespace') { 322 file.imports += `import * as ${toTitleCase(ns)} from ${lit(res.from)};\n`; 323 } 324 } else { 325 file.imports += `import { ${toTitleCase(ns)} } from ${lit(external.imports)};\n`; 326 } 327 328 continue; 329 } 330 331 throw new Error(`'${doc.id}' referenced non-existent '${ns}' namespace`); 332 } 333 } 334 335 // skip files that only have imports and no actual content 336 if (file.exports) { 337 generatedIds.add(doc.id); 338 339 files.push({ 340 filename: filename, 341 code: 342 file.imports + 343 `\n\n` + 344 file.rawschemas + 345 `\n\n` + 346 file.schemadefs + 347 `\n\n` + 348 file.schemas + 349 `\n\n` + 350 file.exports + 351 `\n\n` + 352 file.interfaces + 353 `\n\n` + 354 file.sinterfaces + 355 `\n\n` + 356 file.ambients, 357 }); 358 } 359 } 360 361 { 362 let code = ``; 363 364 for (const doc of map.values()) { 365 if (!generatedIds.has(doc.id)) { 366 continue; 367 } 368 369 code += `export * as ${toTitleCase(doc.id)} from ${lit(`./types/${doc.id.replaceAll('.', '/')}${importExt}`)};\n`; 370 } 371 372 files.push({ 373 filename: 'index.ts', 374 code: code, 375 }); 376 } 377 378 if (opts.prettier) { 379 const config = await prettier.resolveConfig(opts.prettier.cwd, { editorconfig: true }); 380 381 for (const file of files) { 382 const formatted = await prettier.format(file.code, { ...config, parser: 'typescript' }); 383 file.code = formatted; 384 } 385 } 386 387 return { files }; 388}; 389 390const generateXrpcQuery = (imports: ImportSet, path: ParsedLexiconRef, spec: LexXrpcQuery): string => { 391 const params = generateXrpcParameters(imports, path, spec.parameters); 392 const output = generateXrpcBody(imports, path, spec.output); 393 394 return `${PURE} v.query(${lit(formatLexiconRef(path))}, {\n"params": ${params}, "output": ${output} })`; 395}; 396 397const generateXrpcProcedure = ( 398 imports: ImportSet, 399 path: ParsedLexiconRef, 400 spec: LexXrpcProcedure, 401): string => { 402 const params = generateXrpcParameters(imports, path, spec.parameters); 403 const input = generateXrpcBody(imports, path, spec.input); 404 const output = generateXrpcBody(imports, path, spec.output); 405 406 return `${PURE} v.procedure(${lit(formatLexiconRef(path))}, {\n"params": ${params}, "input": ${input}, "output": ${output} })`; 407}; 408 409const generateXrpcSubscription = ( 410 imports: ImportSet, 411 path: ParsedLexiconRef, 412 spec: LexXrpcSubscription, 413): string => { 414 const schema = spec.message?.schema; 415 416 const params = generateXrpcParameters(imports, path, spec.parameters); 417 418 let inner = ``; 419 420 inner += `"params": ${params},`; 421 422 if (schema) { 423 const res = generateType(imports, path, schema); 424 425 inner += `get "message" () { return ${res} },`; 426 } else { 427 inner += `"message": null,`; 428 } 429 430 return `${PURE} v.subscription(${lit(formatLexiconRef(path))}, {\n${inner}})`; 431}; 432 433const generateXrpcBody = ( 434 imports: ImportSet, 435 path: ParsedLexiconRef, 436 spec: LexXrpcBody | undefined, 437): string => { 438 if (spec === undefined) { 439 return `null`; 440 } 441 442 const schema = spec.schema; 443 const encoding = spec.encoding; 444 445 if (schema) { 446 let inner = ``; 447 448 inner += `"type": "lex",`; 449 450 if (schema.type === 'object') { 451 const res = generateObject(imports, path, schema, 'none'); 452 453 inner += `"schema": ${res},`; 454 } else { 455 const res = generateType(imports, path, schema); 456 457 inner += `get "schema" () { return ${res} },`; 458 } 459 460 return `{\n${inner}}`; 461 } 462 463 if (encoding) { 464 const types = encoding.split(',').map((type) => type.trim()); 465 466 let inner = ``; 467 468 inner += `"type": "blob",`; 469 470 if (types.length > 1 || types[0] !== '*/*') { 471 inner += `"encoding": ${lit(types)},`; 472 } 473 474 return `{\n${inner}}`; 475 } 476 477 return `null`; 478}; 479 480const generateXrpcParameters = ( 481 imports: ImportSet, 482 path: ParsedLexiconRef, 483 spec: LexXrpcParameters | undefined, 484): string => { 485 if (spec === undefined) { 486 return `null`; 487 } 488 489 const requiredProps = spec.required; 490 const originalProperties = spec.properties; 491 let transformedProperties: LexXrpcParameters['properties'] | undefined; 492 493 if (originalProperties) { 494 for (const [prop, propSpec] of Object.entries(originalProperties)) { 495 if (propSpec.type === 'array') { 496 if (!requiredProps?.includes(prop)) { 497 continue; 498 } 499 500 if (transformedProperties === undefined) { 501 transformedProperties = { ...originalProperties }; 502 } 503 504 transformedProperties[prop] = { 505 ...propSpec, 506 minLength: Math.max(propSpec.minLength ?? 0, 1), 507 }; 508 } 509 } 510 } 511 512 const mask: LexObject = { 513 type: 'object', 514 description: spec.description, 515 required: spec.required, 516 properties: transformedProperties ?? originalProperties, 517 }; 518 519 return generateObject(imports, path, mask, 'none'); 520}; 521 522const generateRecord = (imports: ImportSet, path: ParsedLexiconRef, spec: LexRecord): string => { 523 const schema = generateObject(imports, path, spec.record, 'required'); 524 525 let key = `${PURE} v.string()`; 526 if (spec.key) { 527 if (spec.key === 'tid') { 528 key = `${PURE} v.tidString()`; 529 } else if (spec.key === 'nsid') { 530 key = `${PURE} v.nsidString()`; 531 } else if (spec.key.startsWith('literal:')) { 532 key = `${PURE} v.literal(${lit(spec.key.slice('literal:'.length))})`; 533 } 534 } 535 536 return `${PURE} v.record(${key}, ${schema})`; 537}; 538 539const generateObject = ( 540 imports: ImportSet, 541 path: ParsedLexiconRef, 542 spec: LexObject, 543 writeType: 'required' | 'optional' | 'none' = 'optional', 544): string => { 545 const required = new Set(spec.required); 546 const nullable = new Set(spec.nullable); 547 548 let inner = ``; 549 550 switch (writeType) { 551 case 'optional': { 552 inner += `"$type": ${PURE} v.optional(${PURE} v.literal(${lit(formatLexiconRef(path))})),`; 553 break; 554 } 555 case 'required': { 556 inner += `"$type": ${PURE} v.literal(${lit(formatLexiconRef(path))}),`; 557 break; 558 } 559 } 560 561 const sortedEntries = Object.entries(spec.properties ?? {}).toSorted(([keyA], [keyB]) => { 562 if (keyA < keyB) { 563 return -1; 564 } 565 if (keyA > keyB) { 566 return 1; 567 } 568 569 return 0; 570 }); 571 572 for (const [prop, propSpec] of sortedEntries) { 573 const lazy = isRefVariant(propSpec.type === 'array' ? propSpec.items : propSpec); 574 const optional = !required.has(prop) && !('default' in propSpec && propSpec.default !== undefined); 575 const nulled = nullable.has(prop); 576 577 let call = generateType(imports, path, propSpec, lazy); 578 579 if (nulled) { 580 call = `${PURE} v.nullable(${call})`; 581 } 582 583 if (optional) { 584 call = `${PURE} v.optional(${call})`; 585 } 586 587 const jsdoc = generateJsdocField(propSpec); 588 if (jsdoc.length !== 0) { 589 inner += `\n${jsdoc}\n`; 590 } 591 592 if (lazy) { 593 inner += `get ${lit(prop)} () { return ${call} },`; 594 } else { 595 inner += `${lit(prop)}: ${call},`; 596 } 597 } 598 599 return `${PURE} v.object({\n${inner}})`; 600}; 601 602const IS_DEPRECATED_PREFIX_RE = /^\s*(?:\(deprecated\)|deprecated[.:;])/i; 603const IS_DEPRECATED_SUFFIX_RE = /\b(?:deprecated(?::[^]+)?)\s*$/i; 604 605const generateJsdocField = (spec: LexUserType | LexRefVariant | LexUnknown) => { 606 const lines: string[] = []; 607 608 if ('description' in spec && spec.description) { 609 let desc = spec.description 610 .replace(/\*\//g, '*\\/') 611 .replace(/@/g, '\\@') 612 .replace(/\r?\n/g, ' ') 613 .replace(/\s+/g, ' ') 614 .trim(); 615 616 if (desc) { 617 lines.push(desc); 618 } 619 620 if (IS_DEPRECATED_PREFIX_RE.test(desc) || IS_DEPRECATED_SUFFIX_RE.test(desc)) { 621 lines.push(`@deprecated`); 622 } 623 } 624 625 // Add annotations based on property spec type 626 switch (spec.type) { 627 case 'boolean': { 628 if (spec.default !== undefined) { 629 lines.push(`@default ${lit(spec.default)}`); 630 } 631 break; 632 } 633 case 'string': { 634 if (spec.minLength !== undefined) { 635 lines.push(`@minLength ${spec.minLength}`); 636 } 637 if (spec.maxLength !== undefined) { 638 lines.push(`@maxLength ${spec.maxLength}`); 639 } 640 if (spec.minGraphemes !== undefined) { 641 lines.push(`@minGraphemes ${spec.minGraphemes}`); 642 } 643 if (spec.maxGraphemes !== undefined) { 644 lines.push(`@maxGraphemes ${spec.maxGraphemes}`); 645 } 646 if (spec.default !== undefined) { 647 lines.push(`@default ${lit(spec.default)}`); 648 } 649 break; 650 } 651 case 'integer': { 652 if (spec.minimum !== undefined) { 653 lines.push(`@minimum ${spec.minimum}`); 654 } 655 if (spec.maximum !== undefined) { 656 lines.push(`@maximum ${spec.maximum}`); 657 } 658 if (spec.default !== undefined) { 659 lines.push(`@default ${lit(spec.default)}`); 660 } 661 break; 662 } 663 case 'bytes': { 664 if (spec.minLength !== undefined) { 665 lines.push(`@minLength ${spec.minLength}`); 666 } 667 if (spec.maxLength !== undefined) { 668 lines.push(`@maxLength ${spec.maxLength}`); 669 } 670 break; 671 } 672 case 'array': { 673 if (spec.minLength !== undefined) { 674 lines.push(`@minLength ${spec.minLength}`); 675 } 676 if (spec.maxLength !== undefined) { 677 lines.push(`@maxLength ${spec.maxLength}`); 678 } 679 break; 680 } 681 case 'blob': { 682 if (spec.accept) { 683 const accept = spec.accept.map((mime) => mime.replace(/\*\//g, '*\\/')).join(', '); 684 lines.push(`@accept ${accept}`); 685 } 686 if (spec.maxSize !== undefined) { 687 lines.push(`@maxSize ${spec.maxSize}`); 688 } 689 break; 690 } 691 } 692 693 let res = ``; 694 if (lines.length > 0) { 695 res += `/**\n`; 696 697 for (let idx = 0, len = lines.length; idx < len; idx++) { 698 const line = lines[idx]; 699 res += ` * ${line}\n`; 700 } 701 702 res += `*/`; 703 } 704 705 return res; 706}; 707 708const generateType = ( 709 imports: ImportSet, 710 path: ParsedLexiconRef, 711 spec: LexDefinableField, 712 lazy = false, 713): string => { 714 switch (spec.type) { 715 // LexRefVariant 716 case 'ref': { 717 const refPath = resolvePath(path, spec.ref); 718 719 if (refPath.nsid === path.nsid) { 720 return `${toCamelCase(refPath.defId)}Schema`; 721 } 722 723 imports.add(refPath.nsid); 724 return `${toTitleCase(refPath.nsid)}.${toCamelCase(refPath.defId)}Schema`; 725 } 726 case 'union': { 727 const refs = spec.refs 728 .map((ref) => { 729 const refPath = resolvePath(path, ref); 730 return { path: refPath, uri: formatLexiconRef(refPath) }; 731 }) 732 // oxlint-disable-next-line unicorn/no-array-sort -- map already clones 733 .sort((a, b) => { 734 if (a.uri < b.uri) { 735 return -1; 736 } 737 if (a.uri > b.uri) { 738 return 1; 739 } 740 741 return 0; 742 }) 743 .map(({ path: refPath }): string => { 744 if (refPath.nsid === path.nsid) { 745 return `${toCamelCase(refPath.defId)}Schema`; 746 } 747 748 imports.add(refPath.nsid); 749 return `${toTitleCase(refPath.nsid)}.${toCamelCase(refPath.defId)}Schema`; 750 }); 751 752 return `${PURE} v.variant([${refs.join(', ')}]${spec.closed ? `, true` : ``})`; 753 } 754 755 // LexArray 756 case 'array': { 757 let item = generateType(imports, path, spec.items); 758 if (!lazy && (spec.items.type === 'ref' || spec.items.type === 'union')) { 759 item = `(() => { return ${item}; })`; 760 } 761 762 let pipe: string[] = []; 763 764 if ((spec.minLength ?? 0) > 0 || spec.maxLength !== undefined) { 765 if (spec.maxLength === undefined) { 766 pipe.push(`${PURE} v.arrayLength(${lit(spec.minLength ?? 0)})`); 767 } else { 768 pipe.push(`${PURE} v.arrayLength(${lit(spec.minLength ?? 0)}, ${lit(spec.maxLength)})`); 769 } 770 } 771 772 let call = `${PURE} v.array(${item})`; 773 774 if (pipe.length !== 0) { 775 call = `${PURE} v.constrain(${call}, [ ${pipe.join(', ')} ])`; 776 } 777 778 return call; 779 } 780 781 // LexPrimitive 782 case 'boolean': { 783 let call = `${PURE} v.boolean()`; 784 785 if (spec.const !== undefined) { 786 call = `${PURE} v.literal(${spec.const})`; 787 } 788 789 if (spec.default !== undefined) { 790 call = `${PURE} v.optional(${call}, ${lit(spec.default)})`; 791 } 792 793 return call; 794 } 795 case 'integer': { 796 let pipe: string[] = []; 797 798 if ((spec.minimum ?? 0) > 0 || spec.maximum !== undefined) { 799 if (spec.maximum === undefined) { 800 pipe.push(`${PURE} v.integerRange(${lit(spec.minimum ?? 0)})`); 801 } else { 802 pipe.push(`${PURE} v.integerRange(${lit(spec.minimum ?? 0)}, ${lit(spec.maximum)})`); 803 } 804 } 805 806 let call = `${PURE} v.integer()`; 807 808 if (spec.const !== undefined) { 809 call = `${PURE} v.literal(${lit(spec.const)})`; 810 } else if (spec.enum !== undefined) { 811 call = `${PURE} v.literalEnum(${lit(spec.enum.toSorted())})`; 812 } else if (pipe.length !== 0) { 813 call = `${PURE} v.constrain(${call}, [ ${pipe.join(', ')} ])`; 814 } 815 816 if (spec.default !== undefined) { 817 call = `${PURE} v.optional(${call}, ${lit(spec.default)})`; 818 } 819 820 return call; 821 } 822 case 'string': { 823 let pipe: string[] = []; 824 825 if ((spec.minLength ?? 0) > 0 || spec.maxLength !== undefined) { 826 if (spec.maxLength === undefined) { 827 pipe.push(`${PURE} v.stringLength(${lit(spec.minLength ?? 0)})`); 828 } else { 829 pipe.push(`${PURE} v.stringLength(${lit(spec.minLength ?? 0)}, ${lit(spec.maxLength)})`); 830 } 831 } 832 833 if ((spec.minGraphemes ?? 0) > 0 || spec.maxGraphemes !== undefined) { 834 if (spec.maxGraphemes === undefined) { 835 pipe.push(`${PURE} v.stringGraphemes(${lit(spec.minGraphemes ?? 0)})`); 836 } else { 837 pipe.push(`${PURE} v.stringGraphemes(${lit(spec.minGraphemes ?? 0)}, ${lit(spec.maxGraphemes)})`); 838 } 839 } 840 841 let call = `${PURE} v.string()`; 842 843 if (spec.knownValues?.length) { 844 call = `${PURE} v.string<${spec.knownValues.toSorted().map(lit).join(' | ')} | (string & {})>()`; 845 } 846 847 switch (spec.format) { 848 case 'at-identifier': { 849 call = `${PURE} v.actorIdentifierString()`; 850 break; 851 } 852 case 'at-uri': { 853 call = `${PURE} v.resourceUriString()`; 854 break; 855 } 856 case 'cid': { 857 call = `${PURE} v.cidString()`; 858 break; 859 } 860 case 'datetime': { 861 call = `${PURE} v.datetimeString()`; 862 break; 863 } 864 case 'did': { 865 call = `${PURE} v.didString()`; 866 break; 867 } 868 case 'handle': { 869 call = `${PURE} v.handleString()`; 870 break; 871 } 872 case 'language': { 873 call = `${PURE} v.languageCodeString()`; 874 break; 875 } 876 case 'nsid': { 877 call = `${PURE} v.nsidString()`; 878 break; 879 } 880 case 'record-key': { 881 call = `${PURE} v.recordKeyString()`; 882 break; 883 } 884 case 'tid': { 885 call = `${PURE} v.tidString()`; 886 break; 887 } 888 case 'uri': { 889 call = `${PURE} v.genericUriString()`; 890 break; 891 } 892 } 893 894 if (spec.const !== undefined) { 895 call = `${PURE} v.literal(${lit(spec.const)})`; 896 } else if (spec.enum !== undefined) { 897 call = `${PURE} v.literalEnum(${lit(spec.enum.toSorted())})`; 898 } else if (pipe.length !== 0) { 899 call = `${PURE} v.constrain(${call}, [ ${pipe.join(', ')} ])`; 900 } 901 902 if (spec.default !== undefined) { 903 call = `${PURE} v.optional(${call}, ${lit(spec.default)})`; 904 } 905 906 return call; 907 } 908 case 'unknown': { 909 return `${PURE} v.unknown()`; 910 } 911 912 // LexBlob 913 case 'blob': { 914 return `${PURE} v.blob()`; 915 } 916 917 // LexIpldType 918 case 'bytes': { 919 let pipe: string[] = []; 920 921 if ((spec.minLength ?? 0) > 0 || spec.maxLength !== undefined) { 922 if (spec.maxLength === undefined) { 923 pipe.push(`${PURE} v.bytesSize(${lit(spec.minLength ?? 0)})`); 924 } else { 925 pipe.push(`${PURE} v.bytesSize(${lit(spec.minLength ?? 0)}, ${lit(spec.maxLength)})`); 926 } 927 } 928 929 let call = `${PURE} v.bytes()`; 930 931 if (pipe.length !== 0) { 932 call = `${PURE} v.constrain(${call}, [ ${pipe.join(', ')} ])`; 933 } 934 935 return call; 936 } 937 case 'cid-link': { 938 return `${PURE} v.cidLink()`; 939 } 940 } 941}; 942 943const isRefVariant = (spec: LexDefinableField): spec is LexRefVariant => { 944 const type = spec.type; 945 return type === 'ref' || type === 'union'; 946}; 947 948const toTitleCase = (v: string): string => { 949 v = v.replace(/^([a-z])/gi, (_, g) => g.toUpperCase()); 950 v = v.replace(/[.#-]([a-z])/gi, (_, g) => g.toUpperCase()); 951 return v.replace(/[.-]/g, ''); 952}; 953 954const toCamelCase = (v: string): string => { 955 v = v.replace(/^([A-Z])/gi, (_, g) => g.toLowerCase()); 956 v = v.replace(/[.#-]([a-z])/gi, (_, g) => g.toUpperCase()); 957 return v.replace(/[.-]/g, ''); 958};