A personal website powered by Astro and ATProto

strongly typed

+302
LEXICON_INTEGRATION.md
··· 1 + # Lexicon Integration Guide 2 + 3 + This guide explains how to add support for new ATproto lexicons in your Astro website. The system provides full type safety and automatic component routing. 4 + 5 + ## Overview 6 + 7 + The lexicon integration system consists of: 8 + 9 + 1. **Schema Files**: JSON lexicon definitions in `src/lexicons/` 10 + 2. **Type Generation**: Automatic TypeScript type generation from schemas 11 + 3. **Component Registry**: Type-safe mapping of lexicon types to Astro components 12 + 4. **Content Display**: Dynamic component routing based on record types 13 + 14 + ## Step-by-Step Guide 15 + 16 + ### 1. Add Lexicon Schema 17 + 18 + Create a JSON schema file in `src/lexicons/` following the ATproto lexicon specification: 19 + 20 + ```json 21 + // src/lexicons/com.example.myrecord.json 22 + { 23 + "lexicon": 1, 24 + "id": "com.example.myrecord", 25 + "description": "My custom record type", 26 + "defs": { 27 + "main": { 28 + "type": "record", 29 + "key": "tid", 30 + "record": { 31 + "type": "object", 32 + "required": ["title", "content"], 33 + "properties": { 34 + "title": { 35 + "type": "string", 36 + "description": "The title of the record" 37 + }, 38 + "content": { 39 + "type": "string", 40 + "description": "The content of the record" 41 + }, 42 + "tags": { 43 + "type": "array", 44 + "items": { 45 + "type": "string" 46 + }, 47 + "description": "Tags for the record" 48 + } 49 + } 50 + } 51 + } 52 + } 53 + } 54 + ``` 55 + 56 + ### 2. Update Site Configuration 57 + 58 + Add the lexicon to your site configuration in `src/lib/config/site.ts`: 59 + 60 + ```typescript 61 + export const defaultConfig: SiteConfig = { 62 + // ... existing config 63 + lexiconSources: { 64 + 'com.whtwnd.blog.entry': './src/lexicons/com.whtwnd.blog.entry.json', 65 + 'com.example.myrecord': './src/lexicons/com.example.myrecord.json', // Add your new lexicon 66 + }, 67 + }; 68 + ``` 69 + 70 + ### 3. Generate TypeScript Types 71 + 72 + Run the type generation script: 73 + 74 + ```bash 75 + npm run gen:types 76 + ``` 77 + 78 + This will create: 79 + - `src/lib/generated/com-example-myrecord.ts` - Individual type definitions 80 + - `src/lib/generated/lexicon-types.ts` - Union types and type maps 81 + 82 + ### 4. Create Your Component 83 + 84 + Create an Astro component to display your record type. **Components receive the typed record value directly**: 85 + 86 + ```astro 87 + --- 88 + // src/components/content/MyRecordDisplay.astro 89 + import type { ComExampleMyrecord } from '../../lib/generated/com-example-myrecord'; 90 + 91 + interface Props { 92 + record: ComExampleMyrecord['value']; // Typed record value, not generic AtprotoRecord 93 + showAuthor?: boolean; 94 + showTimestamp?: boolean; 95 + } 96 + 97 + const { record, showAuthor = true, showTimestamp = true } = Astro.props; 98 + 99 + // The record is already typed - no casting needed! 100 + --- 101 + 102 + <div class="my-record-display"> 103 + <h2 class="text-xl font-bold">{record.title}</h2> 104 + <p class="text-gray-600">{record.content}</p> 105 + 106 + {record.tags && record.tags.length > 0 && ( 107 + <div class="flex flex-wrap gap-2 mt-3"> 108 + {record.tags.map((tag: string) => ( 109 + <span class="bg-blue-100 text-blue-800 px-2 py-1 rounded text-sm"> 110 + {tag} 111 + </span> 112 + ))} 113 + </div> 114 + )} 115 + </div> 116 + ``` 117 + 118 + ### 5. Register Your Component 119 + 120 + Add your component to the registry in `src/lib/components/registry.ts`: 121 + 122 + ```typescript 123 + export const registry: ComponentRegistry = { 124 + 'ComWhtwndBlogEntry': { 125 + component: 'WhitewindBlogPost', 126 + props: {} 127 + }, 128 + 'ComExampleMyrecord': { // Add your new type 129 + component: 'MyRecordDisplay', 130 + props: {} 131 + }, 132 + // ... other components 133 + }; 134 + ``` 135 + 136 + ### 6. Use Your Component 137 + 138 + Your component will now be automatically used when displaying records of your type: 139 + 140 + ```astro 141 + --- 142 + import ContentDisplay from '../../components/content/ContentDisplay.astro'; 143 + import type { AtprotoRecord } from '../../lib/types/atproto'; 144 + 145 + const records: AtprotoRecord[] = await fetchRecords(); 146 + --- 147 + 148 + {records.map(record => ( 149 + <ContentDisplay record={record} showAuthor={true} showTimestamp={true} /> 150 + ))} 151 + ``` 152 + 153 + ## Type Safety Features 154 + 155 + ### Generated Types 156 + 157 + The system generates strongly typed interfaces: 158 + 159 + ```typescript 160 + // Generated from your schema 161 + export interface ComExampleMyrecordRecord { 162 + title: string; 163 + content: string; 164 + tags?: string[]; 165 + } 166 + 167 + export interface ComExampleMyrecord { 168 + $type: 'com.example.myrecord'; 169 + value: ComExampleMyrecordRecord; 170 + } 171 + ``` 172 + 173 + ### Direct Type Access 174 + 175 + Components receive the typed record value directly, not the generic `AtprotoRecord`: 176 + 177 + ```typescript 178 + // ✅ Good - Direct typed access 179 + interface Props { 180 + record: ComExampleMyrecord['value']; // Typed record value 181 + } 182 + 183 + // ❌ Avoid - Generic casting 184 + interface Props { 185 + record: AtprotoRecord; // Generic record 186 + } 187 + const myRecord = record.value as ComExampleMyrecord['value']; // Casting needed 188 + ``` 189 + 190 + ### Component Registry Types 191 + 192 + The registry provides type-safe component lookup: 193 + 194 + ```typescript 195 + // Type-safe component lookup 196 + const componentInfo = getComponentInfo('ComExampleMyrecord'); 197 + // componentInfo.component will be 'MyRecordDisplay' 198 + // componentInfo.props will be typed correctly 199 + ``` 200 + 201 + ### Automatic Fallbacks 202 + 203 + If no component is registered for a type, the system: 204 + 205 + 1. Tries to auto-assign a component name based on the NSID 206 + 2. Falls back to `GenericContentDisplay.astro` for unknown types 207 + 3. Shows debug information in development mode 208 + 209 + ## Advanced Usage 210 + 211 + ### Custom Props 212 + 213 + You can pass custom props to your components: 214 + 215 + ```typescript 216 + export const registry: ComponentRegistry = { 217 + 'ComExampleMyrecord': { 218 + component: 'MyRecordDisplay', 219 + props: { 220 + showTags: true, 221 + maxTags: 5 222 + } 223 + }, 224 + }; 225 + ``` 226 + 227 + ### Multiple Record Types 228 + 229 + Support multiple record types in one component: 230 + 231 + ```astro 232 + --- 233 + // Handle multiple types in one component 234 + const recordType = record?.$type; 235 + 236 + if (recordType === 'com.example.type1') { 237 + // Handle type 1 with typed access 238 + const type1Record = record as ComExampleType1['value']; 239 + } else if (recordType === 'com.example.type2') { 240 + // Handle type 2 with typed access 241 + const type2Record = record as ComExampleType2['value']; 242 + } 243 + --- 244 + ``` 245 + 246 + ### Dynamic Component Loading 247 + 248 + The system dynamically imports components and passes typed data: 249 + 250 + ```typescript 251 + // This happens automatically in ContentDisplay.astro 252 + const Component = await import(`../../components/content/${componentInfo.component}.astro`); 253 + // Component receives record.value (typed) instead of full AtprotoRecord 254 + ``` 255 + 256 + ## Troubleshooting 257 + 258 + ### Type Generation Issues 259 + 260 + If type generation fails: 261 + 262 + 1. Check your JSON schema syntax 263 + 2. Ensure the schema has a `main` record definition 264 + 3. Verify all required fields are properly defined 265 + 266 + ### Component Not Found 267 + 268 + If your component isn't being used: 269 + 270 + 1. Check the registry mapping in `src/lib/components/registry.ts` 271 + 2. Verify the component file exists in `src/components/content/` 272 + 3. Check the component name matches the registry entry 273 + 274 + ### Type Errors 275 + 276 + If you get TypeScript errors: 277 + 278 + 1. Regenerate types: `npm run gen:types` 279 + 2. Check that your component uses the correct generated types 280 + 3. Verify your component receives `RecordType['value']` not `AtprotoRecord` 281 + 282 + ## Best Practices 283 + 284 + 1. **Schema Design**: Follow ATproto lexicon conventions 285 + 2. **Type Safety**: Always use generated types in components 286 + 3. **Direct Access**: Components receive typed data directly, no casting needed 287 + 4. **Component Naming**: Use descriptive component names 288 + 5. **Error Handling**: Provide fallbacks for missing data 289 + 6. **Development**: Use debug mode to troubleshoot issues 290 + 291 + ## Example: Complete Integration 292 + 293 + Here's a complete example adding support for a photo gallery lexicon: 294 + 295 + 1. **Schema**: `src/lexicons/com.example.gallery.json` 296 + 2. **Config**: Add to `lexiconSources` 297 + 3. **Types**: Run `npm run gen:types` 298 + 4. **Component**: Create `GalleryDisplay.astro` with typed props 299 + 5. **Registry**: Add mapping in `registry.ts` 300 + 6. **Usage**: Use `ContentDisplay` component 301 + 302 + The system will automatically route gallery records to your `GalleryDisplay` component with full type safety and direct typed access.
+162
scripts/generate-types.ts
··· 1 + #!/usr/bin/env node 2 + 3 + import { readdir, readFile, writeFile } from 'fs/promises'; 4 + import { join } from 'path'; 5 + import { loadConfig } from '../src/lib/config/site'; 6 + 7 + interface LexiconSchema { 8 + lexicon: number; 9 + id: string; 10 + description?: string; 11 + defs: Record<string, any>; 12 + } 13 + 14 + function generateTypeScriptTypes(schema: LexiconSchema): string { 15 + const nsid = schema.id; 16 + const typeName = nsid.split('.').map(part => 17 + part.charAt(0).toUpperCase() + part.slice(1) 18 + ).join(''); 19 + 20 + const mainDef = schema.defs.main; 21 + if (!mainDef || mainDef.type !== 'record') { 22 + throw new Error(`Schema ${nsid} must have a 'main' record definition`); 23 + } 24 + 25 + const recordSchema = mainDef.record; 26 + const properties = recordSchema.properties || {}; 27 + const required = recordSchema.required || []; 28 + 29 + // Generate property types 30 + const propertyTypes: string[] = []; 31 + 32 + for (const [propName, propSchema] of Object.entries(properties)) { 33 + const isRequired = required.includes(propName); 34 + const optional = isRequired ? '' : '?'; 35 + 36 + let type: string; 37 + 38 + switch (propSchema.type) { 39 + case 'string': 40 + if (propSchema.enum) { 41 + type = propSchema.enum.map((v: string) => `'${v}'`).join(' | '); 42 + } else { 43 + type = 'string'; 44 + } 45 + break; 46 + case 'integer': 47 + type = 'number'; 48 + break; 49 + case 'boolean': 50 + type = 'boolean'; 51 + break; 52 + case 'array': 53 + type = 'any[]'; // Could be more specific based on items schema 54 + break; 55 + case 'object': 56 + type = 'Record<string, any>'; 57 + break; 58 + default: 59 + type = 'any'; 60 + } 61 + 62 + propertyTypes.push(` ${propName}${optional}: ${type};`); 63 + } 64 + 65 + return `// Generated from lexicon schema: ${nsid} 66 + // Do not edit manually - regenerate with: npm run gen:types 67 + 68 + export interface ${typeName}Record { 69 + ${propertyTypes.join('\n')} 70 + } 71 + 72 + export interface ${typeName} { 73 + $type: '${nsid}'; 74 + value: ${typeName}Record; 75 + } 76 + 77 + // Helper type for discriminated unions 78 + export type ${typeName}Union = ${typeName}; 79 + `; 80 + } 81 + 82 + async function generateTypes() { 83 + const config = loadConfig(); 84 + const lexiconsDir = join(process.cwd(), 'src/lexicons'); 85 + const generatedDir = join(process.cwd(), 'src/lib/generated'); 86 + 87 + console.log('🔍 Scanning for lexicon schemas...'); 88 + 89 + try { 90 + const files = await readdir(lexiconsDir); 91 + const jsonFiles = files.filter(file => file.endsWith('.json')); 92 + 93 + if (jsonFiles.length === 0) { 94 + console.log('No lexicon schema files found in src/lexicons/'); 95 + return; 96 + } 97 + 98 + console.log(`Found ${jsonFiles.length} lexicon schema(s):`); 99 + 100 + const generatedTypes: string[] = []; 101 + const unionTypes: string[] = []; 102 + 103 + for (const file of jsonFiles) { 104 + const schemaPath = join(lexiconsDir, file); 105 + const schemaContent = await readFile(schemaPath, 'utf-8'); 106 + const schema: LexiconSchema = JSON.parse(schemaContent); 107 + 108 + console.log(` - ${schema.id} (${file})`); 109 + 110 + try { 111 + const typesCode = generateTypeScriptTypes(schema); 112 + const outputPath = join(generatedDir, `${schema.id.replace(/\./g, '-')}.ts`); 113 + 114 + await writeFile(outputPath, typesCode, 'utf-8'); 115 + console.log(` ✅ Generated types: ${outputPath}`); 116 + 117 + // Add to union types 118 + const typeName = schema.id.split('.').map(part => 119 + part.charAt(0).toUpperCase() + part.slice(1) 120 + ).join(''); 121 + unionTypes.push(typeName); 122 + generatedTypes.push(`import type { ${typeName} } from './${schema.id.replace(/\./g, '-')}';`); 123 + 124 + } catch (error) { 125 + console.error(` ❌ Failed to generate types for ${schema.id}:`, error); 126 + } 127 + } 128 + 129 + // Generate index file with union types 130 + if (generatedTypes.length > 0) { 131 + const indexContent = `// Generated index of all lexicon types 132 + // Do not edit manually - regenerate with: npm run gen:types 133 + 134 + ${generatedTypes.join('\n')} 135 + 136 + // Union type for all generated lexicon records 137 + export type GeneratedLexiconUnion = ${unionTypes.join(' | ')}; 138 + 139 + // Type map for component registry 140 + export type GeneratedLexiconTypeMap = { 141 + ${unionTypes.map(type => ` '${type}': ${type};`).join('\n')} 142 + }; 143 + `; 144 + 145 + const indexPath = join(generatedDir, 'lexicon-types.ts'); 146 + await writeFile(indexPath, indexContent, 'utf-8'); 147 + console.log(` ✅ Generated index: ${indexPath}`); 148 + } 149 + 150 + console.log('\n🎉 Type generation complete!'); 151 + console.log('\nNext steps:'); 152 + console.log('1. Import the generated types in your components'); 153 + console.log('2. Update the component registry with the new types'); 154 + console.log('3. Create components that use the strongly typed records'); 155 + 156 + } catch (error) { 157 + console.error('Error generating types:', error); 158 + process.exit(1); 159 + } 160 + } 161 + 162 + generateTypes();
+49
src/components/content/ContentDisplay.astro
··· 1 + --- 2 + import type { AtprotoRecord } from '../../lib/types/atproto'; 3 + import type { GeneratedLexiconUnion } from '../../lib/generated/lexicon-types'; 4 + import { getComponentInfo, autoAssignComponent } from '../../lib/components/registry'; 5 + import { loadConfig } from '../../lib/config/site'; 6 + 7 + interface Props { 8 + record: AtprotoRecord; 9 + showAuthor?: boolean; 10 + showTimestamp?: boolean; 11 + } 12 + 13 + const { record, showAuthor = true, showTimestamp = true } = Astro.props; 14 + const config = loadConfig(); 15 + 16 + // Extract $type from the record value 17 + const recordType = record.value?.$type || 'unknown'; 18 + 19 + // Try to get component info from registry 20 + const componentInfo = getComponentInfo(recordType as any) || autoAssignComponent(recordType); 21 + 22 + // Dynamic component import 23 + let Component: any = null; 24 + try { 25 + // Try to import the component dynamically 26 + const componentPath = `../../components/content/${componentInfo.component}.astro`; 27 + Component = await import(componentPath); 28 + } catch (error) { 29 + console.warn(`Component ${componentInfo.component} not found for type ${recordType}`); 30 + } 31 + 32 + --- 33 + 34 + {Component && <Component.default 35 + record={record.value} 36 + showAuthor={showAuthor} 37 + showTimestamp={showTimestamp} 38 + {...componentInfo.props} 39 + />} 40 + 41 + {process.env.NODE_ENV === 'development' && ( 42 + <div class="mt-4 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg text-sm"> 43 + <h3 class="font-semibold mb-2">Debug Info:</h3> 44 + <p><strong>Type:</strong> {recordType}</p> 45 + <p><strong>Component:</strong> {componentInfo.component}</p> 46 + <p><strong>Record:</strong></p> 47 + <pre class="text-xs overflow-auto">{JSON.stringify(record, null, 2)}</pre> 48 + </div> 49 + )}
+2 -11
src/components/content/WhitewindBlogPost.astro
··· 1 1 --- 2 2 import type { ComWhtwndBlogEntryRecord } from '../../lib/generated/com-whtwnd-blog-entry'; 3 + import { marked } from 'marked'; 3 4 4 5 interface Props { 5 6 record: ComWhtwndBlogEntryRecord; ··· 32 33 Published on {formatDate(published!)} 33 34 </div> 34 35 )} 35 - 36 - {showTags && record.tags && record.tags.length > 0 && ( 37 - <div class="flex flex-wrap gap-2 mb-4"> 38 - {record.tags.map((tag) => ( 39 - <span class="px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 text-xs rounded-full"> 40 - #{tag} 41 - </span> 42 - ))} 43 - </div> 44 - )} 45 36 </header> 46 37 47 38 <div class="prose prose-gray dark:prose-invert max-w-none"> 48 39 <div class="text-gray-700 dark:text-gray-300 leading-relaxed"> 49 - {record.content} 40 + <Fragment set:html={await marked(record.content || '')} /> 50 41 </div> 51 42 </div> 52 43 </article>
+66
src/lexicons/com.whtwnd.blog.entry.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.whtwnd.blog.entry", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A declaration of a post.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": [ 12 + "content" 13 + ], 14 + "properties": { 15 + "content": { 16 + "type": "string", 17 + "maxLength": 100000 18 + }, 19 + "createdAt": { 20 + "type": "string", 21 + "format": "datetime" 22 + }, 23 + "title": { 24 + "type": "string", 25 + "maxLength": 1000 26 + }, 27 + "subtitle": { 28 + "type": "string", 29 + "maxLength": 1000 30 + }, 31 + "ogp": { 32 + "type": "ref", 33 + "ref": "com.whtwnd.blog.defs#ogp" 34 + }, 35 + "theme": { 36 + "type": "string", 37 + "enum": [ 38 + "github-light" 39 + ] 40 + }, 41 + "blobs": { 42 + "type": "array", 43 + "items": { 44 + "type": "ref", 45 + "ref": "com.whtwnd.blog.defs#blobMetadata" 46 + } 47 + }, 48 + "isDraft": { 49 + "type": "boolean", 50 + "description": "(DEPRECATED) Marks this entry as draft to tell AppViews not to show it to anyone except for the author" 51 + }, 52 + "visibility": { 53 + "type": "string", 54 + "enum": [ 55 + "public", 56 + "url", 57 + "author" 58 + ], 59 + "default": "public", 60 + "description": "Tells the visibility of the article to AppView." 61 + } 62 + } 63 + } 64 + } 65 + } 66 + }
+32
src/lexicons/social.grain.gallery.item.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.gallery.item", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "required": ["createdAt", "gallery", "item"], 11 + "properties": { 12 + "createdAt": { 13 + "type": "string", 14 + "format": "datetime" 15 + }, 16 + "gallery": { 17 + "type": "string", 18 + "format": "at-uri" 19 + }, 20 + "item": { 21 + "type": "string", 22 + "format": "at-uri" 23 + }, 24 + "position": { 25 + "type": "integer", 26 + "default": 0 27 + } 28 + } 29 + } 30 + } 31 + } 32 + }
+30
src/lexicons/social.grain.gallery.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.gallery", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "required": ["title", "createdAt"], 11 + "properties": { 12 + "title": { "type": "string", "maxLength": 100 }, 13 + "description": { "type": "string", "maxLength": 1000 }, 14 + "facets": { 15 + "type": "array", 16 + "description": "Annotations of description text (mentions, URLs, hashtags, etc)", 17 + "items": { "type": "ref", "ref": "app.bsky.richtext.facet" } 18 + }, 19 + "labels": { 20 + "type": "union", 21 + "description": "Self-label values for this post. Effectively content warnings.", 22 + "refs": ["com.atproto.label.defs#selfLabels"] 23 + }, 24 + "updatedAt": { "type": "string", "format": "datetime" }, 25 + "createdAt": { "type": "string", "format": "datetime" } 26 + } 27 + } 28 + } 29 + } 30 + }
+30
src/lexicons/social.grain.photo.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.photo", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "required": ["photo", "aspectRatio", "createdAt"], 11 + "properties": { 12 + "photo": { 13 + "type": "blob", 14 + "accept": ["image/*"], 15 + "maxSize": 1000000 16 + }, 17 + "alt": { 18 + "type": "string", 19 + "description": "Alt text description of the image, for accessibility." 20 + }, 21 + "aspectRatio": { 22 + "type": "ref", 23 + "ref": "social.grain.defs#aspectRatio" 24 + }, 25 + "createdAt": { "type": "string", "format": "datetime" } 26 + } 27 + } 28 + } 29 + } 30 + }