// @ts-ignore: ts-morph npm import import { Project } from "ts-morph"; export interface LexiconProperty { type: string; description?: string; format?: string; knownValues?: string[]; items?: LexiconProperty; ref?: string; refs?: string[]; closed?: boolean; [key: string]: unknown; } export interface LexiconRecord { type: string; properties?: Record; required?: string[]; } export interface LexiconParameters { type: "params"; properties?: Record; required?: string[]; } export interface LexiconIO { encoding: string; schema?: LexiconProperty; } export interface LexiconDefinition { type: string; description?: string; // Record type fields record?: LexiconRecord; key?: string; // Query/Procedure fields parameters?: LexiconParameters; input?: LexiconIO; output?: LexiconIO; // Generic schema fields properties?: Record; required?: string[]; refs?: string[]; closed?: boolean; items?: LexiconProperty; knownValues?: string[]; ref?: string; [key: string]: unknown; } export interface Lexicon { id: string; definitions?: Record; } export interface GenerateOptions { sliceUri: string; } // Normalize lexicons to use consistent property names export function normalizeLexicons(lexicons: unknown[]): Lexicon[] { return lexicons.map((lex: unknown) => { const lexObj = lex as Record; return { ...lexObj, // TODO: Fix upstream to use 'id' and 'defs' consistently id: (lexObj.id as string) || (lexObj.nsid as string), // Normalize nsid to id definitions: (lexObj.defs as Record) || (lexObj.definitions as Record), // Normalize defs to definitions }; }); } // Convert NSID to PascalCase (for record types, no "Record" suffix) export function nsidToPascalCase(nsid: string): string { return nsid .split(".") .map((part) => part .split(/[-_]/) .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join("") ) .join(""); } // Convert NSID to PascalCase for namespaces (without "Record" suffix) export function nsidToNamespace(nsid: string): string { return nsid .split(".") .map((part) => part .split(/[-_]/) .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join("") ) .join(""); } // Convert definition name to PascalCase export function defNameToPascalCase(defName: string): string { return defName .split(/[-_]/) .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(""); } // Capitalize first letter export function capitalizeFirst(str: string): string { return str.charAt(0).toUpperCase() + str.slice(1); } // Sanitize a string to be a valid JavaScript identifier export function sanitizeIdentifier(str: string): string { // Replace hyphens and other non-alphanumeric characters with camelCase return str .split(/[-_]/) .map((word, index) => index === 0 ? word : word.charAt(0).toUpperCase() + word.slice(1) ) .join(""); } // Check if property is required export function isPropertyRequired( recordObj: LexiconRecord, propName: string ): boolean { return Boolean(recordObj.required && recordObj.required.includes(propName)); } // Helper function to check if a field type is sortable export function isFieldSortable(propDef: LexiconProperty): boolean { // Check for direct types if (propDef.type) { const sortableTypes = ["string", "integer", "number", "datetime"]; if (sortableTypes.includes(propDef.type)) { return true; } } // Check for format-based types (datetime strings, etc.) if (propDef.format) { const sortableFormats = ["datetime", "at-identifier", "at-uri"]; if (sortableFormats.includes(propDef.format)) { return true; } } // Arrays, objects, blobs, and complex types are not sortable return false; } export function createProject(): Project { return new Project({ useInMemoryFileSystem: true }); } export function generateUsageExample( lexicons: Lexicon[], sliceUri: string ): string { // Find the first non-network.slices lexicon that has a record type const nonSlicesLexicon = lexicons.find( (lex) => lex.id && !lex.id.startsWith("network.slices.") && lex.definitions && Object.values(lex.definitions).some((def) => def.type === "record") ); if (nonSlicesLexicon) { // Use the first non-slices lexicon const parts = nonSlicesLexicon.id.split("."); // Sanitize parts for JavaScript property access (skip first part "network") const accessPath = parts.map((part, index) => index === 0 ? part : sanitizeIdentifier(part) ).join("."); return `/** * @example Usage * \`\`\`ts * import { AtProtoClient } from "./generated_client.ts"; * * const client = new AtProtoClient( * 'https://api.slices.network', * '${sliceUri}' * ); * * // Get records from the ${nonSlicesLexicon.id} collection * const records = await client.${accessPath}.getRecords(); * * // Get a specific record * const record = await client.${accessPath}.getRecord({ * uri: 'at://did:plc:example/${nonSlicesLexicon.id}/3abc123' * }); * * // Get records with filtering and search * const filteredRecords = await client.${accessPath}.getRecords({ * where: { * text: { contains: "example search term" } * } * }); * * // Use slice-level methods for cross-collection queries with type safety * const sliceRecords = await client.network.slices.slice.getSliceRecords<${nsidToPascalCase( nonSlicesLexicon.id )}>({ * where: { * collection: { eq: '${nonSlicesLexicon.id}' } * } * }); * * // Search across multiple collections using union types * const multiCollectionRecords = await client.network.slices.slice.getSliceRecords<${nsidToPascalCase( nonSlicesLexicon.id )} | AppBskyActorProfile>({ * where: { * collection: { in: ['${nonSlicesLexicon.id}', 'app.bsky.actor.profile'] }, * text: { contains: 'example search term' }, * did: { in: ['did:plc:user1', 'did:plc:user2'] } * }, * limit: 20 * }); * * // Serve the records as JSON * Deno.serve(async () => new Response(JSON.stringify(records.records.map(r => r.value)))); * \`\`\` */`; } else { // Fallback: find any lexicon with a record type (including network.slices) const anyRecordLexicon = lexicons.find( (lex) => lex.definitions && Object.values(lex.definitions).some((def) => def.type === "record") ); if (anyRecordLexicon) { const parts = anyRecordLexicon.id.split("."); // Sanitize parts for JavaScript property access (skip first part "network") const accessPath = parts.map((part, index) => index === 0 ? part : sanitizeIdentifier(part) ).join("."); return `/** * @example Usage * \`\`\`ts * import { AtProtoClient } from "./generated_client.ts"; * * const client = new AtProtoClient( * 'https://api.slices.network', * '${sliceUri}' * ); * * // Get records from the ${anyRecordLexicon.id} collection * const records = await client.${accessPath}.getRecords(); * * // Get a specific record * const record = await client.${accessPath}.getRecord({ * uri: 'at://did:plc:example/${anyRecordLexicon.id}/3abc123' * }); * * // Get records with search and filtering * const filteredRecords = await client.${accessPath}.getRecords({ * where: { * text: { contains: "example search term" } * } * }); * * // Cross-collection operations using base client with type safety * const multiCollectionResults = await client.getSliceRecords<${nsidToPascalCase( anyRecordLexicon.id )}>({ * where: { * collection: { eq: '${anyRecordLexicon.id}' } * }, * limit: 50 * }); * * // Serve the records as JSON * Deno.serve(async () => new Response(JSON.stringify(records.records.map(r => r.value)))); * \`\`\` */`; } // Final fallback if no record types exist return `/** * @example Usage * \`\`\`ts * import { AtProtoClient } from "./generated_client.ts"; * * const client = new AtProtoClient( * 'https://api.slices.network', * '${sliceUri}' * ); * * // Client is ready to use with available collections * // Use client methods based on your lexicon definitions * \`\`\` */`; } } export function generateHeaderComment( lexicons: Lexicon[], usageExample: string ): string { return `// Generated TypeScript client for AT Protocol records // Generated at: ${new Date().toISOString().slice(0, 19).replace("T", " ")} UTC // Lexicons: ${lexicons.length} ${usageExample} import { SlicesClient, type RecordResponse, type GetRecordsResponse, type CountRecordsResponse, type GetRecordParams, type WhereCondition, type IndexedRecordFields, type SortField, type BlobRef, type AuthProvider } from "@slices/client"; import type { OAuthClient } from "@slices/oauth"; `; } // Format the code using deno fmt via temp file export async function formatCode(code: string): Promise { try { const tempFile = await Deno.makeTempFile({ suffix: ".ts" }); // Write unformatted code to temp file await Deno.writeTextFile(tempFile, code); // Format the temp file const process = new Deno.Command("deno", { args: ["fmt", tempFile], stdout: "piped", stderr: "piped", }); const output = await process.output(); if (output.success) { // Read the formatted code back const formattedCode = await Deno.readTextFile(tempFile); await Deno.remove(tempFile); return formattedCode; } else { const error = new TextDecoder().decode(output.stderr); console.warn("deno fmt failed, using unformatted code:", error); await Deno.remove(tempFile); return code; } } catch (error) { console.warn("deno fmt not available, using unformatted code:", error); return code; } } // Convenience function that combines everything export async function generateTypeScript( lexicons: unknown[], options: GenerateOptions ): Promise { const { generateInterfaces } = await import("./interfaces.ts"); const { generateClient } = await import("./client.ts"); const normalizedLexicons = normalizeLexicons(lexicons); const project = createProject(); const sourceFile = project.createSourceFile("generated-client.ts", ""); const usageExample = generateUsageExample( normalizedLexicons, options.sliceUri ); const headerComment = generateHeaderComment( normalizedLexicons, usageExample ); // Add header comment and imports to the source file first sourceFile.insertText(0, headerComment); // Generate interfaces and client generateInterfaces( sourceFile, normalizedLexicons ); generateClient( sourceFile, normalizedLexicons ); // Get the generated code const generatedCode = sourceFile.getFullText(); return await formatCode(generatedCode); }