forked from
slices.network/slices
Highly ambitious ATProtocol AppView service and sdks
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}