Highly ambitious ATProtocol AppView service and sdks
at main 725 lines 22 kB view raw
1import type { SourceFile } from "ts-morph"; 2import type { Lexicon, LexiconDefinition, LexiconProperty } from "./mod.ts"; 3import { 4 nsidToPascalCase, 5 nsidToNamespace, 6 defNameToPascalCase, 7 isPropertyRequired, 8 isFieldSortable, 9} from "./mod.ts"; 10 11// Convert lexicon type to TypeScript type 12function convertLexiconTypeToTypeScript( 13 def: LexiconDefinition | LexiconProperty, 14 currentLexicon: string, 15 propertyName?: string, 16 lexicons?: Lexicon[] 17): string { 18 const type = def.type; 19 switch (type) { 20 case "string": 21 // For knownValues, return the type alias name 22 if ( 23 def.knownValues && 24 Array.isArray(def.knownValues) && 25 def.knownValues.length > 0 && 26 propertyName 27 ) { 28 // Reference the generated type alias with namespace 29 const namespace = nsidToNamespace(currentLexicon); 30 return `${namespace}${defNameToPascalCase(propertyName)}`; 31 } 32 return "string"; 33 case "integer": 34 return "number"; 35 case "boolean": 36 return "boolean"; 37 case "bytes": 38 return "string"; // Base64 encoded 39 case "cid-link": 40 return "{ $link: string }"; 41 case "blob": 42 return "BlobRef"; 43 case "unknown": 44 return "unknown"; 45 case "ref": 46 return resolveTypeReference(def.ref!, currentLexicon, lexicons); 47 case "array": 48 if (def.items) { 49 const itemType = convertLexiconTypeToTypeScript( 50 def.items, 51 currentLexicon, 52 undefined, 53 lexicons 54 ); 55 return `${itemType}[]`; 56 } 57 return "any[]"; 58 case "union": 59 if (def.refs && def.refs.length > 0) { 60 const unionTypes = def.refs.map((ref: string) => 61 resolveTypeReference(ref, currentLexicon, lexicons) 62 ); 63 // Add unknown type for open unions 64 if (!def.closed) { 65 unionTypes.push("{ $type: string; [key: string]: unknown }"); 66 } 67 return unionTypes.join(" | "); 68 } 69 return "unknown"; 70 case "object": 71 return "Record<string, unknown>"; 72 default: 73 return "unknown"; 74 } 75} 76 77// Resolve lexicon type references 78function resolveTypeReference( 79 ref: string, 80 currentLexicon: string, 81 lexicons?: Lexicon[] 82): string { 83 if (ref.startsWith("#")) { 84 // Local reference: #aspectRatio -> CurrentLexiconAspectRatio 85 const defName = ref.slice(1); 86 const namespace = nsidToNamespace(currentLexicon); 87 return `${namespace}["${defNameToPascalCase(defName)}"]`; 88 } else if (ref.includes("#")) { 89 // Cross-lexicon reference: app.bsky.embed.defs#aspectRatio -> AppBskyEmbedDefs["AspectRatio"] 90 const [nsid, defName] = ref.split("#"); 91 92 if (defName === "main") { 93 // Find the lexicon and check if it has multiple definitions 94 const lexicon = lexicons?.find((lex) => lex.id === nsid); 95 if (lexicon && lexicon.definitions) { 96 const defCount = Object.keys(lexicon.definitions).length; 97 const mainDef = lexicon.definitions.main; 98 99 if (defCount === 1 && mainDef) { 100 // Single definition - use clean name 101 if (mainDef.type === "record") { 102 // For records: AppBskyActorProfile 103 return nsidToPascalCase(nsid); 104 } else { 105 // For objects: ComAtprotoRepoStrongRef 106 return nsidToNamespace(nsid); 107 } 108 } else { 109 // Multiple definitions - use namespace pattern 110 const namespace = nsidToNamespace(nsid); 111 return `${namespace}["Main"]`; 112 } 113 } 114 // Fallback 115 return nsidToNamespace(nsid); 116 } 117 118 const namespace = nsidToNamespace(nsid); 119 return `${namespace}["${defNameToPascalCase(defName)}"]`; 120 } else { 121 // Direct lexicon reference: check if single or multiple definitions 122 const lexicon = lexicons?.find((lex) => lex.id === ref); 123 if (lexicon && lexicon.definitions) { 124 const defCount = Object.keys(lexicon.definitions).length; 125 const mainDef = lexicon.definitions.main; 126 127 if (defCount === 1 && mainDef) { 128 // Single definition - use clean name 129 if (mainDef.type === "record") { 130 // For records: AppBskyActorProfile 131 return nsidToPascalCase(ref); 132 } else { 133 // For objects: ComAtprotoRepoStrongRef 134 return nsidToNamespace(ref); 135 } 136 } else if (mainDef) { 137 // Multiple definitions - use namespace pattern 138 const namespace = nsidToNamespace(ref); 139 return `${namespace}["Main"]`; 140 } 141 } 142 // Fallback 143 return nsidToNamespace(ref); 144 } 145} 146 147// Generate interface for object definitions 148function generateObjectInterface( 149 sourceFile: SourceFile, 150 lexicon: Lexicon, 151 defKey: string, 152 defValue: LexiconDefinition, 153 lexicons: Lexicon[] 154): void { 155 const namespace = nsidToNamespace(lexicon.id); 156 157 // For single-definition lexicons with main, use clean name 158 const defCount = Object.keys(lexicon.definitions || {}).length; 159 const interfaceName = 160 defKey === "main" && defCount === 1 161 ? namespace // Clean name: ComAtprotoRepoStrongRef 162 : `${namespace}${defNameToPascalCase(defKey)}`; // Multi-def: AppBskyRichtextFacetMention 163 164 const properties: Array<{ 165 name: string; 166 type: string; 167 hasQuestionToken: boolean; 168 docs?: string[]; 169 }> = []; 170 171 if (defValue.properties) { 172 for (const [propName, propDef] of Object.entries(defValue.properties)) { 173 const tsType = convertLexiconTypeToTypeScript( 174 propDef, 175 lexicon.id, 176 propName, 177 lexicons 178 ); 179 const required = 180 defValue.required && defValue.required.includes(propName); 181 182 properties.push({ 183 name: propName, 184 type: tsType, 185 hasQuestionToken: !required, 186 docs: (propDef as LexiconProperty).description 187 ? [(propDef as LexiconProperty).description!] 188 : undefined, 189 }); 190 } 191 } 192 193 if (properties.length === 0) { 194 sourceFile.addTypeAlias({ 195 name: interfaceName, 196 isExported: true, 197 type: "Record<string, never>", 198 }); 199 } else { 200 sourceFile.addInterface({ 201 name: interfaceName, 202 isExported: true, 203 properties: properties, 204 }); 205 } 206} 207 208// Generate interface for record definitions 209function generateRecordInterface( 210 sourceFile: SourceFile, 211 lexicon: Lexicon, 212 defKey: string, 213 defValue: LexiconDefinition, 214 lexicons: Lexicon[] 215): void { 216 const recordDef = defValue.record; 217 if (!recordDef) return; 218 219 const properties: Array<{ 220 name: string; 221 type: string; 222 hasQuestionToken: boolean; 223 docs?: string[]; 224 }> = []; 225 const fieldNames: string[] = []; 226 227 if (recordDef.properties) { 228 for (const [propName, propDef] of Object.entries(recordDef.properties)) { 229 const tsType = convertLexiconTypeToTypeScript( 230 propDef, 231 lexicon.id, 232 propName, 233 lexicons 234 ); 235 const required = isPropertyRequired(recordDef, propName); 236 237 properties.push({ 238 name: propName, 239 type: tsType, 240 hasQuestionToken: !required, 241 docs: (propDef as LexiconProperty).description 242 ? [(propDef as LexiconProperty).description!] 243 : undefined, 244 }); 245 246 // Collect sortable field names for sort type 247 if (isFieldSortable(propDef)) { 248 fieldNames.push(propName); 249 } 250 } 251 } 252 253 // For main records, use clean naming without Record suffix 254 const interfaceName = 255 defKey === "main" 256 ? nsidToPascalCase(lexicon.id) 257 : `${nsidToNamespace(lexicon.id)}${defNameToPascalCase(defKey)}`; 258 259 if (properties.length === 0) { 260 sourceFile.addTypeAlias({ 261 name: interfaceName, 262 isExported: true, 263 type: "Record<string, never>", 264 }); 265 } else { 266 sourceFile.addInterface({ 267 name: interfaceName, 268 isExported: true, 269 properties: properties, 270 }); 271 } 272 273 // Generate sort fields type union for records (only if there are sortable fields) 274 if (fieldNames.length > 0) { 275 sourceFile.addTypeAlias({ 276 name: `${interfaceName}SortFields`, 277 isExported: true, 278 type: fieldNames.map((f) => `"${f}"`).join(" | "), 279 }); 280 } 281} 282 283// Generate type alias for union definitions 284function generateUnionType( 285 sourceFile: SourceFile, 286 lexicon: Lexicon, 287 defKey: string, 288 defValue: LexiconDefinition, 289 lexicons: Lexicon[] 290): void { 291 const namespace = nsidToNamespace(lexicon.id); 292 const typeName = `${namespace}${defNameToPascalCase(defKey)}`; 293 294 if (defValue.refs && defValue.refs.length > 0) { 295 const unionTypes = defValue.refs.map((ref: string) => 296 resolveTypeReference(ref, lexicon.id, lexicons) 297 ); 298 299 // Add unknown type for open unions 300 if (!defValue.closed) { 301 unionTypes.push("{ $type: string; [key: string]: unknown }"); 302 } 303 304 sourceFile.addTypeAlias({ 305 name: typeName, 306 isExported: true, 307 type: unionTypes.join(" | "), 308 }); 309 } 310} 311 312// Generate type alias for array definitions 313function generateArrayType( 314 sourceFile: SourceFile, 315 lexicon: Lexicon, 316 defKey: string, 317 defValue: LexiconDefinition, 318 lexicons: Lexicon[] 319): void { 320 const namespace = nsidToNamespace(lexicon.id); 321 const typeName = `${namespace}${defNameToPascalCase(defKey)}`; 322 323 if (defValue.items) { 324 const itemType = convertLexiconTypeToTypeScript( 325 defValue.items, 326 lexicon.id, 327 undefined, 328 lexicons 329 ); 330 sourceFile.addTypeAlias({ 331 name: typeName, 332 isExported: true, 333 type: `${itemType}[]`, 334 }); 335 } 336} 337 338// Generate string literal type for token definitions 339function generateTokenType( 340 sourceFile: SourceFile, 341 lexicon: Lexicon, 342 defKey: string 343): void { 344 const namespace = nsidToNamespace(lexicon.id); 345 const typeName = `${namespace}${defNameToPascalCase(defKey)}`; 346 347 sourceFile.addTypeAlias({ 348 name: typeName, 349 isExported: true, 350 type: `"${lexicon.id}${defKey === "main" ? "" : `#${defKey}`}"`, 351 }); 352} 353 354// Generate type aliases for string fields with knownValues 355function generateKnownValuesTypes( 356 sourceFile: SourceFile, 357 lexicons: Lexicon[] 358): void { 359 for (const lexicon of lexicons) { 360 if (lexicon.definitions && typeof lexicon.definitions === "object") { 361 for (const [defKey, defValue] of Object.entries(lexicon.definitions)) { 362 if (defValue.type === "record" && defValue.record?.properties) { 363 for (const [propName, propDef] of Object.entries( 364 defValue.record.properties 365 )) { 366 const prop = propDef as LexiconProperty; 367 if ( 368 prop.type === "string" && 369 prop.knownValues && 370 Array.isArray(prop.knownValues) && 371 prop.knownValues.length > 0 372 ) { 373 // Generate a type alias for this property, namespaced by lexicon 374 const namespace = nsidToNamespace(lexicon.id); 375 const pascalPropName = defNameToPascalCase(propName); 376 const typeName = `${namespace}${pascalPropName}`; 377 378 const knownValueTypes = prop.knownValues 379 .map((value: string) => `'${value}'`) 380 .join("\n | "); 381 const typeDefinition = `${knownValueTypes}\n | (string & Record<string, never>)`; 382 383 sourceFile.addTypeAlias({ 384 name: typeName, 385 isExported: true, 386 type: typeDefinition, 387 leadingTrivia: "\n", 388 }); 389 } 390 } 391 } else if (defValue.type === "object" && defValue.properties) { 392 for (const [propName, propDef] of Object.entries( 393 defValue.properties 394 )) { 395 const prop = propDef as LexiconProperty; 396 if ( 397 prop.type === "string" && 398 prop.knownValues && 399 Array.isArray(prop.knownValues) && 400 prop.knownValues.length > 0 401 ) { 402 // Generate a type alias for this property, namespaced by lexicon 403 const namespace = nsidToNamespace(lexicon.id); 404 const pascalPropName = defNameToPascalCase(propName); 405 const typeName = `${namespace}${pascalPropName}`; 406 407 const knownValueTypes = prop.knownValues 408 .map((value: string) => `'${value}'`) 409 .join("\n | "); 410 const typeDefinition = `${knownValueTypes}\n | (string & Record<string, never>)`; 411 412 sourceFile.addTypeAlias({ 413 name: typeName, 414 isExported: true, 415 type: typeDefinition, 416 leadingTrivia: "\n", 417 }); 418 } 419 } 420 } else if (defValue.type === "string") { 421 // Handle standalone string definitions with knownValues (like labelValue) 422 const stringDef = defValue as LexiconDefinition; 423 if ( 424 stringDef.knownValues && 425 Array.isArray(stringDef.knownValues) && 426 stringDef.knownValues.length > 0 427 ) { 428 // Generate a type alias for this definition, namespaced by lexicon 429 const namespace = nsidToNamespace(lexicon.id); 430 const typeName = `${namespace}${defNameToPascalCase(defKey)}`; 431 432 const knownValueTypes = stringDef.knownValues 433 .map((value: string) => `'${value}'`) 434 .join("\n | "); 435 const typeDefinition = `${knownValueTypes}\n | (string & Record<string, never>)`; 436 437 sourceFile.addTypeAlias({ 438 name: typeName, 439 isExported: true, 440 type: typeDefinition, 441 leadingTrivia: "\n", 442 }); 443 } 444 } 445 } 446 } 447 } 448} 449 450// Generate namespace interfaces for lexicons with multiple definitions 451function addLexiconNamespaces( 452 sourceFile: SourceFile, 453 lexicons: Lexicon[] 454): void { 455 for (const lexicon of lexicons) { 456 if (!lexicon.definitions || typeof lexicon.definitions !== "object") { 457 continue; 458 } 459 460 const definitions = Object.keys(lexicon.definitions); 461 462 // Skip lexicons with only a main definition (they get traditional Record interfaces) 463 if (definitions.length === 1 && definitions[0] === "main") { 464 continue; 465 } 466 467 const namespace = nsidToNamespace(lexicon.id); 468 const namespaceProperties: Array<{ 469 name: string; 470 type: string; 471 isReadonly: boolean; 472 }> = []; 473 474 // Add properties for each definition 475 for (const [defKey, defValue] of Object.entries(lexicon.definitions)) { 476 const defName = defNameToPascalCase(defKey); 477 478 switch (defValue.type) { 479 case "object": { 480 namespaceProperties.push({ 481 name: defName, 482 type: `${namespace}${defName}`, 483 isReadonly: true, 484 }); 485 break; 486 } 487 case "string": { 488 // Check if this is a string type with knownValues 489 const stringDef = defValue as LexiconDefinition; 490 if ( 491 stringDef.knownValues && 492 Array.isArray(stringDef.knownValues) && 493 stringDef.knownValues.length > 0 494 ) { 495 // This generates a type alias, reference it in the namespace with full name 496 namespaceProperties.push({ 497 name: defName, 498 type: `${namespace}${defNameToPascalCase(defKey)}`, 499 isReadonly: true, 500 }); 501 } 502 break; 503 } 504 case "union": 505 case "array": 506 case "token": 507 // These generate type aliases, so we reference the type itself 508 namespaceProperties.push({ 509 name: defName, 510 type: `${namespace}${defName}`, 511 isReadonly: true, 512 }); 513 break; 514 case "record": 515 // Records get their own interfaces, reference them 516 if (defKey === "main") { 517 namespaceProperties.push({ 518 name: "Main", 519 type: nsidToPascalCase(lexicon.id), 520 isReadonly: true, 521 }); 522 } else { 523 namespaceProperties.push({ 524 name: defName, 525 type: `${namespace}${defName}`, 526 isReadonly: true, 527 }); 528 } 529 break; 530 } 531 } 532 533 // Only create namespace if we have properties 534 if (namespaceProperties.length > 0) { 535 sourceFile.addInterface({ 536 name: namespace, 537 isExported: true, 538 properties: namespaceProperties.map((prop) => ({ 539 name: prop.name, 540 type: prop.type, 541 isReadonly: prop.isReadonly, 542 })), 543 }); 544 } 545 } 546} 547 548// Base interfaces are imported from @slices/client, only add network.slices specific interfaces 549function addBaseInterfaces( 550 _sourceFile: SourceFile 551): void { 552 // All interfaces are now generated from lexicons 553 // This function is kept for future extensibility if needed 554} 555 556// Generate interfaces for query and procedure parameters/input/output 557function generateQueryProcedureInterfaces( 558 sourceFile: SourceFile, 559 lexicon: Lexicon, 560 defValue: LexiconDefinition, 561 lexicons: Lexicon[], 562 type: "query" | "procedure" 563): void { 564 const baseName = nsidToPascalCase(lexicon.id); 565 566 // Generate parameters interface if present and has properties 567 if (defValue.parameters?.properties && Object.keys(defValue.parameters.properties).length > 0) { 568 const interfaceName = `${baseName}Params`; 569 const properties = Object.entries(defValue.parameters.properties).map( 570 ([propName, propDef]) => ({ 571 name: propName, 572 type: convertLexiconTypeToTypeScript( 573 propDef, 574 lexicon.id, 575 propName, 576 lexicons 577 ), 578 hasQuestionToken: !(defValue.parameters?.required || []).includes(propName), 579 }) 580 ); 581 582 sourceFile.addInterface({ 583 name: interfaceName, 584 isExported: true, 585 properties, 586 }); 587 } 588 589 // Generate input interface for procedures 590 if (type === "procedure" && defValue.input?.schema) { 591 const interfaceName = `${baseName}Input`; 592 593 if (defValue.input?.schema?.type === "object" && defValue.input.schema.properties) { 594 const properties = Object.entries(defValue.input.schema.properties).map( 595 ([propName, propDef]) => ({ 596 name: propName, 597 type: convertLexiconTypeToTypeScript( 598 propDef, 599 lexicon.id, 600 propName, 601 lexicons 602 ), 603 hasQuestionToken: !((defValue.input?.schema?.required as string[]) || []).includes(propName), 604 }) 605 ); 606 607 sourceFile.addInterface({ 608 name: interfaceName, 609 isExported: true, 610 properties, 611 }); 612 } 613 } 614 615 // Generate output interface if present 616 if (defValue.output?.schema) { 617 const interfaceName = `${baseName}Output`; 618 619 if (defValue.output?.schema?.type === "object" && defValue.output.schema.properties) { 620 const properties = Object.entries(defValue.output.schema.properties).map( 621 ([propName, propDef]) => ({ 622 name: propName, 623 type: convertLexiconTypeToTypeScript( 624 propDef, 625 lexicon.id, 626 propName, 627 lexicons 628 ), 629 hasQuestionToken: !((defValue.output?.schema?.required as string[]) || []).includes(propName), 630 }) 631 ); 632 633 sourceFile.addInterface({ 634 name: interfaceName, 635 isExported: true, 636 properties, 637 }); 638 } else { 639 // Handle non-object output schemas (like refs) by creating a type alias 640 const outputType = convertLexiconTypeToTypeScript( 641 defValue.output.schema, 642 lexicon.id, 643 undefined, 644 lexicons 645 ); 646 647 sourceFile.addTypeAlias({ 648 name: interfaceName, 649 isExported: true, 650 type: outputType, 651 }); 652 } 653 } 654} 655 656export function generateInterfaces( 657 sourceFile: SourceFile, 658 lexicons: Lexicon[] 659): void { 660 // Base interfaces are imported from @slices/client, only add custom interfaces 661 addBaseInterfaces(sourceFile); 662 663 // Generate type aliases for string fields with knownValues 664 generateKnownValuesTypes(sourceFile, lexicons); 665 666 // First pass: Generate all individual definition interfaces/types 667 for (const lexicon of lexicons) { 668 if (lexicon.definitions && typeof lexicon.definitions === "object") { 669 for (const [defKey, defValue] of Object.entries(lexicon.definitions)) { 670 switch (defValue.type) { 671 case "record": 672 if (defValue.record) { 673 generateRecordInterface( 674 sourceFile, 675 lexicon, 676 defKey, 677 defValue, 678 lexicons 679 ); 680 } 681 break; 682 case "object": 683 generateObjectInterface( 684 sourceFile, 685 lexicon, 686 defKey, 687 defValue, 688 lexicons 689 ); 690 break; 691 case "union": 692 generateUnionType(sourceFile, lexicon, defKey, defValue, lexicons); 693 break; 694 case "array": 695 generateArrayType(sourceFile, lexicon, defKey, defValue, lexicons); 696 break; 697 case "token": 698 generateTokenType(sourceFile, lexicon, defKey); 699 break; 700 case "query": 701 generateQueryProcedureInterfaces( 702 sourceFile, 703 lexicon, 704 defValue, 705 lexicons, 706 "query" 707 ); 708 break; 709 case "procedure": 710 generateQueryProcedureInterfaces( 711 sourceFile, 712 lexicon, 713 defValue, 714 lexicons, 715 "procedure" 716 ); 717 break; 718 } 719 } 720 } 721 } 722 723 // Second pass: Generate namespace interfaces for lexicons with multiple definitions 724 addLexiconNamespaces(sourceFile, lexicons); 725}