+216
src/lib/atproto/lexicon-generator.ts
+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
+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
+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>