A personal website powered by Astro and ATProto

type generation from repo collections

Changed files
+595
src
+216
src/lib/atproto/lexicon-generator.ts
··· 1 + // Lexicon generator based on ATProto browser discovery 2 + import { AtprotoBrowser, type AtprotoRecord } from './atproto-browser'; 3 + 4 + export interface LexiconDefinition { 5 + $type: string; 6 + collection: string; 7 + properties: Record<string, any>; 8 + sampleRecord: AtprotoRecord; 9 + description: string; 10 + } 11 + 12 + export interface GeneratedTypes { 13 + lexicons: LexiconDefinition[]; 14 + typeDefinitions: string; 15 + collectionTypes: Record<string, string[]>; 16 + } 17 + 18 + export class LexiconGenerator { 19 + private browser: AtprotoBrowser; 20 + 21 + constructor() { 22 + this.browser = new AtprotoBrowser(); 23 + } 24 + 25 + // Generate types for all lexicons in a repository 26 + async generateTypesForRepository(identifier: string): Promise<GeneratedTypes> { 27 + console.log('🔍 Generating types for repository:', identifier); 28 + 29 + const lexicons: LexiconDefinition[] = []; 30 + const collectionTypes: Record<string, string[]> = {}; 31 + 32 + try { 33 + // Get all collections in the repository 34 + const collections = await this.browser.getAllCollections(identifier); 35 + console.log(`📊 Found ${collections.length} collections:`, collections); 36 + 37 + // Analyze each collection 38 + for (const collection of collections) { 39 + console.log(`🔍 Analyzing collection: ${collection}`); 40 + 41 + try { 42 + const collectionInfo = await this.browser.getCollectionRecords(identifier, collection, 100); 43 + if (collectionInfo && collectionInfo.records.length > 0) { 44 + // Group records by type 45 + const typeGroups = new Map<string, AtprotoRecord[]>(); 46 + 47 + collectionInfo.records.forEach(record => { 48 + const $type = record.$type; 49 + if (!typeGroups.has($type)) { 50 + typeGroups.set($type, []); 51 + } 52 + typeGroups.get($type)!.push(record); 53 + }); 54 + 55 + // Create lexicon definitions for each type 56 + typeGroups.forEach((records, $type) => { 57 + const sampleRecord = records[0]; 58 + const properties = this.extractProperties(sampleRecord.value); 59 + 60 + const lexicon: LexiconDefinition = { 61 + $type, 62 + collection, 63 + properties, 64 + sampleRecord, 65 + description: `Discovered in collection ${collection}` 66 + }; 67 + 68 + lexicons.push(lexicon); 69 + 70 + // Track collection types 71 + if (!collectionTypes[collection]) { 72 + collectionTypes[collection] = []; 73 + } 74 + collectionTypes[collection].push($type); 75 + 76 + console.log(`✅ Generated lexicon for ${$type} in ${collection}`); 77 + }); 78 + } 79 + } catch (error) { 80 + console.error(`❌ Error analyzing collection ${collection}:`, error); 81 + } 82 + } 83 + 84 + // Generate TypeScript type definitions 85 + const typeDefinitions = this.generateTypeScriptTypes(lexicons, collectionTypes); 86 + 87 + console.log(`🎉 Generated ${lexicons.length} lexicon definitions`); 88 + 89 + return { 90 + lexicons, 91 + typeDefinitions, 92 + collectionTypes 93 + }; 94 + 95 + } catch (error) { 96 + console.error('Error generating types:', error); 97 + throw error; 98 + } 99 + } 100 + 101 + // Extract properties from a record value 102 + private extractProperties(value: any): Record<string, any> { 103 + const properties: Record<string, any> = {}; 104 + 105 + if (value && typeof value === 'object') { 106 + Object.keys(value).forEach(key => { 107 + if (key !== '$type') { 108 + properties[key] = { 109 + type: typeof value[key], 110 + value: value[key] 111 + }; 112 + } 113 + }); 114 + } 115 + 116 + return properties; 117 + } 118 + 119 + // Generate TypeScript type definitions 120 + private generateTypeScriptTypes(lexicons: LexiconDefinition[], collectionTypes: Record<string, string[]>): string { 121 + let types = '// Auto-generated TypeScript types for discovered lexicons\n'; 122 + types += '// Generated from ATProto repository analysis\n\n'; 123 + 124 + // Generate interfaces for each lexicon 125 + lexicons.forEach(lexicon => { 126 + const interfaceName = this.generateInterfaceName(lexicon.$type); 127 + types += `export interface ${interfaceName} {\n`; 128 + types += ` $type: '${lexicon.$type}';\n`; 129 + 130 + Object.entries(lexicon.properties).forEach(([key, prop]) => { 131 + const type = this.getTypeScriptType(prop.type, prop.value); 132 + types += ` ${key}: ${type};\n`; 133 + }); 134 + 135 + types += '}\n\n'; 136 + }); 137 + 138 + // Generate collection type mappings 139 + types += '// Collection type mappings\n'; 140 + types += 'export interface CollectionTypes {\n'; 141 + Object.entries(collectionTypes).forEach(([collection, types]) => { 142 + types += ` '${collection}': ${types.map(t => `'${t}'`).join(' | ')};\n`; 143 + }); 144 + types += '}\n\n'; 145 + 146 + // Generate union types for all lexicons 147 + const allTypes = lexicons.map(l => this.generateInterfaceName(l.$type)); 148 + types += `export type AllLexicons = ${allTypes.join(' | ')};\n\n`; 149 + 150 + // Generate helper functions 151 + types += '// Helper functions\n'; 152 + types += 'export function isLexiconType(record: any, type: string): boolean {\n'; 153 + types += ' return record?.$type === type;\n'; 154 + types += '}\n\n'; 155 + 156 + types += 'export function getCollectionTypes(collection: string): string[] {\n'; 157 + types += ' const collectionTypes: Record<string, string[]> = {\n'; 158 + Object.entries(collectionTypes).forEach(([collection, types]) => { 159 + types += ` '${collection}': [${types.map(t => `'${t}'`).join(', ')}],\n`; 160 + }); 161 + types += ' };\n'; 162 + types += ' return collectionTypes[collection] || [];\n'; 163 + types += '}\n'; 164 + 165 + return types; 166 + } 167 + 168 + // Generate interface name from lexicon type 169 + private generateInterfaceName($type: string): string { 170 + // Convert lexicon type to PascalCase interface name 171 + // e.g., "app.bsky.feed.post" -> "AppBskyFeedPost" 172 + return $type 173 + .split('.') 174 + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) 175 + .join(''); 176 + } 177 + 178 + // Get TypeScript type from JavaScript type and value 179 + private getTypeScriptType(jsType: string, value: any): string { 180 + switch (jsType) { 181 + case 'string': 182 + return 'string'; 183 + case 'number': 184 + return typeof value === 'number' && Number.isInteger(value) ? 'number' : 'number'; 185 + case 'boolean': 186 + return 'boolean'; 187 + case 'object': 188 + if (Array.isArray(value)) { 189 + return 'any[]'; 190 + } 191 + return 'Record<string, any>'; 192 + default: 193 + return 'any'; 194 + } 195 + } 196 + 197 + // Save generated types to a file 198 + async saveTypesToFile(identifier: string, outputPath: string): Promise<void> { 199 + try { 200 + const generated = await this.generateTypesForRepository(identifier); 201 + 202 + // Create the file content 203 + const fileContent = `// Auto-generated types for ${identifier}\n`; 204 + const fileContent += `// Generated on ${new Date().toISOString()}\n\n`; 205 + const fileContent += generated.typeDefinitions; 206 + 207 + // In a real implementation, you'd write to the file system 208 + console.log('📝 Generated types:', fileContent); 209 + 210 + return Promise.resolve(); 211 + } catch (error) { 212 + console.error('Error saving types to file:', error); 213 + throw error; 214 + } 215 + } 216 + }
+8
src/pages/index.astro
··· 101 101 Browse ATProto accounts and records like atptools - explore collections and records. 102 102 </p> 103 103 </a> 104 + <a href="/lexicon-generator-test" class="block p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md transition-shadow"> 105 + <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 106 + Lexicon Generator Test 107 + </h3> 108 + <p class="text-gray-600 dark:text-gray-400"> 109 + Generate TypeScript types for all lexicons discovered in any ATProto repository. 110 + </p> 111 + </a> 104 112 </div> 105 113 </section> 106 114 </>
+371
src/pages/lexicon-generator-test.astro
··· 1 + --- 2 + import Layout from '../layouts/Layout.astro'; 3 + import { loadConfig } from '../lib/config/site'; 4 + 5 + const config = loadConfig(); 6 + --- 7 + 8 + <Layout title="Lexicon Generator Test"> 9 + <div class="container mx-auto px-4 py-8"> 10 + <h1 class="text-4xl font-bold mb-8">Lexicon Generator Test</h1> 11 + 12 + <div class="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8"> 13 + <h2 class="text-2xl font-semibold mb-4">Configuration</h2> 14 + <p><strong>Handle:</strong> {config.atproto.handle}</p> 15 + <p><strong>DID:</strong> {config.atproto.did}</p> 16 + <p class="text-sm text-gray-600 mt-2">Generate TypeScript types for all lexicons discovered in your configured repository.</p> 17 + </div> 18 + 19 + <div class="grid grid-cols-1 lg:grid-cols-2 gap-8"> 20 + <div class="bg-white border border-gray-200 rounded-lg p-6"> 21 + <h2 class="text-2xl font-semibold mb-4">Generate Types</h2> 22 + <div class="space-y-4"> 23 + <p class="text-sm text-gray-600">Generating types for: <strong>{config.atproto.handle}</strong></p> 24 + <button id="generate-btn" class="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700"> 25 + Generate Types for My Repository 26 + </button> 27 + </div> 28 + </div> 29 + 30 + <div class="bg-white border border-gray-200 rounded-lg p-6"> 31 + <h2 class="text-2xl font-semibold mb-4">Generation Status</h2> 32 + <div id="status" class="space-y-2"> 33 + <p class="text-gray-500">Click generate to analyze your repository...</p> 34 + </div> 35 + </div> 36 + </div> 37 + 38 + <div class="mt-8"> 39 + <h2 class="text-2xl font-semibold mb-4">Discovered Lexicons</h2> 40 + <div id="lexicons-container" class="space-y-4"> 41 + <p class="text-gray-500 text-center py-8">No lexicons discovered yet...</p> 42 + </div> 43 + </div> 44 + 45 + <div class="mt-8"> 46 + <h2 class="text-2xl font-semibold mb-4">Generated TypeScript Types</h2> 47 + <div class="bg-gray-900 text-green-400 p-4 rounded-lg"> 48 + <pre id="types-output" class="text-sm overflow-x-auto">// Generated types will appear here...</pre> 49 + </div> 50 + <div class="mt-4"> 51 + <button id="copy-btn" class="bg-gray-600 text-white px-4 py-2 rounded hover:bg-gray-700" disabled> 52 + Copy to Clipboard 53 + </button> 54 + <button id="download-btn" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 ml-2" disabled> 55 + Download Types File 56 + </button> 57 + </div> 58 + </div> 59 + </div> 60 + </Layout> 61 + 62 + <script> 63 + // Simple lexicon generator for your configured account 64 + class SimpleLexiconGenerator { 65 + constructor() { 66 + this.config = { 67 + handle: 'tynanpurdy.com', 68 + did: 'did:plc:6ayddqghxhciedbaofoxkcbs', 69 + pdsUrl: 'https://bsky.social' 70 + }; 71 + } 72 + 73 + async generateTypesForRepository() { 74 + console.log('🔍 Generating types for repository:', this.config.handle); 75 + 76 + const lexicons = []; 77 + const collectionTypes = {}; 78 + 79 + try { 80 + // Get all collections in the repository 81 + const collections = await this.getAllCollections(); 82 + console.log(`📊 Found ${collections.length} collections:`, collections); 83 + 84 + // Analyze each collection 85 + for (const collection of collections) { 86 + console.log(`🔍 Analyzing collection: ${collection}`); 87 + 88 + try { 89 + const collectionInfo = await this.getCollectionRecords(collection, 100); 90 + if (collectionInfo && collectionInfo.records.length > 0) { 91 + // Group records by type 92 + const typeGroups = new Map(); 93 + 94 + collectionInfo.records.forEach(record => { 95 + const $type = record.$type; 96 + if (!typeGroups.has($type)) { 97 + typeGroups.set($type, []); 98 + } 99 + typeGroups.get($type).push(record); 100 + }); 101 + 102 + // Create lexicon definitions for each type 103 + typeGroups.forEach((records, $type) => { 104 + const sampleRecord = records[0]; 105 + const properties = this.extractProperties(sampleRecord.value); 106 + 107 + const lexicon = { 108 + $type, 109 + collection, 110 + properties, 111 + sampleRecord, 112 + description: `Discovered in collection ${collection}` 113 + }; 114 + 115 + lexicons.push(lexicon); 116 + 117 + // Track collection types 118 + if (!collectionTypes[collection]) { 119 + collectionTypes[collection] = []; 120 + } 121 + collectionTypes[collection].push($type); 122 + 123 + console.log(`✅ Generated lexicon for ${$type} in ${collection}`); 124 + }); 125 + } 126 + } catch (error) { 127 + console.error(`❌ Error analyzing collection ${collection}:`, error); 128 + } 129 + } 130 + 131 + // Generate TypeScript type definitions 132 + const typeDefinitions = this.generateTypeScriptTypes(lexicons, collectionTypes); 133 + 134 + console.log(`🎉 Generated ${lexicons.length} lexicon definitions`); 135 + 136 + return { 137 + lexicons, 138 + typeDefinitions, 139 + collectionTypes 140 + }; 141 + 142 + } catch (error) { 143 + console.error('Error generating types:', error); 144 + throw error; 145 + } 146 + } 147 + 148 + async getAllCollections() { 149 + try { 150 + const response = await fetch(`https://bsky.social/xrpc/com.atproto.repo.describeRepo?repo=${this.config.did}`); 151 + const data = await response.json(); 152 + return data.collections || []; 153 + } catch (error) { 154 + console.error('Error getting collections:', error); 155 + return []; 156 + } 157 + } 158 + 159 + async getCollectionRecords(collection, limit = 100) { 160 + try { 161 + const response = await fetch(`https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=${this.config.did}&collection=${collection}&limit=${limit}`); 162 + const data = await response.json(); 163 + 164 + const records = data.records.map(record => ({ 165 + uri: record.uri, 166 + cid: record.cid, 167 + value: record.value, 168 + indexedAt: record.indexedAt, 169 + collection: collection, 170 + $type: record.value?.$type || 'unknown', 171 + })); 172 + 173 + return { 174 + collection: collection, 175 + recordCount: records.length, 176 + records: records, 177 + cursor: data.cursor, 178 + }; 179 + } catch (error) { 180 + console.error('Error getting collection records:', error); 181 + return null; 182 + } 183 + } 184 + 185 + extractProperties(value) { 186 + const properties = {}; 187 + 188 + if (value && typeof value === 'object') { 189 + Object.keys(value).forEach(key => { 190 + if (key !== '$type') { 191 + properties[key] = { 192 + type: typeof value[key], 193 + value: value[key] 194 + }; 195 + } 196 + }); 197 + } 198 + 199 + return properties; 200 + } 201 + 202 + generateTypeScriptTypes(lexicons, collectionTypes) { 203 + let types = '// Auto-generated TypeScript types for discovered lexicons\n'; 204 + types += '// Generated from ATProto repository analysis\n\n'; 205 + 206 + // Generate interfaces for each lexicon 207 + lexicons.forEach(lexicon => { 208 + const interfaceName = this.generateInterfaceName(lexicon.$type); 209 + types += `export interface ${interfaceName} {\n`; 210 + types += ` $type: '${lexicon.$type}';\n`; 211 + 212 + Object.entries(lexicon.properties).forEach(([key, prop]) => { 213 + const type = this.getTypeScriptType(prop.type, prop.value); 214 + types += ` ${key}: ${type};\n`; 215 + }); 216 + 217 + types += '}\n\n'; 218 + }); 219 + 220 + // Generate collection type mappings 221 + types += '// Collection type mappings\n'; 222 + types += 'export interface CollectionTypes {\n'; 223 + Object.entries(collectionTypes).forEach(([collection, types]) => { 224 + types += ` '${collection}': ${types.map(t => `'${t}'`).join(' | ')};\n`; 225 + }); 226 + types += '}\n\n'; 227 + 228 + // Generate union types for all lexicons 229 + const allTypes = lexicons.map(l => this.generateInterfaceName(l.$type)); 230 + types += `export type AllLexicons = ${allTypes.join(' | ')};\n\n`; 231 + 232 + // Generate helper functions 233 + types += '// Helper functions\n'; 234 + types += 'export function isLexiconType(record: any, type: string): boolean {\n'; 235 + types += ' return record?.$type === type;\n'; 236 + types += '}\n\n'; 237 + 238 + types += 'export function getCollectionTypes(collection: string): string[] {\n'; 239 + types += ' const collectionTypes: Record<string, string[]> = {\n'; 240 + Object.entries(collectionTypes).forEach(([collection, types]) => { 241 + types += ` '${collection}': [${types.map(t => `'${t}'`).join(', ')}],\n`; 242 + }); 243 + types += ' };\n'; 244 + types += ' return collectionTypes[collection] || [];\n'; 245 + types += '}\n'; 246 + 247 + return types; 248 + } 249 + 250 + generateInterfaceName($type) { 251 + return $type 252 + .split('.') 253 + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) 254 + .join(''); 255 + } 256 + 257 + getTypeScriptType(jsType, value) { 258 + switch (jsType) { 259 + case 'string': 260 + return 'string'; 261 + case 'number': 262 + return typeof value === 'number' && Number.isInteger(value) ? 'number' : 'number'; 263 + case 'boolean': 264 + return 'boolean'; 265 + case 'object': 266 + if (Array.isArray(value)) { 267 + return 'any[]'; 268 + } 269 + return 'Record<string, any>'; 270 + default: 271 + return 'any'; 272 + } 273 + } 274 + } 275 + 276 + const generator = new SimpleLexiconGenerator(); 277 + let generatedTypes = ''; 278 + 279 + // DOM elements 280 + const generateBtn = document.getElementById('generate-btn') as HTMLButtonElement; 281 + const status = document.getElementById('status') as HTMLDivElement; 282 + const lexiconsContainer = document.getElementById('lexicons-container') as HTMLDivElement; 283 + const typesOutput = document.getElementById('types-output') as HTMLPreElement; 284 + const copyBtn = document.getElementById('copy-btn') as HTMLButtonElement; 285 + const downloadBtn = document.getElementById('download-btn') as HTMLButtonElement; 286 + 287 + function updateStatus(message: string, isError = false) { 288 + status.innerHTML = ` 289 + <p class="${isError ? 'text-red-600' : 'text-green-600'}">${message}</p> 290 + `; 291 + } 292 + 293 + function displayLexicons(lexicons: any[]) { 294 + if (lexicons.length === 0) { 295 + lexiconsContainer.innerHTML = '<p class="text-gray-500 text-center py-8">No lexicons discovered</p>'; 296 + return; 297 + } 298 + 299 + lexiconsContainer.innerHTML = ` 300 + <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> 301 + ${lexicons.map(lexicon => ` 302 + <div class="bg-gray-50 border border-gray-200 rounded-lg p-4"> 303 + <h3 class="font-semibold text-gray-900 mb-2">${lexicon.$type}</h3> 304 + <p class="text-sm text-gray-600 mb-2">Collection: ${lexicon.collection}</p> 305 + <p class="text-sm text-gray-600 mb-2">Properties: ${Object.keys(lexicon.properties).length}</p> 306 + <p class="text-xs text-gray-500">${lexicon.description}</p> 307 + </div> 308 + `).join('')} 309 + </div> 310 + `; 311 + } 312 + 313 + function displayTypes(types: string) { 314 + generatedTypes = types; 315 + typesOutput.textContent = types; 316 + copyBtn.disabled = false; 317 + downloadBtn.disabled = false; 318 + } 319 + 320 + generateBtn.addEventListener('click', async () => { 321 + try { 322 + generateBtn.disabled = true; 323 + generateBtn.textContent = 'Generating...'; 324 + updateStatus('Starting type generation...'); 325 + 326 + // Generate types 327 + const result = await generator.generateTypesForRepository(); 328 + 329 + updateStatus(`Generated ${result.lexicons.length} lexicon types successfully!`); 330 + displayLexicons(result.lexicons); 331 + displayTypes(result.typeDefinitions); 332 + 333 + } catch (error) { 334 + console.error('Error generating types:', error); 335 + updateStatus('Error generating types. Check the console for details.', true); 336 + } finally { 337 + generateBtn.disabled = false; 338 + generateBtn.textContent = 'Generate Types for My Repository'; 339 + } 340 + }); 341 + 342 + copyBtn.addEventListener('click', async () => { 343 + try { 344 + await navigator.clipboard.writeText(generatedTypes); 345 + copyBtn.textContent = 'Copied!'; 346 + setTimeout(() => { 347 + copyBtn.textContent = 'Copy to Clipboard'; 348 + }, 2000); 349 + } catch (error) { 350 + console.error('Error copying to clipboard:', error); 351 + alert('Error copying to clipboard'); 352 + } 353 + }); 354 + 355 + downloadBtn.addEventListener('click', () => { 356 + try { 357 + const blob = new Blob([generatedTypes], { type: 'text/typescript' }); 358 + const url = URL.createObjectURL(blob); 359 + const a = document.createElement('a'); 360 + a.href = url; 361 + a.download = 'generated-lexicons.ts'; 362 + document.body.appendChild(a); 363 + a.click(); 364 + document.body.removeChild(a); 365 + URL.revokeObjectURL(url); 366 + } catch (error) { 367 + console.error('Error downloading file:', error); 368 + alert('Error downloading file'); 369 + } 370 + }); 371 + </script>