A personal website powered by Astro and ATProto

Compare changes

Choose any two refs to compare.

+343
CONTENT_SYSTEM.md
··· 1 + # Content System Documentation 2 + 3 + This document explains how the ATProto content system works in this project, including real-time streaming, repository browsing, and type generation. 4 + 5 + ## Overview 6 + 7 + The content system is built around ATProto (Authenticated Transfer Protocol) and provides three main capabilities: 8 + 9 + 1. **Real-time Streaming**: Live updates from your ATProto repository 10 + 2. **Repository Browsing**: Explore collections and records from any ATProto account 11 + 3. **Type Generation**: Automatically generate TypeScript types for discovered lexicons 12 + 13 + ## Architecture 14 + 15 + ### Core Components 16 + 17 + ``` 18 + src/lib/atproto/ 19 + โ”œโ”€โ”€ client.ts # Basic ATProto API client 20 + โ”œโ”€โ”€ jetstream-client.ts # Real-time streaming via WebSocket 21 + โ””โ”€โ”€ atproto-browser.ts # Repository browsing and analysis 22 + ``` 23 + 24 + ### Test Pages 25 + 26 + ``` 27 + src/pages/ 28 + โ”œโ”€โ”€ jetstream-test.astro # Real-time streaming test 29 + โ”œโ”€โ”€ atproto-browser-test.astro # Repository browsing test 30 + โ”œโ”€โ”€ lexicon-generator-test.astro # Type generation test 31 + โ””โ”€โ”€ galleries.astro # Image galleries display 32 + ``` 33 + 34 + ## 1. Real-time Streaming (Jetstream) 35 + 36 + ### How It Works 37 + 38 + The jetstream system connects to ATProto's real-time streaming service to receive live updates from your repository. 39 + 40 + **Key Features:** 41 + - WebSocket connection to `wss://jetstream1.us-east.bsky.network/subscribe` 42 + - DID filtering (only shows your configured account) 43 + - Real-time commit events (create/update/delete) 44 + - Low latency updates 45 + 46 + **Configuration:** 47 + ```typescript 48 + // From src/lib/config/site.ts 49 + export const defaultConfig: SiteConfig = { 50 + atproto: { 51 + handle: 'tynanpurdy.com', 52 + did: 'did:plc:6ayddqghxhciedbaofoxkcbs', 53 + pdsUrl: 'https://bsky.social', 54 + }, 55 + // ... 56 + }; 57 + ``` 58 + 59 + **Usage:** 60 + ```typescript 61 + // In jetstream-test.astro 62 + const client = new JetstreamClient(); 63 + client.onRecord((record) => { 64 + console.log('New record:', record); 65 + // Display record in UI 66 + }); 67 + await client.startStreaming(); 68 + ``` 69 + 70 + **Message Format:** 71 + ```typescript 72 + interface JetstreamRecord { 73 + uri: string; 74 + cid: string; 75 + value: any; 76 + indexedAt: string; 77 + collection: string; 78 + $type: string; 79 + service: string; 80 + did: string; 81 + time_us: number; 82 + operation: 'create' | 'update' | 'delete'; 83 + } 84 + ``` 85 + 86 + ## 2. Repository Browsing (ATProto Browser) 87 + 88 + ### How It Works 89 + 90 + The ATProto browser allows you to explore any ATProto repository's collections and records, similar to atptools. 91 + 92 + **Key Features:** 93 + - Resolve handles to DIDs automatically 94 + - Discover all collections in a repository 95 + - Browse records from specific collections 96 + - Get repository metadata and profiles 97 + 98 + **API Endpoints Used:** 99 + ```typescript 100 + // Resolve handle to DID 101 + GET /xrpc/com.atproto.identity.resolveHandle?handle={handle} 102 + 103 + // Get repository info 104 + GET /xrpc/com.atproto.repo.describeRepo?repo={did} 105 + 106 + // Get records from collection 107 + GET /xrpc/com.atproto.repo.listRecords?repo={did}&collection={collection}&limit={limit} 108 + 109 + // Get specific record 110 + GET /xrpc/com.atproto.repo.getRecord?uri={uri} 111 + ``` 112 + 113 + **Usage:** 114 + ```typescript 115 + // In atproto-browser-test.astro 116 + const browser = new AtprotoBrowser(); 117 + 118 + // Get repository info 119 + const repoInfo = await browser.getRepoInfo('tynanpurdy.com'); 120 + 121 + // Get collections 122 + const collections = await browser.getAllCollections('tynanpurdy.com'); 123 + 124 + // Get records from collection 125 + const records = await browser.getCollectionRecords('tynanpurdy.com', 'app.bsky.feed.post', 50); 126 + ``` 127 + 128 + ## 3. Type Generation (Lexicon Generator) 129 + 130 + ### How It Works 131 + 132 + The lexicon generator analyzes your repository to discover all lexicon types and generates TypeScript interfaces for them. 133 + 134 + **Process:** 135 + 1. Get all collections from your repository 136 + 2. Sample records from each collection 137 + 3. Group records by `$type` 138 + 4. Analyze record structure and properties 139 + 5. Generate TypeScript interfaces 140 + 141 + **Generated Output:** 142 + ```typescript 143 + // Auto-generated TypeScript types 144 + export interface AppBskyFeedPost { 145 + $type: 'app.bsky.feed.post'; 146 + text: string; 147 + createdAt: string; 148 + // ... other properties 149 + } 150 + 151 + export interface CollectionTypes { 152 + 'app.bsky.feed.post': 'app.bsky.feed.post'; 153 + 'app.bsky.actor.profile': 'app.bsky.actor.profile'; 154 + // ... other collections 155 + } 156 + 157 + export type AllLexicons = AppBskyFeedPost | AppBskyActorProfile | /* ... */; 158 + 159 + // Helper functions 160 + export function isLexiconType(record: any, type: string): boolean; 161 + export function getCollectionTypes(collection: string): string[]; 162 + ``` 163 + 164 + **Usage:** 165 + ```typescript 166 + // In lexicon-generator-test.astro 167 + const generator = new SimpleLexiconGenerator(); 168 + const result = await generator.generateTypesForRepository(); 169 + // result.typeDefinitions contains the generated TypeScript code 170 + ``` 171 + 172 + ## Content Flow 173 + 174 + ### 1. Real-time Updates 175 + ``` 176 + ATProto Repository โ†’ Jetstream โ†’ WebSocket โ†’ UI Updates 177 + ``` 178 + 179 + ### 2. Repository Browsing 180 + ``` 181 + User Input โ†’ Handle Resolution โ†’ Collection Discovery โ†’ Record Fetching โ†’ UI Display 182 + ``` 183 + 184 + ### 3. Type Generation 185 + ``` 186 + Repository Analysis โ†’ Lexicon Discovery โ†’ Property Analysis โ†’ TypeScript Generation โ†’ File Export 187 + ``` 188 + 189 + ## Configuration 190 + 191 + ### Environment Variables 192 + ```env 193 + # Your ATProto account 194 + ATPROTO_HANDLE=tynanpurdy.com 195 + ATPROTO_DID=did:plc:6ayddqghxhciedbaofoxkcbs 196 + ATPROTO_PDS_URL=https://bsky.social 197 + 198 + # Site configuration 199 + SITE_TITLE=Your Site Title 200 + SITE_AUTHOR=Your Name 201 + ``` 202 + 203 + ### Site Configuration 204 + ```typescript 205 + // src/lib/config/site.ts 206 + export interface SiteConfig { 207 + site: { 208 + title: string; 209 + description: string; 210 + author: string; 211 + url: string; 212 + }; 213 + atproto: { 214 + handle: string; 215 + did: string; 216 + pdsUrl: string; 217 + }; 218 + } 219 + ``` 220 + 221 + ## Error Handling 222 + 223 + ### Common Issues 224 + 225 + 1. **Jetstream Connection Failures:** 226 + - Check WebSocket endpoint availability 227 + - Verify DID is correct 228 + - Check network connectivity 229 + 230 + 2. **Repository Access Errors:** 231 + - Verify handle/DID exists 232 + - Check PDS server availability 233 + - Ensure proper API permissions 234 + 235 + 3. **Type Generation Issues:** 236 + - Repository must have records to analyze 237 + - Check for valid lexicon types 238 + - Verify record structure 239 + 240 + ### Debugging 241 + 242 + ```typescript 243 + // Enable detailed logging 244 + console.log('๐Ÿ” Analyzing collection:', collection); 245 + console.log('๐Ÿ“ New record from jetstream:', record); 246 + console.log('โœ… Generated lexicon for:', $type); 247 + ``` 248 + 249 + ## Performance Considerations 250 + 251 + ### Caching 252 + - Repository metadata is cached to reduce API calls 253 + - Collection lists are cached during browsing sessions 254 + - Type generation results can be saved locally 255 + 256 + ### Rate Limiting 257 + - ATProto APIs have rate limits 258 + - Implement delays between requests 259 + - Use pagination for large collections 260 + 261 + ### Memory Management 262 + - Limit record samples during type generation 263 + - Clear old records from streaming buffers 264 + - Implement proper cleanup for WebSocket connections 265 + 266 + ## Future Enhancements 267 + 268 + ### Potential Improvements 269 + 270 + 1. **Enhanced Type Generation:** 271 + - Support for nested object types 272 + - Union type detection 273 + - Custom type annotations 274 + 275 + 2. **Advanced Streaming:** 276 + - Multiple DID filtering 277 + - Collection-specific streams 278 + - Event replay capabilities 279 + 280 + 3. **Repository Analysis:** 281 + - Statistical analysis of repository 282 + - Content type distribution 283 + - Usage patterns 284 + 285 + 4. **UI Enhancements:** 286 + - Real-time type updates 287 + - Interactive collection browsing 288 + - Advanced filtering options 289 + 290 + ## Integration with Other Systems 291 + 292 + ### ATProto Ecosystem 293 + - Compatible with any ATProto PDS 294 + - Works with custom lexicons 295 + - Supports Bluesky and other ATProto apps 296 + 297 + ### Development Workflow 298 + - Generated types can be used in TypeScript projects 299 + - Real-time updates can trigger build processes 300 + - Repository changes can trigger deployments 301 + 302 + ## Security Considerations 303 + 304 + ### Data Privacy 305 + - Only access public repository data 306 + - No authentication required for browsing 307 + - Streaming only shows public commits 308 + 309 + ### API Usage 310 + - Respect rate limits 311 + - Implement proper error handling 312 + - Use HTTPS for all API calls 313 + 314 + ## Troubleshooting 315 + 316 + ### Common Problems 317 + 318 + 1. **Jetstream not connecting:** 319 + - Check if endpoint is accessible 320 + - Verify DID format 321 + - Check browser WebSocket support 322 + 323 + 2. **No records found:** 324 + - Verify repository has content 325 + - Check collection names 326 + - Ensure proper API permissions 327 + 328 + 3. **Type generation fails:** 329 + - Repository must have records 330 + - Check for valid lexicon structure 331 + - Verify record format 332 + 333 + ### Debug Commands 334 + 335 + ```bash 336 + # Test ATProto API access 337 + curl "https://bsky.social/xrpc/com.atproto.repo.describeRepo?repo=did:plc:6ayddqghxhciedbaofoxkcbs" 338 + 339 + # Test jetstream connection 340 + wscat -c "wss://jetstream1.us-east.bsky.network/subscribe?dids=did:plc:6ayddqghxhciedbaofoxkcbs" 341 + ``` 342 + 343 + This content system provides a comprehensive foundation for working with ATProto data, from real-time streaming to type-safe development.
+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/atproto/atproto-browser'; 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.
+64 -161
README.md
··· 1 - # ATproto Personal Website Template 1 + # ATproto Personal Website 2 2 3 - A modern personal website template powered by Astro, Tailwind CSS, and the ATproto protocol. This template allows you to create a personal website that displays content from your ATproto repository, including Bluesky posts, custom lexicon types, and more. 3 + A personal website built with Astro that uses your ATproto repository as a CMS. Features full type safety, real-time updates, and automatic component routing. 4 4 5 5 ## Features 6 6 7 - - **Type-safe ATproto Integration**: Full TypeScript support for ATproto records and custom lexicon 8 - - **Component-driven Rendering**: Only render content types that have dedicated components 9 - - **Feed Support**: Display content from custom Bluesky feeds 10 - - **Custom Lexicon Support**: Easy to add new content types with custom components 11 - - **Theme Customization**: Configurable colors, fonts, and styling 12 - - **Performance Optimized**: Caching and efficient data fetching 13 - - **Responsive Design**: Works on all device sizes 14 - - **Dark Mode Support**: Built-in dark/light theme switching 15 - 16 - ## Supported Content Types 17 - 18 - - **Bluesky Posts**: Standard Bluesky posts with text, images, and embeds 19 - - **Whitewind Blog Posts**: Blog posts with titles, content, and tags 20 - - **Leaflet Publications**: Publications with categories and rich content 21 - - **Grain Image Galleries**: Image galleries with descriptions and captions 22 - 23 - ## Pages 24 - 25 - - **Home Page** (`/`): Displays your latest posts and links to other content 26 - - **Galleries Page** (`/galleries`): Shows all your grain.social image galleries 7 + - **Type-Safe Content**: Automatic TypeScript type generation from ATproto lexicon schemas 8 + - **Real-Time Updates**: Live content streaming via ATproto Jetstream 9 + - **Component Registry**: Type-safe mapping of lexicon types to Astro components 10 + - **Dynamic Routing**: Automatic component selection based on record types 11 + - **Blog Support**: Full blog post rendering with markdown support 12 + - **Gallery Display**: Image galleries with EXIF data and hover effects 13 + - **Pagination**: Fetch more than 100 records with cursor-based pagination 27 14 28 15 ## Quick Start 29 16 30 - 1. **Clone the template**: 17 + 1. **Configure Environment**: 31 18 ```bash 32 - git clone <your-repo-url> 33 - cd your-website 19 + cp env.example .env 20 + # Edit .env with your ATproto handle and DID 34 21 ``` 35 22 36 - 2. **Install dependencies**: 23 + 2. **Install Dependencies**: 37 24 ```bash 38 25 npm install 39 26 ``` 40 27 41 - 3. **Configure your environment**: 42 - ```bash 43 - cp env.example .env 44 - ``` 45 - 46 - Edit `.env` with your configuration: 47 - ```env 48 - ATPROTO_DID=did:plc:your-did-here 49 - SITE_TITLE=My Personal Website 50 - SITE_AUTHOR=Your Name 51 - ``` 28 + 3. **Add Lexicon Schemas**: 29 + - Place JSON lexicon schemas in `src/lexicons/` 30 + - Update `src/lib/config/site.ts` with your lexicon sources 31 + - Run `npm run gen:types` to generate TypeScript types 32 + 33 + 4. **Create Components**: 34 + - Create Astro components in `src/components/content/` 35 + - Register them in `src/lib/components/registry.ts` 36 + - Use `ContentDisplay.astro` for automatic routing 52 37 53 - 4. **Start development server**: 38 + 5. **Start Development**: 54 39 ```bash 55 40 npm run dev 56 41 ``` 57 42 58 - ## Configuration 59 - 60 - ### Environment Variables 43 + ## Lexicon Integration 61 44 62 - | Variable | Description | Default | 63 - |----------|-------------|---------| 64 - | `ATPROTO_DID` | Your ATproto DID | Required | 65 - | `ATPROTO_PDS_URL` | PDS server URL | `https://bsky.social` | 66 - | `SITE_TITLE` | Website title | `My Personal Website` | 67 - | `SITE_DESCRIPTION` | Website description | `A personal website powered by ATproto` | 68 - | `SITE_AUTHOR` | Your name | `Your Name` | 69 - | `SITE_URL` | Your website URL | `https://example.com` | 70 - | `THEME_PRIMARY_COLOR` | Primary color | `#3b82f6` | 71 - | `THEME_SECONDARY_COLOR` | Secondary color | `#64748b` | 72 - | `THEME_ACCENT_COLOR` | Accent color | `#f59e0b` | 73 - | `THEME_FONT_FAMILY` | Font family | `Inter, system-ui, sans-serif` | 74 - | `CONTENT_DEFAULT_FEED_LIMIT` | Default feed limit | `20` | 75 - | `CONTENT_CACHE_TTL` | Cache TTL (ms) | `300000` | 45 + The system provides full type safety for ATproto lexicons: 76 46 77 - ## Usage 47 + 1. **Schema Files**: JSON lexicon definitions in `src/lexicons/` 48 + 2. **Type Generation**: Automatic TypeScript type generation 49 + 3. **Component Registry**: Type-safe mapping of lexicon types to components 50 + 4. **Content Display**: Dynamic component routing 78 51 79 - ### Adding Content Components 52 + See [LEXICON_INTEGRATION.md](./LEXICON_INTEGRATION.md) for detailed instructions. 80 53 81 - The template uses a component registry system. To add a new content type: 54 + ## Available Scripts 82 55 83 - 1. **Define the type** in `src/lib/types/atproto.ts`: 84 - ```typescript 85 - export interface MyCustomType extends CustomLexiconRecord { 86 - $type: 'app.bsky.actor.profile#myCustomType'; 87 - title: string; 88 - content: string; 89 - } 90 - ``` 91 - 92 - 2. **Create a component** in `src/components/content/`: 93 - ```astro 94 - --- 95 - interface Props { 96 - title: string; 97 - content: string; 98 - } 99 - const { title, content } = Astro.props; 100 - --- 101 - 102 - <article class="..."> 103 - <h2>{title}</h2> 104 - <div>{content}</div> 105 - </article> 106 - ``` 107 - 108 - 3. **Register the component** in `src/lib/components/register.ts`: 109 - ```typescript 110 - registerComponent('app.bsky.actor.profile#myCustomType', MyCustomComponent); 111 - ``` 112 - 113 - ### Using Feed Components 114 - 115 - Display content from a Bluesky feed: 116 - 117 - ```astro 118 - <BlueskyFeed 119 - feedUri="at://did:plc:.../app.bsky.feed.generator/..." 120 - limit={10} 121 - showAuthor={true} 122 - showTimestamp={true} 123 - /> 124 - ``` 125 - 126 - Display content from your repository: 127 - 128 - ```astro 129 - <ContentFeed 130 - did="did:plc:your-did" 131 - limit={20} 132 - showAuthor={false} 133 - showTimestamp={true} 134 - /> 135 - ``` 136 - 137 - ### Displaying Image Galleries 138 - 139 - The template includes a dedicated galleries page that displays all your grain.social image galleries: 140 - 141 - ```astro 142 - <GrainImageGallery 143 - gallery={galleryData} 144 - showDescription={true} 145 - showTimestamp={true} 146 - columns={3} 147 - /> 148 - ``` 149 - 150 - Visit `/galleries` to see all your image galleries in a beautiful grid layout. 56 + - `npm run dev` - Start development server 57 + - `npm run build` - Build for production 58 + - `npm run preview` - Preview production build 59 + - `npm run discover` - Discover collections from your repo 60 + - `npm run gen:types` - Generate TypeScript types from lexicon schemas 151 61 152 62 ## Project Structure 153 63 154 64 ``` 155 65 src/ 66 + โ”œโ”€โ”€ components/content/ # Content display components 156 67 โ”œโ”€โ”€ lib/ 157 - โ”‚ โ”œโ”€โ”€ atproto/ # ATproto API integration 68 + โ”‚ โ”œโ”€โ”€ atproto/ # ATproto client and utilities 158 69 โ”‚ โ”œโ”€โ”€ components/ # Component registry 159 70 โ”‚ โ”œโ”€โ”€ config/ # Site configuration 160 - โ”‚ โ””โ”€โ”€ types/ # TypeScript definitions 161 - โ”œโ”€โ”€ components/ 162 - โ”‚ โ”œโ”€โ”€ content/ # Content rendering components 163 - โ”‚ โ”œโ”€โ”€ layout/ # Layout components 164 - โ”‚ โ””โ”€โ”€ ui/ # UI components 165 - โ”œโ”€โ”€ pages/ # Astro pages 166 - โ””โ”€โ”€ styles/ # Global styles 71 + โ”‚ โ”œโ”€โ”€ generated/ # Generated TypeScript types 72 + โ”‚ โ”œโ”€โ”€ services/ # Content services 73 + โ”‚ โ””โ”€โ”€ types/ # Type definitions 74 + โ”œโ”€โ”€ lexicons/ # Lexicon schema files 75 + โ””โ”€โ”€ pages/ # Astro pages 167 76 ``` 168 77 169 - ## Deployment 170 - 171 - ### Cloudflare Pages 172 - 173 - 1. Connect your repository to Cloudflare Pages 174 - 2. Set build command: `npm run build` 175 - 3. Set build output directory: `dist` 176 - 4. Add environment variables in Cloudflare Pages settings 78 + ## Configuration 177 79 178 - ### Other Platforms 80 + The system is configured via environment variables and `src/lib/config/site.ts`: 179 81 180 - The site can be deployed to any static hosting platform that supports Astro: 181 - - Vercel 182 - - Netlify 183 - - GitHub Pages 184 - - etc. 82 + - `ATPROTO_HANDLE` - Your Bluesky handle 83 + - `ATPROTO_DID` - Your DID (optional, auto-resolved) 84 + - `SITE_TITLE` - Site title 85 + - `SITE_DESCRIPTION` - Site description 86 + - `SITE_AUTHOR` - Site author 185 87 186 - ## Custom Lexicon 88 + ## Adding New Content Types 187 89 188 - To publish custom lexicon for others to use: 90 + 1. Create a lexicon schema in `src/lexicons/` 91 + 2. Add it to `lexiconSources` in site config 92 + 3. Run `npm run gen:types` 93 + 4. Create a component in `src/components/content/` 94 + 5. Register it in `src/lib/components/registry.ts` 189 95 190 - 1. Define your lexicon schema following the ATproto specification 191 - 2. Publish to your PDS or a public repository 192 - 3. Create components for rendering your custom types 193 - 4. Document the lexicon for other developers 96 + The system will automatically route records to your components with full type safety. 194 97 195 - ## Contributing 98 + ## Development 196 99 197 - 1. Fork the repository 198 - 2. Create a feature branch 199 - 3. Make your changes 200 - 4. Add tests if applicable 201 - 5. Submit a pull request 100 + - Debug mode shows component routing information 101 + - Generic fallback for unknown record types 102 + - Real-time updates via Jetstream 103 + - Type-safe component registry 104 + - Automatic type generation from schemas 202 105 203 106 ## License 204 107 205 - MIT License - see LICENSE file for details. 108 + MIT
+494 -2
package-lock.json
··· 9 9 "version": "0.0.1", 10 10 "dependencies": { 11 11 "@astrojs/check": "^0.9.4", 12 + "@atcute/jetstream": "^1.0.2", 12 13 "@atproto/api": "^0.16.2", 13 14 "@atproto/xrpc": "^0.7.1", 15 + "@nulfrost/leaflet-loader-astro": "^1.1.0", 14 16 "@tailwindcss/typography": "^0.5.16", 15 17 "@tailwindcss/vite": "^4.1.11", 16 18 "@types/node": "^24.2.0", 17 19 "astro": "^5.12.8", 18 20 "dotenv": "^17.2.1", 21 + "marked": "^12.0.2", 19 22 "tailwindcss": "^4.1.11", 23 + "tsx": "^4.19.2", 20 24 "typescript": "^5.9.2" 25 + }, 26 + "devDependencies": { 27 + "@atproto/lex-cli": "^0.9.1" 21 28 } 22 29 }, 23 30 "node_modules/@ampproject/remapping": { ··· 172 179 "yaml": "^2.5.0" 173 180 } 174 181 }, 182 + "node_modules/@atcute/client": { 183 + "version": "4.0.3", 184 + "resolved": "https://registry.npmjs.org/@atcute/client/-/client-4.0.3.tgz", 185 + "integrity": "sha512-RIOZWFVLca/HiPAAUDqQPOdOreCxTbL5cb+WUf5yqQOKIu5yEAP3eksinmlLmgIrlr5qVOE7brazUUzaskFCfw==", 186 + "license": "MIT", 187 + "dependencies": { 188 + "@atcute/identity": "^1.0.2", 189 + "@atcute/lexicons": "^1.0.3" 190 + } 191 + }, 192 + "node_modules/@atcute/identity": { 193 + "version": "1.0.3", 194 + "resolved": "https://registry.npmjs.org/@atcute/identity/-/identity-1.0.3.tgz", 195 + "integrity": "sha512-mNMxbKHFGys03A8JXKk0KfMBzdd0vrYMzZZWjpw1nYTs0+ea6bo5S1hwqVUZxHdo1gFHSe/t63jxQIF4yL9aKw==", 196 + "license": "0BSD", 197 + "dependencies": { 198 + "@atcute/lexicons": "^1.0.4", 199 + "@badrap/valita": "^0.4.5" 200 + } 201 + }, 202 + "node_modules/@atcute/jetstream": { 203 + "version": "1.0.2", 204 + "resolved": "https://registry.npmjs.org/@atcute/jetstream/-/jetstream-1.0.2.tgz", 205 + "integrity": "sha512-ZtdNNxl4zq9cgUpXSL9F+AsXUZt0Zuyj0V7974D7LxdMxfTItPnMZ9dRG8GoFkkGz3+pszdsG888Ix8C0F2+mA==", 206 + "license": "MIT", 207 + "dependencies": { 208 + "@atcute/lexicons": "^1.0.2", 209 + "@badrap/valita": "^0.4.2", 210 + "@mary-ext/event-iterator": "^1.0.0", 211 + "@mary-ext/simple-event-emitter": "^1.0.0", 212 + "partysocket": "^1.1.4", 213 + "type-fest": "^4.41.0", 214 + "yocto-queue": "^1.2.1" 215 + } 216 + }, 217 + "node_modules/@atcute/lexicons": { 218 + "version": "1.1.0", 219 + "resolved": "https://registry.npmjs.org/@atcute/lexicons/-/lexicons-1.1.0.tgz", 220 + "integrity": "sha512-LFqwnria78xLYb62Ri/+WwQpUTgZp2DuyolNGIIOV1dpiKhFFFh//nscHMA6IExFLQRqWDs3tTjy7zv0h3sf1Q==", 221 + "license": "0BSD", 222 + "dependencies": { 223 + "esm-env": "^1.2.2" 224 + } 225 + }, 175 226 "node_modules/@atproto/api": { 176 227 "version": "0.16.2", 177 228 "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.16.2.tgz", ··· 200 251 "zod": "^3.23.8" 201 252 } 202 253 }, 254 + "node_modules/@atproto/lex-cli": { 255 + "version": "0.9.1", 256 + "resolved": "https://registry.npmjs.org/@atproto/lex-cli/-/lex-cli-0.9.1.tgz", 257 + "integrity": "sha512-ftcUZd8rElHeUJq6pTcQkURnTEe7woCF4I1NK3j5GpT/itacEZtcppabjy5o2aUsbktZsALj3ch3xm7ZZ+Zp0w==", 258 + "dev": true, 259 + "license": "MIT", 260 + "dependencies": { 261 + "@atproto/lexicon": "^0.4.12", 262 + "@atproto/syntax": "^0.4.0", 263 + "chalk": "^4.1.2", 264 + "commander": "^9.4.0", 265 + "prettier": "^3.2.5", 266 + "ts-morph": "^24.0.0", 267 + "yesno": "^0.4.0", 268 + "zod": "^3.23.8" 269 + }, 270 + "bin": { 271 + "lex": "dist/index.js" 272 + }, 273 + "engines": { 274 + "node": ">=18.7.0" 275 + } 276 + }, 277 + "node_modules/@atproto/lex-cli/node_modules/ansi-styles": { 278 + "version": "4.3.0", 279 + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", 280 + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", 281 + "dev": true, 282 + "license": "MIT", 283 + "dependencies": { 284 + "color-convert": "^2.0.1" 285 + }, 286 + "engines": { 287 + "node": ">=8" 288 + }, 289 + "funding": { 290 + "url": "https://github.com/chalk/ansi-styles?sponsor=1" 291 + } 292 + }, 293 + "node_modules/@atproto/lex-cli/node_modules/chalk": { 294 + "version": "4.1.2", 295 + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", 296 + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", 297 + "dev": true, 298 + "license": "MIT", 299 + "dependencies": { 300 + "ansi-styles": "^4.1.0", 301 + "supports-color": "^7.1.0" 302 + }, 303 + "engines": { 304 + "node": ">=10" 305 + }, 306 + "funding": { 307 + "url": "https://github.com/chalk/chalk?sponsor=1" 308 + } 309 + }, 203 310 "node_modules/@atproto/lexicon": { 204 311 "version": "0.4.12", 205 312 "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.4.12.tgz", ··· 273 380 }, 274 381 "engines": { 275 382 "node": ">=6.9.0" 383 + } 384 + }, 385 + "node_modules/@badrap/valita": { 386 + "version": "0.4.6", 387 + "resolved": "https://registry.npmjs.org/@badrap/valita/-/valita-0.4.6.tgz", 388 + "integrity": "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==", 389 + "license": "MIT", 390 + "engines": { 391 + "node": ">= 18" 276 392 } 277 393 }, 278 394 "node_modules/@capsizecss/unpack": { ··· 1175 1291 "@jridgewell/sourcemap-codec": "^1.4.14" 1176 1292 } 1177 1293 }, 1294 + "node_modules/@mary-ext/event-iterator": { 1295 + "version": "1.0.0", 1296 + "resolved": "https://registry.npmjs.org/@mary-ext/event-iterator/-/event-iterator-1.0.0.tgz", 1297 + "integrity": "sha512-l6gCPsWJ8aRCe/s7/oCmero70kDHgIK5m4uJvYgwEYTqVxoBOIXbKr5tnkLqUHEg6mNduB4IWvms3h70Hp9ADQ==", 1298 + "license": "BSD-3-Clause", 1299 + "dependencies": { 1300 + "yocto-queue": "^1.2.1" 1301 + } 1302 + }, 1303 + "node_modules/@mary-ext/simple-event-emitter": { 1304 + "version": "1.0.0", 1305 + "resolved": "https://registry.npmjs.org/@mary-ext/simple-event-emitter/-/simple-event-emitter-1.0.0.tgz", 1306 + "integrity": "sha512-meA/zJZKIN1RVBNEYIbjufkUrW7/tRjHH60FjolpG1ixJKo76TB208qefQLNdOVDA7uIG0CGEDuhmMirtHKLAg==", 1307 + "license": "BSD-3-Clause" 1308 + }, 1178 1309 "node_modules/@nodelib/fs.scandir": { 1179 1310 "version": "2.1.5", 1180 1311 "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", ··· 1208 1339 }, 1209 1340 "engines": { 1210 1341 "node": ">= 8" 1342 + } 1343 + }, 1344 + "node_modules/@nulfrost/leaflet-loader-astro": { 1345 + "version": "1.1.0", 1346 + "resolved": "https://registry.npmjs.org/@nulfrost/leaflet-loader-astro/-/leaflet-loader-astro-1.1.0.tgz", 1347 + "integrity": "sha512-A6ONOmds3/3pVFfa+YdpC5YMfOF1shvczAOnSWfVtUYz3bl3NRz26KieUrGW+26iVPgUtHPRLQOfygImrLrhYw==", 1348 + "license": "MIT", 1349 + "dependencies": { 1350 + "@atcute/client": "^4.0.3", 1351 + "@atcute/lexicons": "^1.1.0", 1352 + "@atproto/api": "^0.16.2", 1353 + "katex": "^0.16.22", 1354 + "sanitize-html": "^2.17.0" 1211 1355 } 1212 1356 }, 1213 1357 "node_modules/@oslojs/encoding": { ··· 1857 2001 "vite": "^5.2.0 || ^6 || ^7" 1858 2002 } 1859 2003 }, 2004 + "node_modules/@ts-morph/common": { 2005 + "version": "0.25.0", 2006 + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.25.0.tgz", 2007 + "integrity": "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg==", 2008 + "dev": true, 2009 + "license": "MIT", 2010 + "dependencies": { 2011 + "minimatch": "^9.0.4", 2012 + "path-browserify": "^1.0.1", 2013 + "tinyglobby": "^0.2.9" 2014 + } 2015 + }, 1860 2016 "node_modules/@types/debug": { 1861 2017 "version": "4.1.12", 1862 2018 "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", ··· 2287 2443 "url": "https://github.com/sponsors/wooorm" 2288 2444 } 2289 2445 }, 2446 + "node_modules/balanced-match": { 2447 + "version": "1.0.2", 2448 + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", 2449 + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", 2450 + "dev": true, 2451 + "license": "MIT" 2452 + }, 2290 2453 "node_modules/base-64": { 2291 2454 "version": "1.0.0", 2292 2455 "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", ··· 2353 2516 }, 2354 2517 "funding": { 2355 2518 "url": "https://github.com/sponsors/sindresorhus" 2519 + } 2520 + }, 2521 + "node_modules/brace-expansion": { 2522 + "version": "2.0.2", 2523 + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", 2524 + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", 2525 + "dev": true, 2526 + "license": "MIT", 2527 + "dependencies": { 2528 + "balanced-match": "^1.0.0" 2356 2529 } 2357 2530 }, 2358 2531 "node_modules/braces": { ··· 2596 2769 "node": ">=6" 2597 2770 } 2598 2771 }, 2772 + "node_modules/code-block-writer": { 2773 + "version": "13.0.3", 2774 + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", 2775 + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", 2776 + "dev": true, 2777 + "license": "MIT" 2778 + }, 2599 2779 "node_modules/color": { 2600 2780 "version": "4.2.3", 2601 2781 "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", ··· 2649 2829 "url": "https://github.com/sponsors/wooorm" 2650 2830 } 2651 2831 }, 2832 + "node_modules/commander": { 2833 + "version": "9.5.0", 2834 + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", 2835 + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", 2836 + "dev": true, 2837 + "license": "MIT", 2838 + "engines": { 2839 + "node": "^12.20.0 || >=14" 2840 + } 2841 + }, 2652 2842 "node_modules/common-ancestor-path": { 2653 2843 "version": "1.0.1", 2654 2844 "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-1.0.1.tgz", ··· 2743 2933 "url": "https://github.com/sponsors/wooorm" 2744 2934 } 2745 2935 }, 2936 + "node_modules/deepmerge": { 2937 + "version": "4.3.1", 2938 + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", 2939 + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", 2940 + "license": "MIT", 2941 + "engines": { 2942 + "node": ">=0.10.0" 2943 + } 2944 + }, 2746 2945 "node_modules/defu": { 2747 2946 "version": "6.1.4", 2748 2947 "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", ··· 2825 3024 "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", 2826 3025 "license": "MIT" 2827 3026 }, 3027 + "node_modules/dom-serializer": { 3028 + "version": "2.0.0", 3029 + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", 3030 + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", 3031 + "license": "MIT", 3032 + "dependencies": { 3033 + "domelementtype": "^2.3.0", 3034 + "domhandler": "^5.0.2", 3035 + "entities": "^4.2.0" 3036 + }, 3037 + "funding": { 3038 + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" 3039 + } 3040 + }, 3041 + "node_modules/dom-serializer/node_modules/entities": { 3042 + "version": "4.5.0", 3043 + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", 3044 + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", 3045 + "license": "BSD-2-Clause", 3046 + "engines": { 3047 + "node": ">=0.12" 3048 + }, 3049 + "funding": { 3050 + "url": "https://github.com/fb55/entities?sponsor=1" 3051 + } 3052 + }, 3053 + "node_modules/domelementtype": { 3054 + "version": "2.3.0", 3055 + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", 3056 + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", 3057 + "funding": [ 3058 + { 3059 + "type": "github", 3060 + "url": "https://github.com/sponsors/fb55" 3061 + } 3062 + ], 3063 + "license": "BSD-2-Clause" 3064 + }, 3065 + "node_modules/domhandler": { 3066 + "version": "5.0.3", 3067 + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", 3068 + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", 3069 + "license": "BSD-2-Clause", 3070 + "dependencies": { 3071 + "domelementtype": "^2.3.0" 3072 + }, 3073 + "engines": { 3074 + "node": ">= 4" 3075 + }, 3076 + "funding": { 3077 + "url": "https://github.com/fb55/domhandler?sponsor=1" 3078 + } 3079 + }, 3080 + "node_modules/domutils": { 3081 + "version": "3.2.2", 3082 + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", 3083 + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", 3084 + "license": "BSD-2-Clause", 3085 + "dependencies": { 3086 + "dom-serializer": "^2.0.0", 3087 + "domelementtype": "^2.3.0", 3088 + "domhandler": "^5.0.3" 3089 + }, 3090 + "funding": { 3091 + "url": "https://github.com/fb55/domutils?sponsor=1" 3092 + } 3093 + }, 2828 3094 "node_modules/dotenv": { 2829 3095 "version": "17.2.1", 2830 3096 "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz", ··· 2960 3226 "funding": { 2961 3227 "url": "https://github.com/sponsors/sindresorhus" 2962 3228 } 3229 + }, 3230 + "node_modules/esm-env": { 3231 + "version": "1.2.2", 3232 + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", 3233 + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", 3234 + "license": "MIT" 2963 3235 }, 2964 3236 "node_modules/estree-walker": { 2965 3237 "version": "3.0.3", ··· 2970 3242 "@types/estree": "^1.0.0" 2971 3243 } 2972 3244 }, 3245 + "node_modules/event-target-polyfill": { 3246 + "version": "0.0.4", 3247 + "resolved": "https://registry.npmjs.org/event-target-polyfill/-/event-target-polyfill-0.0.4.tgz", 3248 + "integrity": "sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ==", 3249 + "license": "MIT" 3250 + }, 2973 3251 "node_modules/eventemitter3": { 2974 3252 "version": "5.0.1", 2975 3253 "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", ··· 3126 3404 "url": "https://github.com/sponsors/sindresorhus" 3127 3405 } 3128 3406 }, 3407 + "node_modules/get-tsconfig": { 3408 + "version": "4.10.1", 3409 + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", 3410 + "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", 3411 + "license": "MIT", 3412 + "dependencies": { 3413 + "resolve-pkg-maps": "^1.0.0" 3414 + }, 3415 + "funding": { 3416 + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" 3417 + } 3418 + }, 3129 3419 "node_modules/github-slugger": { 3130 3420 "version": "2.0.0", 3131 3421 "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", ··· 3171 3461 "radix3": "^1.1.2", 3172 3462 "ufo": "^1.6.1", 3173 3463 "uncrypto": "^0.1.3" 3464 + } 3465 + }, 3466 + "node_modules/has-flag": { 3467 + "version": "4.0.0", 3468 + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", 3469 + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", 3470 + "dev": true, 3471 + "license": "MIT", 3472 + "engines": { 3473 + "node": ">=8" 3174 3474 } 3175 3475 }, 3176 3476 "node_modules/hast-util-from-html": { ··· 3376 3676 "url": "https://github.com/sponsors/wooorm" 3377 3677 } 3378 3678 }, 3679 + "node_modules/htmlparser2": { 3680 + "version": "8.0.2", 3681 + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", 3682 + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", 3683 + "funding": [ 3684 + "https://github.com/fb55/htmlparser2?sponsor=1", 3685 + { 3686 + "type": "github", 3687 + "url": "https://github.com/sponsors/fb55" 3688 + } 3689 + ], 3690 + "license": "MIT", 3691 + "dependencies": { 3692 + "domelementtype": "^2.3.0", 3693 + "domhandler": "^5.0.3", 3694 + "domutils": "^3.0.1", 3695 + "entities": "^4.4.0" 3696 + } 3697 + }, 3698 + "node_modules/htmlparser2/node_modules/entities": { 3699 + "version": "4.5.0", 3700 + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", 3701 + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", 3702 + "license": "BSD-2-Clause", 3703 + "engines": { 3704 + "node": ">=0.12" 3705 + }, 3706 + "funding": { 3707 + "url": "https://github.com/fb55/entities?sponsor=1" 3708 + } 3709 + }, 3379 3710 "node_modules/http-cache-semantics": { 3380 3711 "version": "4.2.0", 3381 3712 "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", ··· 3492 3823 "url": "https://github.com/sponsors/sindresorhus" 3493 3824 } 3494 3825 }, 3826 + "node_modules/is-plain-object": { 3827 + "version": "5.0.0", 3828 + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", 3829 + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", 3830 + "license": "MIT", 3831 + "engines": { 3832 + "node": ">=0.10.0" 3833 + } 3834 + }, 3495 3835 "node_modules/is-wsl": { 3496 3836 "version": "3.1.0", 3497 3837 "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", ··· 3545 3885 "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.3.1.tgz", 3546 3886 "integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==", 3547 3887 "license": "MIT" 3888 + }, 3889 + "node_modules/katex": { 3890 + "version": "0.16.22", 3891 + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz", 3892 + "integrity": "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==", 3893 + "funding": [ 3894 + "https://opencollective.com/katex", 3895 + "https://github.com/sponsors/katex" 3896 + ], 3897 + "license": "MIT", 3898 + "dependencies": { 3899 + "commander": "^8.3.0" 3900 + }, 3901 + "bin": { 3902 + "katex": "cli.js" 3903 + } 3904 + }, 3905 + "node_modules/katex/node_modules/commander": { 3906 + "version": "8.3.0", 3907 + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", 3908 + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", 3909 + "license": "MIT", 3910 + "engines": { 3911 + "node": ">= 12" 3912 + } 3548 3913 }, 3549 3914 "node_modules/kleur": { 3550 3915 "version": "4.1.5", ··· 3853 4218 "url": "https://github.com/sponsors/wooorm" 3854 4219 } 3855 4220 }, 4221 + "node_modules/marked": { 4222 + "version": "12.0.2", 4223 + "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz", 4224 + "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==", 4225 + "license": "MIT", 4226 + "bin": { 4227 + "marked": "bin/marked.js" 4228 + }, 4229 + "engines": { 4230 + "node": ">= 18" 4231 + } 4232 + }, 3856 4233 "node_modules/mdast-util-definitions": { 3857 4234 "version": "6.0.0", 3858 4235 "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz", ··· 4681 5058 "url": "https://github.com/sponsors/jonschlinkert" 4682 5059 } 4683 5060 }, 5061 + "node_modules/minimatch": { 5062 + "version": "9.0.5", 5063 + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", 5064 + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", 5065 + "dev": true, 5066 + "license": "ISC", 5067 + "dependencies": { 5068 + "brace-expansion": "^2.0.1" 5069 + }, 5070 + "engines": { 5071 + "node": ">=16 || 14 >=14.17" 5072 + }, 5073 + "funding": { 5074 + "url": "https://github.com/sponsors/isaacs" 5075 + } 5076 + }, 4684 5077 "node_modules/minipass": { 4685 5078 "version": "7.1.2", 4686 5079 "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", ··· 4932 5325 "url": "https://github.com/sponsors/wooorm" 4933 5326 } 4934 5327 }, 5328 + "node_modules/parse-srcset": { 5329 + "version": "1.0.2", 5330 + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", 5331 + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", 5332 + "license": "MIT" 5333 + }, 4935 5334 "node_modules/parse5": { 4936 5335 "version": "7.3.0", 4937 5336 "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", ··· 4944 5343 "url": "https://github.com/inikulin/parse5?sponsor=1" 4945 5344 } 4946 5345 }, 5346 + "node_modules/partysocket": { 5347 + "version": "1.1.5", 5348 + "resolved": "https://registry.npmjs.org/partysocket/-/partysocket-1.1.5.tgz", 5349 + "integrity": "sha512-8uw9foq9bij4sKLCtTSHvyqMrMTQ5FJjrHc7BjoM2s95Vu7xYCN63ABpI7OZHC7ZMP5xaom/A+SsoFPXmTV6ZQ==", 5350 + "license": "MIT", 5351 + "dependencies": { 5352 + "event-target-polyfill": "^0.0.4" 5353 + } 5354 + }, 4947 5355 "node_modules/path-browserify": { 4948 5356 "version": "1.0.1", 4949 5357 "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", ··· 5013 5421 "version": "3.6.2", 5014 5422 "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", 5015 5423 "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", 5424 + "devOptional": true, 5016 5425 "license": "MIT", 5017 - "optional": true, 5018 - "peer": true, 5019 5426 "bin": { 5020 5427 "prettier": "bin/prettier.cjs" 5021 5428 }, ··· 5296 5703 "node": ">=0.10.0" 5297 5704 } 5298 5705 }, 5706 + "node_modules/resolve-pkg-maps": { 5707 + "version": "1.0.0", 5708 + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", 5709 + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", 5710 + "license": "MIT", 5711 + "funding": { 5712 + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" 5713 + } 5714 + }, 5299 5715 "node_modules/restructure": { 5300 5716 "version": "3.0.2", 5301 5717 "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", ··· 5435 5851 "queue-microtask": "^1.2.2" 5436 5852 } 5437 5853 }, 5854 + "node_modules/sanitize-html": { 5855 + "version": "2.17.0", 5856 + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.0.tgz", 5857 + "integrity": "sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==", 5858 + "license": "MIT", 5859 + "dependencies": { 5860 + "deepmerge": "^4.2.2", 5861 + "escape-string-regexp": "^4.0.0", 5862 + "htmlparser2": "^8.0.0", 5863 + "is-plain-object": "^5.0.0", 5864 + "parse-srcset": "^1.0.2", 5865 + "postcss": "^8.3.11" 5866 + } 5867 + }, 5868 + "node_modules/sanitize-html/node_modules/escape-string-regexp": { 5869 + "version": "4.0.0", 5870 + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", 5871 + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", 5872 + "license": "MIT", 5873 + "engines": { 5874 + "node": ">=10" 5875 + }, 5876 + "funding": { 5877 + "url": "https://github.com/sponsors/sindresorhus" 5878 + } 5879 + }, 5438 5880 "node_modules/semver": { 5439 5881 "version": "7.7.2", 5440 5882 "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", ··· 5596 6038 "url": "https://github.com/chalk/strip-ansi?sponsor=1" 5597 6039 } 5598 6040 }, 6041 + "node_modules/supports-color": { 6042 + "version": "7.2.0", 6043 + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", 6044 + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", 6045 + "dev": true, 6046 + "license": "MIT", 6047 + "dependencies": { 6048 + "has-flag": "^4.0.0" 6049 + }, 6050 + "engines": { 6051 + "node": ">=8" 6052 + } 6053 + }, 5599 6054 "node_modules/tailwindcss": { 5600 6055 "version": "4.1.11", 5601 6056 "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz", ··· 5703 6158 "url": "https://github.com/sponsors/wooorm" 5704 6159 } 5705 6160 }, 6161 + "node_modules/ts-morph": { 6162 + "version": "24.0.0", 6163 + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-24.0.0.tgz", 6164 + "integrity": "sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw==", 6165 + "dev": true, 6166 + "license": "MIT", 6167 + "dependencies": { 6168 + "@ts-morph/common": "~0.25.0", 6169 + "code-block-writer": "^13.0.3" 6170 + } 6171 + }, 5706 6172 "node_modules/tsconfck": { 5707 6173 "version": "3.1.6", 5708 6174 "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", ··· 5728 6194 "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", 5729 6195 "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", 5730 6196 "license": "0BSD" 6197 + }, 6198 + "node_modules/tsx": { 6199 + "version": "4.20.3", 6200 + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz", 6201 + "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", 6202 + "license": "MIT", 6203 + "dependencies": { 6204 + "esbuild": "~0.25.0", 6205 + "get-tsconfig": "^4.7.5" 6206 + }, 6207 + "bin": { 6208 + "tsx": "dist/cli.mjs" 6209 + }, 6210 + "engines": { 6211 + "node": ">=18.0.0" 6212 + }, 6213 + "optionalDependencies": { 6214 + "fsevents": "~2.3.3" 6215 + } 5731 6216 }, 5732 6217 "node_modules/type-fest": { 5733 6218 "version": "4.41.0", ··· 6707 7192 "engines": { 6708 7193 "node": ">=8" 6709 7194 } 7195 + }, 7196 + "node_modules/yesno": { 7197 + "version": "0.4.0", 7198 + "resolved": "https://registry.npmjs.org/yesno/-/yesno-0.4.0.tgz", 7199 + "integrity": "sha512-tdBxmHvbXPBKYIg81bMCB7bVeDmHkRzk5rVJyYYXurwKkHq/MCd8rz4HSJUP7hW0H2NlXiq8IFiWvYKEHhlotA==", 7200 + "dev": true, 7201 + "license": "BSD" 6710 7202 }, 6711 7203 "node_modules/yocto-queue": { 6712 7204 "version": "1.2.1",
+10 -1
package.json
··· 6 6 "dev": "astro dev", 7 7 "build": "astro build", 8 8 "preview": "astro preview", 9 - "astro": "astro" 9 + "astro": "astro", 10 + "discover": "tsx scripts/discover-collections.ts", 11 + "gen:types": "tsx scripts/generate-types.ts" 10 12 }, 11 13 "dependencies": { 12 14 "@astrojs/check": "^0.9.4", 15 + "@atcute/jetstream": "^1.0.2", 13 16 "@atproto/api": "^0.16.2", 14 17 "@atproto/xrpc": "^0.7.1", 18 + "@nulfrost/leaflet-loader-astro": "^1.1.0", 15 19 "@tailwindcss/typography": "^0.5.16", 16 20 "@tailwindcss/vite": "^4.1.11", 17 21 "@types/node": "^24.2.0", 18 22 "astro": "^5.12.8", 19 23 "dotenv": "^17.2.1", 24 + "marked": "^12.0.2", 20 25 "tailwindcss": "^4.1.11", 26 + "tsx": "^4.19.2", 21 27 "typescript": "^5.9.2" 28 + }, 29 + "devDependencies": { 30 + "@atproto/lex-cli": "^0.9.1" 22 31 } 23 32 }
+50
scripts/discover-collections.ts
··· 1 + #!/usr/bin/env node 2 + 3 + import { CollectionDiscovery } from '../src/lib/build/collection-discovery'; 4 + import { loadConfig } from '../src/lib/config/site'; 5 + import path from 'path'; 6 + 7 + async function main() { 8 + console.log('๐Ÿš€ Starting collection discovery...'); 9 + 10 + try { 11 + const config = loadConfig(); 12 + const discovery = new CollectionDiscovery(); 13 + 14 + if (!config.atproto.handle || config.atproto.handle === 'your-handle-here') { 15 + console.error('โŒ No ATProto handle configured. Please set ATPROTO_HANDLE in your environment.'); 16 + process.exit(1); 17 + } 18 + 19 + console.log(`๐Ÿ” Discovering collections for: ${config.atproto.handle}`); 20 + 21 + // Discover collections 22 + const results = await discovery.discoverCollections(config.atproto.handle); 23 + 24 + // Save results 25 + const outputPath = path.join(process.cwd(), 'src/lib/generated/discovered-types.ts'); 26 + await discovery.saveDiscoveryResults(results, outputPath); 27 + 28 + console.log('โœ… Collection discovery complete!'); 29 + console.log(`๐Ÿ“Š Summary:`); 30 + console.log(` - Collections: ${results.totalCollections}`); 31 + console.log(` - Records: ${results.totalRecords}`); 32 + console.log(` - Repository: ${results.repository.handle}`); 33 + console.log(` - Output: ${outputPath}`); 34 + 35 + // Log discovered collections 36 + console.log('\n๐Ÿ“ฆ Discovered Collections:'); 37 + for (const collection of results.collections) { 38 + console.log(` - ${collection.name} (${collection.service})`); 39 + console.log(` Types: ${collection.$types.join(', ')}`); 40 + } 41 + 42 + } catch (error) { 43 + console.error('โŒ Collection discovery failed:', error); 44 + process.exit(1); 45 + } 46 + } 47 + 48 + // Run if called directly 49 + // Run the main function 50 + main();
+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();
+6 -10
src/components/content/BlueskyFeed.astro
··· 1 1 --- 2 - import { AtprotoClient } from '../../lib/atproto/client'; 2 + import { AtprotoBrowser } from '../../lib/atproto/atproto-browser'; 3 3 import { loadConfig } from '../../lib/config/site'; 4 4 import BlueskyPost from './BlueskyPost.astro'; 5 5 6 6 interface Props { 7 7 feedUri: string; 8 8 limit?: number; 9 - showAuthor?: boolean; 10 9 showTimestamp?: boolean; 11 10 } 12 11 13 - const { feedUri, limit = 10, showAuthor = true, showTimestamp = true } = Astro.props; 12 + const { feedUri, limit = 10, showTimestamp = true } = Astro.props; 14 13 15 14 const config = loadConfig(); 16 - const client = new AtprotoClient(config.atproto.pdsUrl); 15 + const browser = new AtprotoBrowser(); 17 16 18 17 // Fetch feed data with error handling 19 18 let blueskyPosts: any[] = []; 20 19 try { 21 - const records = await client.getFeed(feedUri, limit); 22 - const filteredRecords = client.filterSupportedRecords(records); 20 + const records = await browser.getFeed(feedUri, limit); 21 + const filteredRecords = records.filter(r => r.value?.$type === 'app.bsky.feed.post'); 23 22 24 23 // Only render Bluesky posts 25 - blueskyPosts = filteredRecords.filter(record => 26 - record.value && record.value.$type === 'app.bsky.feed.post' 27 - ); 24 + blueskyPosts = filteredRecords; 28 25 } catch (error) { 29 26 console.error('Error fetching feed:', error); 30 27 blueskyPosts = []; ··· 36 33 blueskyPosts.map((record) => ( 37 34 <BlueskyPost 38 35 post={record.value} 39 - showAuthor={showAuthor} 40 36 showTimestamp={showTimestamp} 41 37 /> 42 38 ))
+16 -26
src/components/content/BlueskyPost.astro
··· 1 1 --- 2 - import type { BlueskyPost } from '../../lib/types/atproto'; 2 + import type { AppBskyFeedPost } from '@atproto/api'; 3 + import { extractCidFromBlobRef, blobCdnUrl } from '../../lib/atproto/blob-url'; 4 + import { loadConfig } from '../../lib/config/site'; 3 5 4 6 interface Props { 5 - post: BlueskyPost; 6 - showAuthor?: boolean; 7 + post: AppBskyFeedPost.Record; 7 8 showTimestamp?: boolean; 8 9 } 9 10 10 - const { post, showAuthor = false, showTimestamp = true } = Astro.props; 11 + const { post, showTimestamp = true } = Astro.props; 11 12 12 13 // Validate post data 13 14 if (!post || !post.text) { ··· 22 23 }); 23 24 }; 24 25 25 - // Helper function to get image URL from blob reference 26 - const getImageUrl = (imageRef: string) => { 27 - return `https://bsky.social/xrpc/com.atproto.sync.getBlob?did=did:plc:6ayddqghxhciedbaofoxkcbs&cid=${imageRef}`; 26 + const getImageUrl = (ref: unknown) => { 27 + const cid = extractCidFromBlobRef(ref); 28 + if (!cid) return ''; 29 + const did = loadConfig().atproto.did; 30 + if (!did) return ''; 31 + return blobCdnUrl(did, cid); 28 32 }; 29 33 30 34 // Helper function to render images ··· 34 38 return ( 35 39 <div class={`grid gap-2 ${images.length === 1 ? 'grid-cols-1' : images.length === 2 ? 'grid-cols-2' : 'grid-cols-3'}`}> 36 40 {images.map((image: any) => { 37 - const imageUrl = getImageUrl(image.image.ref.$link); 41 + const imageUrl = getImageUrl(image.image?.ref); 38 42 return ( 39 43 <div class="relative"> 40 44 <img ··· 52 56 --- 53 57 54 58 <article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 mb-4"> 55 - {showAuthor && post.author && ( 56 - <div class="flex items-center mb-3"> 57 - <div class="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm font-medium"> 58 - {post.author.displayName?.[0] || 'U'} 59 - </div> 60 - <div class="ml-3"> 61 - <div class="text-sm font-medium text-gray-900 dark:text-white"> 62 - {post.author.displayName || 'Unknown'} 63 - </div> 64 - <div class="text-xs text-gray-500 dark:text-gray-400"> 65 - @{post.author.handle || 'unknown'} 66 - </div> 67 - </div> 68 - </div> 69 - )} 59 + 70 60 71 61 <div class="text-gray-900 dark:text-white mb-3"> 72 62 {post.text} ··· 75 65 {post.embed && ( 76 66 <div class="mb-3"> 77 67 {/* Handle image embeds */} 78 - {post.embed.$type === 'app.bsky.embed.images' && post.embed.images && ( 68 + {post.embed.$type === 'app.bsky.embed.images' && 'images' in post.embed && post.embed.images && ( 79 69 renderImages(post.embed.images) 80 70 )} 81 71 82 72 {/* Handle external link embeds */} 83 - {post.embed.$type === 'app.bsky.embed.external' && post.embed.external && ( 73 + {post.embed.$type === 'app.bsky.embed.external' && 'external' in post.embed && post.embed.external && ( 84 74 <div class="border border-gray-200 dark:border-gray-700 rounded-lg p-3"> 85 75 <div class="text-sm text-gray-600 dark:text-gray-400 mb-1"> 86 76 {post.embed.external.uri} ··· 97 87 )} 98 88 99 89 {/* Handle record embeds (quotes/reposts) */} 100 - {post.embed.$type === 'app.bsky.embed.record' && post.embed.record && ( 90 + {post.embed.$type === 'app.bsky.embed.record' && 'record' in post.embed && post.embed.record && ( 101 91 <div class="border border-gray-200 dark:border-gray-700 rounded-lg p-3 bg-gray-50 dark:bg-gray-700"> 102 92 <div class="text-sm text-gray-600 dark:text-gray-400"> 103 93 Quoted post
+49
src/components/content/ContentDisplay.astro
··· 1 + --- 2 + import type { AtprotoRecord } from '../../lib/atproto/atproto-browser'; 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 + )}
+124 -26
src/components/content/ContentFeed.astro
··· 1 1 --- 2 - import { AtprotoClient } from '../../lib/atproto/client'; 2 + import { AtprotoBrowser } from '../../lib/atproto/atproto-browser'; 3 3 import { loadConfig } from '../../lib/config/site'; 4 - import type { AtprotoRecord } from '../../lib/types/atproto'; 4 + import type { AtprotoRecord } from '../../lib/atproto/atproto-browser'; 5 + import { extractCidFromBlobRef, blobCdnUrl } from '../../lib/atproto/blob-url'; 6 + 5 7 6 8 interface Props { 7 - handle: string; 8 9 collection?: string; 9 10 limit?: number; 10 11 feedUri?: string; 11 - showAuthor?: boolean; 12 12 showTimestamp?: boolean; 13 + live?: boolean; 13 14 } 14 15 15 16 const { 16 - handle, 17 17 collection = 'app.bsky.feed.post', 18 18 limit = 10, 19 19 feedUri, 20 - showAuthor = true, 21 - showTimestamp = true 20 + showTimestamp = true, 21 + live = false, 22 22 } = Astro.props; 23 23 24 24 const config = loadConfig(); 25 - const client = new AtprotoClient(config.atproto.pdsUrl); 25 + const handle = config.atproto.handle; 26 + const browser = new AtprotoBrowser(); 26 27 27 28 // Helper function to get image URL from blob reference 28 - const getImageUrl = (imageRef: string) => { 29 - return `https://bsky.social/xrpc/com.atproto.sync.getBlob?did=did:plc:6ayddqghxhciedbaofoxkcbs&cid=${imageRef}`; 29 + const getImageUrl = (imageRef: unknown) => { 30 + const cid = extractCidFromBlobRef(imageRef); 31 + if (!cid) return ''; 32 + const did = config.atproto.did; 33 + if (!did) return ''; 34 + return blobCdnUrl(did, cid); 30 35 }; 31 36 32 37 // Helper function to format date ··· 42 47 let records: AtprotoRecord[] = []; 43 48 try { 44 49 if (feedUri) { 45 - records = await client.getFeed(feedUri, limit); 50 + records = await browser.getFeed(feedUri, limit); 46 51 } else { 47 - records = await client.getRecords(handle, collection, limit); 52 + const res = await browser.getCollectionRecords(handle, collection, limit); 53 + records = res?.records ?? []; 48 54 } 49 55 50 56 } catch (error) { ··· 55 61 56 62 <div class="space-y-6"> 57 63 {records.length > 0 ? ( 58 - <div class="space-y-4"> 64 + <div id="feed-container" class="space-y-4" data-show-timestamp={String(showTimestamp)} data-initial-limit={String(limit)} data-did={config.atproto.did}> 59 65 {records.map((record) => { 60 66 if (record.value?.$type !== 'app.bsky.feed.post') return null; 61 67 ··· 72 78 {post.embed.$type === 'app.bsky.embed.images' && post.embed.images && ( 73 79 <div class={`grid gap-2 ${post.embed.images.length === 1 ? 'grid-cols-1' : post.embed.images.length === 2 ? 'grid-cols-2' : 'grid-cols-3'}`}> 74 80 {post.embed.images.map((image: any) => { 75 - // Handle both string and BlobRef object formats 76 - let imageRef; 77 - if (typeof image.image?.ref === 'string') { 78 - imageRef = image.image.ref; 79 - } else if (image.image?.ref?.$link) { 80 - imageRef = image.image.ref.$link; 81 - } else if (image.image?.ref?.toString) { 82 - // Handle BlobRef object 83 - imageRef = image.image.ref.toString(); 84 - } 85 - 86 - const imageUrl = imageRef ? getImageUrl(imageRef) : ''; 81 + const imageUrl = getImageUrl(image.image?.ref); 87 82 return ( 88 83 <div class="relative"> 89 84 <img ··· 141 136 <p class="text-sm mt-2">Debug: Handle = {handle}, Records fetched = {records.length}</p> 142 137 </div> 143 138 )} 144 - </div> 139 + </div> 140 + 141 + {live && ( 142 + <script> 143 + // @ts-nocheck 144 + const container = document.getElementById('feed-container'); 145 + if (container) { 146 + const SHOW_TIMESTAMP = container.getAttribute('data-show-timestamp') === 'true'; 147 + const INITIAL_LIMIT = Number(container.getAttribute('data-initial-limit') || '10'); 148 + const maxPrepend = 20; 149 + const DID = container.getAttribute('data-did') || ''; 150 + 151 + function extractCid(ref) { 152 + if (typeof ref === 'string') return ref; 153 + if (ref && typeof ref === 'object') { 154 + if (typeof ref.$link === 'string') return ref.$link; 155 + if (typeof ref.toString === 'function') return ref.toString(); 156 + } 157 + return null; 158 + } 159 + 160 + function buildImagesEl(post, did) { 161 + if (post?.embed?.$type !== 'app.bsky.embed.images' || !post.embed.images) return null; 162 + const grid = document.createElement('div'); 163 + const count = post.embed.images.length; 164 + const cols = count === 1 ? 'grid-cols-1' : count === 2 ? 'grid-cols-2' : 'grid-cols-3'; 165 + grid.className = `grid gap-2 ${cols}`; 166 + for (const img of post.embed.images) { 167 + const cid = extractCid(img?.image?.ref); 168 + if (!cid) continue; 169 + const url = `https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; 170 + const wrapper = document.createElement('div'); 171 + wrapper.className = 'relative'; 172 + const image = document.createElement('img'); 173 + image.src = url; 174 + image.alt = img?.alt || 'Post image'; 175 + image.className = 'rounded-lg w-full h-auto object-cover'; 176 + const arW = (img?.aspectRatio && img.aspectRatio.width) || 1; 177 + const arH = (img?.aspectRatio && img.aspectRatio.height) || 1; 178 + // @ts-ignore 179 + image.style.aspectRatio = `${arW} / ${arH}`; 180 + wrapper.appendChild(image); 181 + grid.appendChild(wrapper); 182 + } 183 + return grid; 184 + } 185 + 186 + function buildPostEl(post, did) { 187 + const article = document.createElement('article'); 188 + article.className = 'bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 mb-4'; 189 + 190 + const textDiv = document.createElement('div'); 191 + textDiv.className = 'text-gray-900 dark:text-white mb-3'; 192 + textDiv.textContent = post?.text ? String(post.text) : ''; 193 + article.appendChild(textDiv); 194 + 195 + const imagesEl = buildImagesEl(post, did); 196 + if (imagesEl) { 197 + const imagesWrap = document.createElement('div'); 198 + imagesWrap.className = 'mb-3'; 199 + imagesWrap.appendChild(imagesEl); 200 + article.appendChild(imagesWrap); 201 + } 202 + 203 + if (SHOW_TIMESTAMP && post?.createdAt) { 204 + const timeDiv = document.createElement('div'); 205 + timeDiv.className = 'text-xs text-gray-500 dark:text-gray-400'; 206 + timeDiv.textContent = new Date(post.createdAt).toLocaleDateString('en-US', { year:'numeric', month:'short', day:'numeric' }); 207 + article.appendChild(timeDiv); 208 + } 209 + 210 + return article; 211 + } 212 + 213 + try { 214 + // Use shared jetstream instead of creating a new connection 215 + const { startSharedStream, subscribeToPosts } = await import('../../lib/atproto/jetstream-client'); 216 + 217 + // Start the shared stream 218 + await startSharedStream(); 219 + 220 + // Subscribe to new posts 221 + const unsubscribe = subscribeToPosts((event) => { 222 + if (event.commit.operation === 'create') { 223 + const el = buildPostEl(event.commit.record, event.did); 224 + // @ts-ignore 225 + container.insertBefore(el, container.firstChild); 226 + const posts = container.children; 227 + if (posts.length > maxPrepend + INITIAL_LIMIT) { 228 + if (container.lastElementChild) container.removeChild(container.lastElementChild); 229 + } 230 + } 231 + }); 232 + 233 + // Cleanup on page unload 234 + window.addEventListener('beforeunload', () => { 235 + unsubscribe(); 236 + }); 237 + } catch (e) { 238 + console.error('jetstream start error', e); 239 + } 240 + } 241 + </script> 242 + )}
+113
src/components/content/GalleryDisplay.astro
··· 1 + --- 2 + import type { ProcessedGallery } from '../../lib/services/gallery-service'; 3 + 4 + interface Props { 5 + gallery: ProcessedGallery; 6 + showDescription?: boolean; 7 + showTimestamp?: boolean; 8 + columns?: number; 9 + showType?: boolean; 10 + } 11 + 12 + const { 13 + gallery, 14 + showDescription = true, 15 + showTimestamp = true, 16 + columns = 3, 17 + showType = false 18 + } = Astro.props; 19 + 20 + const formatDate = (dateString: string) => { 21 + return new Date(dateString).toLocaleDateString('en-US', { 22 + year: 'numeric', 23 + month: 'long', 24 + day: 'numeric', 25 + }); 26 + }; 27 + 28 + const gridCols = { 29 + 1: 'grid-cols-1', 30 + 2: 'grid-cols-2', 31 + 3: 'grid-cols-3', 32 + 4: 'grid-cols-4', 33 + 5: 'grid-cols-5', 34 + 6: 'grid-cols-6', 35 + }[columns] || 'grid-cols-3'; 36 + 37 + // Determine image layout based on number of images 38 + const getImageLayout = (imageCount: number) => { 39 + if (imageCount === 1) return 'grid-cols-1'; 40 + if (imageCount === 2) return 'grid-cols-2'; 41 + if (imageCount === 3) return 'grid-cols-3'; 42 + if (imageCount === 4) return 'grid-cols-2 md:grid-cols-4'; 43 + return gridCols; 44 + }; 45 + 46 + const imageLayout = getImageLayout(gallery.images.length); 47 + --- 48 + 49 + <article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6"> 50 + <header class="mb-4"> 51 + <h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2"> 52 + {gallery.title} 53 + </h2> 54 + 55 + {showDescription && gallery.description && ( 56 + <div class="text-gray-600 dark:text-gray-400 mb-3"> 57 + {gallery.description} 58 + </div> 59 + )} 60 + 61 + {gallery.text && ( 62 + <div class="text-gray-900 dark:text-white mb-4"> 63 + {gallery.text} 64 + </div> 65 + )} 66 + 67 + <div class="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400 mb-4"> 68 + {showTimestamp && ( 69 + <span> 70 + Created on {formatDate(gallery.createdAt)} 71 + </span> 72 + )} 73 + 74 + {showType && ( 75 + <span class="bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded text-xs"> 76 + {gallery.$type} 77 + </span> 78 + )} 79 + 80 + <span class="bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 px-2 py-1 rounded text-xs"> 81 + {gallery.images.length} image{gallery.images.length !== 1 ? 's' : ''} 82 + </span> 83 + </div> 84 + </header> 85 + 86 + {gallery.images && gallery.images.length > 0 && ( 87 + <div class={`grid ${imageLayout} gap-4`}> 88 + {gallery.images.map((image, index) => ( 89 + <div class="relative group"> 90 + <img 91 + src={image.url} 92 + alt={image.alt || `Gallery image ${index + 1}`} 93 + class="w-full h-48 object-cover rounded-lg transition-transform duration-200 group-hover:scale-105" 94 + style={image.aspectRatio ? `aspect-ratio: ${image.aspectRatio.width} / ${image.aspectRatio.height}` : ''} 95 + loading="lazy" 96 + /> 97 + {image.alt && ( 98 + <div class="absolute bottom-0 left-0 right-0 bg-black bg-opacity-50 text-white text-sm p-2 rounded-b-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200"> 99 + {image.alt} 100 + </div> 101 + )} 102 + </div> 103 + ))} 104 + </div> 105 + )} 106 + 107 + <footer class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700"> 108 + <div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400"> 109 + <span>Collection: {gallery.collection}</span> 110 + <span>Type: {gallery.$type}</span> 111 + </div> 112 + </footer> 113 + </article>
+151
src/components/content/GrainGalleryDisplay.astro
··· 1 + --- 2 + import type { ProcessedGrainGallery } from '../../lib/services/grain-gallery-service'; 3 + 4 + interface Props { 5 + gallery: ProcessedGrainGallery; 6 + showDescription?: boolean; 7 + showTimestamp?: boolean; 8 + showCollections?: boolean; 9 + columns?: number; 10 + } 11 + 12 + const { 13 + gallery, 14 + showDescription = true, 15 + showTimestamp = true, 16 + showCollections = false, 17 + columns = 3 18 + } = Astro.props; 19 + 20 + const formatDate = (dateString: string) => { 21 + return new Date(dateString).toLocaleDateString('en-US', { 22 + year: 'numeric', 23 + month: 'long', 24 + day: 'numeric', 25 + }); 26 + }; 27 + 28 + const gridCols = { 29 + 1: 'grid-cols-1', 30 + 2: 'grid-cols-2', 31 + 3: 'grid-cols-3', 32 + 4: 'grid-cols-4', 33 + 5: 'grid-cols-5', 34 + 6: 'grid-cols-6', 35 + }[columns] || 'grid-cols-3'; 36 + 37 + // Determine image layout based on number of images 38 + const getImageLayout = (imageCount: number) => { 39 + if (imageCount === 1) return 'grid-cols-1'; 40 + if (imageCount === 2) return 'grid-cols-2'; 41 + if (imageCount === 3) return 'grid-cols-3'; 42 + if (imageCount === 4) return 'grid-cols-2 md:grid-cols-4'; 43 + return gridCols; 44 + }; 45 + 46 + const imageLayout = getImageLayout(gallery.images.length); 47 + --- 48 + 49 + <article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6"> 50 + <header class="mb-4"> 51 + <div class="flex items-start justify-between mb-2"> 52 + <h2 class="text-2xl font-bold text-gray-900 dark:text-white"> 53 + {gallery.title} 54 + </h2> 55 + <div class="flex items-center gap-2"> 56 + <span class="bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded text-xs"> 57 + {gallery.id} 58 + </span> 59 + </div> 60 + </div> 61 + 62 + {showDescription && gallery.description && ( 63 + <div class="text-gray-600 dark:text-gray-400 mb-3"> 64 + {gallery.description} 65 + </div> 66 + )} 67 + 68 + <div class="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400 mb-4"> 69 + {showTimestamp && ( 70 + <span> 71 + Created on {formatDate(gallery.createdAt)} 72 + </span> 73 + )} 74 + 75 + <span class="bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 px-2 py-1 rounded text-xs"> 76 + {gallery.images.length} image{gallery.images.length !== 1 ? 's' : ''} 77 + </span> 78 + 79 + <span class="bg-green-100 dark:bg-green-700 text-green-600 dark:text-green-300 px-2 py-1 rounded text-xs"> 80 + {gallery.itemCount} item{gallery.itemCount !== 1 ? 's' : ''} 81 + </span> 82 + </div> 83 + 84 + {showCollections && gallery.collections.length > 0 && ( 85 + <div class="mb-4"> 86 + <p class="text-sm text-gray-500 dark:text-gray-400 mb-1">Collections:</p> 87 + <div class="flex flex-wrap gap-1"> 88 + {gallery.collections.map((collection) => ( 89 + <span class="bg-purple-100 dark:bg-purple-800 text-purple-600 dark:text-purple-300 px-2 py-1 rounded text-xs"> 90 + {collection} 91 + </span> 92 + ))} 93 + </div> 94 + </div> 95 + )} 96 + </header> 97 + 98 + {gallery.images && gallery.images.length > 0 && ( 99 + <div class={`grid ${imageLayout} gap-4`}> 100 + {gallery.images.map((image, index) => ( 101 + <div class="relative group"> 102 + <img 103 + src={image.url} 104 + alt={image.alt || `Gallery image ${index + 1}`} 105 + class="w-full h-48 object-cover rounded-lg transition-transform duration-200 group-hover:scale-105" 106 + loading="lazy" 107 + /> 108 + {(image.alt || image.caption || image.exif) && ( 109 + <div class="absolute bottom-0 left-0 right-0 bg-black/60 text-white text-xs p-2 rounded-b-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 space-y-1"> 110 + {image.alt || image.caption ? ( 111 + <div class="font-medium">{image.alt || image.caption}</div> 112 + ) : null} 113 + {image.exif && ( 114 + <div class="grid grid-cols-2 gap-x-2 gap-y-0.5"> 115 + {image.exif.make && image.exif.model && ( 116 + <div class="col-span-2">{image.exif.make} {image.exif.model}</div> 117 + )} 118 + {image.exif.lensMake && image.exif.lensModel && ( 119 + <div class="col-span-2">{image.exif.lensMake} {image.exif.lensModel}</div> 120 + )} 121 + {image.exif.fNumber && ( 122 + <div>ฦ’/{image.exif.fNumber}</div> 123 + )} 124 + {image.exif.exposureTime && ( 125 + <div>{image.exif.exposureTime}s</div> 126 + )} 127 + {image.exif.iSO && ( 128 + <div>ISO {image.exif.iSO}</div> 129 + )} 130 + {image.exif.focalLengthIn35mmFormat && ( 131 + <div>{image.exif.focalLengthIn35mmFormat}mm</div> 132 + )} 133 + {image.exif.dateTimeOriginal && ( 134 + <div class="col-span-2">{new Date(image.exif.dateTimeOriginal).toLocaleDateString()}</div> 135 + )} 136 + </div> 137 + )} 138 + </div> 139 + )} 140 + </div> 141 + ))} 142 + </div> 143 + )} 144 + 145 + <footer class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700"> 146 + <div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400"> 147 + <span>Gallery ID: {gallery.id}</span> 148 + <span>Collections: {gallery.collections.join(', ')}</span> 149 + </div> 150 + </footer> 151 + </article>
-68
src/components/content/GrainImageGallery.astro
··· 1 - --- 2 - import type { GrainImageGallery } from '../../lib/types/atproto'; 3 - 4 - interface Props { 5 - gallery: GrainImageGallery; 6 - showDescription?: boolean; 7 - showTimestamp?: boolean; 8 - columns?: number; 9 - } 10 - 11 - const { gallery, showDescription = true, showTimestamp = true, columns = 3 } = Astro.props; 12 - 13 - const formatDate = (dateString: string) => { 14 - return new Date(dateString).toLocaleDateString('en-US', { 15 - year: 'numeric', 16 - month: 'long', 17 - day: 'numeric', 18 - }); 19 - }; 20 - 21 - const gridCols = { 22 - 1: 'grid-cols-1', 23 - 2: 'grid-cols-2', 24 - 3: 'grid-cols-3', 25 - 4: 'grid-cols-4', 26 - 5: 'grid-cols-5', 27 - 6: 'grid-cols-6', 28 - }[columns] || 'grid-cols-3'; 29 - --- 30 - 31 - <article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6"> 32 - <header class="mb-4"> 33 - <h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2"> 34 - {gallery.title} 35 - </h2> 36 - 37 - {showDescription && gallery.description && ( 38 - <div class="text-gray-600 dark:text-gray-400 mb-3"> 39 - {gallery.description} 40 - </div> 41 - )} 42 - 43 - {showTimestamp && ( 44 - <div class="text-sm text-gray-500 dark:text-gray-400 mb-4"> 45 - Created on {formatDate(gallery.createdAt)} 46 - </div> 47 - )} 48 - </header> 49 - 50 - {gallery.images && gallery.images.length > 0 && ( 51 - <div class={`grid ${gridCols} gap-4`}> 52 - {gallery.images.map((image) => ( 53 - <div class="relative group"> 54 - <img 55 - src={image.url} 56 - alt={image.alt || 'Gallery image'} 57 - class="w-full h-48 object-cover rounded-lg transition-transform duration-200 group-hover:scale-105" 58 - /> 59 - {image.alt && ( 60 - <div class="absolute bottom-0 left-0 right-0 bg-black bg-opacity-50 text-white text-sm p-2 rounded-b-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200"> 61 - {image.alt} 62 - </div> 63 - )} 64 - </div> 65 - ))} 66 - </div> 67 - )} 68 - </article>
-47
src/components/content/LeafletPublication.astro
··· 1 - --- 2 - import type { LeafletPublication } from '../../lib/types/atproto'; 3 - 4 - interface Props { 5 - publication: LeafletPublication; 6 - showCategory?: boolean; 7 - showTimestamp?: boolean; 8 - } 9 - 10 - const { publication, showCategory = true, showTimestamp = true } = Astro.props; 11 - 12 - const formatDate = (dateString: string) => { 13 - return new Date(dateString).toLocaleDateString('en-US', { 14 - year: 'numeric', 15 - month: 'long', 16 - day: 'numeric', 17 - }); 18 - }; 19 - --- 20 - 21 - <article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6"> 22 - <header class="mb-4"> 23 - <div class="flex items-center justify-between mb-2"> 24 - <h2 class="text-2xl font-bold text-gray-900 dark:text-white"> 25 - {publication.title} 26 - </h2> 27 - 28 - {showCategory && publication.category && ( 29 - <span class="px-3 py-1 bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-sm rounded-full"> 30 - {publication.category} 31 - </span> 32 - )} 33 - </div> 34 - 35 - {showTimestamp && ( 36 - <div class="text-sm text-gray-500 dark:text-gray-400 mb-3"> 37 - Published on {formatDate(publication.publishedAt)} 38 - </div> 39 - )} 40 - </header> 41 - 42 - <div class="prose prose-gray dark:prose-invert max-w-none"> 43 - <div class="text-gray-700 dark:text-gray-300 leading-relaxed"> 44 - {publication.content} 45 - </div> 46 - </div> 47 - </article>
+115
src/components/content/StatusUpdate.astro
··· 1 + --- 2 + import type { AStatusUpdateRecord } from '../../lib/generated/a-status-update'; 3 + import { AtprotoBrowser } from '../../lib/atproto/atproto-browser'; 4 + import { loadConfig } from '../../lib/config/site'; 5 + 6 + const config = loadConfig(); 7 + const client = new AtprotoBrowser(); 8 + 9 + // Fetch the latest status update 10 + let latestStatus: AStatusUpdateRecord | null = null; 11 + try { 12 + const records = await client.getAllCollectionRecords(config.atproto.handle, 'a.status.update', 1); 13 + 14 + if (records.length > 0) { 15 + latestStatus = records[0].value as AStatusUpdateRecord; 16 + } 17 + } catch (error) { 18 + console.error('Failed to fetch status update:', error); 19 + } 20 + --- 21 + 22 + <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4" id="status-update-container"> 23 + {latestStatus ? ( 24 + <div class="space-y-2"> 25 + <p class="text-lg font-medium text-gray-900 dark:text-white leading-relaxed" id="status-text"> 26 + {latestStatus.text} 27 + </p> 28 + <time class="text-sm text-gray-500 dark:text-gray-400 block" id="status-time" datetime={latestStatus.createdAt}> 29 + {new Date(latestStatus.createdAt).toLocaleDateString('en-US', { 30 + year: 'numeric', 31 + month: 'short', 32 + day: 'numeric', 33 + hour: '2-digit', 34 + minute: '2-digit' 35 + })} 36 + </time> 37 + </div> 38 + ) : ( 39 + <div class="text-center py-4" id="status-placeholder"> 40 + <p class="text-gray-500 italic">No status updates available</p> 41 + </div> 42 + )} 43 + </div> 44 + 45 + <script> 46 + import { startSharedStream, subscribeToStatusUpdates } from '../../lib/atproto/jetstream-client'; 47 + 48 + // Start the shared stream 49 + startSharedStream(); 50 + 51 + // Subscribe to status updates 52 + const unsubscribe = subscribeToStatusUpdates((event) => { 53 + if (event.commit.operation === 'create') { 54 + updateStatusDisplay(event.commit.record); 55 + } 56 + }); 57 + 58 + function updateStatusDisplay(statusData: any) { 59 + const container = document.getElementById('status-update-container'); 60 + const textEl = document.getElementById('status-text'); 61 + const timeEl = document.getElementById('status-time'); 62 + const placeholderEl = document.getElementById('status-placeholder'); 63 + 64 + if (!container) return; 65 + 66 + // Remove placeholder if it exists 67 + if (placeholderEl) { 68 + placeholderEl.remove(); 69 + } 70 + 71 + // Update or create text element 72 + if (textEl) { 73 + textEl.textContent = statusData.text; 74 + } else { 75 + const newTextEl = document.createElement('p'); 76 + newTextEl.className = 'text-lg font-medium text-gray-900 dark:text-white leading-relaxed'; 77 + newTextEl.id = 'status-text'; 78 + newTextEl.textContent = statusData.text; 79 + container.appendChild(newTextEl); 80 + } 81 + 82 + // Update or create time element 83 + const formattedTime = new Date(statusData.createdAt).toLocaleDateString('en-US', { 84 + year: 'numeric', 85 + month: 'short', 86 + day: 'numeric', 87 + hour: '2-digit', 88 + minute: '2-digit' 89 + }); 90 + 91 + if (timeEl) { 92 + timeEl.textContent = formattedTime; 93 + timeEl.setAttribute('datetime', statusData.createdAt); 94 + } else { 95 + const newTimeEl = document.createElement('time'); 96 + newTimeEl.className = 'text-sm text-gray-500 dark:text-gray-400 block'; 97 + newTimeEl.id = 'status-time'; 98 + newTimeEl.setAttribute('datetime', statusData.createdAt); 99 + newTimeEl.textContent = formattedTime; 100 + container.appendChild(newTimeEl); 101 + } 102 + 103 + // Add a subtle animation to indicate the update 104 + container.style.transition = 'all 0.3s ease'; 105 + container.style.transform = 'scale(1.02)'; 106 + setTimeout(() => { 107 + container.style.transform = 'scale(1)'; 108 + }, 300); 109 + } 110 + 111 + // Cleanup on page unload 112 + window.addEventListener('beforeunload', () => { 113 + unsubscribe(); 114 + }); 115 + </script>
+11 -17
src/components/content/WhitewindBlogPost.astro
··· 1 1 --- 2 - import type { WhitewindBlogPost } from '../../lib/types/atproto'; 2 + import type { ComWhtwndBlogEntryRecord } from '../../lib/generated/com-whtwnd-blog-entry'; 3 + import { marked } from 'marked'; 3 4 4 5 interface Props { 5 - post: WhitewindBlogPost; 6 + record: ComWhtwndBlogEntryRecord; 6 7 showTags?: boolean; 7 8 showTimestamp?: boolean; 8 9 } 9 10 10 - const { post, showTags = true, showTimestamp = true } = Astro.props; 11 + const { record, showTags = true, showTimestamp = true } = Astro.props; 11 12 12 13 const formatDate = (dateString: string) => { 13 14 return new Date(dateString).toLocaleDateString('en-US', { ··· 16 17 day: 'numeric', 17 18 }); 18 19 }; 20 + 21 + const published = record.createdAt; 22 + const isValidDate = published ? !isNaN(new Date(published).getTime()) : false; 19 23 --- 20 24 21 25 <article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6"> 22 26 <header class="mb-4"> 23 27 <h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2"> 24 - {post.title} 28 + {record.title} 25 29 </h2> 26 30 27 - {showTimestamp && ( 31 + {showTimestamp && isValidDate && ( 28 32 <div class="text-sm text-gray-500 dark:text-gray-400 mb-3"> 29 - Published on {formatDate(post.publishedAt)} 30 - </div> 31 - )} 32 - 33 - {showTags && post.tags && post.tags.length > 0 && ( 34 - <div class="flex flex-wrap gap-2 mb-4"> 35 - {post.tags.map((tag) => ( 36 - <span class="px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 text-xs rounded-full"> 37 - #{tag} 38 - </span> 39 - ))} 33 + Published on {formatDate(published!)} 40 34 </div> 41 35 )} 42 36 </header> 43 37 44 38 <div class="prose prose-gray dark:prose-invert max-w-none"> 45 39 <div class="text-gray-700 dark:text-gray-300 leading-relaxed"> 46 - {post.content} 40 + <Fragment set:html={await marked(record.content || '')} /> 47 41 </div> 48 42 </div> 49 43 </article>
+13
src/content.config.ts
··· 1 + import { defineCollection, z } from "astro:content"; 2 + import { leafletStaticLoader } from "@nulfrost/leaflet-loader-astro"; 3 + import { loadConfig } from "./lib/config/site"; 4 + 5 + const config = loadConfig(); 6 + 7 + const documents = defineCollection({ 8 + loader: leafletStaticLoader({ 9 + repo: config.atproto.did! 10 + }), 11 + }); 12 + 13 + export const collections = { documents };
+4 -10
src/layouts/Layout.astro
··· 35 35 <a href="/galleries" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"> 36 36 Galleries 37 37 </a> 38 - <a href="/debug" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"> 39 - Debug 40 - </a> 41 - <a href="/test-api" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"> 42 - Test API 43 - </a> 44 - <a href="/comprehensive-test" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"> 45 - Comprehensive Test 38 + <a href="/blog" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"> 39 + Blog 46 40 </a> 47 - <a href="/collection-config" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"> 48 - Collections 41 + <a href="/now" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors"> 42 + Now 49 43 </a> 50 44 </div> 51 45 </div>
+26
src/lexicons/a.status.update.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "a.status.update", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A simple status update record", 8 + "key": "self", 9 + "record": { 10 + "type": "object", 11 + "required": ["text", "createdAt"], 12 + "properties": { 13 + "text": { 14 + "type": "string", 15 + "description": "The status update text" 16 + }, 17 + "createdAt": { 18 + "type": "string", 19 + "format": "datetime", 20 + "description": "When the status was created" 21 + } 22 + } 23 + } 24 + } 25 + } 26 + }
+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 + }
+32
src/lexicons/social.grain.photo.exif.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "social.grain.photo.exif", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Basic EXIF metadata for a photo. Integers are scaled by 1000000 to accommodate decimal values and potentially other tags in the future.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": [ 12 + "photo", 13 + "createdAt" 14 + ], 15 + "properties": { 16 + "photo": { "type": "string", "format": "at-uri" }, 17 + "createdAt": { "type": "string", "format": "datetime" }, 18 + "dateTimeOriginal": { "type": "string", "format": "datetime" }, 19 + "exposureTime": { "type": "integer" }, 20 + "fNumber": { "type": "integer" }, 21 + "flash": { "type": "string" }, 22 + "focalLengthIn35mmFormat": { "type": "integer" }, 23 + "iSO": { "type": "integer" }, 24 + "lensMake": { "type": "string" }, 25 + "lensModel": { "type": "string" }, 26 + "make": { "type": "string" }, 27 + "model": { "type": "string" } 28 + } 29 + } 30 + } 31 + } 32 + }
+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 + }
+64 -4
src/lib/atproto/atproto-browser.ts
··· 140 140 } 141 141 } 142 142 143 + // Get all records from a collection using pagination 144 + async getAllCollectionRecords( 145 + identifier: string, 146 + collection: string, 147 + maxTotal: number = 1000 148 + ): Promise<AtprotoRecord[]> { 149 + const results: AtprotoRecord[] = []; 150 + let cursor: string | undefined = undefined; 151 + 152 + try { 153 + while (true) { 154 + const page = await this.getCollectionRecords(identifier, collection, 100, cursor); 155 + if (!page) break; 156 + 157 + results.push(...page.records); 158 + 159 + if (!page.cursor) break; 160 + if (results.length >= maxTotal) break; 161 + cursor = page.cursor; 162 + } 163 + } catch (error) { 164 + console.error(`Error paginating collection ${collection}:`, error); 165 + } 166 + 167 + return results.slice(0, maxTotal); 168 + } 169 + 143 170 // Get all collections for a repository 144 171 async getAllCollections(identifier: string): Promise<string[]> { 145 172 try { ··· 157 184 // Get a specific record 158 185 async getRecord(uri: string): Promise<AtprotoRecord | null> { 159 186 try { 187 + // Parse at://did:.../collection/rkey 188 + if (!uri.startsWith('at://')) throw new Error('Invalid at:// URI'); 189 + const parts = uri.replace('at://', '').split('/'); 190 + const repo = parts[0]; 191 + const collection = parts[1]; 192 + const rkey = parts[2]; 193 + 160 194 const response = await this.agent.api.com.atproto.repo.getRecord({ 161 - uri: uri, 195 + repo, 196 + collection, 197 + rkey, 162 198 }); 163 199 164 - const record = response.data; 200 + const record = response.data as any; 165 201 return { 166 - uri: record.uri, 202 + uri: `at://${repo}/${collection}/${rkey}`, 167 203 cid: record.cid, 168 204 value: record.value, 169 205 indexedAt: record.indexedAt, 170 - collection: record.uri.split('/')[2] || 'unknown', 206 + collection: collection || 'unknown', 171 207 $type: (record.value?.$type as string) || 'unknown', 172 208 }; 173 209 } catch (error) { ··· 199 235 return results; 200 236 } catch (error) { 201 237 console.error('Error searching records by type:', error); 238 + return []; 239 + } 240 + } 241 + 242 + // Get posts from an appview feed URI 243 + async getFeed(feedUri: string, limit: number = 20): Promise<AtprotoRecord[]> { 244 + try { 245 + const response = await this.agent.api.app.bsky.feed.getFeed({ 246 + feed: feedUri, 247 + limit, 248 + }); 249 + 250 + const records: AtprotoRecord[] = response.data.feed.map((item: any) => ({ 251 + uri: item.post.uri, 252 + cid: item.post.cid, 253 + value: item.post.record, 254 + indexedAt: item.post.indexedAt, 255 + collection: item.post.uri.split('/')[2] || 'unknown', 256 + $type: (item.post.record?.$type as string) || 'unknown', 257 + })); 258 + 259 + return records; 260 + } catch (error) { 261 + console.error('Error fetching feed:', error); 202 262 return []; 203 263 } 204 264 }
+29
src/lib/atproto/blob-url.ts
··· 1 + import { loadConfig } from '../config/site' 2 + 3 + export type BlobVariant = 'full' | 'avatar' | 'feed' 4 + 5 + export function blobCdnUrl(did: string, cid: string, _variant: BlobVariant = 'full'): string { 6 + const base = 'https://bsky.social/xrpc/com.atproto.sync.getBlob' 7 + const params = new URLSearchParams({ did, cid }) 8 + return `${base}?${params.toString()}` 9 + } 10 + 11 + export function extractCidFromBlobRef(ref: unknown): string | null { 12 + if (typeof ref === 'string') return ref 13 + if (ref && typeof ref === 'object') { 14 + const anyRef = ref as any 15 + if (typeof anyRef.$link === 'string') return anyRef.$link 16 + if (typeof anyRef.toString === 'function') { 17 + const s = anyRef.toString() 18 + if (s && typeof s === 'string') return s 19 + } 20 + } 21 + return null 22 + } 23 + 24 + export function didFromConfig(): string { 25 + const cfg = loadConfig() 26 + return cfg.atproto.did || '' 27 + } 28 + 29 +
-233
src/lib/atproto/client.ts
··· 1 - import { AtpAgent } from '@atproto/api'; 2 - import type { AtprotoRecord } from '../types/atproto'; 3 - 4 - // Simple in-memory cache with TTL 5 - class AtprotoCache { 6 - private cache = new Map<string, { data: any; timestamp: number }>(); 7 - private ttl = 5 * 60 * 1000; // 5 minutes 8 - 9 - set(key: string, data: any): void { 10 - this.cache.set(key, { data, timestamp: Date.now() }); 11 - } 12 - 13 - get(key: string): any | null { 14 - const item = this.cache.get(key); 15 - if (!item) return null; 16 - 17 - if (Date.now() - item.timestamp > this.ttl) { 18 - this.cache.delete(key); 19 - return null; 20 - } 21 - 22 - return item.data; 23 - } 24 - 25 - clear(): void { 26 - this.cache.clear(); 27 - } 28 - } 29 - 30 - export class AtprotoClient { 31 - private agent: AtpAgent; 32 - private cache: AtprotoCache; 33 - 34 - constructor(pdsUrl: string = 'https://bsky.social') { 35 - this.agent = new AtpAgent({ service: pdsUrl }); 36 - this.cache = new AtprotoCache(); 37 - } 38 - 39 - async resolveHandle(handle: string): Promise<string | null> { 40 - const cacheKey = `handle:${handle}`; 41 - const cached = this.cache.get(cacheKey); 42 - if (cached) return cached; 43 - 44 - try { 45 - const response = await this.agent.api.com.atproto.identity.resolveHandle({ 46 - handle: handle, 47 - }); 48 - 49 - const did = response.data.did; 50 - this.cache.set(cacheKey, did); 51 - return did; 52 - } catch (error) { 53 - console.error('Error resolving handle:', error); 54 - return null; 55 - } 56 - } 57 - 58 - async getRecords(identifier: string, collection: string, limit: number = 50): Promise<AtprotoRecord[]> { 59 - // Check if identifier is a handle (contains @) or DID 60 - let did = identifier; 61 - if (identifier.includes('@')) { 62 - console.log('AtprotoClient: Resolving handle to DID:', identifier); 63 - const resolvedDid = await this.resolveHandle(identifier); 64 - if (!resolvedDid) { 65 - console.error('AtprotoClient: Failed to resolve handle:', identifier); 66 - return []; 67 - } 68 - did = resolvedDid; 69 - console.log('AtprotoClient: Resolved handle to DID:', did); 70 - } 71 - 72 - const cacheKey = `records:${did}:${collection}:${limit}`; 73 - const cached = this.cache.get(cacheKey); 74 - if (cached) return cached; 75 - 76 - console.log('AtprotoClient: Fetching records for DID:', did); 77 - console.log('AtprotoClient: Collection:', collection); 78 - console.log('AtprotoClient: Limit:', limit); 79 - 80 - try { 81 - const response = await this.agent.api.com.atproto.repo.listRecords({ 82 - repo: did, 83 - collection, 84 - limit, 85 - }); 86 - 87 - console.log('AtprotoClient: API response received'); 88 - console.log('AtprotoClient: Records count:', response.data.records.length); 89 - 90 - const records = response.data.records.map((record: any) => ({ 91 - uri: record.uri, 92 - cid: record.cid, 93 - value: record.value, 94 - indexedAt: record.indexedAt, 95 - })); 96 - 97 - this.cache.set(cacheKey, records); 98 - return records; 99 - } catch (error) { 100 - console.error('AtprotoClient: Error fetching records:', error); 101 - console.error('AtprotoClient: Error details:', { 102 - did, 103 - collection, 104 - limit, 105 - error: error instanceof Error ? error.message : String(error) 106 - }); 107 - return []; 108 - } 109 - } 110 - 111 - async getFeed(feedUri: string, limit: number = 20): Promise<AtprotoRecord[]> { 112 - const cacheKey = `feed:${feedUri}:${limit}`; 113 - const cached = this.cache.get(cacheKey); 114 - if (cached) return cached; 115 - 116 - try { 117 - const response = await this.agent.api.app.bsky.feed.getFeed({ 118 - feed: feedUri, 119 - limit, 120 - }); 121 - 122 - const records = response.data.feed.map((item: any) => ({ 123 - uri: item.post.uri, 124 - cid: item.post.cid, 125 - value: item.post.record, 126 - indexedAt: item.post.indexedAt, 127 - })); 128 - 129 - this.cache.set(cacheKey, records); 130 - return records; 131 - } catch (error) { 132 - console.error('Error fetching feed:', error); 133 - return []; 134 - } 135 - } 136 - 137 - async getProfile(did: string): Promise<any> { 138 - const cacheKey = `profile:${did}`; 139 - const cached = this.cache.get(cacheKey); 140 - if (cached) return cached; 141 - 142 - try { 143 - const response = await this.agent.api.app.bsky.actor.getProfile({ 144 - actor: did, 145 - }); 146 - 147 - this.cache.set(cacheKey, response.data); 148 - return response.data; 149 - } catch (error) { 150 - console.error('Error fetching profile:', error); 151 - return null; 152 - } 153 - } 154 - 155 - // Filter records by supported content types 156 - filterSupportedRecords(records: AtprotoRecord[]): AtprotoRecord[] { 157 - return records.filter(record => { 158 - const type = record.value?.$type; 159 - return type && ( 160 - type === 'app.bsky.feed.post' || 161 - type === 'app.bsky.actor.profile#whitewindBlogPost' || 162 - type === 'app.bsky.actor.profile#leafletPublication' || 163 - type === 'app.bsky.actor.profile#grainImageGallery' 164 - ); 165 - }); 166 - } 167 - 168 - // Get all records from a repository (using existing API) 169 - async getAllRecords(handle: string, limit: number = 100): Promise<AtprotoRecord[]> { 170 - const cacheKey = `all-records:${handle}:${limit}`; 171 - const cached = this.cache.get(cacheKey); 172 - if (cached) return cached; 173 - 174 - try { 175 - console.log('getAllRecords: Starting with handle:', handle); 176 - 177 - // Resolve handle to DID 178 - const did = await this.resolveHandle(handle); 179 - console.log('getAllRecords: Resolved DID:', did); 180 - 181 - if (!did) { 182 - console.error('getAllRecords: Failed to resolve handle to DID'); 183 - return []; 184 - } 185 - 186 - // Try to get records from common collections 187 - console.log('getAllRecords: Trying common collections...'); 188 - const collections = ['app.bsky.feed.post', 'app.bsky.actor.profile']; 189 - let allRecords: AtprotoRecord[] = []; 190 - 191 - for (const collection of collections) { 192 - try { 193 - console.log(`getAllRecords: Trying collection: ${collection}`); 194 - const response = await this.agent.api.com.atproto.repo.listRecords({ 195 - repo: did, 196 - collection, 197 - limit: Math.floor(limit / collections.length), 198 - }); 199 - 200 - console.log(`getAllRecords: Got ${response.data.records.length} records from ${collection}`); 201 - 202 - const records = response.data.records.map((record: any) => ({ 203 - uri: record.uri, 204 - cid: record.cid, 205 - value: record.value, 206 - indexedAt: record.indexedAt, 207 - })); 208 - 209 - allRecords = allRecords.concat(records); 210 - } catch (error) { 211 - console.log(`getAllRecords: No records in collection ${collection}:`, error); 212 - } 213 - } 214 - 215 - console.log('getAllRecords: Total records found:', allRecords.length); 216 - this.cache.set(cacheKey, allRecords); 217 - return allRecords; 218 - } catch (error) { 219 - console.error('getAllRecords: Error fetching all records:', error); 220 - console.error('getAllRecords: Error details:', { 221 - handle, 222 - limit, 223 - error: error instanceof Error ? error.message : String(error) 224 - }); 225 - return []; 226 - } 227 - } 228 - 229 - // Clear cache (useful for development) 230 - clearCache(): void { 231 - this.cache.clear(); 232 - } 233 - }
-130
src/lib/atproto/collection-discovery.ts
··· 1 - // Comprehensive collection discovery for ATproto repositories 2 - import { AtpAgent } from '@atproto/api'; 3 - import { collectionManager, type CollectionConfig } from '../config/collections'; 4 - 5 - export interface CollectionTest { 6 - collection: string; 7 - exists: boolean; 8 - recordCount: number; 9 - sampleRecords: any[]; 10 - config?: CollectionConfig; 11 - } 12 - 13 - export class CollectionDiscovery { 14 - private agent: AtpAgent; 15 - 16 - constructor(pdsUrl: string = 'https://bsky.social') { 17 - this.agent = new AtpAgent({ service: pdsUrl }); 18 - } 19 - 20 - // Get collection patterns from configuration 21 - private getCollectionPatterns(): string[] { 22 - return collectionManager.getCollectionNames(); 23 - } 24 - 25 - // Test a single collection 26 - async testCollection(did: string, collection: string): Promise<CollectionTest> { 27 - const config = collectionManager.getCollectionInfo(collection); 28 - 29 - try { 30 - const response = await this.agent.api.com.atproto.repo.listRecords({ 31 - repo: did, 32 - collection, 33 - limit: 10, // Just get a few records to test 34 - }); 35 - 36 - return { 37 - collection, 38 - exists: true, 39 - recordCount: response.data.records.length, 40 - sampleRecords: response.data.records.slice(0, 3), 41 - config 42 - }; 43 - } catch (error) { 44 - return { 45 - collection, 46 - exists: false, 47 - recordCount: 0, 48 - sampleRecords: [], 49 - config 50 - }; 51 - } 52 - } 53 - 54 - // Discover all collections by testing all patterns 55 - async discoverAllCollections(did: string): Promise<CollectionTest[]> { 56 - console.log('Starting comprehensive collection discovery...'); 57 - 58 - const patterns = this.getCollectionPatterns(); 59 - console.log(`Testing ${patterns.length} collection patterns from configuration`); 60 - 61 - const results: CollectionTest[] = []; 62 - 63 - // Test collections in parallel batches to speed up discovery 64 - const batchSize = 10; 65 - for (let i = 0; i < patterns.length; i += batchSize) { 66 - const batch = patterns.slice(i, i + batchSize); 67 - console.log(`Testing batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(patterns.length / batchSize)}`); 68 - 69 - const batchPromises = batch.map(collection => this.testCollection(did, collection)); 70 - const batchResults = await Promise.all(batchPromises); 71 - 72 - results.push(...batchResults); 73 - 74 - // Small delay to avoid rate limiting 75 - await new Promise(resolve => setTimeout(resolve, 100)); 76 - } 77 - 78 - const existingCollections = results.filter(r => r.exists); 79 - console.log(`Found ${existingCollections.length} existing collections out of ${patterns.length} tested`); 80 - 81 - return results; 82 - } 83 - 84 - // Resolve handle to DID 85 - async resolveHandle(handle: string): Promise<string | null> { 86 - try { 87 - const response = await this.agent.api.com.atproto.identity.resolveHandle({ 88 - handle: handle, 89 - }); 90 - return response.data.did; 91 - } catch (error) { 92 - console.error('Error resolving handle:', error); 93 - return null; 94 - } 95 - } 96 - 97 - // Get all records from all discovered collections 98 - async getAllRecordsFromAllCollections(did: string): Promise<any[]> { 99 - const collectionTests = await this.discoverAllCollections(did); 100 - const existingCollections = collectionTests.filter(ct => ct.exists); 101 - 102 - const allRecords: any[] = []; 103 - 104 - for (const collectionTest of existingCollections) { 105 - try { 106 - console.log(`Getting all records from ${collectionTest.collection}...`); 107 - const response = await this.agent.api.com.atproto.repo.listRecords({ 108 - repo: did, 109 - collection: collectionTest.collection, 110 - limit: 100, // Get up to 100 records (API limit) 111 - }); 112 - 113 - const records = response.data.records.map((record: any) => ({ 114 - uri: record.uri, 115 - cid: record.cid, 116 - value: record.value, 117 - indexedAt: record.indexedAt, 118 - collection: collectionTest.collection 119 - })); 120 - 121 - allRecords.push(...records); 122 - console.log(`Got ${records.length} records from ${collectionTest.collection}`); 123 - } catch (error) { 124 - console.error(`Error getting records from ${collectionTest.collection}:`, error); 125 - } 126 - } 127 - 128 - return allRecords; 129 - } 130 - }
-309
src/lib/atproto/discovery.ts
··· 1 - import { AtpAgent } from '@atproto/api'; 2 - import type { AtprotoRecord } from '../types/atproto'; 3 - 4 - export interface DiscoveredLexicon { 5 - $type: string; 6 - collection: string; 7 - service: string; 8 - sampleRecord: AtprotoRecord; 9 - properties: Record<string, any>; 10 - description: string; 11 - } 12 - 13 - export interface RepositoryAnalysis { 14 - did: string; 15 - collections: string[]; 16 - lexicons: DiscoveredLexicon[]; 17 - totalRecords: number; 18 - recordTypeCounts: Record<string, number>; 19 - } 20 - 21 - export class ATprotoDiscovery { 22 - private agent: AtpAgent; 23 - 24 - constructor(pdsUrl: string = 'https://bsky.social') { 25 - this.agent = new AtpAgent({ service: pdsUrl }); 26 - } 27 - 28 - // Discover all collections in a repository 29 - async discoverCollections(did: string): Promise<string[]> { 30 - const collections = new Set<string>(); 31 - 32 - // Try common collections that are likely to exist 33 - const commonCollections = [ 34 - // Standard Bluesky collections 35 - 'app.bsky.feed.post', 36 - 'app.bsky.actor.profile', 37 - 'app.bsky.feed.generator', 38 - 'app.bsky.graph.follow', 39 - 'app.bsky.graph.block', 40 - 'app.bsky.feed.like', 41 - 'app.bsky.feed.repost', 42 - // Grain.social collections (if they use different naming) 43 - 'grain.social.feed.post', 44 - 'grain.social.actor.profile', 45 - 'grain.social.feed.gallery', 46 - 'grain.social.feed.image', 47 - // Other potential collections 48 - 'app.bsky.feed.image', 49 - 'app.bsky.feed.gallery', 50 - 'app.bsky.feed.media', 51 - // Generic collections that might contain custom content 52 - 'app.bsky.feed.custom', 53 - 'app.bsky.actor.custom' 54 - ]; 55 - 56 - for (const collection of commonCollections) { 57 - try { 58 - const response = await this.agent.api.com.atproto.repo.listRecords({ 59 - repo: did, 60 - collection, 61 - limit: 1, // Just check if the collection exists 62 - }); 63 - 64 - if (response.data.records.length > 0) { 65 - collections.add(collection); 66 - console.log(`Found collection: ${collection}`); 67 - } 68 - } catch (error) { 69 - console.log(`Collection ${collection} not found or empty`); 70 - } 71 - } 72 - 73 - console.log('Discovered collections:', Array.from(collections)); 74 - return Array.from(collections); 75 - } 76 - 77 - // Get all records from a specific collection 78 - async getRecordsFromCollection(did: string, collection: string, limit: number = 100): Promise<AtprotoRecord[]> { 79 - try { 80 - const response = await this.agent.api.com.atproto.repo.listRecords({ 81 - repo: did, 82 - collection, 83 - limit, 84 - }); 85 - 86 - return response.data.records.map((record: any) => ({ 87 - uri: record.uri, 88 - cid: record.cid, 89 - value: record.value, 90 - indexedAt: record.indexedAt, 91 - })); 92 - } catch (error) { 93 - console.log(`No records found in collection: ${collection}`); 94 - return []; 95 - } 96 - } 97 - 98 - // Analyze a repository completely 99 - async analyzeRepository(handle: string): Promise<RepositoryAnalysis> { 100 - console.log('Starting repository analysis for:', handle); 101 - 102 - // Resolve handle to DID 103 - const did = await this.resolveHandle(handle); 104 - if (!did) { 105 - throw new Error(`Failed to resolve handle: ${handle}`); 106 - } 107 - 108 - console.log('Resolved DID:', did); 109 - 110 - // Discover all collections 111 - const collections = await this.discoverCollections(did); 112 - console.log('Found collections:', collections); 113 - 114 - const lexicons: DiscoveredLexicon[] = []; 115 - const recordTypeCounts: Record<string, number> = {}; 116 - let totalRecords = 0; 117 - 118 - // Analyze each collection 119 - for (const collection of collections) { 120 - console.log(`Analyzing collection: ${collection}`); 121 - try { 122 - const records = await this.getRecordsFromCollection(did, collection, 100); // Increased limit 123 - console.log(`Got ${records.length} records from ${collection}`); 124 - 125 - if (records.length > 0) { 126 - totalRecords += records.length; 127 - 128 - // Group records by type 129 - const typeGroups = new Map<string, AtprotoRecord[]>(); 130 - records.forEach(record => { 131 - const $type = record.value?.$type || 'unknown'; 132 - if (!typeGroups.has($type)) { 133 - typeGroups.set($type, []); 134 - } 135 - typeGroups.get($type)!.push(record); 136 - 137 - // Count record types 138 - recordTypeCounts[$type] = (recordTypeCounts[$type] || 0) + 1; 139 - }); 140 - 141 - console.log(`Found ${typeGroups.size} different types in ${collection}`); 142 - 143 - // Create lexicon definitions for each type 144 - typeGroups.forEach((sampleRecords, $type) => { 145 - const sampleRecord = sampleRecords[0]; 146 - const properties = this.extractProperties(sampleRecord.value); 147 - const service = this.inferService($type, collection); 148 - 149 - lexicons.push({ 150 - $type, 151 - collection, 152 - service, 153 - sampleRecord, 154 - properties, 155 - description: `Discovered in collection ${collection}` 156 - }); 157 - }); 158 - } 159 - } catch (error) { 160 - console.error(`Error analyzing collection ${collection}:`, error); 161 - } 162 - } 163 - 164 - // Also search for grain-related content in existing posts 165 - console.log('Searching for grain-related content in existing posts...'); 166 - const grainContent = await this.findGrainContent(did, collections); 167 - if (grainContent.length > 0) { 168 - console.log(`Found ${grainContent.length} grain-related records`); 169 - grainContent.forEach(record => { 170 - const $type = record.value?.$type || 'unknown'; 171 - if (!recordTypeCounts[$type]) { 172 - recordTypeCounts[$type] = 0; 173 - } 174 - recordTypeCounts[$type]++; 175 - 176 - // Add to lexicons if not already present 177 - const existingLexicon = lexicons.find(l => l.$type === $type); 178 - if (!existingLexicon) { 179 - const properties = this.extractProperties(record.value); 180 - lexicons.push({ 181 - $type, 182 - collection: record.uri.split('/')[2] || 'unknown', 183 - service: 'grain.social', 184 - sampleRecord: record, 185 - properties, 186 - description: 'Grain-related content found in posts' 187 - }); 188 - } 189 - }); 190 - } 191 - 192 - console.log('Analysis complete. Found:', { 193 - collections: collections.length, 194 - lexicons: lexicons.length, 195 - totalRecords 196 - }); 197 - 198 - return { 199 - did, 200 - collections, 201 - lexicons, 202 - totalRecords, 203 - recordTypeCounts 204 - }; 205 - } 206 - 207 - // Find grain-related content in existing posts 208 - private async findGrainContent(did: string, collections: string[]): Promise<AtprotoRecord[]> { 209 - const grainRecords: AtprotoRecord[] = []; 210 - 211 - // Look in posts collection for grain-related content 212 - if (collections.includes('app.bsky.feed.post')) { 213 - try { 214 - const posts = await this.getRecordsFromCollection(did, 'app.bsky.feed.post', 200); 215 - console.log(`Searching ${posts.length} posts for grain content`); 216 - 217 - posts.forEach(post => { 218 - const text = post.value?.text || ''; 219 - const $type = post.value?.$type || ''; 220 - 221 - // Check if post contains grain-related content 222 - if (text.includes('grain.social') || 223 - text.includes('gallery') || 224 - text.includes('grain') || 225 - $type.includes('grain') || 226 - post.uri.includes('grain')) { 227 - grainRecords.push(post); 228 - console.log('Found grain-related post:', { 229 - text: text.substring(0, 100), 230 - type: $type, 231 - uri: post.uri 232 - }); 233 - } 234 - }); 235 - } catch (error) { 236 - console.error('Error searching for grain content:', error); 237 - } 238 - } 239 - 240 - return grainRecords; 241 - } 242 - 243 - // Resolve handle to DID 244 - private async resolveHandle(handle: string): Promise<string | null> { 245 - try { 246 - const response = await this.agent.api.com.atproto.identity.resolveHandle({ 247 - handle: handle, 248 - }); 249 - return response.data.did; 250 - } catch (error) { 251 - console.error('Error resolving handle:', error); 252 - return null; 253 - } 254 - } 255 - 256 - // Extract properties from a record value 257 - private extractProperties(value: any): Record<string, any> { 258 - const properties: Record<string, any> = {}; 259 - 260 - if (value && typeof value === 'object') { 261 - for (const [key, val] of Object.entries(value)) { 262 - if (key === '$type') continue; 263 - properties[key] = this.inferType(val); 264 - } 265 - } 266 - 267 - return properties; 268 - } 269 - 270 - // Infer TypeScript type from value 271 - private inferType(value: any): string { 272 - if (value === null) return 'null'; 273 - if (value === undefined) return 'undefined'; 274 - 275 - const type = typeof value; 276 - 277 - switch (type) { 278 - case 'string': 279 - return 'string'; 280 - case 'number': 281 - return 'number'; 282 - case 'boolean': 283 - return 'boolean'; 284 - case 'object': 285 - if (Array.isArray(value)) { 286 - if (value.length === 0) return 'any[]'; 287 - const itemType = this.inferType(value[0]); 288 - return `${itemType}[]`; 289 - } 290 - return 'Record<string, any>'; 291 - default: 292 - return 'any'; 293 - } 294 - } 295 - 296 - // Infer service from type and collection 297 - private inferService($type: string, collection: string): string { 298 - if ($type.includes('grain')) return 'grain.social'; 299 - if ($type.includes('tangled')) return 'sh.tangled'; 300 - if ($type.includes('bsky')) return 'bsky.app'; 301 - if ($type.includes('atproto')) return 'atproto'; 302 - 303 - // Try to extract service from collection 304 - if (collection.includes('grain')) return 'grain.social'; 305 - if (collection.includes('tangled')) return 'sh.tangled'; 306 - 307 - return 'unknown'; 308 - } 309 - }
+187 -168
src/lib/atproto/jetstream-client.ts
··· 1 - // Jetstream-based repository streaming with DID filtering (based on atptools) 1 + // Complete Jetstream implementation using documented @atcute/jetstream approach 2 + import { JetstreamSubscription, type CommitEvent } from '@atcute/jetstream'; 2 3 import { loadConfig } from '../config/site'; 3 4 4 - export interface JetstreamRecord { 5 - uri: string; 6 - cid: string; 7 - value: any; 8 - indexedAt: string; 9 - collection: string; 10 - $type: string; 11 - service: string; 12 - did: string; 13 - time_us: number; 14 - operation: 'create' | 'update' | 'delete'; 15 - } 16 - 17 5 export interface JetstreamConfig { 18 6 handle: string; 19 7 did?: string; ··· 24 12 } 25 13 26 14 export class JetstreamClient { 27 - private ws: WebSocket | null = null; 15 + private subscription: JetstreamSubscription | null = null; 28 16 private config: JetstreamConfig; 29 - private targetDid: string | null = null; 30 17 private isStreaming = false; 31 18 private listeners: { 32 - onRecord?: (record: JetstreamRecord) => void; 19 + onRecord?: (event: CommitEvent) => void; 33 20 onError?: (error: Error) => void; 34 21 onConnect?: () => void; 35 22 onDisconnect?: () => void; ··· 40 27 this.config = { 41 28 handle: config?.handle || siteConfig.atproto.handle, 42 29 did: config?.did || siteConfig.atproto.did, 43 - endpoint: config?.endpoint || 'wss://jetstream1.us-east.bsky.network/subscribe', 30 + endpoint: config?.endpoint || 'wss://jetstream2.us-east.bsky.network', 44 31 wantedCollections: config?.wantedCollections || [], 45 32 wantedDids: config?.wantedDids || [], 46 33 cursor: config?.cursor, 47 34 }; 48 - this.targetDid = this.config.did || null; 49 35 50 - console.log('๐Ÿ”ง JetstreamClient initialized with handle:', this.config.handle); 51 - console.log('๐ŸŽฏ Target DID for filtering:', this.targetDid); 52 - console.log('๐ŸŒ Endpoint:', this.config.endpoint); 36 + console.log('๐Ÿ”ง JetstreamClient initialized'); 53 37 } 54 38 55 - // Start streaming all repository activity 56 39 async startStreaming(): Promise<void> { 57 40 if (this.isStreaming) { 58 - console.log('โš ๏ธ Already streaming repository'); 41 + console.log('โš ๏ธ Already streaming'); 59 42 return; 60 43 } 61 44 62 - console.log('๐Ÿš€ Starting jetstream repository streaming...'); 45 + console.log('๐Ÿš€ Starting jetstream streaming...'); 63 46 this.isStreaming = true; 64 47 65 48 try { 66 - // Resolve handle to DID if needed 67 - if (!this.targetDid) { 68 - this.targetDid = await this.resolveHandle(this.config.handle); 69 - if (!this.targetDid) { 70 - throw new Error(`Could not resolve handle: ${this.config.handle}`); 71 - } 72 - console.log('โœ… Resolved DID:', this.targetDid); 49 + // Add our DID to wanted DIDs if specified 50 + const wantedDids = [...(this.config.wantedDids || [])]; 51 + if (this.config.did && !wantedDids.includes(this.config.did)) { 52 + wantedDids.push(this.config.did); 73 53 } 74 54 75 - // Add target DID to wanted DIDs 76 - if (this.targetDid && !this.config.wantedDids!.includes(this.targetDid)) { 77 - this.config.wantedDids!.push(this.targetDid); 78 - } 55 + this.subscription = new JetstreamSubscription({ 56 + url: this.config.endpoint!, 57 + wantedCollections: this.config.wantedCollections, 58 + wantedDids: wantedDids as any, 59 + cursor: this.config.cursor, 60 + onConnectionOpen: () => { 61 + console.log('โœ… Connected to jetstream'); 62 + this.listeners.onConnect?.(); 63 + }, 64 + onConnectionClose: () => { 65 + console.log('๐Ÿ”Œ Disconnected from jetstream'); 66 + this.isStreaming = false; 67 + this.listeners.onDisconnect?.(); 68 + }, 69 + onConnectionError: (error) => { 70 + console.error('โŒ Jetstream connection error:', error); 71 + this.listeners.onError?.(new Error('Connection error')); 72 + }, 73 + }); 79 74 80 - // Start WebSocket connection 81 - this.connect(); 75 + // Process events using async iteration as documented 76 + this.processEvents(); 82 77 83 78 } catch (error) { 84 79 this.isStreaming = false; ··· 86 81 } 87 82 } 88 83 89 - // Stop streaming 90 - stopStreaming(): void { 91 - if (this.ws) { 92 - this.ws.close(); 93 - this.ws = null; 94 - } 95 - this.isStreaming = false; 96 - console.log('๐Ÿ›‘ Stopped jetstream streaming'); 97 - this.listeners.onDisconnect?.(); 98 - } 84 + private async processEvents(): Promise<void> { 85 + if (!this.subscription) return; 99 86 100 - // Connect to jetstream WebSocket 101 - private connect(): void { 102 87 try { 103 - const url = new URL(this.config.endpoint!); 104 - 105 - // Add query parameters for filtering (using atptools' parameter names) 106 - this.config.wantedCollections!.forEach((collection) => { 107 - url.searchParams.append('wantedCollections', collection); 108 - }); 109 - this.config.wantedDids!.forEach((did) => { 110 - url.searchParams.append('wantedDids', did); 111 - }); 112 - if (this.config.cursor) { 113 - url.searchParams.set('cursor', this.config.cursor.toString()); 88 + // Use the documented async iteration approach 89 + for await (const event of this.subscription) { 90 + if (event.kind === 'commit') { 91 + console.log('๐Ÿ“ New commit:', { 92 + collection: event.commit.collection, 93 + operation: event.commit.operation, 94 + did: event.did, 95 + }); 96 + 97 + this.listeners.onRecord?.(event); 98 + } 114 99 } 115 - 116 - console.log('๐Ÿ”Œ Connecting to jetstream:', url.toString()); 117 - 118 - this.ws = new WebSocket(url.toString()); 119 - 120 - this.ws.onopen = () => { 121 - console.log('โœ… Connected to jetstream'); 122 - this.listeners.onConnect?.(); 123 - }; 124 - 125 - this.ws.onmessage = (event) => { 126 - try { 127 - const data = JSON.parse(event.data); 128 - this.handleMessage(data); 129 - } catch (error) { 130 - console.error('Error parsing jetstream message:', error); 131 - } 132 - }; 133 - 134 - this.ws.onerror = (error) => { 135 - console.error('โŒ Jetstream WebSocket error:', error); 136 - this.listeners.onError?.(new Error('WebSocket error')); 137 - }; 138 - 139 - this.ws.onclose = () => { 140 - console.log('๐Ÿ”Œ Disconnected from jetstream'); 141 - this.isStreaming = false; 142 - this.listeners.onDisconnect?.(); 143 - }; 144 - 145 100 } catch (error) { 146 - console.error('Error connecting to jetstream:', error); 101 + console.error('Error processing jetstream events:', error); 147 102 this.listeners.onError?.(error as Error); 103 + } finally { 104 + this.isStreaming = false; 105 + this.listeners.onDisconnect?.(); 148 106 } 149 107 } 150 108 151 - // Handle incoming jetstream messages 152 - private handleMessage(data: any): void { 153 - try { 154 - // Handle different message types based on atptools' format 155 - if (data.kind === 'commit') { 156 - this.handleCommit(data); 157 - } else if (data.kind === 'account') { 158 - console.log('Account event:', data); 159 - } else if (data.kind === 'identity') { 160 - console.log('Identity event:', data); 161 - } else { 162 - console.log('Unknown message type:', data); 163 - } 164 - } catch (error) { 165 - console.error('Error handling jetstream message:', error); 166 - } 167 - } 168 - 169 - // Handle commit events (record changes) 170 - private handleCommit(data: any): void { 171 - try { 172 - const commit = data.commit; 173 - const event = data; 174 - 175 - // Filter by DID if specified 176 - if (this.targetDid && event.did !== this.targetDid) { 177 - return; 178 - } 179 - 180 - const jetstreamRecord: JetstreamRecord = { 181 - uri: `at://${event.did}/${commit.collection}/${commit.rkey}`, 182 - cid: commit.cid || '', 183 - value: commit.record || {}, 184 - indexedAt: new Date(event.time_us / 1000).toISOString(), 185 - collection: commit.collection, 186 - $type: (commit.record?.$type as string) || 'unknown', 187 - service: this.inferService((commit.record?.$type as string) || '', commit.collection), 188 - did: event.did, 189 - time_us: event.time_us, 190 - operation: commit.operation, 191 - }; 192 - 193 - console.log('๐Ÿ“ New record from jetstream:', { 194 - collection: jetstreamRecord.collection, 195 - $type: jetstreamRecord.$type, 196 - operation: jetstreamRecord.operation, 197 - uri: jetstreamRecord.uri, 198 - service: jetstreamRecord.service 199 - }); 200 - 201 - this.listeners.onRecord?.(jetstreamRecord); 202 - } catch (error) { 203 - console.error('Error handling commit:', error); 204 - } 205 - } 206 - 207 - // Infer service from record type and collection 208 - private inferService($type: string, collection: string): string { 209 - if (collection.startsWith('grain.social')) return 'grain.social'; 210 - if (collection.startsWith('app.bsky')) return 'bsky.app'; 211 - if ($type.includes('grain')) return 'grain.social'; 212 - return 'unknown'; 213 - } 214 - 215 - // Resolve handle to DID 216 - private async resolveHandle(handle: string): Promise<string | null> { 217 - try { 218 - // For now, use the configured DID 219 - // In a real implementation, you'd call the ATProto API 220 - return this.config.did || null; 221 - } catch (error) { 222 - console.error('Error resolving handle:', error); 223 - return null; 224 - } 109 + stopStreaming(): void { 110 + this.subscription = null; 111 + this.isStreaming = false; 112 + console.log('๐Ÿ›‘ Stopped jetstream streaming'); 113 + this.listeners.onDisconnect?.(); 225 114 } 226 115 227 116 // Event listeners 228 - onRecord(callback: (record: JetstreamRecord) => void): void { 117 + onRecord(callback: (event: CommitEvent) => void): void { 229 118 this.listeners.onRecord = callback; 230 119 } 231 120 ··· 241 130 this.listeners.onDisconnect = callback; 242 131 } 243 132 244 - // Get streaming status 245 133 getStatus(): 'streaming' | 'stopped' { 246 134 return this.isStreaming ? 'streaming' : 'stopped'; 247 135 } 136 + } 137 + 138 + // Shared Jetstream functionality 139 + let sharedJetstream: JetstreamClient | null = null; 140 + let connectionCount = 0; 141 + const listeners: Map<string, Set<(event: CommitEvent) => void>> = new Map(); 142 + 143 + export function getSharedJetstream(): JetstreamClient { 144 + if (!sharedJetstream) { 145 + // Create a shared client with common collections 146 + sharedJetstream = new JetstreamClient({ 147 + wantedCollections: [ 148 + 'app.bsky.feed.post', 149 + 'a.status.update', 150 + 'social.grain.gallery', 151 + 'social.grain.gallery.item', 152 + 'social.grain.photo', 153 + 'com.whtwnd.blog.entry' 154 + ] 155 + }); 156 + 157 + // Set up the main record handler that distributes to filtered listeners 158 + sharedJetstream.onRecord((event) => { 159 + // Distribute to all listeners that match the filter 160 + listeners.forEach((listenerSet, filterKey) => { 161 + if (matchesFilter(event, filterKey)) { 162 + listenerSet.forEach(callback => callback(event)); 163 + } 164 + }); 165 + }); 166 + } 167 + return sharedJetstream; 168 + } 169 + 170 + // Start the shared stream (call once when first component needs it) 171 + export async function startSharedStream(): Promise<void> { 172 + const jetstream = getSharedJetstream(); 173 + if (connectionCount === 0) { 174 + await jetstream.startStreaming(); 175 + } 176 + connectionCount++; 177 + } 178 + 179 + // Stop the shared stream (call when last component is done) 180 + export function stopSharedStream(): void { 181 + connectionCount--; 182 + if (connectionCount <= 0 && sharedJetstream) { 183 + sharedJetstream.stopStreaming(); 184 + connectionCount = 0; 185 + } 186 + } 187 + 188 + // Subscribe to filtered records 189 + export function subscribeToRecords( 190 + filter: string | ((event: CommitEvent) => boolean), 191 + callback: (event: CommitEvent) => void 192 + ): () => void { 193 + const filterKey = typeof filter === 'string' ? filter : filter.toString(); 194 + 195 + if (!listeners.has(filterKey)) { 196 + listeners.set(filterKey, new Set()); 197 + } 198 + 199 + const listenerSet = listeners.get(filterKey)!; 200 + listenerSet.add(callback); 201 + 202 + // Return unsubscribe function 203 + return () => { 204 + const set = listeners.get(filterKey); 205 + if (set) { 206 + set.delete(callback); 207 + if (set.size === 0) { 208 + listeners.delete(filterKey); 209 + } 210 + } 211 + }; 212 + } 213 + 214 + // Helper to check if a record matches a filter 215 + function matchesFilter(event: CommitEvent, filterKey: string): boolean { 216 + // Handle delete operations (no record property) 217 + if (event.commit.operation === 'delete') { 218 + // For delete operations, only support collection and operation matching 219 + if (filterKey.startsWith('collection:')) { 220 + const expectedCollection = filterKey.substring(11); 221 + return event.commit.collection === expectedCollection; 222 + } 223 + if (filterKey.startsWith('operation:')) { 224 + const expectedOperation = filterKey.substring(10); 225 + return event.commit.operation === expectedOperation; 226 + } 227 + return false; 228 + } 229 + 230 + // For create/update operations, we have record data 231 + const record = event.commit.record; 232 + const $type = record?.$type as string; 233 + 234 + // Support simple $type matching 235 + if (filterKey.startsWith('$type:')) { 236 + const expectedType = filterKey.substring(6); 237 + return $type === expectedType; 238 + } 239 + 240 + // Support collection matching 241 + if (filterKey.startsWith('collection:')) { 242 + const expectedCollection = filterKey.substring(11); 243 + return event.commit.collection === expectedCollection; 244 + } 245 + 246 + // Support operation matching 247 + if (filterKey.startsWith('operation:')) { 248 + const expectedOperation = filterKey.substring(10); 249 + return event.commit.operation === expectedOperation; 250 + } 251 + 252 + // Default to exact match 253 + return $type === filterKey; 254 + } 255 + 256 + // Convenience functions for common filters 257 + export function subscribeToStatusUpdates(callback: (event: CommitEvent) => void): () => void { 258 + return subscribeToRecords('$type:a.status.update', callback); 259 + } 260 + 261 + export function subscribeToPosts(callback: (event: CommitEvent) => void): () => void { 262 + return subscribeToRecords('$type:app.bsky.feed.post', callback); 263 + } 264 + 265 + export function subscribeToGalleryUpdates(callback: (event: CommitEvent) => void): () => void { 266 + return subscribeToRecords('collection:social.grain.gallery', callback); 248 267 }
-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 - }
-268
src/lib/atproto/repository-stream.ts
··· 1 - // Comprehensive repository streaming system for ATProto repositories 2 - import { AtpAgent } from '@atproto/api'; 3 - import { loadConfig } from '../config/site'; 4 - 5 - export interface RepositoryRecord { 6 - uri: string; 7 - cid: string; 8 - value: any; 9 - indexedAt: string; 10 - collection: string; 11 - $type: string; 12 - service: string; 13 - } 14 - 15 - export interface RepositoryStreamConfig { 16 - handle: string; 17 - did?: string; 18 - pdsUrl?: string; 19 - pollInterval?: number; // milliseconds 20 - maxRecordsPerCollection?: number; 21 - } 22 - 23 - export class RepositoryStream { 24 - private agent: AtpAgent; 25 - private config: RepositoryStreamConfig; 26 - private targetDid: string | null = null; 27 - private discoveredCollections: string[] = []; 28 - private isStreaming = false; 29 - private pollInterval: NodeJS.Timeout | null = null; 30 - private lastSeenRecords: Map<string, string> = new Map(); // collection -> last CID 31 - private listeners: { 32 - onRecord?: (record: RepositoryRecord) => void; 33 - onError?: (error: Error) => void; 34 - onConnect?: () => void; 35 - onDisconnect?: () => void; 36 - onCollectionDiscovered?: (collection: string) => void; 37 - } = {}; 38 - 39 - constructor(config?: Partial<RepositoryStreamConfig>) { 40 - const siteConfig = loadConfig(); 41 - this.config = { 42 - handle: config?.handle || siteConfig.atproto.handle, 43 - did: config?.did || siteConfig.atproto.did, 44 - pdsUrl: config?.pdsUrl || siteConfig.atproto.pdsUrl || 'https://bsky.social', 45 - pollInterval: config?.pollInterval || 5000, // 5 seconds 46 - maxRecordsPerCollection: config?.maxRecordsPerCollection || 50, 47 - }; 48 - this.targetDid = this.config.did || null; 49 - this.agent = new AtpAgent({ service: this.config.pdsUrl || 'https://bsky.social' }); 50 - 51 - console.log('๐Ÿ”ง RepositoryStream initialized with handle:', this.config.handle); 52 - console.log('๐ŸŽฏ Target DID for filtering:', this.targetDid); 53 - } 54 - 55 - // Start streaming all repository content 56 - async startStreaming(): Promise<void> { 57 - if (this.isStreaming) { 58 - console.log('โš ๏ธ Already streaming repository'); 59 - return; 60 - } 61 - 62 - console.log('๐Ÿš€ Starting comprehensive repository streaming...'); 63 - this.isStreaming = true; 64 - 65 - try { 66 - // Resolve handle to DID if needed 67 - if (!this.targetDid) { 68 - this.targetDid = await this.resolveHandle(this.config.handle); 69 - if (!this.targetDid) { 70 - throw new Error(`Could not resolve handle: ${this.config.handle}`); 71 - } 72 - console.log('โœ… Resolved DID:', this.targetDid); 73 - } 74 - 75 - // Discover all collections 76 - await this.discoverCollections(); 77 - 78 - // Start polling all collections 79 - this.startPolling(); 80 - 81 - this.listeners.onConnect?.(); 82 - 83 - } catch (error) { 84 - this.isStreaming = false; 85 - throw error; 86 - } 87 - } 88 - 89 - // Stop streaming 90 - stopStreaming(): void { 91 - if (this.pollInterval) { 92 - clearInterval(this.pollInterval); 93 - this.pollInterval = null; 94 - } 95 - this.isStreaming = false; 96 - console.log('๐Ÿ›‘ Stopped repository streaming'); 97 - this.listeners.onDisconnect?.(); 98 - } 99 - 100 - // Discover all collections in the repository 101 - private async discoverCollections(): Promise<void> { 102 - console.log('๐Ÿ” Discovering collections using repository sync...'); 103 - 104 - this.discoveredCollections = []; 105 - 106 - try { 107 - // Get all records from the repository using sync API 108 - const response = await this.agent.api.com.atproto.repo.listRecords({ 109 - repo: this.targetDid!, 110 - collection: 'app.bsky.feed.post', // Start with posts 111 - limit: 1000, // Get a large sample 112 - }); 113 - 114 - const collectionSet = new Set<string>(); 115 - 116 - // Extract collections from URIs 117 - for (const record of response.data.records) { 118 - const uriParts = record.uri.split('/'); 119 - if (uriParts.length >= 3) { 120 - const collection = uriParts[2]; 121 - collectionSet.add(collection); 122 - } 123 - } 124 - 125 - // Convert to array and sort 126 - this.discoveredCollections = Array.from(collectionSet).sort(); 127 - 128 - console.log(`๐Ÿ“Š Discovered ${this.discoveredCollections.length} collections:`, this.discoveredCollections); 129 - 130 - // Notify listeners for each discovered collection 131 - for (const collection of this.discoveredCollections) { 132 - console.log(`โœ… Found collection: ${collection}`); 133 - this.listeners.onCollectionDiscovered?.(collection); 134 - } 135 - 136 - } catch (error) { 137 - console.error('Error discovering collections:', error); 138 - // Fallback to basic collections if discovery fails 139 - this.discoveredCollections = ['app.bsky.feed.post', 'app.bsky.actor.profile']; 140 - } 141 - } 142 - 143 - // Start polling all discovered collections 144 - private startPolling(): void { 145 - console.log('โฐ Starting collection polling...'); 146 - 147 - this.pollInterval = setInterval(async () => { 148 - if (!this.isStreaming) return; 149 - 150 - for (const collection of this.discoveredCollections) { 151 - try { 152 - await this.pollCollection(collection); 153 - } catch (error) { 154 - console.error(`โŒ Error polling collection ${collection}:`, error); 155 - } 156 - } 157 - }, this.config.pollInterval); 158 - } 159 - 160 - // Poll a specific collection for new records 161 - private async pollCollection(collection: string): Promise<void> { 162 - try { 163 - const response = await this.agent.api.com.atproto.repo.listRecords({ 164 - repo: this.targetDid!, 165 - collection, 166 - limit: this.config.maxRecordsPerCollection!, 167 - }); 168 - 169 - if (response.data.records.length === 0) return; 170 - 171 - const lastSeenCid = this.lastSeenRecords.get(collection); 172 - const newRecords: RepositoryRecord[] = []; 173 - 174 - for (const record of response.data.records) { 175 - // Check if this is a new record 176 - if (!lastSeenCid || record.cid !== lastSeenCid) { 177 - const repositoryRecord: RepositoryRecord = { 178 - uri: record.uri, 179 - cid: record.cid, 180 - value: record.value, 181 - indexedAt: (record as any).indexedAt || new Date().toISOString(), 182 - collection, 183 - $type: (record.value?.$type as string) || 'unknown', 184 - service: this.inferService((record.value?.$type as string) || '', collection), 185 - }; 186 - 187 - newRecords.push(repositoryRecord); 188 - } else { 189 - // We've reached records we've already seen 190 - break; 191 - } 192 - } 193 - 194 - // Update last seen CID 195 - if (response.data.records.length > 0) { 196 - this.lastSeenRecords.set(collection, response.data.records[0].cid); 197 - } 198 - 199 - // Process new records 200 - for (const record of newRecords.reverse()) { // Process oldest first 201 - console.log('๐Ÿ“ New record from collection:', { 202 - collection: record.collection, 203 - $type: record.$type, 204 - uri: record.uri, 205 - service: record.service 206 - }); 207 - 208 - this.listeners.onRecord?.(record); 209 - } 210 - 211 - } catch (error) { 212 - console.error(`โŒ Error polling collection ${collection}:`, error); 213 - } 214 - } 215 - 216 - // Infer service from record type and collection 217 - private inferService($type: string, collection: string): string { 218 - if (collection.startsWith('grain.social')) return 'grain.social'; 219 - if (collection.startsWith('app.bsky')) return 'bsky.app'; 220 - if ($type.includes('grain')) return 'grain.social'; 221 - if (collection === 'grain.social.content') return 'grain.social'; 222 - return 'unknown'; 223 - } 224 - 225 - // Resolve handle to DID 226 - private async resolveHandle(handle: string): Promise<string | null> { 227 - try { 228 - const response = await this.agent.api.com.atproto.identity.resolveHandle({ 229 - handle: handle, 230 - }); 231 - return response.data.did; 232 - } catch (error) { 233 - console.error('Error resolving handle:', error); 234 - return null; 235 - } 236 - } 237 - 238 - // Event listeners 239 - onRecord(callback: (record: RepositoryRecord) => void): void { 240 - this.listeners.onRecord = callback; 241 - } 242 - 243 - onError(callback: (error: Error) => void): void { 244 - this.listeners.onError = callback; 245 - } 246 - 247 - onConnect(callback: () => void): void { 248 - this.listeners.onConnect = callback; 249 - } 250 - 251 - onDisconnect(callback: () => void): void { 252 - this.listeners.onDisconnect = callback; 253 - } 254 - 255 - onCollectionDiscovered(callback: (collection: string) => void): void { 256 - this.listeners.onCollectionDiscovered = callback; 257 - } 258 - 259 - // Get streaming status 260 - getStatus(): 'streaming' | 'stopped' { 261 - return this.isStreaming ? 'streaming' : 'stopped'; 262 - } 263 - 264 - // Get discovered collections 265 - getDiscoveredCollections(): string[] { 266 - return [...this.discoveredCollections]; 267 - } 268 - }
-142
src/lib/atproto/turbostream.ts
··· 1 - // Simple Graze Turbostream client for real-time, hydrated ATproto records 2 - import { loadConfig } from '../config/site'; 3 - 4 - export interface TurbostreamRecord { 5 - at_uri: string; 6 - did: string; 7 - time_us: number; 8 - message: any; // Raw jetstream record 9 - hydrated_metadata: { 10 - user?: any; // profileViewDetailed 11 - mentions?: Record<string, any>; // Map of mentioned DIDs to profile objects 12 - parent_post?: any; // postViewBasic 13 - reply_post?: any; // postView 14 - quote_post?: any; // postView or null 15 - }; 16 - } 17 - 18 - export class TurbostreamClient { 19 - private ws: WebSocket | null = null; 20 - private config: any; 21 - private targetDid: string | null = null; 22 - private listeners: { 23 - onRecord?: (record: TurbostreamRecord) => void; 24 - onError?: (error: Error) => void; 25 - onConnect?: () => void; 26 - onDisconnect?: () => void; 27 - } = {}; 28 - 29 - constructor() { 30 - const siteConfig = loadConfig(); 31 - this.config = { 32 - handle: siteConfig.atproto.handle, 33 - did: siteConfig.atproto.did, 34 - }; 35 - this.targetDid = siteConfig.atproto.did || null; 36 - console.log('๐Ÿ”ง TurbostreamClient initialized with handle:', this.config.handle); 37 - console.log('๐ŸŽฏ Target DID for filtering:', this.targetDid); 38 - } 39 - 40 - // Connect to Turbostream WebSocket 41 - async connect(): Promise<void> { 42 - if (this.ws?.readyState === WebSocket.OPEN) { 43 - console.log('โš ๏ธ Already connected to Turbostream'); 44 - return; 45 - } 46 - 47 - console.log('๐Ÿ”Œ Connecting to Graze Turbostream...'); 48 - console.log('๐Ÿ“ WebSocket URL: wss://api.graze.social/app/api/v1/turbostream/turbostream'); 49 - 50 - const wsUrl = `wss://api.graze.social/app/api/v1/turbostream/turbostream`; 51 - this.ws = new WebSocket(wsUrl); 52 - 53 - this.ws.onopen = () => { 54 - console.log('โœ… Connected to Graze Turbostream'); 55 - console.log('๐Ÿ“ก WebSocket readyState:', this.ws?.readyState); 56 - this.listeners.onConnect?.(); 57 - }; 58 - 59 - this.ws.onmessage = (event) => { 60 - try { 61 - const data = JSON.parse(event.data); 62 - this.processMessage(data); 63 - } catch (error) { 64 - console.error('โŒ Error parsing Turbostream message:', error); 65 - this.listeners.onError?.(error as Error); 66 - } 67 - }; 68 - 69 - this.ws.onerror = (error) => { 70 - console.error('โŒ Turbostream WebSocket error:', error); 71 - this.listeners.onError?.(new Error('WebSocket error')); 72 - }; 73 - 74 - this.ws.onclose = (event) => { 75 - console.log('๐Ÿ”Œ Turbostream WebSocket closed:', event.code, event.reason); 76 - this.listeners.onDisconnect?.(); 77 - }; 78 - } 79 - 80 - // Disconnect from Turbostream 81 - disconnect(): void { 82 - if (this.ws) { 83 - console.log('๐Ÿ”Œ Disconnecting from Turbostream...'); 84 - this.ws.close(1000, 'Manual disconnect'); 85 - this.ws = null; 86 - } 87 - } 88 - 89 - // Process incoming messages from Turbostream 90 - private processMessage(data: any): void { 91 - if (Array.isArray(data)) { 92 - data.forEach((record: TurbostreamRecord) => { 93 - this.processRecord(record); 94 - }); 95 - } else if (data && typeof data === 'object') { 96 - this.processRecord(data as TurbostreamRecord); 97 - } 98 - } 99 - 100 - // Process individual record 101 - private processRecord(record: TurbostreamRecord): void { 102 - // Filter records to only show those from our configured handle 103 - if (this.targetDid && record.did !== this.targetDid) { 104 - return; 105 - } 106 - 107 - console.log('๐Ÿ“ Processing record from target DID:', { 108 - uri: record.at_uri, 109 - did: record.did, 110 - time: new Date(record.time_us / 1000).toISOString(), 111 - hasUser: !!record.hydrated_metadata?.user, 112 - hasText: !!record.message?.text, 113 - textPreview: record.message?.text?.substring(0, 50) + '...' 114 - }); 115 - 116 - this.listeners.onRecord?.(record); 117 - } 118 - 119 - // Event listeners 120 - onRecord(callback: (record: TurbostreamRecord) => void): void { 121 - this.listeners.onRecord = callback; 122 - } 123 - 124 - onError(callback: (error: Error) => void): void { 125 - this.listeners.onError = callback; 126 - } 127 - 128 - onConnect(callback: () => void): void { 129 - this.listeners.onConnect = callback; 130 - } 131 - 132 - onDisconnect(callback: () => void): void { 133 - this.listeners.onDisconnect = callback; 134 - } 135 - 136 - // Get connection status 137 - getStatus(): 'connecting' | 'connected' | 'disconnected' { 138 - if (this.ws?.readyState === WebSocket.CONNECTING) return 'connecting'; 139 - if (this.ws?.readyState === WebSocket.OPEN) return 'connected'; 140 - return 'disconnected'; 141 - } 142 - }
+303
src/lib/build/collection-discovery.ts
··· 1 + import { AtprotoBrowser } from '../atproto/atproto-browser'; 2 + import { loadConfig } from '../config/site'; 3 + import fs from 'fs/promises'; 4 + import path from 'path'; 5 + 6 + export interface CollectionType { 7 + name: string; 8 + description: string; 9 + service: string; 10 + sampleRecords: any[]; 11 + generatedTypes: string; 12 + $types: string[]; 13 + } 14 + 15 + export interface BuildTimeDiscovery { 16 + collections: CollectionType[]; 17 + totalCollections: number; 18 + totalRecords: number; 19 + generatedAt: string; 20 + repository: { 21 + handle: string; 22 + did: string; 23 + recordCount: number; 24 + }; 25 + } 26 + 27 + export class CollectionDiscovery { 28 + private browser: AtprotoBrowser; 29 + private config: any; 30 + 31 + constructor() { 32 + this.config = loadConfig(); 33 + this.browser = new AtprotoBrowser(); 34 + } 35 + 36 + // Discover all collections and generate types 37 + async discoverCollections(identifier: string): Promise<BuildTimeDiscovery> { 38 + console.log('๐Ÿ” Starting collection discovery for:', identifier); 39 + 40 + try { 41 + // Get repository info 42 + const repoInfo = await this.browser.getRepoInfo(identifier); 43 + if (!repoInfo) { 44 + throw new Error(`Could not get repository info for: ${identifier}`); 45 + } 46 + 47 + console.log('๐Ÿ“Š Repository info:', { 48 + handle: repoInfo.handle, 49 + did: repoInfo.did, 50 + collections: repoInfo.collections.length, 51 + recordCount: repoInfo.recordCount 52 + }); 53 + 54 + const collections: CollectionType[] = []; 55 + let totalRecords = 0; 56 + 57 + // Process each collection 58 + for (const collectionName of repoInfo.collections) { 59 + console.log(`๐Ÿ“ฆ Processing collection: ${collectionName}`); 60 + 61 + const collectionType = await this.processCollection(identifier, collectionName); 62 + if (collectionType) { 63 + collections.push(collectionType); 64 + totalRecords += collectionType.sampleRecords.length; 65 + } 66 + } 67 + 68 + const discovery: BuildTimeDiscovery = { 69 + collections, 70 + totalCollections: collections.length, 71 + totalRecords, 72 + generatedAt: new Date().toISOString(), 73 + repository: { 74 + handle: repoInfo.handle, 75 + did: repoInfo.did, 76 + recordCount: repoInfo.recordCount 77 + } 78 + }; 79 + 80 + console.log(`โœ… Discovery complete: ${collections.length} collections, ${totalRecords} records`); 81 + return discovery; 82 + 83 + } catch (error) { 84 + console.error('Error discovering collections:', error); 85 + throw error; 86 + } 87 + } 88 + 89 + // Process a single collection 90 + private async processCollection(identifier: string, collectionName: string): Promise<CollectionType | null> { 91 + try { 92 + // Get records from collection 93 + const records = await this.browser.getCollectionRecords(identifier, collectionName, 10); 94 + if (!records || records.records.length === 0) { 95 + console.log(`โš ๏ธ No records found in collection: ${collectionName}`); 96 + return null; 97 + } 98 + 99 + // Group records by $type 100 + const recordsByType = new Map<string, any[]>(); 101 + for (const record of records.records) { 102 + const $type = record.$type || 'unknown'; 103 + if (!recordsByType.has($type)) { 104 + recordsByType.set($type, []); 105 + } 106 + recordsByType.get($type)!.push(record.value); 107 + } 108 + 109 + // Generate types for each $type 110 + const generatedTypes: string[] = []; 111 + const $types: string[] = []; 112 + 113 + for (const [$type, typeRecords] of recordsByType) { 114 + if ($type === 'unknown') continue; 115 + 116 + $types.push($type); 117 + const typeDefinition = this.generateTypeDefinition($type, typeRecords); 118 + generatedTypes.push(typeDefinition); 119 + } 120 + 121 + // Create collection type 122 + const collectionType: CollectionType = { 123 + name: collectionName, 124 + description: this.getCollectionDescription(collectionName), 125 + service: this.inferService(collectionName), 126 + sampleRecords: records.records.slice(0, 3).map(r => r.value), 127 + generatedTypes: generatedTypes.join('\n\n'), 128 + $types 129 + }; 130 + 131 + console.log(`โœ… Processed collection ${collectionName}: ${$types.length} types`); 132 + return collectionType; 133 + 134 + } catch (error) { 135 + console.error(`Error processing collection ${collectionName}:`, error); 136 + return null; 137 + } 138 + } 139 + 140 + // Generate TypeScript type definition 141 + private generateTypeDefinition($type: string, records: any[]): string { 142 + if (records.length === 0) return ''; 143 + 144 + // Analyze the first record to understand the structure 145 + const sampleRecord = records[0]; 146 + const properties = this.extractProperties(sampleRecord); 147 + 148 + // Generate interface 149 + const interfaceName = this.$typeToInterfaceName($type); 150 + let typeDefinition = `export interface ${interfaceName} {\n`; 151 + 152 + // Add $type property 153 + typeDefinition += ` $type: '${$type}';\n`; 154 + 155 + // Add other properties 156 + for (const [key, value] of Object.entries(properties)) { 157 + const type = this.inferType(value); 158 + typeDefinition += ` ${key}?: ${type};\n`; 159 + } 160 + 161 + typeDefinition += '}\n'; 162 + 163 + return typeDefinition; 164 + } 165 + 166 + // Extract properties from a record 167 + private extractProperties(obj: any, maxDepth: number = 3, currentDepth: number = 0): Record<string, any> { 168 + if (currentDepth >= maxDepth || !obj || typeof obj !== 'object') { 169 + return {}; 170 + } 171 + 172 + const properties: Record<string, any> = {}; 173 + 174 + for (const [key, value] of Object.entries(obj)) { 175 + if (key === '$type') continue; // Skip $type as it's handled separately 176 + 177 + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { 178 + // Nested object 179 + const nestedProps = this.extractProperties(value, maxDepth, currentDepth + 1); 180 + if (Object.keys(nestedProps).length > 0) { 181 + properties[key] = nestedProps; 182 + } 183 + } else { 184 + // Simple property 185 + properties[key] = value; 186 + } 187 + } 188 + 189 + return properties; 190 + } 191 + 192 + // Infer TypeScript type from value 193 + private inferType(value: any): string { 194 + if (value === null) return 'null'; 195 + if (value === undefined) return 'undefined'; 196 + 197 + const type = typeof value; 198 + 199 + switch (type) { 200 + case 'string': 201 + return 'string'; 202 + case 'number': 203 + return 'number'; 204 + case 'boolean': 205 + return 'boolean'; 206 + case 'object': 207 + if (Array.isArray(value)) { 208 + if (value.length === 0) return 'any[]'; 209 + const elementType = this.inferType(value[0]); 210 + return `${elementType}[]`; 211 + } 212 + return 'Record<string, any>'; 213 + default: 214 + return 'any'; 215 + } 216 + } 217 + 218 + // Convert $type to interface name 219 + private $typeToInterfaceName($type: string): string { 220 + // Convert app.bsky.feed.post to AppBskyFeedPost 221 + return $type 222 + .split('.') 223 + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) 224 + .join(''); 225 + } 226 + 227 + // Get collection description 228 + private getCollectionDescription(collectionName: string): string { 229 + const descriptions: Record<string, string> = { 230 + 'app.bsky.feed.post': 'Bluesky posts', 231 + 'app.bsky.actor.profile': 'Bluesky profile information', 232 + 'app.bsky.feed.generator': 'Bluesky custom feeds', 233 + 'app.bsky.graph.follow': 'Bluesky follow relationships', 234 + 'app.bsky.graph.block': 'Bluesky block relationships', 235 + 'app.bsky.feed.like': 'Bluesky like records', 236 + 'app.bsky.feed.repost': 'Bluesky repost records', 237 + 'social.grain.gallery': 'Grain.social image galleries', 238 + 'grain.social.feed.gallery': 'Grain.social galleries', 239 + 'grain.social.feed.post': 'Grain.social posts', 240 + 'grain.social.actor.profile': 'Grain.social profile information', 241 + }; 242 + 243 + return descriptions[collectionName] || `${collectionName} records`; 244 + } 245 + 246 + // Infer service from collection name 247 + private inferService(collectionName: string): string { 248 + if (collectionName.startsWith('grain.social') || collectionName.startsWith('social.grain')) { 249 + return 'grain.social'; 250 + } 251 + if (collectionName.startsWith('app.bsky')) { 252 + return 'bsky.app'; 253 + } 254 + if (collectionName.startsWith('sh.tangled')) { 255 + return 'sh.tangled'; 256 + } 257 + return 'unknown'; 258 + } 259 + 260 + // Save discovery results to file 261 + async saveDiscoveryResults(discovery: BuildTimeDiscovery, outputPath: string): Promise<void> { 262 + try { 263 + // Create output directory if it doesn't exist 264 + const outputDir = path.dirname(outputPath); 265 + await fs.mkdir(outputDir, { recursive: true }); 266 + 267 + // Save discovery metadata 268 + const metadataPath = outputPath.replace('.ts', '.json'); 269 + await fs.writeFile(metadataPath, JSON.stringify(discovery, null, 2)); 270 + 271 + // Generate TypeScript file with all types 272 + let typesContent = '// Auto-generated types from collection discovery\n'; 273 + typesContent += `// Generated at: ${discovery.generatedAt}\n`; 274 + typesContent += `// Repository: ${discovery.repository.handle} (${discovery.repository.did})\n`; 275 + typesContent += `// Collections: ${discovery.totalCollections}, Records: ${discovery.totalRecords}\n\n`; 276 + 277 + // Add all generated types 278 + for (const collection of discovery.collections) { 279 + typesContent += `// Collection: ${collection.name}\n`; 280 + typesContent += `// Service: ${collection.service}\n`; 281 + typesContent += `// Types: ${collection.$types.join(', ')}\n`; 282 + typesContent += collection.generatedTypes; 283 + typesContent += '\n\n'; 284 + } 285 + 286 + // Add union type for all discovered types 287 + const allTypes = discovery.collections.flatMap(c => c.$types); 288 + if (allTypes.length > 0) { 289 + typesContent += '// Union type for all discovered types\n'; 290 + typesContent += `export type DiscoveredTypes = ${allTypes.map(t => `'${t}'`).join(' | ')};\n\n`; 291 + } 292 + 293 + await fs.writeFile(outputPath, typesContent); 294 + 295 + console.log(`๐Ÿ’พ Saved discovery results to: ${outputPath}`); 296 + console.log(`๐Ÿ“Š Generated ${discovery.totalCollections} collection types`); 297 + 298 + } catch (error) { 299 + console.error('Error saving discovery results:', error); 300 + throw error; 301 + } 302 + } 303 + }
-23
src/lib/components/register.ts
··· 1 - import { registerComponent } from './registry'; 2 - import BlueskyPost from '../../components/content/BlueskyPost.astro'; 3 - import WhitewindBlogPost from '../../components/content/WhitewindBlogPost.astro'; 4 - import LeafletPublication from '../../components/content/LeafletPublication.astro'; 5 - import GrainImageGallery from '../../components/content/GrainImageGallery.astro'; 6 - 7 - // Register all content components 8 - export function registerAllComponents() { 9 - // Register Bluesky post component 10 - registerComponent('app.bsky.feed.post', BlueskyPost); 11 - 12 - // Register Whitewind blog post component 13 - registerComponent('app.bsky.actor.profile#whitewindBlogPost', WhitewindBlogPost); 14 - 15 - // Register Leaflet publication component 16 - registerComponent('app.bsky.actor.profile#leafletPublication', LeafletPublication); 17 - 18 - // Register Grain image gallery component 19 - registerComponent('app.bsky.actor.profile#grainImageGallery', GrainImageGallery); 20 - } 21 - 22 - // Auto-register components when this module is imported 23 - registerAllComponents();
+51 -46
src/lib/components/registry.ts
··· 1 - import type { ContentComponent } from '../types/atproto'; 1 + import type { GeneratedLexiconUnion, GeneratedLexiconTypeMap } from '../generated/lexicon-types'; 2 2 3 - // Component registry for type-safe content rendering 4 - class ComponentRegistry { 5 - private components = new Map<string, ContentComponent>(); 3 + // Type-safe component registry 4 + export interface ComponentRegistryEntry<T = any> { 5 + component: string; 6 + props?: T; 7 + } 6 8 7 - // Register a component for a specific content type 8 - register(type: string, component: any, props?: Record<string, any>): void { 9 - this.components.set(type, { 10 - type, 11 - component, 12 - props, 13 - }); 14 - } 9 + export type ComponentRegistry = { 10 + [K in keyof GeneratedLexiconTypeMap]?: ComponentRegistryEntry; 11 + } & { 12 + [key: string]: ComponentRegistryEntry; // Fallback for unknown types 13 + }; 15 14 16 - // Get a component for a specific content type 17 - get(type: string): ContentComponent | undefined { 18 - return this.components.get(type); 19 - } 15 + // Default registry - add your components here 16 + export const registry: ComponentRegistry = { 17 + 'ComWhtwndBlogEntry': { 18 + component: 'WhitewindBlogPost', 19 + props: {} 20 + }, 21 + 'AStatusUpdate': { 22 + component: 'StatusUpdate', 23 + props: {} 24 + }, 25 + // Bluesky posts (not in generated types, but used by components) 26 + 'app.bsky.feed.post': { 27 + component: 'BlueskyPost', 28 + props: {} 29 + }, 20 30 21 - // Check if a component exists for a content type 22 - has(type: string): boolean { 23 - return this.components.has(type); 24 - } 31 + }; 25 32 26 - // Get all registered component types 27 - getRegisteredTypes(): string[] { 28 - return Array.from(this.components.keys()); 29 - } 30 - 31 - // Clear all registered components 32 - clear(): void { 33 - this.components.clear(); 34 - } 33 + // Type-safe component lookup 34 + export function getComponentInfo<T extends keyof GeneratedLexiconTypeMap>( 35 + $type: T 36 + ): ComponentRegistryEntry | null { 37 + return registry[$type] || null; 35 38 } 36 39 37 - // Global component registry instance 38 - export const componentRegistry = new ComponentRegistry(); 39 - 40 - // Type-safe component registration helper 41 - export function registerComponent( 42 - type: string, 43 - component: any, 44 - props?: Record<string, any> 40 + // Helper to register a new component 41 + export function registerComponent<T extends keyof GeneratedLexiconTypeMap>( 42 + $type: T, 43 + component: string, 44 + props?: any 45 45 ): void { 46 - componentRegistry.register(type, component, props); 47 - } 48 - 49 - // Type-safe component retrieval helper 50 - export function getComponent(type: string): ContentComponent | undefined { 51 - return componentRegistry.get(type); 46 + registry[$type] = { component, props }; 52 47 } 53 48 54 - // Check if content type has a registered component 55 - export function hasComponent(type: string): boolean { 56 - return componentRegistry.has(type); 49 + // Auto-assignment for unknown types (fallback) 50 + export function autoAssignComponent($type: string): ComponentRegistryEntry { 51 + // Convert NSID to component name 52 + const parts = $type.split('.'); 53 + const componentName = parts[parts.length - 1] 54 + .split('-') 55 + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) 56 + .join(''); 57 + 58 + return { 59 + component: componentName, 60 + props: {} 61 + }; 57 62 }
+8 -1
src/lib/config/collections.ts
··· 62 62 63 63 // Grain.social collections (high priority for your use case) 64 64 { 65 - name: 'grain.social.feed.gallery', 65 + name: 'social.grain.gallery', 66 66 description: 'Grain.social image galleries', 67 67 service: 'grain.social', 68 68 priority: 95, 69 + enabled: true 70 + }, 71 + { 72 + name: 'grain.social.feed.gallery', 73 + description: 'Grain.social image galleries (legacy)', 74 + service: 'grain.social', 75 + priority: 85, 69 76 enabled: true 70 77 }, 71 78 {
+12
src/lib/config/site.ts
··· 33 33 content: { 34 34 defaultFeedLimit: number; 35 35 cacheTTL: number; // in milliseconds 36 + collections: string[]; 37 + maxRecords: number; 36 38 }; 39 + lexiconSources: Record<string, string>; // NSID -> local schema file path 37 40 } 38 41 39 42 // Default configuration with static handle ··· 58 61 content: { 59 62 defaultFeedLimit: 20, 60 63 cacheTTL: 5 * 60 * 1000, // 5 minutes 64 + collections: ['app.bsky.feed.post', 'com.whtwnd.blog.entry'], 65 + maxRecords: 500, 66 + }, 67 + lexiconSources: { 68 + 'com.whtwnd.blog.entry': './src/lexicons/com.whtwnd.blog.entry.json', 69 + // Add more NSIDs -> schema file mappings here 61 70 }, 62 71 }; 63 72 ··· 89 98 content: { 90 99 defaultFeedLimit: parseInt(process.env.CONTENT_DEFAULT_FEED_LIMIT || '20'), 91 100 cacheTTL: parseInt(process.env.CONTENT_CACHE_TTL || '300000'), 101 + collections: defaultConfig.content.collections, // Keep default collections 102 + maxRecords: parseInt(process.env.CONTENT_MAX_RECORDS || '500'), 92 103 }, 104 + lexiconSources: defaultConfig.lexiconSources, // Keep default lexicon sources 93 105 }; 94 106 }
+15
src/lib/generated/a-status-update.ts
··· 1 + // Generated from lexicon schema: a.status.update 2 + // Do not edit manually - regenerate with: npm run gen:types 3 + 4 + export interface AStatusUpdateRecord { 5 + text: string; 6 + createdAt: string; 7 + } 8 + 9 + export interface AStatusUpdate { 10 + $type: 'a.status.update'; 11 + value: AStatusUpdateRecord; 12 + } 13 + 14 + // Helper type for discriminated unions 15 + export type AStatusUpdateUnion = AStatusUpdate;
+22
src/lib/generated/com-whtwnd-blog-entry.ts
··· 1 + // Generated from lexicon schema: com.whtwnd.blog.entry 2 + // Do not edit manually - regenerate with: npm run gen:types 3 + 4 + export interface ComWhtwndBlogEntryRecord { 5 + content: string; 6 + createdAt?: string; 7 + title?: string; 8 + subtitle?: string; 9 + ogp?: any; 10 + theme?: 'github-light'; 11 + blobs?: any[]; 12 + isDraft?: boolean; 13 + visibility?: 'public' | 'url' | 'author'; 14 + } 15 + 16 + export interface ComWhtwndBlogEntry { 17 + $type: 'com.whtwnd.blog.entry'; 18 + value: ComWhtwndBlogEntryRecord; 19 + } 20 + 21 + // Helper type for discriminated unions 22 + export type ComWhtwndBlogEntryUnion = ComWhtwndBlogEntry;
+2360
src/lib/generated/discovered-types.json
··· 1 + { 2 + "collections": [ 3 + { 4 + "name": "app.bsky.actor.profile", 5 + "description": "Bluesky profile information", 6 + "service": "bsky.app", 7 + "sampleRecords": [ 8 + { 9 + "$type": "app.bsky.actor.profile", 10 + "avatar": { 11 + "$type": "blob", 12 + "ref": { 13 + "$link": "bafkreig6momh2fkdfhhqwkcjsw4vycubptufe6aeolsddtgg6felh4bvoe" 14 + }, 15 + "mimeType": "image/jpeg", 16 + "size": 915425 17 + }, 18 + "banner": { 19 + "$type": "blob", 20 + "ref": { 21 + "$link": "bafkreicyefphti37yc4grac2q47eedkdwj67tnbomr5rwz62ikvrqvvcg4" 22 + }, 23 + "mimeType": "image/jpeg", 24 + "size": 806361 25 + }, 26 + "description": "Experience designer for the decentralized web\n\n๐Ÿ“ Boston ๐Ÿ‡บ๐Ÿ‡ธ\n\nmy work: tynanpurdy.com\nmy writing: blog.tynanpurdy.com\n\nI'm on Germ DM ๐Ÿ”‘ and Signal @tynanpurdy.95\nhttps://ger.mx/A1CM6J3FugcfRh4NCNElrRdMsA9tLViN0QqqcMoMxkdS#did:plc:6ayddqghxhciedbaofoxkcbs", 27 + "displayName": "Tynan Purdy" 28 + } 29 + ], 30 + "generatedTypes": "export interface AppBskyActorProfile {\n $type: 'app.bsky.actor.profile';\n avatar?: Record<string, any>;\n banner?: Record<string, any>;\n description?: string;\n displayName?: string;\n}\n", 31 + "$types": [ 32 + "app.bsky.actor.profile" 33 + ] 34 + }, 35 + { 36 + "name": "app.bsky.feed.like", 37 + "description": "Bluesky like records", 38 + "service": "bsky.app", 39 + "sampleRecords": [ 40 + { 41 + "$type": "app.bsky.feed.like", 42 + "subject": { 43 + "cid": "bafyreigdkn5773gshisfjoawof5aahcetkmwvc4pjjgo4xv3wu23ownss4", 44 + "uri": "at://did:plc:x56l2n7i7babgdzqul4bd433/app.bsky.feed.post/3lvqn6sjvtc2u" 45 + }, 46 + "createdAt": "2025-08-06T17:07:51.363Z" 47 + }, 48 + { 49 + "$type": "app.bsky.feed.like", 50 + "subject": { 51 + "cid": "bafyreihunfswi26wp276moerpjfyaoxa3qfcyxfk5aiohk5kitkhjw2nye", 52 + "uri": "at://did:plc:vc7f4oafdgxsihk4cry2xpze/app.bsky.feed.post/3lvqfhz6tyk2z" 53 + }, 54 + "createdAt": "2025-08-06T15:09:06.729Z" 55 + }, 56 + { 57 + "$type": "app.bsky.feed.like", 58 + "subject": { 59 + "cid": "bafyreibcvqmt6cnhx3gwhji7ljltsncah2lf6rlqc4wwopxqz7r5oyyryy", 60 + "uri": "at://did:plc:pkeo4mm3uwnik45y3tgjc5cs/app.bsky.feed.post/3lvqicp2fvb2z" 61 + }, 62 + "createdAt": "2025-08-06T15:00:41.448Z" 63 + } 64 + ], 65 + "generatedTypes": "export interface AppBskyFeedLike {\n $type: 'app.bsky.feed.like';\n subject?: Record<string, any>;\n createdAt?: string;\n}\n", 66 + "$types": [ 67 + "app.bsky.feed.like" 68 + ] 69 + }, 70 + { 71 + "name": "app.bsky.feed.post", 72 + "description": "Bluesky posts", 73 + "service": "bsky.app", 74 + "sampleRecords": [ 75 + { 76 + "text": "yep shoulda realized the graze turbostream would only include bluesky records", 77 + "$type": "app.bsky.feed.post", 78 + "langs": [ 79 + "en" 80 + ], 81 + "createdAt": "2025-08-06T14:08:17.583Z" 82 + }, 83 + { 84 + "text": "this is a test", 85 + "$type": "app.bsky.feed.post", 86 + "langs": [ 87 + "en" 88 + ], 89 + "createdAt": "2025-08-06T14:04:21.632Z" 90 + }, 91 + { 92 + "text": "Is there not merit to 'sign in with atproto'? The decentralized identity is a major selling point of the protocol, not just the usage of lexicon.", 93 + "$type": "app.bsky.feed.post", 94 + "langs": [ 95 + "en" 96 + ], 97 + "reply": { 98 + "root": { 99 + "cid": "bafyreigxgudy4k2zvpikpnpqxwbwmkb6whfzkefr2rovd4y7pcppoggj7y", 100 + "uri": "at://did:plc:nmc77zslrwafxn75j66mep6o/app.bsky.feed.post/3lvpyn2gacc2e" 101 + }, 102 + "parent": { 103 + "cid": "bafyreigxgudy4k2zvpikpnpqxwbwmkb6whfzkefr2rovd4y7pcppoggj7y", 104 + "uri": "at://did:plc:nmc77zslrwafxn75j66mep6o/app.bsky.feed.post/3lvpyn2gacc2e" 105 + } 106 + }, 107 + "createdAt": "2025-08-06T13:28:03.405Z" 108 + } 109 + ], 110 + "generatedTypes": "export interface AppBskyFeedPost {\n $type: 'app.bsky.feed.post';\n text?: string;\n langs?: string[];\n createdAt?: string;\n}\n", 111 + "$types": [ 112 + "app.bsky.feed.post" 113 + ] 114 + }, 115 + { 116 + "name": "app.bsky.feed.postgate", 117 + "description": "app.bsky.feed.postgate records", 118 + "service": "bsky.app", 119 + "sampleRecords": [ 120 + { 121 + "post": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.bsky.feed.post/3laub4w4obc2a", 122 + "$type": "app.bsky.feed.postgate", 123 + "createdAt": "2024-11-13T21:11:08.065Z", 124 + "embeddingRules": [ 125 + { 126 + "$type": "app.bsky.feed.postgate#disableRule" 127 + } 128 + ], 129 + "detachedEmbeddingUris": [] 130 + } 131 + ], 132 + "generatedTypes": "export interface AppBskyFeedPostgate {\n $type: 'app.bsky.feed.postgate';\n post?: string;\n createdAt?: string;\n embeddingRules?: Record<string, any>[];\n detachedEmbeddingUris?: any[];\n}\n", 133 + "$types": [ 134 + "app.bsky.feed.postgate" 135 + ] 136 + }, 137 + { 138 + "name": "app.bsky.feed.repost", 139 + "description": "Bluesky repost records", 140 + "service": "bsky.app", 141 + "sampleRecords": [ 142 + { 143 + "$type": "app.bsky.feed.repost", 144 + "subject": { 145 + "cid": "bafyreihwclfpjxtgkjdempzvvwo4ayj5ynm3aezry4ym3its6tdiy5kz3i", 146 + "uri": "at://did:plc:i6y3jdklpvkjvynvsrnqfdoq/app.bsky.feed.post/3lvofxjoqt22g" 147 + }, 148 + "createdAt": "2025-08-05T19:50:46.238Z" 149 + }, 150 + { 151 + "$type": "app.bsky.feed.repost", 152 + "subject": { 153 + "cid": "bafyreibcfwujyfz2fnpks62tujff5qxlqtfrndz7bllv3u4l7zp6xes6ka", 154 + "uri": "at://did:plc:sfjxpxxyvewb2zlxwoz2vduw/app.bsky.feed.post/3lvldmqr7fs2q" 155 + }, 156 + "createdAt": "2025-08-04T14:10:40.273Z" 157 + }, 158 + { 159 + "$type": "app.bsky.feed.repost", 160 + "subject": { 161 + "cid": "bafyreicxla5m4mw2ocmxwtsodwijcy7hlgedml2gbohpgvc5ftf2j2z3sa", 162 + "uri": "at://did:plc:tpg43qhh4lw4ksiffs4nbda3/app.bsky.feed.post/3lvizvvkvhk2d" 163 + }, 164 + "createdAt": "2025-08-03T21:17:44.210Z" 165 + } 166 + ], 167 + "generatedTypes": "export interface AppBskyFeedRepost {\n $type: 'app.bsky.feed.repost';\n subject?: Record<string, any>;\n createdAt?: string;\n}\n", 168 + "$types": [ 169 + "app.bsky.feed.repost" 170 + ] 171 + }, 172 + { 173 + "name": "app.bsky.feed.threadgate", 174 + "description": "app.bsky.feed.threadgate records", 175 + "service": "bsky.app", 176 + "sampleRecords": [ 177 + { 178 + "post": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.bsky.feed.post/3laub4w4obc2a", 179 + "$type": "app.bsky.feed.threadgate", 180 + "allow": [ 181 + { 182 + "$type": "app.bsky.feed.threadgate#mentionRule" 183 + } 184 + ], 185 + "createdAt": "2024-11-13T21:11:08.361Z", 186 + "hiddenReplies": [] 187 + } 188 + ], 189 + "generatedTypes": "export interface AppBskyFeedThreadgate {\n $type: 'app.bsky.feed.threadgate';\n post?: string;\n allow?: Record<string, any>[];\n createdAt?: string;\n hiddenReplies?: any[];\n}\n", 190 + "$types": [ 191 + "app.bsky.feed.threadgate" 192 + ] 193 + }, 194 + { 195 + "name": "app.bsky.graph.block", 196 + "description": "Bluesky block relationships", 197 + "service": "bsky.app", 198 + "sampleRecords": [ 199 + { 200 + "$type": "app.bsky.graph.block", 201 + "subject": "did:plc:7tw5kcf6375mlghspilpmpix", 202 + "createdAt": "2025-07-23T22:11:19.502Z" 203 + }, 204 + { 205 + "$type": "app.bsky.graph.block", 206 + "subject": "did:plc:f6y7lzl5jslkfn7ta47e7to3", 207 + "createdAt": "2025-07-23T20:01:13.114Z" 208 + }, 209 + { 210 + "$type": "app.bsky.graph.block", 211 + "subject": "did:plc:efwg4pw7qdyuh3qgr5x454fd", 212 + "createdAt": "2025-06-27T18:04:36.550Z" 213 + } 214 + ], 215 + "generatedTypes": "export interface AppBskyGraphBlock {\n $type: 'app.bsky.graph.block';\n subject?: string;\n createdAt?: string;\n}\n", 216 + "$types": [ 217 + "app.bsky.graph.block" 218 + ] 219 + }, 220 + { 221 + "name": "app.bsky.graph.follow", 222 + "description": "Bluesky follow relationships", 223 + "service": "bsky.app", 224 + "sampleRecords": [ 225 + { 226 + "$type": "app.bsky.graph.follow", 227 + "subject": "did:plc:mdailwqaetwpqnysw6qllqwl", 228 + "createdAt": "2025-08-06T14:57:20.859Z" 229 + }, 230 + { 231 + "$type": "app.bsky.graph.follow", 232 + "subject": "did:plc:f2np526hugxvamu25t6l4y6e", 233 + "createdAt": "2025-08-05T21:41:26.560Z" 234 + }, 235 + { 236 + "$type": "app.bsky.graph.follow", 237 + "subject": "did:plc:7axcqwj4roha6mqpdhpdwczx", 238 + "createdAt": "2025-08-05T21:37:20.051Z" 239 + } 240 + ], 241 + "generatedTypes": "export interface AppBskyGraphFollow {\n $type: 'app.bsky.graph.follow';\n subject?: string;\n createdAt?: string;\n}\n", 242 + "$types": [ 243 + "app.bsky.graph.follow" 244 + ] 245 + }, 246 + { 247 + "name": "app.bsky.graph.list", 248 + "description": "app.bsky.graph.list records", 249 + "service": "bsky.app", 250 + "sampleRecords": [ 251 + { 252 + "name": "hehe comics", 253 + "$type": "app.bsky.graph.list", 254 + "purpose": "app.bsky.graph.defs#curatelist", 255 + "createdAt": "2025-08-04T15:44:09.398Z", 256 + "description": "these are fun" 257 + }, 258 + { 259 + "name": "What's up Boston", 260 + "$type": "app.bsky.graph.list", 261 + "purpose": "app.bsky.graph.defs#referencelist", 262 + "createdAt": "2025-08-04T15:15:39.837Z" 263 + }, 264 + { 265 + "name": "My domain is proof of my identity", 266 + "$type": "app.bsky.graph.list", 267 + "avatar": { 268 + "$type": "blob", 269 + "ref": { 270 + "$link": "bafkreie56qtqmtp5iu56zqqjkvwhvzqcudtsbetgs2joaowvuljbcluvze" 271 + }, 272 + "mimeType": "image/jpeg", 273 + "size": 941683 274 + }, 275 + "purpose": "app.bsky.graph.defs#curatelist", 276 + "createdAt": "2025-07-31T15:57:34.609Z", 277 + "description": "You know this is me because its tynanpurdy.com", 278 + "descriptionFacets": [ 279 + { 280 + "index": { 281 + "byteEnd": 46, 282 + "byteStart": 32 283 + }, 284 + "features": [ 285 + { 286 + "uri": "https://tynanpurdy.com", 287 + "$type": "app.bsky.richtext.facet#link" 288 + } 289 + ] 290 + } 291 + ] 292 + } 293 + ], 294 + "generatedTypes": "export interface AppBskyGraphList {\n $type: 'app.bsky.graph.list';\n name?: string;\n purpose?: string;\n createdAt?: string;\n description?: string;\n}\n", 295 + "$types": [ 296 + "app.bsky.graph.list" 297 + ] 298 + }, 299 + { 300 + "name": "app.bsky.graph.listitem", 301 + "description": "app.bsky.graph.listitem records", 302 + "service": "bsky.app", 303 + "sampleRecords": [ 304 + { 305 + "list": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.bsky.graph.list/3lquir4dvpk2v", 306 + "$type": "app.bsky.graph.listitem", 307 + "subject": "did:plc:mdailwqaetwpqnysw6qllqwl", 308 + "createdAt": "2025-08-06T14:45:30.004Z" 309 + }, 310 + { 311 + "list": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.bsky.graph.list/3lquir4dvpk2v", 312 + "$type": "app.bsky.graph.listitem", 313 + "subject": "did:plc:k2r4d4exuuord4sos4wavcoj", 314 + "createdAt": "2025-08-05T13:39:59.770Z" 315 + }, 316 + { 317 + "list": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.bsky.graph.list/3lvljxc7e2r2j", 318 + "$type": "app.bsky.graph.listitem", 319 + "subject": "did:plc:v3n5wr27y6flohaycmcgsrij", 320 + "createdAt": "2025-08-04T19:01:10.319Z" 321 + } 322 + ], 323 + "generatedTypes": "export interface AppBskyGraphListitem {\n $type: 'app.bsky.graph.listitem';\n list?: string;\n subject?: string;\n createdAt?: string;\n}\n", 324 + "$types": [ 325 + "app.bsky.graph.listitem" 326 + ] 327 + }, 328 + { 329 + "name": "app.bsky.graph.starterpack", 330 + "description": "app.bsky.graph.starterpack records", 331 + "service": "bsky.app", 332 + "sampleRecords": [ 333 + { 334 + "list": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.bsky.graph.list/3lvliedprgp2t", 335 + "name": "What's up Boston", 336 + "$type": "app.bsky.graph.starterpack", 337 + "feeds": [ 338 + { 339 + "cid": "bafyreigd75aawyao7dikal4bm634dxtgf7xp43msbywcx4ctcyqojjubni", 340 + "did": "did:web:skyfeed.me", 341 + "uri": "at://did:plc:r2mpjf3gz2ygfaodkzzzfddg/app.bsky.feed.generator/aaag6jgz6tfou", 342 + "labels": [], 343 + "viewer": {}, 344 + "creator": { 345 + "did": "did:plc:r2mpjf3gz2ygfaodkzzzfddg", 346 + "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:r2mpjf3gz2ygfaodkzzzfddg/bafkreidvm7mnr3mwg7lnktrpeebsr7tz5u4ic3acrdlnte7ojp45vrtmjm@jpeg", 347 + "handle": "boston.gov", 348 + "labels": [ 349 + { 350 + "cts": "2025-02-12T02:03:24.048Z", 351 + "src": "did:plc:m6adptn62dcahfaq34tce3j5", 352 + "uri": "did:plc:r2mpjf3gz2ygfaodkzzzfddg", 353 + "val": "joined-feb-c", 354 + "ver": 1 355 + }, 356 + { 357 + "cts": "2025-06-06T17:54:44.623Z", 358 + "src": "did:plc:fqfzpua2rp5io5nmxcixvdvm", 359 + "uri": "did:plc:r2mpjf3gz2ygfaodkzzzfddg", 360 + "val": "voting-for-kodos", 361 + "ver": 1 362 + } 363 + ], 364 + "viewer": { 365 + "muted": false, 366 + "blockedBy": false, 367 + "following": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.bsky.graph.follow/3lvlj4yyafh2w" 368 + }, 369 + "createdAt": "2024-02-09T16:18:11.855Z", 370 + "indexedAt": "2025-04-29T23:55:10.143Z", 371 + "associated": { 372 + "activitySubscription": { 373 + "allowSubscriptions": "followers" 374 + } 375 + }, 376 + "description": "The official Bluesky account of the City of Boston. For non-emergency services, please call 311. \n\nBoston.gov", 377 + "displayName": "City of Boston", 378 + "verification": { 379 + "verifications": [ 380 + { 381 + "uri": "at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.graph.verification/3lu23uowywx2m", 382 + "issuer": "did:plc:z72i7hdynmk6r22z27h6tvur", 383 + "isValid": true, 384 + "createdAt": "2025-07-15T23:51:42.492Z" 385 + } 386 + ], 387 + "verifiedStatus": "valid", 388 + "trustedVerifierStatus": "none" 389 + } 390 + }, 391 + "indexedAt": "2024-11-27T19:48:59.425Z", 392 + "likeCount": 21, 393 + "description": "Official City of Boston accounts representing departments, cabinets, commissions and more teams working to make Boston a home for everyone.", 394 + "displayName": "City of Boston" 395 + } 396 + ], 397 + "createdAt": "2025-08-04T15:15:40.040Z", 398 + "updatedAt": "2025-08-04T15:36:11.792Z" 399 + } 400 + ], 401 + "generatedTypes": "export interface AppBskyGraphStarterpack {\n $type: 'app.bsky.graph.starterpack';\n list?: string;\n name?: string;\n feeds?: Record<string, any>[];\n createdAt?: string;\n updatedAt?: string;\n}\n", 402 + "$types": [ 403 + "app.bsky.graph.starterpack" 404 + ] 405 + }, 406 + { 407 + "name": "app.bsky.graph.verification", 408 + "description": "app.bsky.graph.verification records", 409 + "service": "bsky.app", 410 + "sampleRecords": [ 411 + { 412 + "$type": "app.bsky.graph.verification", 413 + "handle": "goodwillhinton.com", 414 + "subject": "did:plc:ek7ua4ev2a35cdgehdxo4jsg", 415 + "createdAt": "2025-07-10T19:57:12.524Z", 416 + "displayName": "Good Will Hinton" 417 + }, 418 + { 419 + "$type": "app.bsky.graph.verification", 420 + "handle": "jacknicolaus.bsky.social", 421 + "subject": "did:plc:5ldqrqjevj5q5s5dyc3w6q2a", 422 + "createdAt": "2025-07-10T19:55:00.870Z", 423 + "displayName": "Jack Purdy" 424 + }, 425 + { 426 + "$type": "app.bsky.graph.verification", 427 + "handle": "jennpurdy.bsky.social", 428 + "subject": "did:plc:7tai7ouufljuzlhjajpiojyw", 429 + "createdAt": "2025-07-10T19:54:44.028Z", 430 + "displayName": "Jenn Purdy" 431 + } 432 + ], 433 + "generatedTypes": "export interface AppBskyGraphVerification {\n $type: 'app.bsky.graph.verification';\n handle?: string;\n subject?: string;\n createdAt?: string;\n displayName?: string;\n}\n", 434 + "$types": [ 435 + "app.bsky.graph.verification" 436 + ] 437 + }, 438 + { 439 + "name": "app.popsky.list", 440 + "description": "app.popsky.list records", 441 + "service": "unknown", 442 + "sampleRecords": [ 443 + { 444 + "name": "Played Games", 445 + "$type": "app.popsky.list", 446 + "authorDid": "did:plc:6ayddqghxhciedbaofoxkcbs", 447 + "createdAt": "2025-07-29T18:45:43.158Z", 448 + "indexedAt": "2025-07-29T18:45:43.158Z", 449 + "description": "" 450 + }, 451 + { 452 + "name": "The good life", 453 + "tags": [], 454 + "$type": "app.popsky.list", 455 + "ordered": false, 456 + "createdAt": "2025-07-29T18:42:13.706Z", 457 + "description": "" 458 + }, 459 + { 460 + "name": "Listened Albums & EPs", 461 + "$type": "app.popsky.list", 462 + "authorDid": "did:plc:6ayddqghxhciedbaofoxkcbs", 463 + "createdAt": "2025-07-29T18:40:45.138Z", 464 + "indexedAt": "2025-07-29T18:40:45.138Z", 465 + "description": "" 466 + } 467 + ], 468 + "generatedTypes": "export interface AppPopskyList {\n $type: 'app.popsky.list';\n name?: string;\n authorDid?: string;\n createdAt?: string;\n indexedAt?: string;\n description?: string;\n}\n", 469 + "$types": [ 470 + "app.popsky.list" 471 + ] 472 + }, 473 + { 474 + "name": "app.popsky.listItem", 475 + "description": "app.popsky.listItem records", 476 + "service": "unknown", 477 + "sampleRecords": [ 478 + { 479 + "$type": "app.popsky.listItem", 480 + "addedAt": "2025-08-04T20:56:17.962Z", 481 + "listUri": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.popsky.list/3lv4qhdmaoc2f", 482 + "identifiers": { 483 + "isbn10": "1991152310", 484 + "isbn13": "9781991152312" 485 + }, 486 + "creativeWorkType": "book" 487 + }, 488 + { 489 + "$type": "app.popsky.listItem", 490 + "addedAt": "2025-08-04T20:56:14.809Z", 491 + "listUri": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.popsky.list/3lv4r46l6ck2f", 492 + "identifiers": { 493 + "isbn10": "1991152310", 494 + "isbn13": "9781991152312" 495 + }, 496 + "creativeWorkType": "book" 497 + }, 498 + { 499 + "$type": "app.popsky.listItem", 500 + "addedAt": "2025-08-04T20:55:30.197Z", 501 + "listUri": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.popsky.list/3ltyvzvrlkk2k", 502 + "identifiers": { 503 + "isbn10": "1988575060", 504 + "isbn13": "9781988575063" 505 + }, 506 + "creativeWorkType": "book" 507 + } 508 + ], 509 + "generatedTypes": "export interface AppPopskyListItem {\n $type: 'app.popsky.listItem';\n addedAt?: string;\n listUri?: string;\n identifiers?: Record<string, any>;\n creativeWorkType?: string;\n}\n", 510 + "$types": [ 511 + "app.popsky.listItem" 512 + ] 513 + }, 514 + { 515 + "name": "app.popsky.profile", 516 + "description": "app.popsky.profile records", 517 + "service": "unknown", 518 + "sampleRecords": [ 519 + { 520 + "$type": "app.popsky.profile", 521 + "createdAt": "2025-07-29T18:29:43.645Z", 522 + "description": "", 523 + "displayName": "Tynan Purdy" 524 + } 525 + ], 526 + "generatedTypes": "export interface AppPopskyProfile {\n $type: 'app.popsky.profile';\n createdAt?: string;\n description?: string;\n displayName?: string;\n}\n", 527 + "$types": [ 528 + "app.popsky.profile" 529 + ] 530 + }, 531 + { 532 + "name": "app.popsky.review", 533 + "description": "app.popsky.review records", 534 + "service": "unknown", 535 + "sampleRecords": [ 536 + { 537 + "tags": [], 538 + "$type": "app.popsky.review", 539 + "facets": [], 540 + "rating": 10, 541 + "createdAt": "2025-07-29T18:45:40.559Z", 542 + "isRevisit": false, 543 + "reviewText": "", 544 + "identifiers": { 545 + "igdbId": "28512" 546 + }, 547 + "containsSpoilers": false, 548 + "creativeWorkType": "video_game" 549 + }, 550 + { 551 + "tags": [], 552 + "$type": "app.popsky.review", 553 + "facets": [], 554 + "rating": 9, 555 + "createdAt": "2024-01-05T00:00:00.000Z", 556 + "isRevisit": false, 557 + "reviewText": "", 558 + "identifiers": { 559 + "isbn13": "9781797114613" 560 + }, 561 + "containsSpoilers": false, 562 + "creativeWorkType": "book" 563 + }, 564 + { 565 + "tags": [], 566 + "$type": "app.popsky.review", 567 + "facets": [], 568 + "rating": 8, 569 + "createdAt": "2024-05-02T00:00:00.000Z", 570 + "isRevisit": false, 571 + "reviewText": "", 572 + "identifiers": { 573 + "isbn13": "9781415959138" 574 + }, 575 + "containsSpoilers": false, 576 + "creativeWorkType": "book" 577 + } 578 + ], 579 + "generatedTypes": "export interface AppPopskyReview {\n $type: 'app.popsky.review';\n tags?: any[];\n facets?: any[];\n rating?: number;\n createdAt?: string;\n isRevisit?: boolean;\n reviewText?: string;\n identifiers?: Record<string, any>;\n containsSpoilers?: boolean;\n creativeWorkType?: string;\n}\n", 580 + "$types": [ 581 + "app.popsky.review" 582 + ] 583 + }, 584 + { 585 + "name": "app.rocksky.album", 586 + "description": "app.rocksky.album records", 587 + "service": "unknown", 588 + "sampleRecords": [ 589 + { 590 + "year": 2025, 591 + "$type": "app.rocksky.album", 592 + "title": "Heartbreak Hysteria", 593 + "artist": "Sawyer Hill", 594 + "albumArt": { 595 + "$type": "blob", 596 + "ref": { 597 + "$link": "bafkreibaol5obcwhudbcli2o33nh7ats764hqnkpfxjxqn3ykjeb65ht5q" 598 + }, 599 + "mimeType": "image/jpeg", 600 + "size": 38011 601 + }, 602 + "createdAt": "2025-07-12T20:41:54.087Z", 603 + "releaseDate": "2025-04-18T00:00:00.000Z" 604 + }, 605 + { 606 + "year": 2023, 607 + "$type": "app.rocksky.album", 608 + "title": "I Love You, Iโ€™m Trying", 609 + "artist": "grandson", 610 + "albumArt": { 611 + "$type": "blob", 612 + "ref": { 613 + "$link": "bafkreih72c6ye2vd7scwbedjbfoqzue26fnsfs42dc2quqououlvqzdly4" 614 + }, 615 + "mimeType": "image/jpeg", 616 + "size": 266870 617 + }, 618 + "createdAt": "2025-07-12T20:39:46.979Z", 619 + "releaseDate": "2023-05-05T00:00:00.000Z" 620 + }, 621 + { 622 + "year": 2025, 623 + "$type": "app.rocksky.album", 624 + "title": "june", 625 + "artist": "DE'WAYNE", 626 + "albumArt": { 627 + "$type": "blob", 628 + "ref": { 629 + "$link": "bafkreihiydajfy66mbmk4g2cc4ymvcj5jkba2rxrnucxjcxl63rvch37ee" 630 + }, 631 + "mimeType": "image/jpeg", 632 + "size": 78882 633 + }, 634 + "createdAt": "2025-07-12T20:36:54.769Z", 635 + "releaseDate": "2025-05-14T00:00:00.000Z" 636 + } 637 + ], 638 + "generatedTypes": "export interface AppRockskyAlbum {\n $type: 'app.rocksky.album';\n year?: number;\n title?: string;\n artist?: string;\n albumArt?: Record<string, any>;\n createdAt?: string;\n releaseDate?: string;\n}\n", 639 + "$types": [ 640 + "app.rocksky.album" 641 + ] 642 + }, 643 + { 644 + "name": "app.rocksky.artist", 645 + "description": "app.rocksky.artist records", 646 + "service": "unknown", 647 + "sampleRecords": [ 648 + { 649 + "name": "Sawyer Hill", 650 + "$type": "app.rocksky.artist", 651 + "picture": { 652 + "$type": "blob", 653 + "ref": { 654 + "$link": "bafkreibndm5e5idr7o26wlci5twbcavtjrfatg4eeal7beieqxvqyipfnu" 655 + }, 656 + "mimeType": "image/jpeg", 657 + "size": 92121 658 + }, 659 + "createdAt": "2025-07-12T20:41:51.867Z" 660 + }, 661 + { 662 + "name": "DE'WAYNE", 663 + "$type": "app.rocksky.artist", 664 + "picture": { 665 + "$type": "blob", 666 + "ref": { 667 + "$link": "bafkreihvir6gp4ls2foh7grfx7cgouc4scxc7ubhk535ig7nbrvtpscf7u" 668 + }, 669 + "mimeType": "image/jpeg", 670 + "size": 167229 671 + }, 672 + "createdAt": "2025-07-12T20:36:52.320Z" 673 + }, 674 + { 675 + "name": "Bryce Fox", 676 + "$type": "app.rocksky.artist", 677 + "picture": { 678 + "$type": "blob", 679 + "ref": { 680 + "$link": "bafkreigx4a5reezucwsujfuyv27lun5xkavjtgkzvifknrsblh3jnvznhi" 681 + }, 682 + "mimeType": "image/jpeg", 683 + "size": 119015 684 + }, 685 + "createdAt": "2025-07-12T20:34:10.891Z" 686 + } 687 + ], 688 + "generatedTypes": "export interface AppRockskyArtist {\n $type: 'app.rocksky.artist';\n name?: string;\n picture?: Record<string, any>;\n createdAt?: string;\n}\n", 689 + "$types": [ 690 + "app.rocksky.artist" 691 + ] 692 + }, 693 + { 694 + "name": "app.rocksky.like", 695 + "description": "app.rocksky.like records", 696 + "service": "unknown", 697 + "sampleRecords": [ 698 + { 699 + "$type": "app.rocksky.like", 700 + "subject": { 701 + "cid": "bafyreiejzq3p7bqjuo6sgt4x3rwojnvu4jajrolzread7eqpzo7jryoega", 702 + "uri": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.rocksky.song/3lmhvldhhgs2r" 703 + }, 704 + "createdAt": "2025-04-17T14:30:30.388Z" 705 + }, 706 + { 707 + "$type": "app.rocksky.like", 708 + "subject": { 709 + "cid": "bafyreihvboqggljg677rxl6xxr2mqudlefmixhkrvf2gltmpzbhyzduqba", 710 + "uri": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.rocksky.song/3lmhqwxjxek2m" 711 + }, 712 + "createdAt": "2025-04-15T19:25:44.473Z" 713 + }, 714 + { 715 + "$type": "app.rocksky.like", 716 + "subject": { 717 + "cid": "bafyreiah4sm2jjrgcnqhmfaeze44th3svwlqbftpqepi67c2i6khzjqwya", 718 + "uri": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.rocksky.song/3lloqnprwwk2c" 719 + }, 720 + "createdAt": "2025-03-31T16:38:14.223Z" 721 + } 722 + ], 723 + "generatedTypes": "export interface AppRockskyLike {\n $type: 'app.rocksky.like';\n subject?: Record<string, any>;\n createdAt?: string;\n}\n", 724 + "$types": [ 725 + "app.rocksky.like" 726 + ] 727 + }, 728 + { 729 + "name": "app.rocksky.scrobble", 730 + "description": "app.rocksky.scrobble records", 731 + "service": "unknown", 732 + "sampleRecords": [ 733 + { 734 + "year": 2025, 735 + "$type": "app.rocksky.scrobble", 736 + "album": "Heartbreak Hysteria", 737 + "title": "One Shot", 738 + "artist": "Sawyer Hill", 739 + "albumArt": { 740 + "$type": "blob", 741 + "ref": { 742 + "$link": "bafkreibaol5obcwhudbcli2o33nh7ats764hqnkpfxjxqn3ykjeb65ht5q" 743 + }, 744 + "mimeType": "image/jpeg", 745 + "size": 38011 746 + }, 747 + "duration": 198380, 748 + "createdAt": "2025-07-12T20:41:58.353Z", 749 + "discNumber": 1, 750 + "albumArtist": "Sawyer Hill", 751 + "releaseDate": "2025-04-18T00:00:00.000Z", 752 + "spotifyLink": "https://open.spotify.com/track/4yiiRbfHkqhCj0ChW62ASx", 753 + "trackNumber": 2 754 + }, 755 + { 756 + "year": 2023, 757 + "$type": "app.rocksky.scrobble", 758 + "album": "I Love You, Iโ€™m Trying", 759 + "title": "Something To Hide", 760 + "artist": "grandson", 761 + "albumArt": { 762 + "$type": "blob", 763 + "ref": { 764 + "$link": "bafkreih72c6ye2vd7scwbedjbfoqzue26fnsfs42dc2quqououlvqzdly4" 765 + }, 766 + "mimeType": "image/jpeg", 767 + "size": 266870 768 + }, 769 + "duration": 119047, 770 + "createdAt": "2025-07-12T20:39:51.055Z", 771 + "discNumber": 1, 772 + "albumArtist": "grandson", 773 + "releaseDate": "2023-05-05T00:00:00.000Z", 774 + "spotifyLink": "https://open.spotify.com/track/1rjZicKSpIJ3WYffIK9Fuy", 775 + "trackNumber": 3 776 + }, 777 + { 778 + "year": 2025, 779 + "$type": "app.rocksky.scrobble", 780 + "album": "june", 781 + "title": "june", 782 + "artist": "DE'WAYNE", 783 + "albumArt": { 784 + "$type": "blob", 785 + "ref": { 786 + "$link": "bafkreihiydajfy66mbmk4g2cc4ymvcj5jkba2rxrnucxjcxl63rvch37ee" 787 + }, 788 + "mimeType": "image/jpeg", 789 + "size": 78882 790 + }, 791 + "duration": 168000, 792 + "createdAt": "2025-07-12T20:36:58.633Z", 793 + "discNumber": 1, 794 + "albumArtist": "DE'WAYNE", 795 + "releaseDate": "2025-05-14T00:00:00.000Z", 796 + "spotifyLink": "https://open.spotify.com/track/6PBJfoq40a8gsUULbn0oyG", 797 + "trackNumber": 1 798 + } 799 + ], 800 + "generatedTypes": "export interface AppRockskyScrobble {\n $type: 'app.rocksky.scrobble';\n year?: number;\n album?: string;\n title?: string;\n artist?: string;\n albumArt?: Record<string, any>;\n duration?: number;\n createdAt?: string;\n discNumber?: number;\n albumArtist?: string;\n releaseDate?: string;\n spotifyLink?: string;\n trackNumber?: number;\n}\n", 801 + "$types": [ 802 + "app.rocksky.scrobble" 803 + ] 804 + }, 805 + { 806 + "name": "app.rocksky.song", 807 + "description": "app.rocksky.song records", 808 + "service": "unknown", 809 + "sampleRecords": [ 810 + { 811 + "year": 2023, 812 + "$type": "app.rocksky.song", 813 + "album": "I Love You, Iโ€™m Trying", 814 + "title": "Stuck Here With Me", 815 + "artist": "grandson", 816 + "albumArt": { 817 + "$type": "blob", 818 + "ref": { 819 + "$link": "bafkreih72c6ye2vd7scwbedjbfoqzue26fnsfs42dc2quqououlvqzdly4" 820 + }, 821 + "mimeType": "image/jpeg", 822 + "size": 266870 823 + }, 824 + "duration": 235930, 825 + "createdAt": "2025-07-12T20:50:45.291Z", 826 + "discNumber": 1, 827 + "albumArtist": "grandson", 828 + "releaseDate": "2023-05-05T00:00:00.000Z", 829 + "spotifyLink": "https://open.spotify.com/track/29FvUqoMbmQ50fdHlgs5om", 830 + "trackNumber": 12 831 + }, 832 + { 833 + "year": 2023, 834 + "$type": "app.rocksky.song", 835 + "album": "I Love You, Iโ€™m Trying", 836 + "title": "Heather", 837 + "artist": "grandson", 838 + "albumArt": { 839 + "$type": "blob", 840 + "ref": { 841 + "$link": "bafkreih72c6ye2vd7scwbedjbfoqzue26fnsfs42dc2quqououlvqzdly4" 842 + }, 843 + "mimeType": "image/jpeg", 844 + "size": 266870 845 + }, 846 + "duration": 198468, 847 + "createdAt": "2025-07-12T20:49:46.259Z", 848 + "discNumber": 1, 849 + "albumArtist": "grandson", 850 + "releaseDate": "2023-05-05T00:00:00.000Z", 851 + "spotifyLink": "https://open.spotify.com/track/05jgkkHC2o5edhP92u9pgU", 852 + "trackNumber": 11 853 + }, 854 + { 855 + "year": 2023, 856 + "$type": "app.rocksky.song", 857 + "album": "I Love You, Iโ€™m Trying", 858 + "title": "I Will Be Here When Youโ€™re Ready To Wake Up (feat. Wafia)", 859 + "artist": "grandson, Wafia", 860 + "albumArt": { 861 + "$type": "blob", 862 + "ref": { 863 + "$link": "bafkreih72c6ye2vd7scwbedjbfoqzue26fnsfs42dc2quqououlvqzdly4" 864 + }, 865 + "mimeType": "image/jpeg", 866 + "size": 266870 867 + }, 868 + "duration": 60604, 869 + "createdAt": "2025-07-12T20:48:46.161Z", 870 + "discNumber": 1, 871 + "albumArtist": "grandson", 872 + "releaseDate": "2023-05-05T00:00:00.000Z", 873 + "spotifyLink": "https://open.spotify.com/track/2fQQZQpze9c44ebtCYx8Jl", 874 + "trackNumber": 10 875 + } 876 + ], 877 + "generatedTypes": "export interface AppRockskySong {\n $type: 'app.rocksky.song';\n year?: number;\n album?: string;\n title?: string;\n artist?: string;\n albumArt?: Record<string, any>;\n duration?: number;\n createdAt?: string;\n discNumber?: number;\n albumArtist?: string;\n releaseDate?: string;\n spotifyLink?: string;\n trackNumber?: number;\n}\n", 878 + "$types": [ 879 + "app.rocksky.song" 880 + ] 881 + }, 882 + { 883 + "name": "blue.flashes.actor.profile", 884 + "description": "blue.flashes.actor.profile records", 885 + "service": "unknown", 886 + "sampleRecords": [ 887 + { 888 + "$type": "blue.flashes.actor.profile", 889 + "createdAt": "2025-07-28T18:24:43.072Z", 890 + "showFeeds": true, 891 + "showLikes": false, 892 + "showLists": true, 893 + "showMedia": true, 894 + "enablePortfolio": false, 895 + "portfolioLayout": "grid", 896 + "allowRawDownload": false 897 + } 898 + ], 899 + "generatedTypes": "export interface BlueFlashesActorProfile {\n $type: 'blue.flashes.actor.profile';\n createdAt?: string;\n showFeeds?: boolean;\n showLikes?: boolean;\n showLists?: boolean;\n showMedia?: boolean;\n enablePortfolio?: boolean;\n portfolioLayout?: string;\n allowRawDownload?: boolean;\n}\n", 900 + "$types": [ 901 + "blue.flashes.actor.profile" 902 + ] 903 + }, 904 + { 905 + "name": "blue.linkat.board", 906 + "description": "blue.linkat.board records", 907 + "service": "unknown", 908 + "sampleRecords": [ 909 + { 910 + "$type": "blue.linkat.board", 911 + "cards": [ 912 + { 913 + "url": "https://tynanpurdy.com", 914 + "text": "Portfolio", 915 + "emoji": "๐Ÿ”—" 916 + }, 917 + { 918 + "url": "https://blog.tynanpurdy.com", 919 + "text": "Blog", 920 + "emoji": "๐Ÿ“ฐ" 921 + }, 922 + { 923 + "url": "https://github.com/tynanpurdy", 924 + "text": "GitHub", 925 + "emoji": "" 926 + }, 927 + { 928 + "url": "https://www.linkedin.com/in/tynanpurdy", 929 + "text": "LinkedIn", 930 + "emoji": "๐Ÿ‘”" 931 + }, 932 + { 933 + "url": "https://mastodon.social/@tynanpurdy.com@bsky.brid.gy", 934 + "text": "Mastodon (Bridged)", 935 + "emoji": "" 936 + } 937 + ] 938 + } 939 + ], 940 + "generatedTypes": "export interface BlueLinkatBoard {\n $type: 'blue.linkat.board';\n cards?: Record<string, any>[];\n}\n", 941 + "$types": [ 942 + "blue.linkat.board" 943 + ] 944 + }, 945 + { 946 + "name": "buzz.bookhive.book", 947 + "description": "buzz.bookhive.book records", 948 + "service": "unknown", 949 + "sampleRecords": [ 950 + { 951 + "$type": "buzz.bookhive.book", 952 + "cover": { 953 + "$type": "blob", 954 + "ref": { 955 + "$link": "bafkreih7cdewtoxyo7fpl4x6bsnf3edjs2jg26jdy23opkzz7eug6zqff4" 956 + }, 957 + "mimeType": "image/jpeg", 958 + "size": 52491 959 + }, 960 + "title": "A โ€‹Court of Silver Flames", 961 + "hiveId": "bk_J1U6l2ckLEM4sALVBxUp", 962 + "status": "buzz.bookhive.defs#finished", 963 + "authors": "Sarah J. Maas", 964 + "createdAt": "2025-07-15T03:45:29.303Z" 965 + }, 966 + { 967 + "$type": "buzz.bookhive.book", 968 + "cover": { 969 + "$type": "blob", 970 + "ref": { 971 + "$link": "bafkreigsicpiwolxv7ap2iaaljy356bzjwf6dtuvgzwahz4uokz5kz6k6m" 972 + }, 973 + "mimeType": "image/jpeg", 974 + "size": 124025 975 + }, 976 + "title": "When We're in Charge: The Next Generationโ€™s Guide to Leadership", 977 + "hiveId": "bk_1D28ImhUcffLrWt8G9UW", 978 + "status": "buzz.bookhive.defs#wantToRead", 979 + "authors": "Amanda Litman", 980 + "createdAt": "2025-05-17T20:03:38.336Z" 981 + }, 982 + { 983 + "$type": "buzz.bookhive.book", 984 + "cover": { 985 + "$type": "blob", 986 + "ref": { 987 + "$link": "bafkreig5s2k5s42ccbdren2sfdasxyq2e2er7qcb6qs2escdhlt7lsuxtm" 988 + }, 989 + "mimeType": "image/jpeg", 990 + "size": 127596 991 + }, 992 + "title": "Dune", 993 + "hiveId": "bk_GUShjG8U9l93XqIrGiKV", 994 + "status": "buzz.bookhive.defs#finished", 995 + "authors": "Frank Herbert", 996 + "createdAt": "2025-05-17T19:24:44.640Z" 997 + } 998 + ], 999 + "generatedTypes": "export interface BuzzBookhiveBook {\n $type: 'buzz.bookhive.book';\n cover?: Record<string, any>;\n title?: string;\n hiveId?: string;\n status?: string;\n authors?: string;\n createdAt?: string;\n}\n", 1000 + "$types": [ 1001 + "buzz.bookhive.book" 1002 + ] 1003 + }, 1004 + { 1005 + "name": "chat.bsky.actor.declaration", 1006 + "description": "chat.bsky.actor.declaration records", 1007 + "service": "unknown", 1008 + "sampleRecords": [ 1009 + { 1010 + "$type": "chat.bsky.actor.declaration", 1011 + "allowIncoming": "following" 1012 + } 1013 + ], 1014 + "generatedTypes": "export interface ChatBskyActorDeclaration {\n $type: 'chat.bsky.actor.declaration';\n allowIncoming?: string;\n}\n", 1015 + "$types": [ 1016 + "chat.bsky.actor.declaration" 1017 + ] 1018 + }, 1019 + { 1020 + "name": "chat.roomy.01JPNX7AA9BSM6TY2GWW1TR5V7.catalog", 1021 + "description": "chat.roomy.01JPNX7AA9BSM6TY2GWW1TR5V7.catalog records", 1022 + "service": "unknown", 1023 + "sampleRecords": [ 1024 + { 1025 + "id": "leaf:ypcj310ntdfmzpg670b5j29xe3034dnh3kcvk76p5amwwy35hqt0", 1026 + "$type": "chat.roomy.01JPNX7AA9BSM6TY2GWW1TR5V7.catalog" 1027 + } 1028 + ], 1029 + "generatedTypes": "export interface ChatRoomy01JPNX7AA9BSM6TY2GWW1TR5V7Catalog {\n $type: 'chat.roomy.01JPNX7AA9BSM6TY2GWW1TR5V7.catalog';\n id?: string;\n}\n", 1030 + "$types": [ 1031 + "chat.roomy.01JPNX7AA9BSM6TY2GWW1TR5V7.catalog" 1032 + ] 1033 + }, 1034 + { 1035 + "name": "chat.roomy.profile", 1036 + "description": "chat.roomy.profile records", 1037 + "service": "unknown", 1038 + "sampleRecords": [ 1039 + { 1040 + "$type": "chat.roomy.profile", 1041 + "accountId": "co_zhgWve43YBnn266mcVACjUuVG3d", 1042 + "profileId": "co_zDFNXMtNLkqorucsoFX89zb9xKq" 1043 + } 1044 + ], 1045 + "generatedTypes": "export interface ChatRoomyProfile {\n $type: 'chat.roomy.profile';\n accountId?: string;\n profileId?: string;\n}\n", 1046 + "$types": [ 1047 + "chat.roomy.profile" 1048 + ] 1049 + }, 1050 + { 1051 + "name": "com.germnetwork.keypackage", 1052 + "description": "com.germnetwork.keypackage records", 1053 + "service": "unknown", 1054 + "sampleRecords": [ 1055 + { 1056 + "$type": "com.germnetwork.keypackage", 1057 + "anchorHello": "AHHqxtKPJc9RLZyPZVexLVS3trjVRRE/JYDxDyB71KyTBYB9vQL1yyHSlNytf+5OWb0S1h5WM5F43uJuty8XTAT/AaUAAAOKVay8GnbTuqQtVPA11MVoVY61HW8e5vnxDQmdmVT9+wICAAAB/wE5AAEABQABAAMgVyrTJ4GcogoE02O68mMrGrmhuRXIPxYmzJ4GoUaVPDMgiVabYjh9CTZ/Kiefa/IQT9htiTEzvxAzR+hrw0b4qgUgGALXa4Kf0riu5j1k+gqYxQOvpzihscrm7IIAoaVdo5cAASEDilWsvBp207qkLVTwNdTFaFWOtR1vHub58Q0JnZlU/fsCAAEKAAIABwAFAAEAAwAAAgABAQAAAABokkwRAAAAAGpzf5EAQEDrZKju/Dqxnmcnpfc/tbClFyaA7ojWK9uVH7ZFieHdcEwNSRwtdi3pYJcSselbyUpJXRsc0jlOq428bTAQIx0IAEBAk2n8vRJ32A+09QEbh7gIjAdarFwAXH0JUBf3h2fY7LcTpopvo68IaNPDtf9aZBgJukfWZKzrQzSK8pWFAZUiCQBvVL5Mcq1KiCMH26+O2m/3+6XaKedsXm8BhHxzM9rfzUVJe1YbNMxv+SqRaDUaX7AACEaTw0m4KSjNVj7OPyYC" 1058 + } 1059 + ], 1060 + "generatedTypes": "export interface ComGermnetworkKeypackage {\n $type: 'com.germnetwork.keypackage';\n anchorHello?: string;\n}\n", 1061 + "$types": [ 1062 + "com.germnetwork.keypackage" 1063 + ] 1064 + }, 1065 + { 1066 + "name": "com.whtwnd.blog.entry", 1067 + "description": "com.whtwnd.blog.entry records", 1068 + "service": "unknown", 1069 + "sampleRecords": [ 1070 + { 1071 + "$type": "com.whtwnd.blog.entry", 1072 + "theme": "github-light", 1073 + "title": "Historic Atlanta Neighborhood Brand (Project Test)", 1074 + "content": "![image](https://framerusercontent.com/images/Ap45SgGnBdON8exvIwcXYpsdy8.jpg)\n\n> ๐Ÿ“— The team at Design Bloc worked closely with the residents of the historic Hunter Hills community to design a neighborhood brand that accurately represents their rich history. Design Bloc staff conducted extensive ethnographic research to understand the social, environmental, and material considerations for a representative brand.\n\n![image](https://framerusercontent.com/images/81IRCb1XebTy2hdaYwrgtRPFdLY.jpg)\n\n# Ethnographic Research\n\nOur journey begins with empathy. As one of the first planned black neighborhoods in Atlanta, Hunter Hills carries a long and significant history that must be preserved in its representation.\n\n> _The Hunter Hills brand should_ **reinforce the historic values of a unified community.**\n\n`literature reviews` helped us understand the context.\n\n`neighborhood walks` helped us understand the landscape.\n\n`resident interviews` helped us understand the experience.\n\n![image](https://framerusercontent.com/images/dpMAPMQKiJDIBHeBRo81pwV2Z1A.jpg)\n\n# Designing With the Community\n\nWe met with the Hunter Hills Neighborhood Association after each of several rounds of logo ideation, gathering feedback to lead us to the best final logo.\n\n![image](https://framerusercontent.com/images/gVlBWy07LHsZtPKWVpBlyQVki5o.jpg)\n\nStaff generated dozens of sketch thumbnails.\n\n![image](https://framerusercontent.com/images/9c419zGznQFCIypyYJjmxWWMmg.png)\n\nI iterated on another staffโ€™s sketch to arrive at the final logo on the right.\n\n# Collaborative InDesign\n\nI prepared a template file for our staff to create assigned pages of the final book.\n\n![image](https://framerusercontent.com/images/aKUnGNeWZKE9YMLNYiVHsRPzI0.png)\n\nPage plan with proportional grid fields and measured type\n\n![image](https://framerusercontent.com/images/D6wDwYfhkEXIaJeWdYLmiEss1U.png)\n\nPredefined text styles designed to fit in the grid and integrate with InDesignโ€™s TOC feature\n\n![image](https://framerusercontent.com/images/91Ea2Y2GzSMpdc4NAvOinG3Mr4.png)\n\nStandard page layouts for collaborative consistency\n\n# The Handoff\n\n![image](https://framerusercontent.com/images/wRzUdVzX9cVMmshSso1s8Y74.jpg)\n\n# Acknowledgements\n\nThanks to my team mates: Mars Lovelace, Hannan Abdi, Cole Campbell, Jordan Lym, Hunter Schaufel, Margaret Lu\nThanks to our leads: Shawn Harris, Michael Flanigan, Wayne Li\nThanks to the residents: Char Johnson, Lisa Reyes, Alfred Tucker, everyone from the Hunter Hills Neighborhood Association, and all the neighbors that contributed via interviews and workshops", 1075 + "createdAt": "2025-07-21T20:00:36.906Z", 1076 + "visibility": "public" 1077 + }, 1078 + { 1079 + "$type": "com.whtwnd.blog.entry", 1080 + "theme": "github-light", 1081 + "title": "The experiment continues!", 1082 + "content": "I do want to see how layouts behave when there are multiple posts to handle. Nothing to see here yet!", 1083 + "createdAt": "2025-07-12T19:57:03.240Z", 1084 + "visibility": "public" 1085 + }, 1086 + { 1087 + "$type": "com.whtwnd.blog.entry", 1088 + "theme": "github-light", 1089 + "title": "An ongoing experiment", 1090 + "content": "I want to own my website. I want it's content to live on the ATProtocol. Claude is giving me a hand with the implementation. Currently the stack uses Astro. I need demo content on WhiteWind to test it's ability to display my blog posts. This is the aforementioned demo content.", 1091 + "createdAt": "2025-07-11T21:04:33.022Z", 1092 + "visibility": "public" 1093 + } 1094 + ], 1095 + "generatedTypes": "export interface ComWhtwndBlogEntry {\n $type: 'com.whtwnd.blog.entry';\n theme?: string;\n title?: string;\n content?: string;\n createdAt?: string;\n visibility?: string;\n}\n", 1096 + "$types": [ 1097 + "com.whtwnd.blog.entry" 1098 + ] 1099 + }, 1100 + { 1101 + "name": "community.lexicon.calendar.rsvp", 1102 + "description": "community.lexicon.calendar.rsvp records", 1103 + "service": "unknown", 1104 + "sampleRecords": [ 1105 + { 1106 + "$type": "community.lexicon.calendar.rsvp", 1107 + "status": "community.lexicon.calendar.rsvp#interested", 1108 + "subject": { 1109 + "cid": "bafyreic6ev7ulowb7il4egk7kr5vwfgw5nweyj5dhkzlkid5sf3aqsvfji", 1110 + "uri": "at://did:plc:lehcqqkwzcwvjvw66uthu5oq/community.lexicon.calendar.event/3ltl5aficno2m" 1111 + }, 1112 + "createdAt": "2025-07-15T17:51:44.366Z" 1113 + }, 1114 + { 1115 + "$type": "community.lexicon.calendar.rsvp", 1116 + "status": "community.lexicon.calendar.rsvp#going", 1117 + "subject": { 1118 + "cid": "bafyreiapk47atkjb326wafy4z55ty4hdezmjmr57vf7korqfq7h2bcbhki", 1119 + "uri": "at://did:plc:stznz7qsokto2345qtdzogjb/community.lexicon.calendar.event/3lu3t4qnkqv2s" 1120 + }, 1121 + "createdAt": "2025-08-04T16:09:00.435Z" 1122 + } 1123 + ], 1124 + "generatedTypes": "export interface CommunityLexiconCalendarRsvp {\n $type: 'community.lexicon.calendar.rsvp';\n status?: string;\n subject?: Record<string, any>;\n createdAt?: string;\n}\n", 1125 + "$types": [ 1126 + "community.lexicon.calendar.rsvp" 1127 + ] 1128 + }, 1129 + { 1130 + "name": "events.smokesignal.app.profile", 1131 + "description": "events.smokesignal.app.profile records", 1132 + "service": "unknown", 1133 + "sampleRecords": [ 1134 + { 1135 + "tz": "America/New_York", 1136 + "$type": "events.smokesignal.app.profile" 1137 + } 1138 + ], 1139 + "generatedTypes": "export interface EventsSmokesignalAppProfile {\n $type: 'events.smokesignal.app.profile';\n tz?: string;\n}\n", 1140 + "$types": [ 1141 + "events.smokesignal.app.profile" 1142 + ] 1143 + }, 1144 + { 1145 + "name": "events.smokesignal.calendar.event", 1146 + "description": "events.smokesignal.calendar.event records", 1147 + "service": "unknown", 1148 + "sampleRecords": [ 1149 + { 1150 + "mode": "events.smokesignal.calendar.event#inperson", 1151 + "name": "IDSA Boston Open Studio Series - Motiv", 1152 + "text": "This May we're cohosting a series of open studios across Boston. Join us for a peek into your favorite studios and lots of socializing!\r\n\r\nJoin us for the second studio tour in our May Studio Series! Motiv is generously opening their doors to our community for a special peek into their studio, their work, and their culture.\r\n\r\nAfter the studio tour we'll head down the street to The Broadway (726 E Broadway, Boston) for food and friendship (BYO$).\r\n\r\nThis event is free to the public and open to both IDSA Members and Non-Members.", 1153 + "$type": "events.smokesignal.calendar.event", 1154 + "endsAt": "2025-05-16T00:00:00.000Z", 1155 + "status": "events.smokesignal.calendar.event#scheduled", 1156 + "location": { 1157 + "name": "Motiv Design", 1158 + "$type": "events.smokesignal.calendar.location#place", 1159 + "region": "MA", 1160 + "street": "803 Summer Street, #2nd floor", 1161 + "country": "US", 1162 + "locality": "Boston", 1163 + "postalCode": "02127" 1164 + }, 1165 + "startsAt": "2025-05-15T22:00:00.000Z", 1166 + "createdAt": "2025-05-13T20:11:16.764Z" 1167 + } 1168 + ], 1169 + "generatedTypes": "export interface EventsSmokesignalCalendarEvent {\n $type: 'events.smokesignal.calendar.event';\n mode?: string;\n name?: string;\n text?: string;\n endsAt?: string;\n status?: string;\n location?: Record<string, any>;\n startsAt?: string;\n createdAt?: string;\n}\n", 1170 + "$types": [ 1171 + "events.smokesignal.calendar.event" 1172 + ] 1173 + }, 1174 + { 1175 + "name": "farm.smol.games.skyrdle.score", 1176 + "description": "farm.smol.games.skyrdle.score records", 1177 + "service": "unknown", 1178 + "sampleRecords": [ 1179 + { 1180 + "hash": "8a77a61dcb3a7f8f41314d2ab411bdfccf1643f2ea3ce43315ae07b5d20b9210", 1181 + "$type": "farm.smol.games.skyrdle.score", 1182 + "isWin": true, 1183 + "score": 5, 1184 + "guesses": [ 1185 + { 1186 + "letters": [ 1187 + "P", 1188 + "O", 1189 + "I", 1190 + "N", 1191 + "T" 1192 + ], 1193 + "evaluation": [ 1194 + "present", 1195 + "present", 1196 + "absent", 1197 + "absent", 1198 + "absent" 1199 + ] 1200 + }, 1201 + { 1202 + "letters": [ 1203 + "S", 1204 + "L", 1205 + "O", 1206 + "P", 1207 + "E" 1208 + ], 1209 + "evaluation": [ 1210 + "absent", 1211 + "absent", 1212 + "present", 1213 + "present", 1214 + "absent" 1215 + ] 1216 + }, 1217 + { 1218 + "letters": [ 1219 + "C", 1220 + "R", 1221 + "O", 1222 + "G", 1223 + "A" 1224 + ], 1225 + "evaluation": [ 1226 + "absent", 1227 + "present", 1228 + "present", 1229 + "absent", 1230 + "present" 1231 + ] 1232 + }, 1233 + { 1234 + "letters": [ 1235 + "R", 1236 + "A", 1237 + "P", 1238 + "O", 1239 + "R" 1240 + ], 1241 + "evaluation": [ 1242 + "absent", 1243 + "correct", 1244 + "correct", 1245 + "correct", 1246 + "correct" 1247 + ] 1248 + }, 1249 + { 1250 + "letters": [ 1251 + "V", 1252 + "A", 1253 + "P", 1254 + "O", 1255 + "R" 1256 + ], 1257 + "evaluation": [ 1258 + "correct", 1259 + "correct", 1260 + "correct", 1261 + "correct", 1262 + "correct" 1263 + ] 1264 + } 1265 + ], 1266 + "timestamp": "2025-06-21T17:50:53.767Z", 1267 + "gameNumber": 9 1268 + }, 1269 + { 1270 + "hash": "2c3312d4eee2bb032d59b67676588cab5b72035c47f1696ab42845b6c0a36fa2", 1271 + "$type": "farm.smol.games.skyrdle.score", 1272 + "isWin": true, 1273 + "score": 5, 1274 + "guesses": [ 1275 + { 1276 + "letters": [ 1277 + "P", 1278 + "R", 1279 + "I", 1280 + "C", 1281 + "K" 1282 + ], 1283 + "evaluation": [ 1284 + "absent", 1285 + "correct", 1286 + "absent", 1287 + "absent", 1288 + "absent" 1289 + ] 1290 + }, 1291 + { 1292 + "letters": [ 1293 + "D", 1294 + "R", 1295 + "O", 1296 + "N", 1297 + "E" 1298 + ], 1299 + "evaluation": [ 1300 + "absent", 1301 + "correct", 1302 + "correct", 1303 + "absent", 1304 + "absent" 1305 + ] 1306 + }, 1307 + { 1308 + "letters": [ 1309 + "F", 1310 + "R", 1311 + "O", 1312 + "G", 1313 + "S" 1314 + ], 1315 + "evaluation": [ 1316 + "correct", 1317 + "correct", 1318 + "correct", 1319 + "absent", 1320 + "present" 1321 + ] 1322 + }, 1323 + { 1324 + "letters": [ 1325 + "F", 1326 + "F", 1327 + "O", 1328 + "S", 1329 + "T" 1330 + ], 1331 + "evaluation": [ 1332 + "correct", 1333 + "absent", 1334 + "correct", 1335 + "correct", 1336 + "correct" 1337 + ] 1338 + }, 1339 + { 1340 + "letters": [ 1341 + "F", 1342 + "R", 1343 + "O", 1344 + "S", 1345 + "T" 1346 + ], 1347 + "evaluation": [ 1348 + "correct", 1349 + "correct", 1350 + "correct", 1351 + "correct", 1352 + "correct" 1353 + ] 1354 + } 1355 + ], 1356 + "timestamp": "2025-06-20T13:33:55.182Z", 1357 + "gameNumber": 8 1358 + }, 1359 + { 1360 + "hash": "6e536c0ae04b57f9afe595541cf41844672d5eae9e59de1d707784748feba5a1", 1361 + "$type": "farm.smol.games.skyrdle.score", 1362 + "isWin": true, 1363 + "score": 3, 1364 + "guesses": [ 1365 + { 1366 + "letters": [ 1367 + "P", 1368 + "R", 1369 + "I", 1370 + "M", 1371 + "E" 1372 + ], 1373 + "evaluation": [ 1374 + "absent", 1375 + "absent", 1376 + "present", 1377 + "absent", 1378 + "absent" 1379 + ] 1380 + }, 1381 + { 1382 + "letters": [ 1383 + "S", 1384 + "C", 1385 + "H", 1386 + "I", 1387 + "T" 1388 + ], 1389 + "evaluation": [ 1390 + "correct", 1391 + "present", 1392 + "absent", 1393 + "correct", 1394 + "present" 1395 + ] 1396 + }, 1397 + { 1398 + "letters": [ 1399 + "S", 1400 + "T", 1401 + "O", 1402 + "I", 1403 + "C" 1404 + ], 1405 + "evaluation": [ 1406 + "correct", 1407 + "correct", 1408 + "correct", 1409 + "correct", 1410 + "correct" 1411 + ] 1412 + } 1413 + ], 1414 + "timestamp": "2025-06-19T14:30:27.746Z", 1415 + "gameNumber": 7 1416 + } 1417 + ], 1418 + "generatedTypes": "export interface FarmSmolGamesSkyrdleScore {\n $type: 'farm.smol.games.skyrdle.score';\n hash?: string;\n isWin?: boolean;\n score?: number;\n guesses?: Record<string, any>[];\n timestamp?: string;\n gameNumber?: number;\n}\n", 1419 + "$types": [ 1420 + "farm.smol.games.skyrdle.score" 1421 + ] 1422 + }, 1423 + { 1424 + "name": "fyi.bluelinks.links", 1425 + "description": "fyi.bluelinks.links records", 1426 + "service": "unknown", 1427 + "sampleRecords": [ 1428 + { 1429 + "$type": "fyi.bluelinks.links", 1430 + "links": [ 1431 + { 1432 + "id": "4fc54af2-46ad-4f89-aa94-a14bbfd60afb", 1433 + "url": "https://tynanpurdy.com", 1434 + "name": "Portfolio", 1435 + "$type": "fyi.bluelinks.links#link", 1436 + "order": 1, 1437 + "createdAt": "2025-06-16T20:23:31.823Z", 1438 + "description": "My past work" 1439 + }, 1440 + { 1441 + "id": "8d864819-c69c-43da-8d7b-b9635e36f67f", 1442 + "url": "https://blog.tynanpurdy.com", 1443 + "name": "Blog", 1444 + "$type": "fyi.bluelinks.links#link", 1445 + "order": 2, 1446 + "createdAt": "2025-06-16T20:24:07.424Z", 1447 + "description": "My writing" 1448 + } 1449 + ] 1450 + } 1451 + ], 1452 + "generatedTypes": "export interface FyiBluelinksLinks {\n $type: 'fyi.bluelinks.links';\n links?: Record<string, any>[];\n}\n", 1453 + "$types": [ 1454 + "fyi.bluelinks.links" 1455 + ] 1456 + }, 1457 + { 1458 + "name": "fyi.unravel.frontpage.comment", 1459 + "description": "fyi.unravel.frontpage.comment records", 1460 + "service": "unknown", 1461 + "sampleRecords": [ 1462 + { 1463 + "post": { 1464 + "cid": "bafyreicxjsuwe7thbqcu3qh5biliuxyou26nbmac6hhxv74u2jeuexx334", 1465 + "uri": "at://did:plc:vro3sykit2gjemuza2pwvxwy/fyi.unravel.frontpage.post/3lvbcvpm3js2c" 1466 + }, 1467 + "$type": "fyi.unravel.frontpage.comment", 1468 + "content": "I can confirm my vibecoded app has like 3 approaches to the same thing and its a mess to untangle. Esp for a noob who doesn't really know what is the right way to converge on", 1469 + "createdAt": "2025-08-02T01:06:19.685Z" 1470 + }, 1471 + { 1472 + "post": { 1473 + "cid": "bafyreibiy36sr55cyjd7d6kn7yuuadxk242cm5yqsyw7strdpdbhesoxga", 1474 + "uri": "at://did:plc:ofrbh253gwicbkc5nktqepol/fyi.unravel.frontpage.post/3luxczcviqk2h" 1475 + }, 1476 + "$type": "fyi.unravel.frontpage.comment", 1477 + "parent": { 1478 + "cid": "bafyreicoezztd45k677sfqtuevgg3zqi5dhdheevry5qcb2cydrfp72uuq", 1479 + "uri": "at://did:plc:6ayddqghxhciedbaofoxkcbs/fyi.unravel.frontpage.comment/3lv2echl6xk2e" 1480 + }, 1481 + "content": "https://tangled.sh/@tynanpurdy.com/at-home", 1482 + "createdAt": "2025-07-28T19:48:17.572Z" 1483 + }, 1484 + { 1485 + "post": { 1486 + "cid": "bafyreibiy36sr55cyjd7d6kn7yuuadxk242cm5yqsyw7strdpdbhesoxga", 1487 + "uri": "at://did:plc:ofrbh253gwicbkc5nktqepol/fyi.unravel.frontpage.post/3luxczcviqk2h" 1488 + }, 1489 + "$type": "fyi.unravel.frontpage.comment", 1490 + "content": "My version of this has a slightly different stack but largely the same idea. I'd love to collab on what additional lexicon might be useful for the personal website category. Portfolio projects are still a struggle to me.", 1491 + "createdAt": "2025-07-28T19:47:46.719Z" 1492 + } 1493 + ], 1494 + "generatedTypes": "export interface FyiUnravelFrontpageComment {\n $type: 'fyi.unravel.frontpage.comment';\n post?: Record<string, any>;\n content?: string;\n createdAt?: string;\n}\n", 1495 + "$types": [ 1496 + "fyi.unravel.frontpage.comment" 1497 + ] 1498 + }, 1499 + { 1500 + "name": "fyi.unravel.frontpage.post", 1501 + "description": "fyi.unravel.frontpage.post records", 1502 + "service": "unknown", 1503 + "sampleRecords": [ 1504 + { 1505 + "url": "https://brittanyellich.com/bluesky-comments-likes/", 1506 + "$type": "fyi.unravel.frontpage.post", 1507 + "title": "I finally added Bluesky comments and likes to my blog (and you can too!)", 1508 + "createdAt": "2025-08-06T13:38:34.417Z" 1509 + }, 1510 + { 1511 + "url": "https://www.citationneeded.news/curate-with-rss/", 1512 + "$type": "fyi.unravel.frontpage.post", 1513 + "title": "Curate your own newspaper with RSS", 1514 + "createdAt": "2025-07-31T17:18:08.281Z" 1515 + }, 1516 + { 1517 + "url": "https://baileytownsend.dev/articles/host-a-pds-with-a-cloudflare-tunnel", 1518 + "$type": "fyi.unravel.frontpage.post", 1519 + "title": "Host a PDS via a Cloudflare Tunnel", 1520 + "createdAt": "2025-07-29T13:47:49.739Z" 1521 + } 1522 + ], 1523 + "generatedTypes": "export interface FyiUnravelFrontpagePost {\n $type: 'fyi.unravel.frontpage.post';\n url?: string;\n title?: string;\n createdAt?: string;\n}\n", 1524 + "$types": [ 1525 + "fyi.unravel.frontpage.post" 1526 + ] 1527 + }, 1528 + { 1529 + "name": "fyi.unravel.frontpage.vote", 1530 + "description": "fyi.unravel.frontpage.vote records", 1531 + "service": "unknown", 1532 + "sampleRecords": [ 1533 + { 1534 + "$type": "fyi.unravel.frontpage.vote", 1535 + "subject": { 1536 + "cid": "bafyreicxjsuwe7thbqcu3qh5biliuxyou26nbmac6hhxv74u2jeuexx334", 1537 + "uri": "at://did:plc:vro3sykit2gjemuza2pwvxwy/fyi.unravel.frontpage.post/3lvbcvpm3js2c" 1538 + }, 1539 + "createdAt": "2025-08-06T15:01:18.710Z" 1540 + }, 1541 + { 1542 + "$type": "fyi.unravel.frontpage.vote", 1543 + "subject": { 1544 + "cid": "bafyreihrgrqftgwr6u37p3tkoa23bh6z7vecc44hivvtichrar4rnl55ti", 1545 + "uri": "at://did:plc:mdjhvva6vlrswsj26cftjttd/fyi.unravel.frontpage.post/3lvbqj5kpm22v" 1546 + }, 1547 + "createdAt": "2025-08-06T14:29:08.506Z" 1548 + }, 1549 + { 1550 + "$type": "fyi.unravel.frontpage.vote", 1551 + "subject": { 1552 + "cid": "bafyreicbdf5lbimyqo6d2hgj2z7y3v2ligwqlczjzp2fmgl2s7vci5yqj4", 1553 + "uri": "at://did:plc:mdjhvva6vlrswsj26cftjttd/fyi.unravel.frontpage.post/3lu6mwifrmk2x" 1554 + }, 1555 + "createdAt": "2025-08-06T14:06:38.568Z" 1556 + } 1557 + ], 1558 + "generatedTypes": "export interface FyiUnravelFrontpageVote {\n $type: 'fyi.unravel.frontpage.vote';\n subject?: Record<string, any>;\n createdAt?: string;\n}\n", 1559 + "$types": [ 1560 + "fyi.unravel.frontpage.vote" 1561 + ] 1562 + }, 1563 + { 1564 + "name": "im.flushing.right.now", 1565 + "description": "im.flushing.right.now records", 1566 + "service": "unknown", 1567 + "sampleRecords": [ 1568 + { 1569 + "text": "is flushing", 1570 + "$type": "im.flushing.right.now", 1571 + "emoji": "๐Ÿ’ฉ", 1572 + "createdAt": "2025-06-17T16:53:24-04:00" 1573 + } 1574 + ], 1575 + "generatedTypes": "export interface ImFlushingRightNow {\n $type: 'im.flushing.right.now';\n text?: string;\n emoji?: string;\n createdAt?: string;\n}\n", 1576 + "$types": [ 1577 + "im.flushing.right.now" 1578 + ] 1579 + }, 1580 + { 1581 + "name": "link.woosh.linkPage", 1582 + "description": "link.woosh.linkPage records", 1583 + "service": "unknown", 1584 + "sampleRecords": [ 1585 + { 1586 + "$type": "link.woosh.linkPage", 1587 + "collections": [ 1588 + { 1589 + "label": "Socials", 1590 + "links": [ 1591 + { 1592 + "uri": "https://bsky.app/profile/tynanpurdy.com", 1593 + "title": "Bluesky" 1594 + }, 1595 + { 1596 + "uri": "https://github.com/tynanpurdy", 1597 + "title": "GitHub" 1598 + }, 1599 + { 1600 + "uri": "https://www.linkedin.com/in/tynanpurdy", 1601 + "title": "LinkedIn" 1602 + } 1603 + ] 1604 + } 1605 + ] 1606 + } 1607 + ], 1608 + "generatedTypes": "export interface LinkWooshLinkPage {\n $type: 'link.woosh.linkPage';\n collections?: Record<string, any>[];\n}\n", 1609 + "$types": [ 1610 + "link.woosh.linkPage" 1611 + ] 1612 + }, 1613 + { 1614 + "name": "my.skylights.rel", 1615 + "description": "my.skylights.rel records", 1616 + "service": "unknown", 1617 + "sampleRecords": [ 1618 + { 1619 + "item": { 1620 + "ref": "tmdb:m", 1621 + "value": "861" 1622 + }, 1623 + "note": { 1624 + "value": "Great LOL moments. Fantastic special effects, especially in the makeup and prosthetics dept. ", 1625 + "createdAt": "2025-05-21T21:24:37.174Z", 1626 + "updatedAt": "2025-05-21T21:24:37.174Z" 1627 + }, 1628 + "$type": "my.skylights.rel", 1629 + "rating": { 1630 + "value": 8, 1631 + "createdAt": "2025-05-21T21:23:21.661Z" 1632 + } 1633 + } 1634 + ], 1635 + "generatedTypes": "export interface MySkylightsRel {\n $type: 'my.skylights.rel';\n item?: Record<string, any>;\n note?: Record<string, any>;\n rating?: Record<string, any>;\n}\n", 1636 + "$types": [ 1637 + "my.skylights.rel" 1638 + ] 1639 + }, 1640 + { 1641 + "name": "org.owdproject.application.windows", 1642 + "description": "org.owdproject.application.windows records", 1643 + "service": "unknown", 1644 + "sampleRecords": [ 1645 + { 1646 + "$type": "org.owdproject.application.windows", 1647 + "windows": {} 1648 + }, 1649 + { 1650 + "$type": "org.owdproject.application.windows", 1651 + "windows": {} 1652 + } 1653 + ], 1654 + "generatedTypes": "export interface OrgOwdprojectApplicationWindows {\n $type: 'org.owdproject.application.windows';\n}\n", 1655 + "$types": [ 1656 + "org.owdproject.application.windows" 1657 + ] 1658 + }, 1659 + { 1660 + "name": "org.owdproject.desktop", 1661 + "description": "org.owdproject.desktop records", 1662 + "service": "unknown", 1663 + "sampleRecords": [ 1664 + { 1665 + "$type": "org.owdproject.desktop", 1666 + "state": { 1667 + "volume": { 1668 + "master": 100 1669 + }, 1670 + "window": { 1671 + "positionZ": 2 1672 + }, 1673 + "workspace": { 1674 + "list": [ 1675 + "cNOD12iO", 1676 + "MjCjyI3o" 1677 + ], 1678 + "active": "cNOD12iO", 1679 + "overview": false 1680 + } 1681 + } 1682 + } 1683 + ], 1684 + "generatedTypes": "export interface OrgOwdprojectDesktop {\n $type: 'org.owdproject.desktop';\n state?: Record<string, any>;\n}\n", 1685 + "$types": [ 1686 + "org.owdproject.desktop" 1687 + ] 1688 + }, 1689 + { 1690 + "name": "org.scrapboard.list", 1691 + "description": "org.scrapboard.list records", 1692 + "service": "unknown", 1693 + "sampleRecords": [ 1694 + { 1695 + "name": "", 1696 + "$type": "org.scrapboard.list", 1697 + "createdAt": "2025-08-04T14:48:50.207Z", 1698 + "description": "" 1699 + } 1700 + ], 1701 + "generatedTypes": "export interface OrgScrapboardList {\n $type: 'org.scrapboard.list';\n name?: string;\n createdAt?: string;\n description?: string;\n}\n", 1702 + "$types": [ 1703 + "org.scrapboard.list" 1704 + ] 1705 + }, 1706 + { 1707 + "name": "org.scrapboard.listitem", 1708 + "description": "org.scrapboard.listitem records", 1709 + "service": "unknown", 1710 + "sampleRecords": [ 1711 + { 1712 + "url": "at://did:plc:gerrk3zpej5oloffu5cqtnly/app.bsky.feed.post/3lvj6fteywk2u?image=0", 1713 + "list": "at://did:plc:6ayddqghxhciedbaofoxkcbs/org.scrapboard.list/3lvlguetmjw2i", 1714 + "$type": "org.scrapboard.listitem", 1715 + "createdAt": "2025-08-04T14:48:58.704Z" 1716 + } 1717 + ], 1718 + "generatedTypes": "export interface OrgScrapboardListitem {\n $type: 'org.scrapboard.listitem';\n url?: string;\n list?: string;\n createdAt?: string;\n}\n", 1719 + "$types": [ 1720 + "org.scrapboard.listitem" 1721 + ] 1722 + }, 1723 + { 1724 + "name": "place.stream.chat.message", 1725 + "description": "place.stream.chat.message records", 1726 + "service": "unknown", 1727 + "sampleRecords": [ 1728 + { 1729 + "text": "thanks yall!", 1730 + "$type": "place.stream.chat.message", 1731 + "streamer": "did:plc:stznz7qsokto2345qtdzogjb", 1732 + "createdAt": "2025-08-04T17:59:34.556Z" 1733 + }, 1734 + { 1735 + "text": "thanks yall!", 1736 + "$type": "place.stream.chat.message", 1737 + "streamer": "did:plc:stznz7qsokto2345qtdzogjb", 1738 + "createdAt": "2025-08-04T17:59:34.555Z" 1739 + }, 1740 + { 1741 + "text": "spark, skyswipe, et al", 1742 + "$type": "place.stream.chat.message", 1743 + "streamer": "did:plc:stznz7qsokto2345qtdzogjb", 1744 + "createdAt": "2025-08-04T17:52:24.424Z" 1745 + } 1746 + ], 1747 + "generatedTypes": "export interface PlaceStreamChatMessage {\n $type: 'place.stream.chat.message';\n text?: string;\n streamer?: string;\n createdAt?: string;\n}\n", 1748 + "$types": [ 1749 + "place.stream.chat.message" 1750 + ] 1751 + }, 1752 + { 1753 + "name": "place.stream.chat.profile", 1754 + "description": "place.stream.chat.profile records", 1755 + "service": "unknown", 1756 + "sampleRecords": [ 1757 + { 1758 + "$type": "place.stream.chat.profile", 1759 + "color": { 1760 + "red": 76, 1761 + "blue": 118, 1762 + "green": 175 1763 + } 1764 + } 1765 + ], 1766 + "generatedTypes": "export interface PlaceStreamChatProfile {\n $type: 'place.stream.chat.profile';\n color?: Record<string, any>;\n}\n", 1767 + "$types": [ 1768 + "place.stream.chat.profile" 1769 + ] 1770 + }, 1771 + { 1772 + "name": "pub.leaflet.document", 1773 + "description": "pub.leaflet.document records", 1774 + "service": "unknown", 1775 + "sampleRecords": [ 1776 + { 1777 + "$type": "pub.leaflet.document", 1778 + "pages": [ 1779 + { 1780 + "$type": "pub.leaflet.pages.linearDocument", 1781 + "blocks": [ 1782 + { 1783 + "$type": "pub.leaflet.pages.linearDocument#block", 1784 + "block": { 1785 + "$type": "pub.leaflet.blocks.text", 1786 + "facets": [ 1787 + { 1788 + "index": { 1789 + "byteEnd": 521, 1790 + "byteStart": 508 1791 + }, 1792 + "features": [ 1793 + { 1794 + "$type": "pub.leaflet.richtext.facet#italic" 1795 + } 1796 + ] 1797 + } 1798 + ], 1799 + "plaintext": "I love all this community-led development of the open social web. Tech infra architecture and business models can break down the oppressive and monopolistic internet giants we have grown up with. This blog post is published over the AT protocol, an open standard for all kinds of social experiences. The data, followers, and interactions are stored on a Personal Data Server. I control the data. I can move to a new PDS if I wish. I can even host my PDS myself. My account hosted on my PDS is my account for any and every ATproto app in the world. " 1800 + } 1801 + }, 1802 + { 1803 + "$type": "pub.leaflet.pages.linearDocument#block", 1804 + "block": { 1805 + "$type": "pub.leaflet.blocks.text", 1806 + "facets": [], 1807 + "plaintext": "You can read this post on the leaflet website, which I have connected to the domain I own. You can also read it on Bluesky in a custom feed. You can also use any other client you want that can read an ATproto feed. There are dozens of clients for browsing and interacting with ATproto posts. You don't have to use Bluesky or it's infrastructure if you wish not to." 1808 + } 1809 + }, 1810 + { 1811 + "$type": "pub.leaflet.pages.linearDocument#block", 1812 + "block": { 1813 + "$type": "pub.leaflet.blocks.text", 1814 + "facets": [], 1815 + "plaintext": "The hype around Bluesky is great to see. It is also somewhat shortsighted to the broader vision of the future of social. It's the protocol that matters. The things it enables. It can be hard to realize how restricted the big platforms are until you see an alternative. The projects any indie dev can spin up in no time with ATproto are incredible. I encourage you to check some of them out. " 1816 + } 1817 + }, 1818 + { 1819 + "$type": "pub.leaflet.pages.linearDocument#block", 1820 + "block": { 1821 + "$type": "pub.leaflet.blocks.text", 1822 + "facets": [], 1823 + "plaintext": "" 1824 + } 1825 + }, 1826 + { 1827 + "$type": "pub.leaflet.pages.linearDocument#block", 1828 + "block": { 1829 + "$type": "pub.leaflet.blocks.text", 1830 + "facets": [ 1831 + { 1832 + "index": { 1833 + "byteEnd": 66, 1834 + "byteStart": 0 1835 + }, 1836 + "features": [ 1837 + { 1838 + "$type": "pub.leaflet.richtext.facet#italic" 1839 + } 1840 + ] 1841 + } 1842 + ], 1843 + "plaintext": "I know I did not address the subtitle, saving for a future post ;)" 1844 + } 1845 + } 1846 + ] 1847 + } 1848 + ], 1849 + "title": "Blogging directly on ATproto", 1850 + "author": "did:plc:6ayddqghxhciedbaofoxkcbs", 1851 + "postRef": { 1852 + "cid": "bafyreihwmgzema3jpptki4jod3skf4bmsjjikqgbkgkz7qreggjbiwwkpa", 1853 + "uri": "at://did:plc:6ayddqghxhciedbaofoxkcbs/app.bsky.feed.post/3lrquu5rnsk2w", 1854 + "commit": { 1855 + "cid": "bafyreib2y34ku7v5gzfrjcgibg3qoitwgwbxtgo2azl2mnw3xz35bzh2lq", 1856 + "rev": "3lrquu5ured2l" 1857 + }, 1858 + "validationStatus": "valid" 1859 + }, 1860 + "description": "Which is somehow different from micro.blog...", 1861 + "publication": "at://did:plc:6ayddqghxhciedbaofoxkcbs/pub.leaflet.publication/3lptvotm3ms2o", 1862 + "publishedAt": "2025-06-16T21:01:42.095Z" 1863 + } 1864 + ], 1865 + "generatedTypes": "export interface PubLeafletDocument {\n $type: 'pub.leaflet.document';\n pages?: Record<string, any>[];\n title?: string;\n author?: string;\n postRef?: Record<string, any>;\n description?: string;\n publication?: string;\n publishedAt?: string;\n}\n", 1866 + "$types": [ 1867 + "pub.leaflet.document" 1868 + ] 1869 + }, 1870 + { 1871 + "name": "pub.leaflet.graph.subscription", 1872 + "description": "pub.leaflet.graph.subscription records", 1873 + "service": "unknown", 1874 + "sampleRecords": [ 1875 + { 1876 + "$type": "pub.leaflet.graph.subscription", 1877 + "publication": "at://did:plc:2cxgdrgtsmrbqnjkwyplmp43/pub.leaflet.publication/3lpqbbzc7x224" 1878 + }, 1879 + { 1880 + "$type": "pub.leaflet.graph.subscription", 1881 + "publication": "at://did:plc:u2grpouz5553mrn4x772pyfa/pub.leaflet.publication/3lve2jmb7c22j" 1882 + }, 1883 + { 1884 + "$type": "pub.leaflet.graph.subscription", 1885 + "publication": "at://did:plc:e3tv2pzlnuppocnc3wirsvl4/pub.leaflet.publication/3lrgwj6ytis2k" 1886 + } 1887 + ], 1888 + "generatedTypes": "export interface PubLeafletGraphSubscription {\n $type: 'pub.leaflet.graph.subscription';\n publication?: string;\n}\n", 1889 + "$types": [ 1890 + "pub.leaflet.graph.subscription" 1891 + ] 1892 + }, 1893 + { 1894 + "name": "pub.leaflet.publication", 1895 + "description": "pub.leaflet.publication records", 1896 + "service": "unknown", 1897 + "sampleRecords": [ 1898 + { 1899 + "icon": { 1900 + "$type": "blob", 1901 + "ref": { 1902 + "$link": "bafkreihz6y3xxbl5xasgrsfykyh6zizc63uv276lzengbvslikkqgndabe" 1903 + }, 1904 + "mimeType": "image/png", 1905 + "size": 75542 1906 + }, 1907 + "name": "Tynan's Leaflets", 1908 + "$type": "pub.leaflet.publication", 1909 + "base_path": "leaflets.tynanpurdy.com", 1910 + "description": "I'll play around with any ATproto gizmo" 1911 + } 1912 + ], 1913 + "generatedTypes": "export interface PubLeafletPublication {\n $type: 'pub.leaflet.publication';\n icon?: Record<string, any>;\n name?: string;\n base_path?: string;\n description?: string;\n}\n", 1914 + "$types": [ 1915 + "pub.leaflet.publication" 1916 + ] 1917 + }, 1918 + { 1919 + "name": "sh.tangled.actor.profile", 1920 + "description": "sh.tangled.actor.profile records", 1921 + "service": "sh.tangled", 1922 + "sampleRecords": [ 1923 + { 1924 + "$type": "sh.tangled.actor.profile", 1925 + "links": [ 1926 + "https://tynanpurdy.com", 1927 + "https://blog.tynanpurdy.com", 1928 + "", 1929 + "", 1930 + "" 1931 + ], 1932 + "stats": [ 1933 + "", 1934 + "" 1935 + ], 1936 + "bluesky": true, 1937 + "location": "", 1938 + "description": "", 1939 + "pinnedRepositories": [ 1940 + "", 1941 + "", 1942 + "", 1943 + "", 1944 + "", 1945 + "" 1946 + ] 1947 + } 1948 + ], 1949 + "generatedTypes": "export interface ShTangledActorProfile {\n $type: 'sh.tangled.actor.profile';\n links?: string[];\n stats?: string[];\n bluesky?: boolean;\n location?: string;\n description?: string;\n pinnedRepositories?: string[];\n}\n", 1950 + "$types": [ 1951 + "sh.tangled.actor.profile" 1952 + ] 1953 + }, 1954 + { 1955 + "name": "sh.tangled.feed.star", 1956 + "description": "sh.tangled.feed.star records", 1957 + "service": "sh.tangled", 1958 + "sampleRecords": [ 1959 + { 1960 + "$type": "sh.tangled.feed.star", 1961 + "subject": "at://did:plc:tgudj2fjm77pzkuawquqhsxm/sh.tangled.repo/3lnkvfhpcz422", 1962 + "createdAt": "2025-07-31T01:08:31Z" 1963 + } 1964 + ], 1965 + "generatedTypes": "export interface ShTangledFeedStar {\n $type: 'sh.tangled.feed.star';\n subject?: string;\n createdAt?: string;\n}\n", 1966 + "$types": [ 1967 + "sh.tangled.feed.star" 1968 + ] 1969 + }, 1970 + { 1971 + "name": "sh.tangled.publicKey", 1972 + "description": "sh.tangled.publicKey records", 1973 + "service": "sh.tangled", 1974 + "sampleRecords": [ 1975 + { 1976 + "key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN0U2oHBrZAOYUO0klCU7HpwgGEAJprdrI3Nk8H0YzOo", 1977 + "name": "ray", 1978 + "$type": "sh.tangled.publicKey", 1979 + "createdAt": "2025-07-07T23:52:11Z" 1980 + } 1981 + ], 1982 + "generatedTypes": "export interface ShTangledPublicKey {\n $type: 'sh.tangled.publicKey';\n key?: string;\n name?: string;\n createdAt?: string;\n}\n", 1983 + "$types": [ 1984 + "sh.tangled.publicKey" 1985 + ] 1986 + }, 1987 + { 1988 + "name": "sh.tangled.repo", 1989 + "description": "sh.tangled.repo records", 1990 + "service": "sh.tangled", 1991 + "sampleRecords": [ 1992 + { 1993 + "knot": "knot1.tangled.sh", 1994 + "name": "at-home", 1995 + "$type": "sh.tangled.repo", 1996 + "owner": "did:plc:6ayddqghxhciedbaofoxkcbs", 1997 + "createdAt": "2025-07-11T22:07:14Z" 1998 + }, 1999 + { 2000 + "knot": "knot1.tangled.sh", 2001 + "name": "atprofile", 2002 + "$type": "sh.tangled.repo", 2003 + "owner": "did:plc:6ayddqghxhciedbaofoxkcbs", 2004 + "createdAt": "2025-07-07T23:27:33Z" 2005 + } 2006 + ], 2007 + "generatedTypes": "export interface ShTangledRepo {\n $type: 'sh.tangled.repo';\n knot?: string;\n name?: string;\n owner?: string;\n createdAt?: string;\n}\n", 2008 + "$types": [ 2009 + "sh.tangled.repo" 2010 + ] 2011 + }, 2012 + { 2013 + "name": "so.sprk.actor.profile", 2014 + "description": "so.sprk.actor.profile records", 2015 + "service": "unknown", 2016 + "sampleRecords": [ 2017 + { 2018 + "$type": "so.sprk.actor.profile", 2019 + "avatar": { 2020 + "$type": "blob", 2021 + "ref": { 2022 + "$link": "bafkreig6momh2fkdfhhqwkcjsw4vycubptufe6aeolsddtgg6felh4bvoe" 2023 + }, 2024 + "mimeType": "image/jpeg", 2025 + "size": 915425 2026 + }, 2027 + "description": "he/him\nExperience Designer | Tech nerd | Curious Creative\n๐Ÿ“ Boston ๐Ÿ‡บ๐Ÿ‡ธ\n๐Ÿ“– acotar, a different kind of power\n\nmy work: tynanpurdy.com\nmy writing: blog.tynanpurdy.com", 2028 + "displayName": "Tynan Purdy" 2029 + } 2030 + ], 2031 + "generatedTypes": "export interface SoSprkActorProfile {\n $type: 'so.sprk.actor.profile';\n avatar?: Record<string, any>;\n description?: string;\n displayName?: string;\n}\n", 2032 + "$types": [ 2033 + "so.sprk.actor.profile" 2034 + ] 2035 + }, 2036 + { 2037 + "name": "so.sprk.feed.like", 2038 + "description": "so.sprk.feed.like records", 2039 + "service": "unknown", 2040 + "sampleRecords": [ 2041 + { 2042 + "$type": "so.sprk.feed.like", 2043 + "subject": { 2044 + "cid": "bafyreibezk3pahyxmgt32xf6bdox6ycpx4uxeetzj2ol5xtat6nyf3uw5i", 2045 + "uri": "at://did:plc:owhabhwzxfp2zxh6nxszkzmg/app.bsky.feed.post/3lv7kobh6js2w" 2046 + }, 2047 + "createdAt": "2025-07-30T21:26:26.637994Z" 2048 + } 2049 + ], 2050 + "generatedTypes": "export interface SoSprkFeedLike {\n $type: 'so.sprk.feed.like';\n subject?: Record<string, any>;\n createdAt?: string;\n}\n", 2051 + "$types": [ 2052 + "so.sprk.feed.like" 2053 + ] 2054 + }, 2055 + { 2056 + "name": "so.sprk.feed.story", 2057 + "description": "so.sprk.feed.story records", 2058 + "service": "unknown", 2059 + "sampleRecords": [ 2060 + { 2061 + "tags": [], 2062 + "$type": "so.sprk.feed.story", 2063 + "media": { 2064 + "$type": "so.sprk.embed.images", 2065 + "images": [ 2066 + { 2067 + "alt": "Schnauzer dog on couch. Fresh hair cut and wearing a green bowtie.", 2068 + "image": { 2069 + "$type": "blob", 2070 + "ref": { 2071 + "$link": "bafkreifdfejeiitg46ec2wijg7mjmna4hyne2c5m45435jm4yiiw3nids4" 2072 + }, 2073 + "mimeType": "image/jpeg", 2074 + "size": 2651327 2075 + } 2076 + } 2077 + ] 2078 + }, 2079 + "createdAt": "2025-07-08T17:35:01.830824", 2080 + "selfLabels": [] 2081 + } 2082 + ], 2083 + "generatedTypes": "export interface SoSprkFeedStory {\n $type: 'so.sprk.feed.story';\n tags?: any[];\n media?: Record<string, any>;\n createdAt?: string;\n selfLabels?: any[];\n}\n", 2084 + "$types": [ 2085 + "so.sprk.feed.story" 2086 + ] 2087 + }, 2088 + { 2089 + "name": "social.grain.actor.profile", 2090 + "description": "social.grain.actor.profile records", 2091 + "service": "grain.social", 2092 + "sampleRecords": [ 2093 + { 2094 + "$type": "social.grain.actor.profile", 2095 + "avatar": { 2096 + "$type": "blob", 2097 + "ref": { 2098 + "$link": "bafkreias2logev3efvxo6kvplme2s6j2whvmhxpwpnsaq4mo52kqy7ktay" 2099 + }, 2100 + "mimeType": "image/jpeg", 2101 + "size": 992154 2102 + }, 2103 + "description": "he/him\r\nExperience Designer | Tech nerd | Curious Creative\r\n๐Ÿ“ Boston ๐Ÿ‡บ๐Ÿ‡ธ\r\n\r\nmy work: tynanpurdy.com\r\nmy writing: blog.tynanpurdy.com", 2104 + "displayName": "Tynan Purdy" 2105 + } 2106 + ], 2107 + "generatedTypes": "export interface SocialGrainActorProfile {\n $type: 'social.grain.actor.profile';\n avatar?: Record<string, any>;\n description?: string;\n displayName?: string;\n}\n", 2108 + "$types": [ 2109 + "social.grain.actor.profile" 2110 + ] 2111 + }, 2112 + { 2113 + "name": "social.grain.favorite", 2114 + "description": "social.grain.favorite records", 2115 + "service": "grain.social", 2116 + "sampleRecords": [ 2117 + { 2118 + "$type": "social.grain.favorite", 2119 + "subject": "at://did:plc:njgakmquzxdmz6t32j27hgee/social.grain.gallery/3lrpq72tqo22d", 2120 + "createdAt": "2025-06-17T21:19:47.133Z" 2121 + } 2122 + ], 2123 + "generatedTypes": "export interface SocialGrainFavorite {\n $type: 'social.grain.favorite';\n subject?: string;\n createdAt?: string;\n}\n", 2124 + "$types": [ 2125 + "social.grain.favorite" 2126 + ] 2127 + }, 2128 + { 2129 + "name": "social.grain.gallery", 2130 + "description": "Grain.social image galleries", 2131 + "service": "grain.social", 2132 + "sampleRecords": [ 2133 + { 2134 + "$type": "social.grain.gallery", 2135 + "title": "Zuko the dog", 2136 + "createdAt": "2025-07-28T14:43:35.815Z", 2137 + "updatedAt": "2025-07-28T14:43:35.815Z", 2138 + "description": "" 2139 + }, 2140 + { 2141 + "$type": "social.grain.gallery", 2142 + "title": "Opensauce 2025", 2143 + "createdAt": "2025-07-23T22:18:41.069Z", 2144 + "updatedAt": "2025-07-23T22:18:41.069Z", 2145 + "description": "I truly do not understand Bay Area summer. Why is it cold!?" 2146 + }, 2147 + { 2148 + "$type": "social.grain.gallery", 2149 + "title": "Engagement shoot", 2150 + "createdAt": "2025-06-17T21:45:24.826Z", 2151 + "description": "The date is set! Stay tuned. \n๐Ÿ“ Harvard Arboretum\n๐Ÿ“ท Karina Bhattacharya" 2152 + } 2153 + ], 2154 + "generatedTypes": "export interface SocialGrainGallery {\n $type: 'social.grain.gallery';\n title?: string;\n createdAt?: string;\n updatedAt?: string;\n description?: string;\n}\n", 2155 + "$types": [ 2156 + "social.grain.gallery" 2157 + ] 2158 + }, 2159 + { 2160 + "name": "social.grain.gallery.item", 2161 + "description": "social.grain.gallery.item records", 2162 + "service": "grain.social", 2163 + "sampleRecords": [ 2164 + { 2165 + "item": "at://did:plc:6ayddqghxhciedbaofoxkcbs/social.grain.photo/3luztcsssrs2h", 2166 + "$type": "social.grain.gallery.item", 2167 + "gallery": "at://did:plc:6ayddqghxhciedbaofoxkcbs/social.grain.gallery/3luztckj2us2h", 2168 + "position": 0, 2169 + "createdAt": "2025-07-28T14:43:45.462Z" 2170 + }, 2171 + { 2172 + "item": "at://did:plc:6ayddqghxhciedbaofoxkcbs/social.grain.photo/3luo2mrz2v22s", 2173 + "$type": "social.grain.gallery.item", 2174 + "gallery": "at://did:plc:6ayddqghxhciedbaofoxkcbs/social.grain.gallery/3luo2fpjjyc2s", 2175 + "position": 13, 2176 + "createdAt": "2025-07-23T22:22:39.555Z" 2177 + }, 2178 + { 2179 + "item": "at://did:plc:6ayddqghxhciedbaofoxkcbs/social.grain.photo/3luo2miqkm22s", 2180 + "$type": "social.grain.gallery.item", 2181 + "gallery": "at://did:plc:6ayddqghxhciedbaofoxkcbs/social.grain.gallery/3luo2fpjjyc2s", 2182 + "position": 12, 2183 + "createdAt": "2025-07-23T22:22:29.750Z" 2184 + } 2185 + ], 2186 + "generatedTypes": "export interface SocialGrainGalleryItem {\n $type: 'social.grain.gallery.item';\n item?: string;\n gallery?: string;\n position?: number;\n createdAt?: string;\n}\n", 2187 + "$types": [ 2188 + "social.grain.gallery.item" 2189 + ] 2190 + }, 2191 + { 2192 + "name": "social.grain.graph.follow", 2193 + "description": "social.grain.graph.follow records", 2194 + "service": "grain.social", 2195 + "sampleRecords": [ 2196 + { 2197 + "$type": "social.grain.graph.follow", 2198 + "subject": "did:plc:qttsv4e7pu2jl3ilanfgc3zn", 2199 + "createdAt": "2025-07-25T20:28:17.255Z" 2200 + }, 2201 + { 2202 + "$type": "social.grain.graph.follow", 2203 + "subject": "did:plc:qttsv4e7pu2jl3ilanfgc3zn", 2204 + "createdAt": "2025-07-25T20:28:16.699Z" 2205 + } 2206 + ], 2207 + "generatedTypes": "export interface SocialGrainGraphFollow {\n $type: 'social.grain.graph.follow';\n subject?: string;\n createdAt?: string;\n}\n", 2208 + "$types": [ 2209 + "social.grain.graph.follow" 2210 + ] 2211 + }, 2212 + { 2213 + "name": "social.grain.photo", 2214 + "description": "social.grain.photo records", 2215 + "service": "grain.social", 2216 + "sampleRecords": [ 2217 + { 2218 + "alt": "Grey miniature schnauzer plopped on a wood fenced porch overlooking trees and a morning sky", 2219 + "cid": "bafyreibigenfwakuv3rohdzoi4rsypqtjuvuguyxbzepvpltbc6srvumeu", 2220 + "did": "did:plc:6ayddqghxhciedbaofoxkcbs", 2221 + "uri": "at://did:plc:6ayddqghxhciedbaofoxkcbs/social.grain.photo/3luztcsssrs2h", 2222 + "$type": "social.grain.photo", 2223 + "photo": { 2224 + "$type": "blob", 2225 + "ref": { 2226 + "$link": "bafkreibnepqn6itqpukdr776mm4qi2afbmzw7gwbs6i56pogvlnzr7tt5q" 2227 + }, 2228 + "mimeType": "image/jpeg", 2229 + "size": 965086 2230 + }, 2231 + "createdAt": "2025-07-28T14:43:44.523Z", 2232 + "indexedAt": "2025-07-28T14:43:45.223Z", 2233 + "aspectRatio": { 2234 + "width": 2000, 2235 + "height": 2667 2236 + } 2237 + }, 2238 + { 2239 + "alt": "", 2240 + "$type": "social.grain.photo", 2241 + "photo": { 2242 + "$type": "blob", 2243 + "ref": { 2244 + "$link": "bafkreiangeamxp4rlmc66fheiosnk2odfpw4h5dzynfv7nhj6sy2jcyxzu" 2245 + }, 2246 + "mimeType": "image/jpeg", 2247 + "size": 981537 2248 + }, 2249 + "createdAt": "2025-07-23T22:22:38.556Z", 2250 + "aspectRatio": { 2251 + "width": 2000, 2252 + "height": 2667 2253 + } 2254 + }, 2255 + { 2256 + "alt": "", 2257 + "$type": "social.grain.photo", 2258 + "photo": { 2259 + "$type": "blob", 2260 + "ref": { 2261 + "$link": "bafkreiarwsd5ksvgzt5ydexepqfdq54sthj2mzone3d5hgou66qv333yi4" 2262 + }, 2263 + "mimeType": "image/jpeg", 2264 + "size": 963879 2265 + }, 2266 + "createdAt": "2025-07-23T22:22:28.840Z", 2267 + "aspectRatio": { 2268 + "width": 2000, 2269 + "height": 2667 2270 + } 2271 + } 2272 + ], 2273 + "generatedTypes": "export interface SocialGrainPhoto {\n $type: 'social.grain.photo';\n alt?: string;\n cid?: string;\n did?: string;\n uri?: string;\n photo?: Record<string, any>;\n createdAt?: string;\n indexedAt?: string;\n aspectRatio?: Record<string, any>;\n}\n", 2274 + "$types": [ 2275 + "social.grain.photo" 2276 + ] 2277 + }, 2278 + { 2279 + "name": "social.grain.photo.exif", 2280 + "description": "social.grain.photo.exif records", 2281 + "service": "grain.social", 2282 + "sampleRecords": [ 2283 + { 2284 + "iSO": 25000000, 2285 + "make": "Apple", 2286 + "$type": "social.grain.photo.exif", 2287 + "flash": "Flash did not fire, compulsory flash mode", 2288 + "model": "iPhone 14 Pro", 2289 + "photo": "at://did:plc:6ayddqghxhciedbaofoxkcbs/social.grain.photo/3luztcsssrs2h", 2290 + "fNumber": 2800000, 2291 + "lensMake": "Apple", 2292 + "createdAt": "2025-07-28T14:43:45.239Z", 2293 + "lensModel": "iPhone 14 Pro back camera 9mm f/2.8", 2294 + "exposureTime": 3378, 2295 + "dateTimeOriginal": "2025-07-28T09:50:08", 2296 + "focalLengthIn35mmFormat": 77000000 2297 + }, 2298 + { 2299 + "iSO": 80000000, 2300 + "make": "Apple", 2301 + "$type": "social.grain.photo.exif", 2302 + "flash": "Flash did not fire, compulsory flash mode", 2303 + "model": "iPhone 14 Pro", 2304 + "photo": "at://did:plc:6ayddqghxhciedbaofoxkcbs/social.grain.photo/3luo2mrz2v22s", 2305 + "fNumber": 1780000, 2306 + "lensMake": "Apple", 2307 + "createdAt": "2025-07-23T22:22:39.342Z", 2308 + "lensModel": "iPhone 14 Pro back triple camera 6.86mm f/1.78", 2309 + "exposureTime": 347, 2310 + "dateTimeOriginal": "2025-07-19T16:11:52", 2311 + "focalLengthIn35mmFormat": 24000000 2312 + }, 2313 + { 2314 + "iSO": 800000000, 2315 + "make": "Apple", 2316 + "$type": "social.grain.photo.exif", 2317 + "flash": "Flash did not fire, compulsory flash mode", 2318 + "model": "iPhone 14 Pro", 2319 + "photo": "at://did:plc:6ayddqghxhciedbaofoxkcbs/social.grain.photo/3luo2miqkm22s", 2320 + "fNumber": 1780000, 2321 + "lensMake": "Apple", 2322 + "createdAt": "2025-07-23T22:22:29.512Z", 2323 + "lensModel": "iPhone 14 Pro back triple camera 6.86mm f/1.78", 2324 + "exposureTime": 16667, 2325 + "dateTimeOriginal": "2025-07-17T20:15:51", 2326 + "focalLengthIn35mmFormat": 24000000 2327 + } 2328 + ], 2329 + "generatedTypes": "export interface SocialGrainPhotoExif {\n $type: 'social.grain.photo.exif';\n iSO?: number;\n make?: string;\n flash?: string;\n model?: string;\n photo?: string;\n fNumber?: number;\n lensMake?: string;\n createdAt?: string;\n lensModel?: string;\n exposureTime?: number;\n dateTimeOriginal?: string;\n focalLengthIn35mmFormat?: number;\n}\n", 2330 + "$types": [ 2331 + "social.grain.photo.exif" 2332 + ] 2333 + }, 2334 + { 2335 + "name": "social.pinksky.app.preference", 2336 + "description": "social.pinksky.app.preference records", 2337 + "service": "unknown", 2338 + "sampleRecords": [ 2339 + { 2340 + "slug": "onboarding", 2341 + "$type": "social.pinksky.app.preference", 2342 + "value": "completed", 2343 + "createdAt": "2025-05-21T21:27:01.789Z" 2344 + } 2345 + ], 2346 + "generatedTypes": "export interface SocialPinkskyAppPreference {\n $type: 'social.pinksky.app.preference';\n slug?: string;\n value?: string;\n createdAt?: string;\n}\n", 2347 + "$types": [ 2348 + "social.pinksky.app.preference" 2349 + ] 2350 + } 2351 + ], 2352 + "totalCollections": 64, 2353 + "totalRecords": 124, 2354 + "generatedAt": "2025-08-06T17:33:43.119Z", 2355 + "repository": { 2356 + "handle": "tynanpurdy.com", 2357 + "did": "did:plc:6ayddqghxhciedbaofoxkcbs", 2358 + "recordCount": 0 2359 + } 2360 + }
+759
src/lib/generated/discovered-types.ts
··· 1 + // Auto-generated types from collection discovery 2 + // Generated at: 2025-08-06T17:33:43.119Z 3 + // Repository: tynanpurdy.com (did:plc:6ayddqghxhciedbaofoxkcbs) 4 + // Collections: 64, Records: 124 5 + 6 + // Collection: app.bsky.actor.profile 7 + // Service: bsky.app 8 + // Types: app.bsky.actor.profile 9 + export interface AppBskyActorProfile { 10 + $type: 'app.bsky.actor.profile'; 11 + avatar?: Record<string, any>; 12 + banner?: Record<string, any>; 13 + description?: string; 14 + displayName?: string; 15 + } 16 + 17 + 18 + // Collection: app.bsky.feed.like 19 + // Service: bsky.app 20 + // Types: app.bsky.feed.like 21 + export interface AppBskyFeedLike { 22 + $type: 'app.bsky.feed.like'; 23 + subject?: Record<string, any>; 24 + createdAt?: string; 25 + } 26 + 27 + 28 + // Collection: app.bsky.feed.post 29 + // Service: bsky.app 30 + // Types: app.bsky.feed.post 31 + export interface AppBskyFeedPost { 32 + $type: 'app.bsky.feed.post'; 33 + text?: string; 34 + langs?: string[]; 35 + createdAt?: string; 36 + } 37 + 38 + 39 + // Collection: app.bsky.feed.postgate 40 + // Service: bsky.app 41 + // Types: app.bsky.feed.postgate 42 + export interface AppBskyFeedPostgate { 43 + $type: 'app.bsky.feed.postgate'; 44 + post?: string; 45 + createdAt?: string; 46 + embeddingRules?: Record<string, any>[]; 47 + detachedEmbeddingUris?: any[]; 48 + } 49 + 50 + 51 + // Collection: app.bsky.feed.repost 52 + // Service: bsky.app 53 + // Types: app.bsky.feed.repost 54 + export interface AppBskyFeedRepost { 55 + $type: 'app.bsky.feed.repost'; 56 + subject?: Record<string, any>; 57 + createdAt?: string; 58 + } 59 + 60 + 61 + // Collection: app.bsky.feed.threadgate 62 + // Service: bsky.app 63 + // Types: app.bsky.feed.threadgate 64 + export interface AppBskyFeedThreadgate { 65 + $type: 'app.bsky.feed.threadgate'; 66 + post?: string; 67 + allow?: Record<string, any>[]; 68 + createdAt?: string; 69 + hiddenReplies?: any[]; 70 + } 71 + 72 + 73 + // Collection: app.bsky.graph.block 74 + // Service: bsky.app 75 + // Types: app.bsky.graph.block 76 + export interface AppBskyGraphBlock { 77 + $type: 'app.bsky.graph.block'; 78 + subject?: string; 79 + createdAt?: string; 80 + } 81 + 82 + 83 + // Collection: app.bsky.graph.follow 84 + // Service: bsky.app 85 + // Types: app.bsky.graph.follow 86 + export interface AppBskyGraphFollow { 87 + $type: 'app.bsky.graph.follow'; 88 + subject?: string; 89 + createdAt?: string; 90 + } 91 + 92 + 93 + // Collection: app.bsky.graph.list 94 + // Service: bsky.app 95 + // Types: app.bsky.graph.list 96 + export interface AppBskyGraphList { 97 + $type: 'app.bsky.graph.list'; 98 + name?: string; 99 + purpose?: string; 100 + createdAt?: string; 101 + description?: string; 102 + } 103 + 104 + 105 + // Collection: app.bsky.graph.listitem 106 + // Service: bsky.app 107 + // Types: app.bsky.graph.listitem 108 + export interface AppBskyGraphListitem { 109 + $type: 'app.bsky.graph.listitem'; 110 + list?: string; 111 + subject?: string; 112 + createdAt?: string; 113 + } 114 + 115 + 116 + // Collection: app.bsky.graph.starterpack 117 + // Service: bsky.app 118 + // Types: app.bsky.graph.starterpack 119 + export interface AppBskyGraphStarterpack { 120 + $type: 'app.bsky.graph.starterpack'; 121 + list?: string; 122 + name?: string; 123 + feeds?: Record<string, any>[]; 124 + createdAt?: string; 125 + updatedAt?: string; 126 + } 127 + 128 + 129 + // Collection: app.bsky.graph.verification 130 + // Service: bsky.app 131 + // Types: app.bsky.graph.verification 132 + export interface AppBskyGraphVerification { 133 + $type: 'app.bsky.graph.verification'; 134 + handle?: string; 135 + subject?: string; 136 + createdAt?: string; 137 + displayName?: string; 138 + } 139 + 140 + 141 + // Collection: app.popsky.list 142 + // Service: unknown 143 + // Types: app.popsky.list 144 + export interface AppPopskyList { 145 + $type: 'app.popsky.list'; 146 + name?: string; 147 + authorDid?: string; 148 + createdAt?: string; 149 + indexedAt?: string; 150 + description?: string; 151 + } 152 + 153 + 154 + // Collection: app.popsky.listItem 155 + // Service: unknown 156 + // Types: app.popsky.listItem 157 + export interface AppPopskyListItem { 158 + $type: 'app.popsky.listItem'; 159 + addedAt?: string; 160 + listUri?: string; 161 + identifiers?: Record<string, any>; 162 + creativeWorkType?: string; 163 + } 164 + 165 + 166 + // Collection: app.popsky.profile 167 + // Service: unknown 168 + // Types: app.popsky.profile 169 + export interface AppPopskyProfile { 170 + $type: 'app.popsky.profile'; 171 + createdAt?: string; 172 + description?: string; 173 + displayName?: string; 174 + } 175 + 176 + 177 + // Collection: app.popsky.review 178 + // Service: unknown 179 + // Types: app.popsky.review 180 + export interface AppPopskyReview { 181 + $type: 'app.popsky.review'; 182 + tags?: any[]; 183 + facets?: any[]; 184 + rating?: number; 185 + createdAt?: string; 186 + isRevisit?: boolean; 187 + reviewText?: string; 188 + identifiers?: Record<string, any>; 189 + containsSpoilers?: boolean; 190 + creativeWorkType?: string; 191 + } 192 + 193 + 194 + // Collection: app.rocksky.album 195 + // Service: unknown 196 + // Types: app.rocksky.album 197 + export interface AppRockskyAlbum { 198 + $type: 'app.rocksky.album'; 199 + year?: number; 200 + title?: string; 201 + artist?: string; 202 + albumArt?: Record<string, any>; 203 + createdAt?: string; 204 + releaseDate?: string; 205 + } 206 + 207 + 208 + // Collection: app.rocksky.artist 209 + // Service: unknown 210 + // Types: app.rocksky.artist 211 + export interface AppRockskyArtist { 212 + $type: 'app.rocksky.artist'; 213 + name?: string; 214 + picture?: Record<string, any>; 215 + createdAt?: string; 216 + } 217 + 218 + 219 + // Collection: app.rocksky.like 220 + // Service: unknown 221 + // Types: app.rocksky.like 222 + export interface AppRockskyLike { 223 + $type: 'app.rocksky.like'; 224 + subject?: Record<string, any>; 225 + createdAt?: string; 226 + } 227 + 228 + 229 + // Collection: app.rocksky.scrobble 230 + // Service: unknown 231 + // Types: app.rocksky.scrobble 232 + export interface AppRockskyScrobble { 233 + $type: 'app.rocksky.scrobble'; 234 + year?: number; 235 + album?: string; 236 + title?: string; 237 + artist?: string; 238 + albumArt?: Record<string, any>; 239 + duration?: number; 240 + createdAt?: string; 241 + discNumber?: number; 242 + albumArtist?: string; 243 + releaseDate?: string; 244 + spotifyLink?: string; 245 + trackNumber?: number; 246 + } 247 + 248 + 249 + // Collection: app.rocksky.song 250 + // Service: unknown 251 + // Types: app.rocksky.song 252 + export interface AppRockskySong { 253 + $type: 'app.rocksky.song'; 254 + year?: number; 255 + album?: string; 256 + title?: string; 257 + artist?: string; 258 + albumArt?: Record<string, any>; 259 + duration?: number; 260 + createdAt?: string; 261 + discNumber?: number; 262 + albumArtist?: string; 263 + releaseDate?: string; 264 + spotifyLink?: string; 265 + trackNumber?: number; 266 + } 267 + 268 + 269 + // Collection: blue.flashes.actor.profile 270 + // Service: unknown 271 + // Types: blue.flashes.actor.profile 272 + export interface BlueFlashesActorProfile { 273 + $type: 'blue.flashes.actor.profile'; 274 + createdAt?: string; 275 + showFeeds?: boolean; 276 + showLikes?: boolean; 277 + showLists?: boolean; 278 + showMedia?: boolean; 279 + enablePortfolio?: boolean; 280 + portfolioLayout?: string; 281 + allowRawDownload?: boolean; 282 + } 283 + 284 + 285 + // Collection: blue.linkat.board 286 + // Service: unknown 287 + // Types: blue.linkat.board 288 + export interface BlueLinkatBoard { 289 + $type: 'blue.linkat.board'; 290 + cards?: Record<string, any>[]; 291 + } 292 + 293 + 294 + // Collection: buzz.bookhive.book 295 + // Service: unknown 296 + // Types: buzz.bookhive.book 297 + export interface BuzzBookhiveBook { 298 + $type: 'buzz.bookhive.book'; 299 + cover?: Record<string, any>; 300 + title?: string; 301 + hiveId?: string; 302 + status?: string; 303 + authors?: string; 304 + createdAt?: string; 305 + } 306 + 307 + 308 + // Collection: chat.bsky.actor.declaration 309 + // Service: unknown 310 + // Types: chat.bsky.actor.declaration 311 + export interface ChatBskyActorDeclaration { 312 + $type: 'chat.bsky.actor.declaration'; 313 + allowIncoming?: string; 314 + } 315 + 316 + 317 + // Collection: chat.roomy.01JPNX7AA9BSM6TY2GWW1TR5V7.catalog 318 + // Service: unknown 319 + // Types: chat.roomy.01JPNX7AA9BSM6TY2GWW1TR5V7.catalog 320 + export interface ChatRoomy01JPNX7AA9BSM6TY2GWW1TR5V7Catalog { 321 + $type: 'chat.roomy.01JPNX7AA9BSM6TY2GWW1TR5V7.catalog'; 322 + id?: string; 323 + } 324 + 325 + 326 + // Collection: chat.roomy.profile 327 + // Service: unknown 328 + // Types: chat.roomy.profile 329 + export interface ChatRoomyProfile { 330 + $type: 'chat.roomy.profile'; 331 + accountId?: string; 332 + profileId?: string; 333 + } 334 + 335 + 336 + // Collection: com.germnetwork.keypackage 337 + // Service: unknown 338 + // Types: com.germnetwork.keypackage 339 + export interface ComGermnetworkKeypackage { 340 + $type: 'com.germnetwork.keypackage'; 341 + anchorHello?: string; 342 + } 343 + 344 + 345 + // Collection: com.whtwnd.blog.entry 346 + // Service: unknown 347 + // Types: com.whtwnd.blog.entry 348 + export interface ComWhtwndBlogEntry { 349 + $type: 'com.whtwnd.blog.entry'; 350 + theme?: string; 351 + title?: string; 352 + content?: string; 353 + createdAt?: string; 354 + visibility?: string; 355 + } 356 + 357 + 358 + // Collection: community.lexicon.calendar.rsvp 359 + // Service: unknown 360 + // Types: community.lexicon.calendar.rsvp 361 + export interface CommunityLexiconCalendarRsvp { 362 + $type: 'community.lexicon.calendar.rsvp'; 363 + status?: string; 364 + subject?: Record<string, any>; 365 + createdAt?: string; 366 + } 367 + 368 + 369 + // Collection: events.smokesignal.app.profile 370 + // Service: unknown 371 + // Types: events.smokesignal.app.profile 372 + export interface EventsSmokesignalAppProfile { 373 + $type: 'events.smokesignal.app.profile'; 374 + tz?: string; 375 + } 376 + 377 + 378 + // Collection: events.smokesignal.calendar.event 379 + // Service: unknown 380 + // Types: events.smokesignal.calendar.event 381 + export interface EventsSmokesignalCalendarEvent { 382 + $type: 'events.smokesignal.calendar.event'; 383 + mode?: string; 384 + name?: string; 385 + text?: string; 386 + endsAt?: string; 387 + status?: string; 388 + location?: Record<string, any>; 389 + startsAt?: string; 390 + createdAt?: string; 391 + } 392 + 393 + 394 + // Collection: farm.smol.games.skyrdle.score 395 + // Service: unknown 396 + // Types: farm.smol.games.skyrdle.score 397 + export interface FarmSmolGamesSkyrdleScore { 398 + $type: 'farm.smol.games.skyrdle.score'; 399 + hash?: string; 400 + isWin?: boolean; 401 + score?: number; 402 + guesses?: Record<string, any>[]; 403 + timestamp?: string; 404 + gameNumber?: number; 405 + } 406 + 407 + 408 + // Collection: fyi.bluelinks.links 409 + // Service: unknown 410 + // Types: fyi.bluelinks.links 411 + export interface FyiBluelinksLinks { 412 + $type: 'fyi.bluelinks.links'; 413 + links?: Record<string, any>[]; 414 + } 415 + 416 + 417 + // Collection: fyi.unravel.frontpage.comment 418 + // Service: unknown 419 + // Types: fyi.unravel.frontpage.comment 420 + export interface FyiUnravelFrontpageComment { 421 + $type: 'fyi.unravel.frontpage.comment'; 422 + post?: Record<string, any>; 423 + content?: string; 424 + createdAt?: string; 425 + } 426 + 427 + 428 + // Collection: fyi.unravel.frontpage.post 429 + // Service: unknown 430 + // Types: fyi.unravel.frontpage.post 431 + export interface FyiUnravelFrontpagePost { 432 + $type: 'fyi.unravel.frontpage.post'; 433 + url?: string; 434 + title?: string; 435 + createdAt?: string; 436 + } 437 + 438 + 439 + // Collection: fyi.unravel.frontpage.vote 440 + // Service: unknown 441 + // Types: fyi.unravel.frontpage.vote 442 + export interface FyiUnravelFrontpageVote { 443 + $type: 'fyi.unravel.frontpage.vote'; 444 + subject?: Record<string, any>; 445 + createdAt?: string; 446 + } 447 + 448 + 449 + // Collection: im.flushing.right.now 450 + // Service: unknown 451 + // Types: im.flushing.right.now 452 + export interface ImFlushingRightNow { 453 + $type: 'im.flushing.right.now'; 454 + text?: string; 455 + emoji?: string; 456 + createdAt?: string; 457 + } 458 + 459 + 460 + // Collection: link.woosh.linkPage 461 + // Service: unknown 462 + // Types: link.woosh.linkPage 463 + export interface LinkWooshLinkPage { 464 + $type: 'link.woosh.linkPage'; 465 + collections?: Record<string, any>[]; 466 + } 467 + 468 + 469 + // Collection: my.skylights.rel 470 + // Service: unknown 471 + // Types: my.skylights.rel 472 + export interface MySkylightsRel { 473 + $type: 'my.skylights.rel'; 474 + item?: Record<string, any>; 475 + note?: Record<string, any>; 476 + rating?: Record<string, any>; 477 + } 478 + 479 + 480 + // Collection: org.owdproject.application.windows 481 + // Service: unknown 482 + // Types: org.owdproject.application.windows 483 + export interface OrgOwdprojectApplicationWindows { 484 + $type: 'org.owdproject.application.windows'; 485 + } 486 + 487 + 488 + // Collection: org.owdproject.desktop 489 + // Service: unknown 490 + // Types: org.owdproject.desktop 491 + export interface OrgOwdprojectDesktop { 492 + $type: 'org.owdproject.desktop'; 493 + state?: Record<string, any>; 494 + } 495 + 496 + 497 + // Collection: org.scrapboard.list 498 + // Service: unknown 499 + // Types: org.scrapboard.list 500 + export interface OrgScrapboardList { 501 + $type: 'org.scrapboard.list'; 502 + name?: string; 503 + createdAt?: string; 504 + description?: string; 505 + } 506 + 507 + 508 + // Collection: org.scrapboard.listitem 509 + // Service: unknown 510 + // Types: org.scrapboard.listitem 511 + export interface OrgScrapboardListitem { 512 + $type: 'org.scrapboard.listitem'; 513 + url?: string; 514 + list?: string; 515 + createdAt?: string; 516 + } 517 + 518 + 519 + // Collection: place.stream.chat.message 520 + // Service: unknown 521 + // Types: place.stream.chat.message 522 + export interface PlaceStreamChatMessage { 523 + $type: 'place.stream.chat.message'; 524 + text?: string; 525 + streamer?: string; 526 + createdAt?: string; 527 + } 528 + 529 + 530 + // Collection: place.stream.chat.profile 531 + // Service: unknown 532 + // Types: place.stream.chat.profile 533 + export interface PlaceStreamChatProfile { 534 + $type: 'place.stream.chat.profile'; 535 + color?: Record<string, any>; 536 + } 537 + 538 + 539 + // Collection: pub.leaflet.document 540 + // Service: unknown 541 + // Types: pub.leaflet.document 542 + export interface PubLeafletDocument { 543 + $type: 'pub.leaflet.document'; 544 + pages?: Record<string, any>[]; 545 + title?: string; 546 + author?: string; 547 + postRef?: Record<string, any>; 548 + description?: string; 549 + publication?: string; 550 + publishedAt?: string; 551 + } 552 + 553 + 554 + // Collection: pub.leaflet.graph.subscription 555 + // Service: unknown 556 + // Types: pub.leaflet.graph.subscription 557 + export interface PubLeafletGraphSubscription { 558 + $type: 'pub.leaflet.graph.subscription'; 559 + publication?: string; 560 + } 561 + 562 + 563 + // Collection: pub.leaflet.publication 564 + // Service: unknown 565 + // Types: pub.leaflet.publication 566 + export interface PubLeafletPublication { 567 + $type: 'pub.leaflet.publication'; 568 + icon?: Record<string, any>; 569 + name?: string; 570 + base_path?: string; 571 + description?: string; 572 + } 573 + 574 + 575 + // Collection: sh.tangled.actor.profile 576 + // Service: sh.tangled 577 + // Types: sh.tangled.actor.profile 578 + export interface ShTangledActorProfile { 579 + $type: 'sh.tangled.actor.profile'; 580 + links?: string[]; 581 + stats?: string[]; 582 + bluesky?: boolean; 583 + location?: string; 584 + description?: string; 585 + pinnedRepositories?: string[]; 586 + } 587 + 588 + 589 + // Collection: sh.tangled.feed.star 590 + // Service: sh.tangled 591 + // Types: sh.tangled.feed.star 592 + export interface ShTangledFeedStar { 593 + $type: 'sh.tangled.feed.star'; 594 + subject?: string; 595 + createdAt?: string; 596 + } 597 + 598 + 599 + // Collection: sh.tangled.publicKey 600 + // Service: sh.tangled 601 + // Types: sh.tangled.publicKey 602 + export interface ShTangledPublicKey { 603 + $type: 'sh.tangled.publicKey'; 604 + key?: string; 605 + name?: string; 606 + createdAt?: string; 607 + } 608 + 609 + 610 + // Collection: sh.tangled.repo 611 + // Service: sh.tangled 612 + // Types: sh.tangled.repo 613 + export interface ShTangledRepo { 614 + $type: 'sh.tangled.repo'; 615 + knot?: string; 616 + name?: string; 617 + owner?: string; 618 + createdAt?: string; 619 + } 620 + 621 + 622 + // Collection: so.sprk.actor.profile 623 + // Service: unknown 624 + // Types: so.sprk.actor.profile 625 + export interface SoSprkActorProfile { 626 + $type: 'so.sprk.actor.profile'; 627 + avatar?: Record<string, any>; 628 + description?: string; 629 + displayName?: string; 630 + } 631 + 632 + 633 + // Collection: so.sprk.feed.like 634 + // Service: unknown 635 + // Types: so.sprk.feed.like 636 + export interface SoSprkFeedLike { 637 + $type: 'so.sprk.feed.like'; 638 + subject?: Record<string, any>; 639 + createdAt?: string; 640 + } 641 + 642 + 643 + // Collection: so.sprk.feed.story 644 + // Service: unknown 645 + // Types: so.sprk.feed.story 646 + export interface SoSprkFeedStory { 647 + $type: 'so.sprk.feed.story'; 648 + tags?: any[]; 649 + media?: Record<string, any>; 650 + createdAt?: string; 651 + selfLabels?: any[]; 652 + } 653 + 654 + 655 + // Collection: social.grain.actor.profile 656 + // Service: grain.social 657 + // Types: social.grain.actor.profile 658 + export interface SocialGrainActorProfile { 659 + $type: 'social.grain.actor.profile'; 660 + avatar?: Record<string, any>; 661 + description?: string; 662 + displayName?: string; 663 + } 664 + 665 + 666 + // Collection: social.grain.favorite 667 + // Service: grain.social 668 + // Types: social.grain.favorite 669 + export interface SocialGrainFavorite { 670 + $type: 'social.grain.favorite'; 671 + subject?: string; 672 + createdAt?: string; 673 + } 674 + 675 + 676 + // Collection: social.grain.gallery 677 + // Service: grain.social 678 + // Types: social.grain.gallery 679 + export interface SocialGrainGallery { 680 + $type: 'social.grain.gallery'; 681 + title?: string; 682 + createdAt?: string; 683 + updatedAt?: string; 684 + description?: string; 685 + } 686 + 687 + 688 + // Collection: social.grain.gallery.item 689 + // Service: grain.social 690 + // Types: social.grain.gallery.item 691 + export interface SocialGrainGalleryItem { 692 + $type: 'social.grain.gallery.item'; 693 + item?: string; 694 + gallery?: string; 695 + position?: number; 696 + createdAt?: string; 697 + } 698 + 699 + 700 + // Collection: social.grain.graph.follow 701 + // Service: grain.social 702 + // Types: social.grain.graph.follow 703 + export interface SocialGrainGraphFollow { 704 + $type: 'social.grain.graph.follow'; 705 + subject?: string; 706 + createdAt?: string; 707 + } 708 + 709 + 710 + // Collection: social.grain.photo 711 + // Service: grain.social 712 + // Types: social.grain.photo 713 + export interface SocialGrainPhoto { 714 + $type: 'social.grain.photo'; 715 + alt?: string; 716 + cid?: string; 717 + did?: string; 718 + uri?: string; 719 + photo?: Record<string, any>; 720 + createdAt?: string; 721 + indexedAt?: string; 722 + aspectRatio?: Record<string, any>; 723 + } 724 + 725 + 726 + // Collection: social.grain.photo.exif 727 + // Service: grain.social 728 + // Types: social.grain.photo.exif 729 + export interface SocialGrainPhotoExif { 730 + $type: 'social.grain.photo.exif'; 731 + iSO?: number; 732 + make?: string; 733 + flash?: string; 734 + model?: string; 735 + photo?: string; 736 + fNumber?: number; 737 + lensMake?: string; 738 + createdAt?: string; 739 + lensModel?: string; 740 + exposureTime?: number; 741 + dateTimeOriginal?: string; 742 + focalLengthIn35mmFormat?: number; 743 + } 744 + 745 + 746 + // Collection: social.pinksky.app.preference 747 + // Service: unknown 748 + // Types: social.pinksky.app.preference 749 + export interface SocialPinkskyAppPreference { 750 + $type: 'social.pinksky.app.preference'; 751 + slug?: string; 752 + value?: string; 753 + createdAt?: string; 754 + } 755 + 756 + 757 + // Union type for all discovered types 758 + export type DiscoveredTypes = 'app.bsky.actor.profile' | 'app.bsky.feed.like' | 'app.bsky.feed.post' | 'app.bsky.feed.postgate' | 'app.bsky.feed.repost' | 'app.bsky.feed.threadgate' | 'app.bsky.graph.block' | 'app.bsky.graph.follow' | 'app.bsky.graph.list' | 'app.bsky.graph.listitem' | 'app.bsky.graph.starterpack' | 'app.bsky.graph.verification' | 'app.popsky.list' | 'app.popsky.listItem' | 'app.popsky.profile' | 'app.popsky.review' | 'app.rocksky.album' | 'app.rocksky.artist' | 'app.rocksky.like' | 'app.rocksky.scrobble' | 'app.rocksky.song' | 'blue.flashes.actor.profile' | 'blue.linkat.board' | 'buzz.bookhive.book' | 'chat.bsky.actor.declaration' | 'chat.roomy.01JPNX7AA9BSM6TY2GWW1TR5V7.catalog' | 'chat.roomy.profile' | 'com.germnetwork.keypackage' | 'com.whtwnd.blog.entry' | 'community.lexicon.calendar.rsvp' | 'events.smokesignal.app.profile' | 'events.smokesignal.calendar.event' | 'farm.smol.games.skyrdle.score' | 'fyi.bluelinks.links' | 'fyi.unravel.frontpage.comment' | 'fyi.unravel.frontpage.post' | 'fyi.unravel.frontpage.vote' | 'im.flushing.right.now' | 'link.woosh.linkPage' | 'my.skylights.rel' | 'org.owdproject.application.windows' | 'org.owdproject.desktop' | 'org.scrapboard.list' | 'org.scrapboard.listitem' | 'place.stream.chat.message' | 'place.stream.chat.profile' | 'pub.leaflet.document' | 'pub.leaflet.graph.subscription' | 'pub.leaflet.publication' | 'sh.tangled.actor.profile' | 'sh.tangled.feed.star' | 'sh.tangled.publicKey' | 'sh.tangled.repo' | 'so.sprk.actor.profile' | 'so.sprk.feed.like' | 'so.sprk.feed.story' | 'social.grain.actor.profile' | 'social.grain.favorite' | 'social.grain.gallery' | 'social.grain.gallery.item' | 'social.grain.graph.follow' | 'social.grain.photo' | 'social.grain.photo.exif' | 'social.pinksky.app.preference'; 759 +
+22
src/lib/generated/lexicon-types.ts
··· 1 + // Generated index of all lexicon types 2 + // Do not edit manually - regenerate with: npm run gen:types 3 + 4 + import type { AStatusUpdate } from './a-status-update'; 5 + import type { ComWhtwndBlogEntry } from './com-whtwnd-blog-entry'; 6 + import type { SocialGrainGalleryItem } from './social-grain-gallery-item'; 7 + import type { SocialGrainGallery } from './social-grain-gallery'; 8 + import type { SocialGrainPhotoExif } from './social-grain-photo-exif'; 9 + import type { SocialGrainPhoto } from './social-grain-photo'; 10 + 11 + // Union type for all generated lexicon records 12 + export type GeneratedLexiconUnion = AStatusUpdate | ComWhtwndBlogEntry | SocialGrainGalleryItem | SocialGrainGallery | SocialGrainPhotoExif | SocialGrainPhoto; 13 + 14 + // Type map for component registry 15 + export type GeneratedLexiconTypeMap = { 16 + 'AStatusUpdate': AStatusUpdate; 17 + 'ComWhtwndBlogEntry': ComWhtwndBlogEntry; 18 + 'SocialGrainGalleryItem': SocialGrainGalleryItem; 19 + 'SocialGrainGallery': SocialGrainGallery; 20 + 'SocialGrainPhotoExif': SocialGrainPhotoExif; 21 + 'SocialGrainPhoto': SocialGrainPhoto; 22 + };
+19
src/lib/generated/social-grain-gallery.ts
··· 1 + // Generated from lexicon schema: social.grain.gallery 2 + // Do not edit manually - regenerate with: npm run gen:types 3 + 4 + export interface SocialGrainGalleryRecord { 5 + title: string; 6 + description?: string; 7 + facets?: any[]; 8 + labels?: any; 9 + updatedAt?: string; 10 + createdAt: string; 11 + } 12 + 13 + export interface SocialGrainGallery { 14 + $type: 'social.grain.gallery'; 15 + value: SocialGrainGalleryRecord; 16 + } 17 + 18 + // Helper type for discriminated unions 19 + export type SocialGrainGalleryUnion = SocialGrainGallery;
+25
src/lib/generated/social-grain-photo-exif.ts
··· 1 + // Generated from lexicon schema: social.grain.photo.exif 2 + // Do not edit manually - regenerate with: npm run gen:types 3 + 4 + export interface SocialGrainPhotoExifRecord { 5 + photo: string; 6 + createdAt: string; 7 + dateTimeOriginal?: string; 8 + exposureTime?: number; 9 + fNumber?: number; 10 + flash?: string; 11 + focalLengthIn35mmFormat?: number; 12 + iSO?: number; 13 + lensMake?: string; 14 + lensModel?: string; 15 + make?: string; 16 + model?: string; 17 + } 18 + 19 + export interface SocialGrainPhotoExif { 20 + $type: 'social.grain.photo.exif'; 21 + value: SocialGrainPhotoExifRecord; 22 + } 23 + 24 + // Helper type for discriminated unions 25 + export type SocialGrainPhotoExifUnion = SocialGrainPhotoExif;
+17
src/lib/generated/social-grain-photo.ts
··· 1 + // Generated from lexicon schema: social.grain.photo 2 + // Do not edit manually - regenerate with: npm run gen:types 3 + 4 + export interface SocialGrainPhotoRecord { 5 + photo: any; 6 + alt?: string; 7 + aspectRatio: any; 8 + createdAt: string; 9 + } 10 + 11 + export interface SocialGrainPhoto { 12 + $type: 'social.grain.photo'; 13 + value: SocialGrainPhotoRecord; 14 + } 15 + 16 + // Helper type for discriminated unions 17 + export type SocialGrainPhotoUnion = SocialGrainPhoto;
+232
src/lib/services/content-renderer.ts
··· 1 + import { AtprotoBrowser } from '../atproto/atproto-browser'; 2 + import { loadConfig } from '../config/site'; 3 + import type { AtprotoRecord } from '../atproto/atproto-browser'; 4 + 5 + export interface ContentRendererOptions { 6 + showAuthor?: boolean; 7 + showTimestamp?: boolean; 8 + showType?: boolean; 9 + limit?: number; 10 + filter?: (record: AtprotoRecord) => boolean; 11 + } 12 + 13 + export interface RenderedContent { 14 + type: string; 15 + component: string; 16 + props: Record<string, any>; 17 + metadata: { 18 + uri: string; 19 + cid: string; 20 + collection: string; 21 + $type: string; 22 + createdAt: string; 23 + }; 24 + } 25 + 26 + export class ContentRenderer { 27 + private browser: AtprotoBrowser; 28 + private config: any; 29 + 30 + constructor() { 31 + this.config = loadConfig(); 32 + this.browser = new AtprotoBrowser(); 33 + } 34 + 35 + // Determine the appropriate component for a record type 36 + private getComponentForType($type: string): string { 37 + // Map ATProto types to component names 38 + const componentMap: Record<string, string> = { 39 + 'app.bsky.feed.post': 'BlueskyPost', 40 + 'app.bsky.actor.profile#whitewindBlogPost': 'WhitewindBlogPost', 41 + 'app.bsky.actor.profile#leafletPublication': 'LeafletPublication', 42 + 43 + 'gallery.display': 'GalleryDisplay', 44 + }; 45 + 46 + // Check for gallery-related types 47 + if ($type.includes('gallery') || $type.includes('grain')) { 48 + return 'GalleryDisplay'; 49 + } 50 + 51 + return componentMap[$type] || 'BlueskyPost'; 52 + } 53 + 54 + // Process a record into a renderable format 55 + private processRecord(record: AtprotoRecord): RenderedContent | null { 56 + const value = record.value; 57 + if (!value || !value.$type) return null; 58 + 59 + const component = this.getComponentForType(value.$type); 60 + 61 + // Extract common metadata 62 + const metadata = { 63 + uri: record.uri, 64 + cid: record.cid, 65 + collection: record.collection, 66 + $type: value.$type, 67 + createdAt: value.createdAt || record.indexedAt, 68 + }; 69 + 70 + // For gallery display, use the gallery service format 71 + if (component === 'GalleryDisplay') { 72 + // This would need to be processed by the gallery service 73 + // For now, return a basic format 74 + return { 75 + type: 'gallery', 76 + component: 'GalleryDisplay', 77 + props: { 78 + gallery: { 79 + uri: record.uri, 80 + cid: record.cid, 81 + title: value.title || 'Untitled Gallery', 82 + description: value.description, 83 + text: value.text, 84 + createdAt: value.createdAt || record.indexedAt, 85 + images: this.extractImages(value), 86 + $type: value.$type, 87 + collection: record.collection, 88 + }, 89 + showDescription: true, 90 + showTimestamp: true, 91 + showType: false, 92 + columns: 3, 93 + }, 94 + metadata, 95 + }; 96 + } 97 + 98 + // For other content types, return the record directly 99 + return { 100 + type: 'content', 101 + component, 102 + props: { 103 + post: value, 104 + showAuthor: false, 105 + showTimestamp: true, 106 + }, 107 + metadata, 108 + }; 109 + } 110 + 111 + // Extract images from various embed formats 112 + private extractImages(value: any): Array<{ alt?: string; url: string }> { 113 + const images: Array<{ alt?: string; url: string }> = []; 114 + 115 + // Extract from embed.images 116 + if (value.embed?.$type === 'app.bsky.embed.images' && value.embed.images) { 117 + for (const image of value.embed.images) { 118 + if (image.image?.ref) { 119 + const did = this.config.atproto.did; 120 + const url = `https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${image.image.ref}`; 121 + images.push({ 122 + alt: image.alt, 123 + url, 124 + }); 125 + } 126 + } 127 + } 128 + 129 + // Extract from direct images array 130 + if (value.images && Array.isArray(value.images)) { 131 + for (const image of value.images) { 132 + if (image.url) { 133 + images.push({ 134 + alt: image.alt, 135 + url: image.url, 136 + }); 137 + } 138 + } 139 + } 140 + 141 + return images; 142 + } 143 + 144 + // Fetch and render content for a given identifier 145 + async renderContent( 146 + identifier: string, 147 + options: ContentRendererOptions = {} 148 + ): Promise<RenderedContent[]> { 149 + try { 150 + const { limit = 50, filter } = options; 151 + 152 + // Get repository info 153 + const repoInfo = await this.browser.getRepoInfo(identifier); 154 + if (!repoInfo) { 155 + throw new Error(`Could not get repository info for: ${identifier}`); 156 + } 157 + 158 + const renderedContent: RenderedContent[] = []; 159 + 160 + // Get records from main collections 161 + const collections = ['app.bsky.feed.post', 'app.bsky.actor.profile', 'social.grain.gallery']; 162 + 163 + for (const collection of collections) { 164 + if (repoInfo.collections.includes(collection)) { 165 + const records = await this.browser.getCollectionRecords(identifier, collection, limit); 166 + if (records && records.records) { 167 + for (const record of records.records) { 168 + // Apply filter if provided 169 + if (filter && !filter(record)) continue; 170 + 171 + const rendered = this.processRecord(record); 172 + if (rendered) { 173 + renderedContent.push(rendered); 174 + } 175 + } 176 + } 177 + } 178 + } 179 + 180 + // Sort by creation date (newest first) 181 + renderedContent.sort((a, b) => { 182 + const dateA = new Date(a.metadata.createdAt); 183 + const dateB = new Date(b.metadata.createdAt); 184 + return dateB.getTime() - dateA.getTime(); 185 + }); 186 + 187 + return renderedContent; 188 + } catch (error) { 189 + console.error('Error rendering content:', error); 190 + return []; 191 + } 192 + } 193 + 194 + // Render a specific record by URI 195 + async renderRecord(uri: string): Promise<RenderedContent | null> { 196 + try { 197 + const record = await this.browser.getRecord(uri); 198 + if (!record) return null; 199 + 200 + return this.processRecord(record); 201 + } catch (error) { 202 + console.error('Error rendering record:', error); 203 + return null; 204 + } 205 + } 206 + 207 + // Get available content types for an identifier 208 + async getContentTypes(identifier: string): Promise<string[]> { 209 + try { 210 + const repoInfo = await this.browser.getRepoInfo(identifier); 211 + if (!repoInfo) return []; 212 + 213 + const types = new Set<string>(); 214 + 215 + for (const collection of repoInfo.collections) { 216 + const records = await this.browser.getCollectionRecords(identifier, collection, 10); 217 + if (records && records.records) { 218 + for (const record of records.records) { 219 + if (record.value?.$type) { 220 + types.add(record.value.$type); 221 + } 222 + } 223 + } 224 + } 225 + 226 + return Array.from(types); 227 + } catch (error) { 228 + console.error('Error getting content types:', error); 229 + return []; 230 + } 231 + } 232 + }
+178
src/lib/services/content-service.ts
··· 1 + import { AtprotoBrowser } from '../atproto/atproto-browser'; 2 + import { loadConfig } from '../config/site'; 3 + 4 + export interface ContentRecord { 5 + uri: string; 6 + cid: string; 7 + value: any; 8 + indexedAt: string; 9 + collection: string; 10 + } 11 + 12 + export interface ProcessedContent { 13 + $type: string; 14 + collection: string; 15 + uri: string; 16 + data: any; 17 + createdAt: Date; 18 + } 19 + 20 + export class ContentService { 21 + private browser: AtprotoBrowser; 22 + private config: any; 23 + private cache: Map<string, ProcessedContent[]> = new Map(); 24 + 25 + constructor() { 26 + this.config = loadConfig(); 27 + this.browser = new AtprotoBrowser(); 28 + } 29 + 30 + async getContentFromCollection(identifier: string, collection: string): Promise<ProcessedContent[]> { 31 + const cacheKey = `${identifier}:${collection}`; 32 + 33 + if (this.cache.has(cacheKey)) { 34 + return this.cache.get(cacheKey)!; 35 + } 36 + 37 + try { 38 + console.log(`Fetching ${collection} for ${identifier}...`); 39 + const collectionInfo = await this.browser.getCollectionRecords(identifier, collection); 40 + console.log(`Collection info for ${collection}:`, collectionInfo); 41 + 42 + if (!collectionInfo || !collectionInfo.records) { 43 + console.log(`No records found for ${collection}`); 44 + return []; 45 + } 46 + 47 + console.log(`Found ${collectionInfo.records.length} records for ${collection}`); 48 + 49 + // Debug: Show first few records 50 + if (collectionInfo.records.length > 0) { 51 + console.log(`First record in ${collection}:`, JSON.stringify(collectionInfo.records[0], null, 2)); 52 + } 53 + 54 + const processed = collectionInfo.records.map(record => this.processRecord(record)); 55 + 56 + this.cache.set(cacheKey, processed); 57 + return processed; 58 + } catch (error) { 59 + console.error(`Error fetching ${collection}:`, error); 60 + return []; 61 + } 62 + } 63 + 64 + private processRecord(record: ContentRecord): ProcessedContent { 65 + return { 66 + $type: record.value.$type || 'unknown', 67 + collection: record.collection, 68 + uri: record.uri, 69 + data: record.value, 70 + createdAt: new Date(record.value.createdAt || record.indexedAt) 71 + }; 72 + } 73 + 74 + // Get galleries specifically 75 + async getGalleries(identifier: string): Promise<ProcessedContent[]> { 76 + return this.getContentFromCollection(identifier, 'social.grain.gallery'); 77 + } 78 + 79 + // Get gallery items specifically with linked photos 80 + async getGalleryItems(identifier: string): Promise<ProcessedContent[]> { 81 + console.log(`Fetching gallery items for ${identifier}...`); 82 + const galleryItems = await this.getContentFromCollection(identifier, 'social.grain.gallery.item'); 83 + console.log(`Found ${galleryItems.length} gallery items`); 84 + 85 + if (galleryItems.length === 0) { 86 + console.log('No gallery items found - this might be the issue'); 87 + // Let's also try to fetch galleries to see if they exist 88 + const galleries = await this.getContentFromCollection(identifier, 'social.grain.gallery'); 89 + console.log(`Found ${galleries.length} galleries`); 90 + return []; 91 + } 92 + 93 + // For each gallery item, try to fetch the linked photo 94 + const enrichedItems = await Promise.all( 95 + galleryItems.map(async (item) => { 96 + console.log(`Processing gallery item: ${item.uri}`); 97 + console.log(`Item data:`, JSON.stringify(item.data, null, 2)); 98 + 99 + if (item.data.item && typeof item.data.item === 'string') { 100 + try { 101 + // Extract the photo URI from the item field 102 + const photoUri = item.data.item; 103 + console.log(`Fetching linked photo: ${photoUri}`); 104 + 105 + // Make sure we have a complete URI with record ID 106 + if (!photoUri.includes('/social.grain.photo/')) { 107 + console.log(`Invalid photo URI format: ${photoUri}`); 108 + return item; 109 + } 110 + 111 + const photoRecord = await this.browser.getRecord(photoUri); 112 + 113 + if (photoRecord && photoRecord.value) { 114 + console.log(`Found photo record:`, JSON.stringify(photoRecord.value, null, 2)); 115 + // Merge the photo data into the gallery item 116 + return { 117 + ...item, 118 + data: { 119 + ...item.data, 120 + linkedPhoto: photoRecord.value, 121 + photoUri: photoUri 122 + } 123 + }; 124 + } else { 125 + console.log(`No photo record found for: ${photoUri}`); 126 + console.log(`Photo record was:`, photoRecord); 127 + 128 + // Let's try to fetch the photo record directly to see what's happening 129 + try { 130 + console.log(`Attempting to fetch photo record directly...`); 131 + const directResponse = await this.browser.agent.api.com.atproto.repo.getRecord({ 132 + uri: photoUri 133 + }); 134 + console.log(`Direct API response:`, directResponse); 135 + } catch (directError) { 136 + console.log(`Direct API error:`, directError); 137 + } 138 + } 139 + } catch (error) { 140 + console.log(`Could not fetch linked photo for ${item.uri}:`, error); 141 + } 142 + } else { 143 + console.log(`No item field found in gallery item:`, item.data); 144 + } 145 + return item; 146 + }) 147 + ); 148 + 149 + console.log(`Returning ${enrichedItems.length} enriched gallery items`); 150 + return enrichedItems; 151 + } 152 + 153 + // Get posts 154 + async getPosts(identifier: string): Promise<ProcessedContent[]> { 155 + return this.getContentFromCollection(identifier, 'app.bsky.feed.post'); 156 + } 157 + 158 + // Get profile 159 + async getProfile(identifier: string): Promise<ProcessedContent[]> { 160 + return this.getContentFromCollection(identifier, 'app.bsky.actor.profile'); 161 + } 162 + 163 + // Get all content from multiple collections 164 + async getAllContent(identifier: string, collections: string[]): Promise<ProcessedContent[]> { 165 + const allContent: ProcessedContent[] = []; 166 + 167 + for (const collection of collections) { 168 + const content = await this.getContentFromCollection(identifier, collection); 169 + allContent.push(...content); 170 + } 171 + 172 + return allContent; 173 + } 174 + 175 + clearCache(): void { 176 + this.cache.clear(); 177 + } 178 + }
-148
src/lib/types/atproto.ts
··· 1 - // Base ATproto record types 2 - export interface AtprotoRecord { 3 - uri: string; 4 - cid: string; 5 - value: any; 6 - indexedAt: string; 7 - } 8 - 9 - // Bluesky post types with proper embed handling 10 - export interface BlueskyPost { 11 - text: string; 12 - createdAt: string; 13 - embed?: { 14 - $type: 'app.bsky.embed.images' | 'app.bsky.embed.external' | 'app.bsky.embed.record'; 15 - images?: Array<{ 16 - alt?: string; 17 - image: { 18 - $type: 'blob'; 19 - ref: { 20 - $link: string; 21 - }; 22 - mimeType: string; 23 - size: number; 24 - }; 25 - aspectRatio?: { 26 - width: number; 27 - height: number; 28 - }; 29 - }>; 30 - external?: { 31 - uri: string; 32 - title: string; 33 - description?: string; 34 - }; 35 - record?: { 36 - uri: string; 37 - cid: string; 38 - }; 39 - }; 40 - author?: { 41 - displayName?: string; 42 - handle?: string; 43 - }; 44 - reply?: { 45 - root: { 46 - uri: string; 47 - cid: string; 48 - }; 49 - parent: { 50 - uri: string; 51 - cid: string; 52 - }; 53 - }; 54 - facets?: Array<{ 55 - index: { 56 - byteStart: number; 57 - byteEnd: number; 58 - }; 59 - features: Array<{ 60 - $type: string; 61 - [key: string]: any; 62 - }>; 63 - }>; 64 - langs?: string[]; 65 - uri?: string; 66 - cid?: string; 67 - } 68 - 69 - // Custom lexicon types (to be extended) 70 - export interface CustomLexiconRecord { 71 - $type: string; 72 - [key: string]: any; 73 - } 74 - 75 - // Whitewind blog post type 76 - export interface WhitewindBlogPost extends CustomLexiconRecord { 77 - $type: 'app.bsky.actor.profile#whitewindBlogPost'; 78 - title: string; 79 - content: string; 80 - publishedAt: string; 81 - tags?: string[]; 82 - } 83 - 84 - // Leaflet publication type 85 - export interface LeafletPublication extends CustomLexiconRecord { 86 - $type: 'app.bsky.actor.profile#leafletPublication'; 87 - title: string; 88 - content: string; 89 - publishedAt: string; 90 - category?: string; 91 - } 92 - 93 - // Grain social image gallery type 94 - export interface GrainImageGallery extends CustomLexiconRecord { 95 - $type: 'app.bsky.actor.profile#grainImageGallery'; 96 - title: string; 97 - description?: string; 98 - images: Array<{ 99 - alt: string; 100 - url: string; 101 - }>; 102 - createdAt: string; 103 - } 104 - 105 - // Generic grain gallery post type (for posts that contain galleries) 106 - export interface GrainGalleryPost extends CustomLexiconRecord { 107 - $type: 'app.bsky.feed.post#grainGallery' | 'app.bsky.feed.post#grainImageGallery'; 108 - text?: string; 109 - createdAt: string; 110 - embed?: { 111 - $type: 'app.bsky.embed.images'; 112 - images?: Array<{ 113 - alt?: string; 114 - image: { 115 - $type: 'blob'; 116 - ref: string; 117 - mimeType: string; 118 - size: number; 119 - }; 120 - aspectRatio?: { 121 - width: number; 122 - height: number; 123 - }; 124 - }>; 125 - }; 126 - } 127 - 128 - // Union type for all supported content types 129 - export type SupportedContentType = 130 - | BlueskyPost 131 - | WhitewindBlogPost 132 - | LeafletPublication 133 - | GrainImageGallery 134 - | GrainGalleryPost; 135 - 136 - // Component registry type 137 - export interface ContentComponent { 138 - type: string; 139 - component: any; 140 - props?: Record<string, any>; 141 - } 142 - 143 - // Feed configuration type 144 - export interface FeedConfig { 145 - uri: string; 146 - limit?: number; 147 - filter?: (record: AtprotoRecord) => boolean; 148 - }
-145
src/lib/types/generator.ts
··· 1 - import type { DiscoveredLexicon } from '../atproto/discovery'; 2 - 3 - export interface GeneratedType { 4 - name: string; 5 - interface: string; 6 - $type: string; 7 - properties: Record<string, any>; 8 - service: string; 9 - collection: string; 10 - } 11 - 12 - export class TypeGenerator { 13 - private generatedTypes: Map<string, GeneratedType> = new Map(); 14 - 15 - // Generate TypeScript interface from a discovered lexicon 16 - generateTypeFromLexicon(lexicon: DiscoveredLexicon): GeneratedType { 17 - const $type = lexicon.$type; 18 - 19 - // Skip if already generated 20 - if (this.generatedTypes.has($type)) { 21 - return this.generatedTypes.get($type)!; 22 - } 23 - 24 - const typeName = this.generateTypeName($type); 25 - const interfaceCode = this.generateInterfaceCode(typeName, lexicon); 26 - 27 - const generatedType: GeneratedType = { 28 - name: typeName, 29 - interface: interfaceCode, 30 - $type, 31 - properties: lexicon.properties, 32 - service: lexicon.service, 33 - collection: lexicon.collection 34 - }; 35 - 36 - this.generatedTypes.set($type, generatedType); 37 - return generatedType; 38 - } 39 - 40 - // Generate type name from $type 41 - private generateTypeName($type: string): string { 42 - const parts = $type.split('#'); 43 - if (parts.length > 1) { 44 - return parts[1].charAt(0).toUpperCase() + parts[1].slice(1); 45 - } 46 - 47 - const lastPart = $type.split('.').pop() || 'Unknown'; 48 - return lastPart.charAt(0).toUpperCase() + lastPart.slice(1); 49 - } 50 - 51 - // Generate TypeScript interface code 52 - private generateInterfaceCode(name: string, lexicon: DiscoveredLexicon): string { 53 - const propertyLines = Object.entries(lexicon.properties).map(([key, type]) => { 54 - return ` ${key}: ${type};`; 55 - }); 56 - 57 - return `export interface ${name} extends CustomLexiconRecord { 58 - $type: '${lexicon.$type}'; 59 - ${propertyLines.join('\n')} 60 - }`; 61 - } 62 - 63 - // Generate all types from discovered lexicons 64 - generateTypesFromLexicons(lexicons: DiscoveredLexicon[]): GeneratedType[] { 65 - const types: GeneratedType[] = []; 66 - 67 - lexicons.forEach(lexicon => { 68 - const type = this.generateTypeFromLexicon(lexicon); 69 - types.push(type); 70 - }); 71 - 72 - return types; 73 - } 74 - 75 - // Get all generated types 76 - getAllGeneratedTypes(): GeneratedType[] { 77 - return Array.from(this.generatedTypes.values()); 78 - } 79 - 80 - // Generate complete types file content 81 - generateTypesFile(lexicons: DiscoveredLexicon[]): string { 82 - const types = this.generateTypesFromLexicons(lexicons); 83 - 84 - if (types.length === 0) { 85 - return '// No types generated'; 86 - } 87 - 88 - const imports = `import type { CustomLexiconRecord } from './atproto';`; 89 - const interfaces = types.map(type => type.interface).join('\n\n'); 90 - const unionType = this.generateUnionType(types); 91 - const serviceGroups = this.generateServiceGroups(types); 92 - 93 - return `${imports} 94 - 95 - ${interfaces} 96 - 97 - ${unionType} 98 - 99 - ${serviceGroups}`; 100 - } 101 - 102 - // Generate union type for all generated types 103 - private generateUnionType(types: GeneratedType[]): string { 104 - const typeNames = types.map(t => t.name); 105 - return `// Union type for all generated content types 106 - export type GeneratedContentType = ${typeNames.join(' | ')};`; 107 - } 108 - 109 - // Generate service-specific type groups 110 - private generateServiceGroups(types: GeneratedType[]): string { 111 - const serviceGroups = new Map<string, GeneratedType[]>(); 112 - 113 - types.forEach(type => { 114 - if (!serviceGroups.has(type.service)) { 115 - serviceGroups.set(type.service, []); 116 - } 117 - serviceGroups.get(type.service)!.push(type); 118 - }); 119 - 120 - let serviceGroupsCode = ''; 121 - serviceGroups.forEach((types, service) => { 122 - const typeNames = types.map(t => t.name); 123 - serviceGroupsCode += ` 124 - // ${service} types 125 - export type ${this.capitalizeService(service)}ContentType = ${typeNames.join(' | ')};`; 126 - }); 127 - 128 - return serviceGroupsCode; 129 - } 130 - 131 - // Capitalize service name for type name 132 - private capitalizeService(service: string): string { 133 - return service.split('.').map(part => 134 - part.charAt(0).toUpperCase() + part.slice(1) 135 - ).join(''); 136 - } 137 - 138 - // Clear all generated types 139 - clear(): void { 140 - this.generatedTypes.clear(); 141 - } 142 - } 143 - 144 - // Global type generator instance 145 - export const typeGenerator = new TypeGenerator();
-176
src/pages/atproto-browser-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="ATProto Browser Test"> 9 - <div class="container mx-auto px-4 py-8"> 10 - <h1 class="text-4xl font-bold mb-8">ATProto Browser 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">Browse ATProto accounts and records like atptools.</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">Browse Account</h2> 22 - <div class="space-y-4"> 23 - <div> 24 - <label class="block text-sm font-medium text-gray-700 mb-2">Account (handle or DID)</label> 25 - <input id="account-input" type="text" value={config.atproto.handle} class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> 26 - </div> 27 - <button id="browse-btn" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"> 28 - Browse Account 29 - </button> 30 - </div> 31 - </div> 32 - 33 - <div class="bg-white border border-gray-200 rounded-lg p-6"> 34 - <h2 class="text-2xl font-semibold mb-4">Account Info</h2> 35 - <div id="account-info" class="space-y-2"> 36 - <p class="text-gray-500">Enter an account to browse...</p> 37 - </div> 38 - </div> 39 - </div> 40 - 41 - <div class="mt-8"> 42 - <h2 class="text-2xl font-semibold mb-4">Collections</h2> 43 - <div id="collections-container" class="space-y-4"> 44 - <p class="text-gray-500 text-center py-8">No collections loaded...</p> 45 - </div> 46 - </div> 47 - 48 - <div class="mt-8"> 49 - <h2 class="text-2xl font-semibold mb-4">Records</h2> 50 - <div id="records-container" class="space-y-4"> 51 - <p class="text-gray-500 text-center py-8">No records loaded...</p> 52 - </div> 53 - </div> 54 - </div> 55 - </Layout> 56 - 57 - <script> 58 - import { AtprotoBrowser } from '../lib/atproto/atproto-browser'; 59 - 60 - const browser = new AtprotoBrowser(); 61 - let currentAccount: string | null = null; 62 - 63 - // DOM elements 64 - const accountInput = document.getElementById('account-input') as HTMLInputElement; 65 - const browseBtn = document.getElementById('browse-btn') as HTMLButtonElement; 66 - const accountInfo = document.getElementById('account-info') as HTMLDivElement; 67 - const collectionsContainer = document.getElementById('collections-container') as HTMLDivElement; 68 - const recordsContainer = document.getElementById('records-container') as HTMLDivElement; 69 - 70 - function displayAccountInfo(repoInfo: any) { 71 - accountInfo.innerHTML = ` 72 - <div class="space-y-2"> 73 - <div><strong>DID:</strong> <span class="font-mono text-sm">${repoInfo.did}</span></div> 74 - <div><strong>Handle:</strong> ${repoInfo.handle}</div> 75 - <div><strong>Collections:</strong> ${repoInfo.collections.length}</div> 76 - <div><strong>Total Records:</strong> ${repoInfo.recordCount}</div> 77 - ${repoInfo.profile ? `<div><strong>Display Name:</strong> ${repoInfo.profile.displayName || 'N/A'}</div>` : ''} 78 - </div> 79 - `; 80 - } 81 - 82 - function displayCollections(collections: string[]) { 83 - if (collections.length === 0) { 84 - collectionsContainer.innerHTML = '<p class="text-gray-500 text-center py-8">No collections found</p>'; 85 - return; 86 - } 87 - 88 - collectionsContainer.innerHTML = ` 89 - <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> 90 - ${collections.map(collection => ` 91 - <div class="bg-gray-50 border border-gray-200 rounded-lg p-4 cursor-pointer hover:bg-gray-100 transition-colors" onclick="loadCollection('${collection}')"> 92 - <h3 class="font-semibold text-gray-900">${collection}</h3> 93 - <p class="text-sm text-gray-600">Click to view records</p> 94 - </div> 95 - `).join('')} 96 - </div> 97 - `; 98 - } 99 - 100 - async function loadCollection(collection: string) { 101 - if (!currentAccount) return; 102 - 103 - try { 104 - const collectionInfo = await browser.getCollectionRecords(currentAccount, collection, 50); 105 - if (collectionInfo) { 106 - displayRecords(collectionInfo.records, collection); 107 - } 108 - } catch (error) { 109 - console.error('Error loading collection:', error); 110 - recordsContainer.innerHTML = '<p class="text-red-500 text-center py-8">Error loading collection</p>'; 111 - } 112 - } 113 - 114 - function displayRecords(records: any[], collection: string) { 115 - if (records.length === 0) { 116 - recordsContainer.innerHTML = '<p class="text-gray-500 text-center py-8">No records found in this collection</p>'; 117 - return; 118 - } 119 - 120 - recordsContainer.innerHTML = ` 121 - <div class="mb-4"> 122 - <h3 class="text-lg font-semibold">${collection} (${records.length} records)</h3> 123 - </div> 124 - <div class="space-y-4"> 125 - ${records.map(record => ` 126 - <div class="bg-white border border-gray-200 rounded-lg p-4"> 127 - <div class="flex items-center space-x-2 mb-2"> 128 - <span class="bg-blue-100 text-blue-800 px-2 py-1 rounded text-xs font-medium">${record.collection}</span> 129 - <span class="bg-purple-100 text-purple-800 px-2 py-1 rounded text-xs font-medium">${record.$type}</span> 130 - </div> 131 - ${record.value?.text ? `<p class="text-sm text-gray-600 mb-2">${record.value.text}</p>` : ''} 132 - <p class="text-xs text-gray-500">${new Date(record.indexedAt).toLocaleString()}</p> 133 - <p class="text-xs font-mono text-gray-400 mt-1 break-all">${record.uri}</p> 134 - </div> 135 - `).join('')} 136 - </div> 137 - `; 138 - } 139 - 140 - browseBtn.addEventListener('click', async () => { 141 - const account = accountInput.value.trim(); 142 - if (!account) { 143 - alert('Please enter an account handle or DID'); 144 - return; 145 - } 146 - 147 - try { 148 - browseBtn.disabled = true; 149 - browseBtn.textContent = 'Loading...'; 150 - 151 - // Get account info 152 - const repoInfo = await browser.getRepoInfo(account); 153 - if (!repoInfo) { 154 - alert('Could not load account information'); 155 - return; 156 - } 157 - 158 - currentAccount = account; 159 - displayAccountInfo(repoInfo); 160 - displayCollections(repoInfo.collections); 161 - 162 - // Clear previous records 163 - recordsContainer.innerHTML = '<p class="text-gray-500 text-center py-8">Select a collection to view records</p>'; 164 - 165 - } catch (error) { 166 - console.error('Error browsing account:', error); 167 - alert('Error browsing account. Check the console for details.'); 168 - } finally { 169 - browseBtn.disabled = false; 170 - browseBtn.textContent = 'Browse Account'; 171 - } 172 - }); 173 - 174 - // Make loadCollection available globally 175 - (window as any).loadCollection = loadCollection; 176 - </script>
+59
src/pages/blog/[rkey].astro
··· 1 + --- 2 + import Layout from '../../layouts/Layout.astro'; 3 + import { AtprotoBrowser } from '../../lib/atproto/atproto-browser'; 4 + import { loadConfig } from '../../lib/config/site'; 5 + import WhitewindBlogPost from '../../components/content/WhitewindBlogPost.astro'; 6 + 7 + export async function getStaticPaths() { 8 + const config = loadConfig(); 9 + const browser = new AtprotoBrowser(); 10 + const paths: Array<{ params: { rkey: string } }> = []; 11 + try { 12 + const records = await browser.getAllCollectionRecords(config.atproto.handle, 'com.whtwnd.blog.entry', 2000); 13 + for (const rec of records) { 14 + if (rec.value?.$type === 'com.whtwnd.blog.entry') { 15 + const rkey = rec.uri.split('/').pop(); 16 + if (rkey) paths.push({ params: { rkey } }); 17 + } 18 + } 19 + } catch (e) { 20 + console.error('getStaticPaths whitewind', e); 21 + } 22 + return paths; 23 + } 24 + 25 + const { rkey } = Astro.params as Record<string, string>; 26 + 27 + const config = loadConfig(); 28 + const browser = new AtprotoBrowser(); 29 + 30 + let record: any = null; 31 + let title = 'Post'; 32 + 33 + try { 34 + const did = config.atproto.did || (await browser.resolveHandle(config.atproto.handle)); 35 + if (did) { 36 + const uri = `at://${did}/com.whtwnd.blog.entry/${rkey}`; 37 + const rec = await browser.getRecord(uri); 38 + if (rec && rec.value?.$type === 'com.whtwnd.blog.entry') { 39 + record = rec.value; 40 + title = record.title || title; 41 + } 42 + } 43 + } catch (e) { 44 + console.error('Error loading whitewind post', e); 45 + } 46 + --- 47 + 48 + <Layout title={title}> 49 + <div class="container mx-auto px-4 py-8"> 50 + {record ? ( 51 + <div class="max-w-3xl mx-auto"> 52 + <WhitewindBlogPost record={record} showTags={true} showTimestamp={true} /> 53 + </div> 54 + ) : ( 55 + <div class="text-center text-gray-500 dark:text-gray-400 py-16">Post not found.</div> 56 + )} 57 + </div> 58 + </Layout> 59 +
+89
src/pages/blog.astro
··· 1 + --- 2 + import Layout from '../layouts/Layout.astro'; 3 + import { AtprotoBrowser } from '../lib/atproto/atproto-browser'; 4 + import { loadConfig } from '../lib/config/site'; 5 + import type { ComWhtwndBlogEntryRecord } from '../lib/generated/com-whtwnd-blog-entry'; 6 + 7 + const config = loadConfig(); 8 + const browser = new AtprotoBrowser(); 9 + 10 + // Fetch Whitewind blog posts from the repo using generated types 11 + let posts: Array<{ 12 + uri: string; 13 + record: ComWhtwndBlogEntryRecord; 14 + createdAt: string; 15 + }> = []; 16 + 17 + try { 18 + const records = await browser.getAllCollectionRecords(config.atproto.handle, 'com.whtwnd.blog.entry', 2000); 19 + posts = records 20 + .filter((r: any) => r.value?.$type === 'com.whtwnd.blog.entry') 21 + .map((r: any) => { 22 + const record = r.value as ComWhtwndBlogEntryRecord; 23 + const createdAt = (record as any).createdAt || r.indexedAt; 24 + return { 25 + uri: r.uri, 26 + record, 27 + createdAt, 28 + }; 29 + }); 30 + posts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); 31 + } catch (e) { 32 + console.error('Error loading whitewind posts', e); 33 + } 34 + 35 + function excerpt(text: string, maxChars = 240) { 36 + let t = text 37 + .replace(/!\[[^\]]*\]\([^\)]+\)/g, ' ') 38 + .replace(/\[[^\]]+\]\(([^\)]+)\)/g, '$1') 39 + .replace(/`{3}[\s\S]*?`{3}/g, ' ') 40 + .replace(/`([^`]+)`/g, '$1') 41 + .replace(/^#{1,6}\s+/gm, '') 42 + .replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, '$1'); 43 + t = t.replace(/\s+/g, ' ').trim(); 44 + if (t.length <= maxChars) return t; 45 + return t.slice(0, maxChars).trimEnd() + 'โ€ฆ'; 46 + } 47 + 48 + function postPathFromUri(uri: string) { 49 + // at://did/.../<collection>/<rkey> 50 + const parts = uri.split('/'); 51 + const rkey = parts[parts.length - 1]; 52 + return `/blog/${rkey}`; 53 + } 54 + --- 55 + 56 + <Layout title="Blog"> 57 + <div class="container mx-auto px-4 py-8"> 58 + <header class="text-center mb-12"> 59 + <h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4">Blog</h1> 60 + <p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">Writing powered by the Whitewind lexicon</p> 61 + </header> 62 + 63 + <main class="max-w-3xl mx-auto"> 64 + {posts.length === 0 ? ( 65 + <div class="text-center text-gray-500 dark:text-gray-400 py-16">No posts yet.</div> 66 + ) : ( 67 + <div class="space-y-8"> 68 + {posts.map((p) => ( 69 + <article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6"> 70 + <h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2"> 71 + <a href={postPathFromUri(p.uri)} class="hover:underline"> 72 + {p.record.title || 'Untitled'} 73 + </a> 74 + </h2> 75 + <div class="text-sm text-gray-500 dark:text-gray-400 mb-3"> 76 + {(() => { 77 + const d = new Date(p.createdAt); 78 + return isNaN(d.getTime()) ? '' : d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); 79 + })()} 80 + </div> 81 + <p class="text-gray-700 dark:text-gray-300">{excerpt(p.record.content || '')}</p> 82 + </article> 83 + ))} 84 + </div> 85 + )} 86 + </main> 87 + </div> 88 + </Layout> 89 +
+15 -74
src/pages/galleries.astro
··· 1 1 --- 2 2 import Layout from '../layouts/Layout.astro'; 3 - import GrainImageGallery from '../components/content/GrainImageGallery.astro'; 4 - import { ATprotoDiscovery } from '../lib/atproto/discovery'; 3 + import GrainGalleryDisplay from '../components/content/GrainGalleryDisplay.astro'; 4 + import { GrainGalleryService, type ProcessedGrainGallery } from '../lib/services/grain-gallery-service'; 5 5 import { loadConfig } from '../lib/config/site'; 6 - import type { AtprotoRecord } from '../lib/types/atproto'; 7 6 8 7 const config = loadConfig(); 9 - const discovery = new ATprotoDiscovery(config.atproto.pdsUrl); 8 + const grainGalleryService = new GrainGalleryService(); 10 9 11 - // Fetch all records and filter for galleries 12 - let galleries: AtprotoRecord[] = []; 10 + // Fetch galleries using the Grain.social service 11 + let galleries: ProcessedGrainGallery[] = []; 13 12 try { 14 13 if (config.atproto.handle && config.atproto.handle !== 'your-handle-here') { 15 - // Perform comprehensive repository analysis 16 - const analysis = await discovery.analyzeRepository(config.atproto.handle); 17 - 18 - // Filter for grain-related content from all discovered lexicons 19 - galleries = analysis.lexicons 20 - .filter(lexicon => 21 - lexicon.$type.includes('grain') || 22 - lexicon.$type.includes('gallery') || 23 - lexicon.service === 'grain.social' || 24 - lexicon.description.includes('grain') 25 - ) 26 - .map(lexicon => lexicon.sampleRecord); 27 - 28 - // Sort by creation date (newest first) 29 - galleries.sort((a, b) => { 30 - const dateA = new Date(a.value?.createdAt || a.indexedAt || 0); 31 - const dateB = new Date(b.value?.createdAt || b.indexedAt || 0); 32 - return dateB.getTime() - dateA.getTime(); 33 - }); 14 + galleries = await grainGalleryService.getGalleries(config.atproto.handle); 34 15 } 35 16 } catch (error) { 36 17 console.error('Galleries page: Error fetching galleries:', error); ··· 53 34 {config.atproto.handle && config.atproto.handle !== 'your-handle-here' ? ( 54 35 galleries.length > 0 ? ( 55 36 <div class="space-y-8"> 56 - {galleries.map((record) => { 57 - const $type = record.value?.$type; 58 - 59 - // Handle different types of grain records 60 - if ($type?.includes('grain') || $type?.includes('gallery')) { 61 - // For now, display as a generic gallery component 62 - return ( 63 - <article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6"> 64 - <header class="mb-4"> 65 - <h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2"> 66 - {record.value?.title || 'Gallery'} 67 - </h2> 68 - 69 - {record.value?.description && ( 70 - <div class="text-gray-600 dark:text-gray-400 mb-3"> 71 - {record.value.description} 72 - </div> 73 - )} 74 - 75 - <div class="text-sm text-gray-500 dark:text-gray-400 mb-4"> 76 - Type: {record.value?.$type || 'Unknown'} 77 - </div> 78 - </header> 79 - 80 - {record.value?.text && ( 81 - <div class="text-gray-900 dark:text-white mb-4"> 82 - {record.value.text} 83 - </div> 84 - )} 85 - 86 - {record.value?.embed && record.value.embed.$type === 'app.bsky.embed.images' && ( 87 - <div class="grid grid-cols-3 gap-4"> 88 - {record.value.embed.images?.map((image: any) => ( 89 - <div class="relative group"> 90 - <img 91 - src={image.image?.ref ? `https://bsky.social/xrpc/com.atproto.sync.getBlob?did=did:plc:6ayddqghxhciedbaofoxkcbs&cid=${image.image.ref}` : ''} 92 - alt={image.alt || 'Gallery image'} 93 - class="w-full h-48 object-cover rounded-lg transition-transform duration-200 group-hover:scale-105" 94 - /> 95 - </div> 96 - ))} 97 - </div> 98 - )} 99 - </article> 100 - ); 101 - } 102 - 103 - return null; 104 - })} 37 + {galleries.map((gallery) => ( 38 + <GrainGalleryDisplay 39 + gallery={gallery} 40 + showDescription={true} 41 + showTimestamp={true} 42 + showCollections={true} 43 + columns={3} 44 + /> 45 + ))} 105 46 </div> 106 47 ) : ( 107 48 <div class="text-center py-12">
+30 -119
src/pages/index.astro
··· 1 1 --- 2 2 import Layout from '../layouts/Layout.astro'; 3 3 import ContentFeed from '../components/content/ContentFeed.astro'; 4 + import StatusUpdate from '../components/content/StatusUpdate.astro'; 4 5 import { loadConfig } from '../lib/config/site'; 5 6 6 7 const config = loadConfig(); 7 8 --- 8 9 9 - <Layout title="Home Page"> 10 + <Layout title="Home"> 10 11 <div class="container mx-auto px-4 py-8"> 11 - <header class="text-center mb-12"> 12 + <header class="mb-8"> 12 13 <h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4"> 13 - Welcome to {config.site.title || 'Tynanverse'} 14 + Welcome to {config.site.title} 14 15 </h1> 15 - <p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto"> 16 - {config.site.description || 'A personal website powered by ATproto, made by Tynan'} 16 + <p class="text-xl text-gray-600 dark:text-gray-300 mb-6"> 17 + {config.site.description} 17 18 </p> 19 + <nav class="flex space-x-4"> 20 + <a href="/" class="text-blue-600 dark:text-blue-400 hover:underline">Home</a> 21 + <a href="/blog" class="text-blue-600 dark:text-blue-400 hover:underline">Blog</a> 22 + <a href="/galleries" class="text-blue-600 dark:text-blue-400 hover:underline">Galleries</a> 23 + <a href="/leaflets" class="text-blue-600 dark:text-blue-400 hover:underline">Leaflets</a> 24 + </nav> 18 25 </header> 19 26 20 - <main class="max-w-4xl mx-auto space-y-12"> 21 - <!-- My Posts Feed --> 22 - {config.atproto.handle && config.atproto.handle !== 'your-handle-here' ? ( 23 - <> 24 - <section> 25 - <h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6"> 26 - My Posts 27 - </h2> 28 - <ContentFeed 29 - handle={config.atproto.handle} 30 - limit={10} 31 - showAuthor={false} 32 - showTimestamp={true} 33 - /> 34 - </section> 35 - 36 - <section> 37 - <h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6"> 38 - Live Feed 39 - </h2> 40 - <div class="bg-blue-50 border border-blue-200 rounded-lg p-6"> 41 - <h3 class="text-xl font-semibold mb-2">Turbostream Test</h3> 42 - <p class="text-gray-600 mb-4"> 43 - Test the real-time Turbostream connection to see live posts from your handle. 44 - </p> 45 - <a href="/turbostream-test" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 inline-block"> 46 - Test Turbostream 47 - </a> 48 - </div> 49 - </section> 50 - 51 - <section> 52 - <h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6"> 53 - Explore More 54 - </h2> 55 - <div class="grid md:grid-cols-2 gap-6"> 56 - <a href="/galleries" 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"> 57 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 58 - Image Galleries 59 - </h3> 60 - <p class="text-gray-600 dark:text-gray-400"> 61 - View my grain.social image galleries and photo collections. 62 - </p> 63 - </a> 64 - <a href="/turbostream-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"> 65 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 66 - Turbostream Test 67 - </h3> 68 - <p class="text-gray-600 dark:text-gray-400"> 69 - Test the Graze Turbostream connection and see live records. 70 - </p> 71 - </a> 72 - <a href="/turbostream-simple" 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"> 73 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 74 - Simple Debug Test 75 - </h3> 76 - <p class="text-gray-600 dark:text-gray-400"> 77 - Simple connection test with detailed console logging. 78 - </p> 79 - </a> 80 - <a href="/repository-stream-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"> 81 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 82 - Repository Stream Test 83 - </h3> 84 - <p class="text-gray-600 dark:text-gray-400"> 85 - Comprehensive repository streaming (like atptools) - all collections, all content types. 86 - </p> 87 - </a> 88 - <a href="/jetstream-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"> 89 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 90 - Jetstream Test 91 - </h3> 92 - <p class="text-gray-600 dark:text-gray-400"> 93 - ATProto sync API streaming with DID filtering (like atptools) - low latency, real-time. 94 - </p> 95 - </a> 96 - <a href="/atproto-browser-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"> 97 - <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2"> 98 - ATProto Browser Test 99 - </h3> 100 - <p class="text-gray-600 dark:text-gray-400"> 101 - Browse ATProto accounts and records like atptools - explore collections and records. 102 - </p> 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> 112 - </div> 113 - </section> 114 - </> 115 - ) : ( 116 - <section> 117 - <h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6"> 118 - Configuration Required 119 - </h2> 120 - <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6"> 121 - <p class="text-yellow-800 dark:text-yellow-200 mb-4"> 122 - To display your posts, please configure your Bluesky handle in the environment variables. 123 - </p> 124 - <div class="text-sm text-yellow-700 dark:text-yellow-300"> 125 - <p class="mb-2">Create a <code class="bg-yellow-100 dark:bg-yellow-800 px-1 rounded">.env</code> file with:</p> 126 - <pre class="bg-yellow-100 dark:bg-yellow-800 p-3 rounded text-xs overflow-x-auto"> 127 - ATPROTO_HANDLE=your-handle.bsky.social 128 - SITE_TITLE=Your Site Title 129 - SITE_AUTHOR=Your Name</pre> 130 - </div> 131 - </div> 132 - </section> 133 - )} 27 + <main> 28 + <section class="mb-8"> 29 + <h2 class="text-2xl font-semibold text-gray-900 dark:text-white mb-4"> 30 + Current Status 31 + </h2> 32 + <StatusUpdate /> 33 + </section> 34 + 35 + <section class="mb-8"> 36 + <h2 class="text-2xl font-semibold text-gray-900 dark:text-white mb-4"> 37 + Latest Posts 38 + </h2> 39 + <ContentFeed 40 + limit={10} 41 + showTimestamp={true} 42 + live={true} 43 + /> 44 + </section> 134 45 </main> 135 46 </div> 136 47 </Layout>
-164
src/pages/jetstream-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="Jetstream Test"> 9 - <div class="container mx-auto px-4 py-8"> 10 - <h1 class="text-4xl font-bold mb-8">Jetstream Repository 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">This uses ATProto sync API to stream all repository activity with DID filtering, similar to atptools.</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">Connection</h2> 22 - <button id="start-btn" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"> 23 - Start Jetstream 24 - </button> 25 - <button id="stop-btn" class="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 ml-2" disabled> 26 - Stop Stream 27 - </button> 28 - <div id="status" class="mt-4 p-2 rounded bg-gray-100"> 29 - Status: <span id="status-text">Stopped</span> 30 - </div> 31 - </div> 32 - 33 - <div class="bg-white border border-gray-200 rounded-lg p-6"> 34 - <h2 class="text-2xl font-semibold mb-4">Statistics</h2> 35 - <div class="space-y-2"> 36 - <div>Records received: <span id="records-count" class="font-bold">0</span></div> 37 - <div>Streaming status: <span id="streaming-status" class="font-bold">Stopped</span></div> 38 - <div>Last sync: <span id="last-sync" class="font-bold">Never</span></div> 39 - </div> 40 - </div> 41 - </div> 42 - 43 - <div class="mt-8"> 44 - <h2 class="text-2xl font-semibold mb-4">Live Records</h2> 45 - <div id="records-container" class="space-y-4 max-h-96 overflow-y-auto"> 46 - <p class="text-gray-500 text-center py-8">No records received yet...</p> 47 - </div> 48 - </div> 49 - </div> 50 - </Layout> 51 - 52 - <script> 53 - import { JetstreamClient } from '../lib/atproto/jetstream-client'; 54 - 55 - let client: JetstreamClient | null = null; 56 - let recordsCount = 0; 57 - 58 - // DOM elements 59 - const startBtn = document.getElementById('start-btn') as HTMLButtonElement; 60 - const stopBtn = document.getElementById('stop-btn') as HTMLButtonElement; 61 - const statusText = document.getElementById('status-text') as HTMLSpanElement; 62 - const recordsCountEl = document.getElementById('records-count') as HTMLSpanElement; 63 - const streamingStatusEl = document.getElementById('streaming-status') as HTMLSpanElement; 64 - const lastSyncEl = document.getElementById('last-sync') as HTMLSpanElement; 65 - const recordsContainer = document.getElementById('records-container') as HTMLDivElement; 66 - 67 - function updateStatus(status: string) { 68 - statusText.textContent = status; 69 - streamingStatusEl.textContent = status; 70 - 71 - if (status === 'Streaming') { 72 - startBtn.disabled = true; 73 - stopBtn.disabled = false; 74 - } else { 75 - startBtn.disabled = false; 76 - stopBtn.disabled = true; 77 - } 78 - } 79 - 80 - function updateLastSync() { 81 - lastSyncEl.textContent = new Date().toLocaleTimeString(); 82 - } 83 - 84 - function addRecord(record: any) { 85 - recordsCount++; 86 - recordsCountEl.textContent = recordsCount.toString(); 87 - 88 - const recordEl = document.createElement('div'); 89 - recordEl.className = 'bg-gray-50 border border-gray-200 rounded-lg p-4'; 90 - 91 - const time = new Date(record.time_us / 1000).toLocaleString(); 92 - const collection = record.collection; 93 - const $type = record.$type; 94 - const service = record.service; 95 - const text = record.value?.text || 'No text content'; 96 - 97 - recordEl.innerHTML = ` 98 - <div class="flex items-start space-x-3"> 99 - <div class="flex-1 min-w-0"> 100 - <div class="flex items-center space-x-2 mb-2"> 101 - <span class="bg-blue-100 text-blue-800 px-2 py-1 rounded text-xs font-medium">${service}</span> 102 - <span class="bg-green-100 text-green-800 px-2 py-1 rounded text-xs font-medium">${collection}</span> 103 - <span class="bg-purple-100 text-purple-800 px-2 py-1 rounded text-xs font-medium">${$type}</span> 104 - <span class="bg-${record.operation === 'create' ? 'green' : record.operation === 'update' ? 'yellow' : 'red'}-100 text-${record.operation === 'create' ? 'green' : record.operation === 'update' ? 'yellow' : 'red'}-800 px-2 py-1 rounded text-xs font-medium">${record.operation}</span> 105 - </div> 106 - ${text ? `<p class="text-sm text-gray-600 mb-2">${text}</p>` : ''} 107 - <p class="text-xs text-gray-500">${time}</p> 108 - <p class="text-xs font-mono text-gray-400 mt-1 break-all">${record.uri}</p> 109 - </div> 110 - </div> 111 - `; 112 - 113 - recordsContainer.insertBefore(recordEl, recordsContainer.firstChild); 114 - 115 - // Keep only the last 20 records 116 - const records = recordsContainer.querySelectorAll('div'); 117 - if (records.length > 20) { 118 - records[records.length - 1].remove(); 119 - } 120 - } 121 - 122 - startBtn.addEventListener('click', async () => { 123 - try { 124 - updateStatus('Starting...'); 125 - 126 - client = new JetstreamClient(); 127 - 128 - client.onConnect(() => { 129 - updateStatus('Streaming'); 130 - console.log('Jetstream connected'); 131 - }); 132 - 133 - client.onDisconnect(() => { 134 - updateStatus('Stopped'); 135 - console.log('Jetstream disconnected'); 136 - }); 137 - 138 - client.onError((error) => { 139 - console.error('Jetstream error:', error); 140 - updateStatus('Error'); 141 - }); 142 - 143 - client.onRecord((record) => { 144 - console.log('Record received:', record); 145 - addRecord(record); 146 - updateLastSync(); 147 - }); 148 - 149 - await client.startStreaming(); 150 - 151 - } catch (error) { 152 - console.error('Failed to start jetstream:', error); 153 - updateStatus('Error'); 154 - alert('Failed to start jetstream. Check the console for details.'); 155 - } 156 - }); 157 - 158 - stopBtn.addEventListener('click', () => { 159 - if (client) { 160 - client.stopStreaming(); 161 - client = null; 162 - } 163 - }); 164 - </script>
+60
src/pages/leaflets/[leaflet].astro
··· 1 + --- 2 + import Layout from '../../layouts/Layout.astro'; 3 + import { getCollection, getEntry, render } from "astro:content"; 4 + 5 + export async function getStaticPaths() { 6 + const documents = await getCollection("documents"); 7 + return documents.map((document) => ({ 8 + params: { leaflet: document.id }, 9 + props: document, 10 + })); 11 + } 12 + 13 + const document = await getEntry("documents", Astro.params.leaflet); 14 + 15 + if (!document) { 16 + throw new Error(`Document with id "${Astro.params.leaflet}" not found`); 17 + } 18 + 19 + const { Content } = await render(document); 20 + --- 21 + 22 + <Layout title={document.data.title}> 23 + <div class="container mx-auto px-4 py-8"> 24 + <header class="mb-8"> 25 + <nav class="mb-6"> 26 + <a href="/leaflets" class="text-blue-600 dark:text-blue-400 hover:underline"> 27 + โ† Back to Leaflets 28 + </a> 29 + </nav> 30 + 31 + <h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4"> 32 + {document.data.title} 33 + </h1> 34 + 35 + {document.data.description && ( 36 + <p class="text-xl text-gray-600 dark:text-gray-400 mb-6"> 37 + {document.data.description} 38 + </p> 39 + )} 40 + 41 + <div class="text-sm text-gray-500 dark:text-gray-400 mb-6"> 42 + {document.data.publishedAt && ( 43 + <span> 44 + {new Date(document.data.publishedAt).toLocaleDateString('en-US', { 45 + year: 'numeric', 46 + month: 'long', 47 + day: 'numeric', 48 + })} 49 + </span> 50 + )} 51 + </div> 52 + </header> 53 + 54 + <main class="max-w-4xl mx-auto"> 55 + <article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-8"> 56 + <Content /> 57 + </article> 58 + </main> 59 + </div> 60 + </Layout>
+98
src/pages/leaflets.astro
··· 1 + --- 2 + import Layout from '../layouts/Layout.astro'; 3 + import { getCollection } from "astro:content"; 4 + import { loadConfig } from '../lib/config/site'; 5 + 6 + const config = loadConfig(); 7 + const documents = await getCollection("documents"); 8 + 9 + // Sort documents by published date (newest first) 10 + const sortedDocuments = documents.sort((a, b) => { 11 + const dateA = a.data.publishedAt ? new Date(a.data.publishedAt).getTime() : 0; 12 + const dateB = b.data.publishedAt ? new Date(b.data.publishedAt).getTime() : 0; 13 + return dateB - dateA; 14 + }); 15 + --- 16 + 17 + <Layout title="Leaflet Documents"> 18 + <div class="container mx-auto px-4 py-8"> 19 + <header class="text-center mb-12"> 20 + <h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-4"> 21 + Leaflet Documents 22 + </h1> 23 + <p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto"> 24 + A collection of my leaflet.pub documents 25 + </p> 26 + </header> 27 + 28 + <main class="max-w-4xl mx-auto"> 29 + {config.atproto.handle && config.atproto.handle !== 'your-handle-here' ? ( 30 + sortedDocuments.length > 0 ? ( 31 + <div class="space-y-6"> 32 + {sortedDocuments.map((document) => ( 33 + <article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6"> 34 + <header class="mb-4"> 35 + <h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2"> 36 + <a href={`/leaflets/${document.id}`} class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors"> 37 + {document.data.title} 38 + </a> 39 + </h2> 40 + 41 + {document.data.description && ( 42 + <p class="text-gray-600 dark:text-gray-400 mb-3"> 43 + {document.data.description} 44 + </p> 45 + )} 46 + 47 + <div class="text-sm text-gray-500 dark:text-gray-400"> 48 + {document.data.publishedAt && ( 49 + <span> 50 + Published: {new Date(document.data.publishedAt).toLocaleDateString('en-US', { 51 + year: 'numeric', 52 + month: 'long', 53 + day: 'numeric', 54 + })} 55 + </span> 56 + )} 57 + </div> 58 + </header> 59 + </article> 60 + ))} 61 + </div> 62 + ) : ( 63 + <div class="text-center py-12"> 64 + <div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-8"> 65 + <h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-4"> 66 + No Leaflet Documents Found 67 + </h3> 68 + <p class="text-gray-600 dark:text-gray-400 mb-4"> 69 + No leaflet.pub documents were found for your account. 70 + </p> 71 + <p class="text-sm text-gray-500 dark:text-gray-500"> 72 + Make sure you have created documents using leaflet.pub and they are properly indexed. 73 + </p> 74 + </div> 75 + </div> 76 + ) 77 + ) : ( 78 + <div class="text-center py-12"> 79 + <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-8"> 80 + <h3 class="text-xl font-semibold text-yellow-800 dark:text-yellow-200 mb-4"> 81 + Configuration Required 82 + </h3> 83 + <p class="text-yellow-700 dark:text-yellow-300 mb-4"> 84 + To display your Leaflet documents, please configure your Bluesky handle in the environment variables. 85 + </p> 86 + <div class="text-sm text-yellow-600 dark:text-yellow-400"> 87 + <p class="mb-2">Create a <code class="bg-yellow-100 dark:bg-yellow-800 px-1 rounded">.env</code> file with:</p> 88 + <pre class="bg-yellow-100 dark:bg-yellow-800 p-3 rounded text-xs overflow-x-auto"> 89 + ATPROTO_HANDLE=your-handle.bsky.social 90 + SITE_TITLE=Your Site Title 91 + SITE_AUTHOR=Your Name</pre> 92 + </div> 93 + </div> 94 + </div> 95 + )} 96 + </main> 97 + </div> 98 + </Layout>
-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>
+17
src/pages/now.astro
··· 1 + --- 2 + import Layout from '../layouts/Layout.astro'; 3 + import StatusUpdate from '../components/content/StatusUpdate.astro'; 4 + --- 5 + 6 + <Layout title="Now"> 7 + <main class="max-w-2xl mx-auto py-8 px-4 space-y-8"> 8 + <section> 9 + <h1 class="text-2xl font-bold mb-2">Now</h1> 10 + <p>This is the now page.</p> 11 + </section> 12 + <section> 13 + <h2 class="text-xl font-bold mb-2">Status</h2> 14 + <StatusUpdate /> 15 + </section> 16 + </main> 17 + </Layout>
-187
src/pages/repository-stream-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="Repository Stream Test"> 9 - <div class="container mx-auto px-4 py-8"> 10 - <h1 class="text-4xl font-bold mb-8">Repository Stream 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">This streams ALL repository content, not just posts. Includes galleries, profiles, follows, etc.</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">Connection</h2> 22 - <button id="start-btn" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"> 23 - Start Repository Stream 24 - </button> 25 - <button id="stop-btn" class="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 ml-2" disabled> 26 - Stop Stream 27 - </button> 28 - <div id="status" class="mt-4 p-2 rounded bg-gray-100"> 29 - Status: <span id="status-text">Stopped</span> 30 - </div> 31 - </div> 32 - 33 - <div class="bg-white border border-gray-200 rounded-lg p-6"> 34 - <h2 class="text-2xl font-semibold mb-4">Statistics</h2> 35 - <div class="space-y-2"> 36 - <div>Records received: <span id="records-count" class="font-bold">0</span></div> 37 - <div>Collections discovered: <span id="collections-count" class="font-bold">0</span></div> 38 - <div>Streaming status: <span id="streaming-status" class="font-bold">Stopped</span></div> 39 - </div> 40 - </div> 41 - </div> 42 - 43 - <div class="mt-8"> 44 - <h2 class="text-2xl font-semibold mb-4">Discovered Collections</h2> 45 - <div id="collections-container" class="bg-white border border-gray-200 rounded-lg p-6"> 46 - <p class="text-gray-500">No collections discovered yet...</p> 47 - </div> 48 - </div> 49 - 50 - <div class="mt-8"> 51 - <h2 class="text-2xl font-semibold mb-4">Live Records</h2> 52 - <div id="records-container" class="space-y-4 max-h-96 overflow-y-auto"> 53 - <p class="text-gray-500 text-center py-8">No records received yet...</p> 54 - </div> 55 - </div> 56 - </div> 57 - </Layout> 58 - 59 - <script> 60 - import { RepositoryStream } from '../lib/atproto/repository-stream'; 61 - 62 - let stream: RepositoryStream | null = null; 63 - let recordsCount = 0; 64 - let discoveredCollections: string[] = []; 65 - 66 - // DOM elements 67 - const startBtn = document.getElementById('start-btn') as HTMLButtonElement; 68 - const stopBtn = document.getElementById('stop-btn') as HTMLButtonElement; 69 - const statusText = document.getElementById('status-text') as HTMLSpanElement; 70 - const recordsCountEl = document.getElementById('records-count') as HTMLSpanElement; 71 - const collectionsCountEl = document.getElementById('collections-count') as HTMLSpanElement; 72 - const streamingStatusEl = document.getElementById('streaming-status') as HTMLSpanElement; 73 - const collectionsContainer = document.getElementById('collections-container') as HTMLDivElement; 74 - const recordsContainer = document.getElementById('records-container') as HTMLDivElement; 75 - 76 - function updateStatus(status: string) { 77 - statusText.textContent = status; 78 - streamingStatusEl.textContent = status; 79 - 80 - if (status === 'Streaming') { 81 - startBtn.disabled = true; 82 - stopBtn.disabled = false; 83 - } else { 84 - startBtn.disabled = false; 85 - stopBtn.disabled = true; 86 - } 87 - } 88 - 89 - function updateCollections() { 90 - collectionsCountEl.textContent = discoveredCollections.length.toString(); 91 - 92 - if (discoveredCollections.length === 0) { 93 - collectionsContainer.innerHTML = '<p class="text-gray-500">No collections discovered yet...</p>'; 94 - } else { 95 - collectionsContainer.innerHTML = discoveredCollections.map(collection => 96 - `<div class="bg-gray-50 border border-gray-200 rounded p-3 mb-2"> 97 - <span class="font-mono text-sm">${collection}</span> 98 - </div>` 99 - ).join(''); 100 - } 101 - } 102 - 103 - function addRecord(record: any) { 104 - recordsCount++; 105 - recordsCountEl.textContent = recordsCount.toString(); 106 - 107 - const recordEl = document.createElement('div'); 108 - recordEl.className = 'bg-gray-50 border border-gray-200 rounded-lg p-4'; 109 - 110 - const time = new Date(record.indexedAt).toLocaleString(); 111 - const collection = record.collection; 112 - const $type = record.$type; 113 - const service = record.service; 114 - const text = record.value?.text || 'No text content'; 115 - 116 - recordEl.innerHTML = ` 117 - <div class="flex items-start space-x-3"> 118 - <div class="flex-1 min-w-0"> 119 - <div class="flex items-center space-x-2 mb-2"> 120 - <span class="bg-blue-100 text-blue-800 px-2 py-1 rounded text-xs font-medium">${service}</span> 121 - <span class="bg-green-100 text-green-800 px-2 py-1 rounded text-xs font-medium">${collection}</span> 122 - <span class="bg-purple-100 text-purple-800 px-2 py-1 rounded text-xs font-medium">${$type}</span> 123 - </div> 124 - ${text ? `<p class="text-sm text-gray-600 mb-2">${text}</p>` : ''} 125 - <p class="text-xs text-gray-500">${time}</p> 126 - <p class="text-xs font-mono text-gray-400 mt-1 break-all">${record.uri}</p> 127 - </div> 128 - </div> 129 - `; 130 - 131 - recordsContainer.insertBefore(recordEl, recordsContainer.firstChild); 132 - 133 - // Keep only the last 20 records 134 - const records = recordsContainer.querySelectorAll('div'); 135 - if (records.length > 20) { 136 - records[records.length - 1].remove(); 137 - } 138 - } 139 - 140 - startBtn.addEventListener('click', async () => { 141 - try { 142 - updateStatus('Starting...'); 143 - 144 - stream = new RepositoryStream(); 145 - 146 - stream.onConnect(() => { 147 - updateStatus('Streaming'); 148 - console.log('Repository stream connected'); 149 - }); 150 - 151 - stream.onDisconnect(() => { 152 - updateStatus('Stopped'); 153 - console.log('Repository stream disconnected'); 154 - }); 155 - 156 - stream.onError((error) => { 157 - console.error('Repository stream error:', error); 158 - updateStatus('Error'); 159 - }); 160 - 161 - stream.onCollectionDiscovered((collection) => { 162 - discoveredCollections.push(collection); 163 - updateCollections(); 164 - console.log('Collection discovered:', collection); 165 - }); 166 - 167 - stream.onRecord((record) => { 168 - console.log('Record received:', record); 169 - addRecord(record); 170 - }); 171 - 172 - await stream.startStreaming(); 173 - 174 - } catch (error) { 175 - console.error('Failed to start repository stream:', error); 176 - updateStatus('Error'); 177 - alert('Failed to start repository stream. Check the console for details.'); 178 - } 179 - }); 180 - 181 - stopBtn.addEventListener('click', () => { 182 - if (stream) { 183 - stream.stopStreaming(); 184 - stream = null; 185 - } 186 - }); 187 - </script>
-114
src/pages/turbostream-simple.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="Simple Turbostream Test"> 9 - <div class="container mx-auto px-4 py-8"> 10 - <h1 class="text-4xl font-bold mb-8">Simple Turbostream 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">Filtering for records from your handle only. Open your browser's developer console (F12) to see detailed logs.</p> 17 - </div> 18 - 19 - <div class="bg-white border border-gray-200 rounded-lg p-6 mb-8"> 20 - <h2 class="text-2xl font-semibold mb-4">Connection</h2> 21 - <button id="connect-btn" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"> 22 - Connect to Turbostream 23 - </button> 24 - <button id="disconnect-btn" class="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 ml-2" disabled> 25 - Disconnect 26 - </button> 27 - <div id="status" class="mt-4 p-2 rounded bg-gray-100"> 28 - Status: <span id="status-text">Disconnected</span> 29 - </div> 30 - </div> 31 - 32 - <div class="bg-white border border-gray-200 rounded-lg p-6"> 33 - <h2 class="text-2xl font-semibold mb-4">Statistics</h2> 34 - <div class="space-y-2"> 35 - <div>Records received: <span id="count" class="font-bold">0</span></div> 36 - <div>Connection status: <span id="connection-status" class="font-bold">Disconnected</span></div> 37 - </div> 38 - </div> 39 - </div> 40 - </Layout> 41 - 42 - <script> 43 - import { TurbostreamClient } from '../lib/atproto/turbostream'; 44 - 45 - let client: TurbostreamClient | null = null; 46 - let recordsCount = 0; 47 - 48 - const connectBtn = document.getElementById('connect-btn') as HTMLButtonElement; 49 - const disconnectBtn = document.getElementById('disconnect-btn') as HTMLButtonElement; 50 - const statusText = document.getElementById('status-text') as HTMLSpanElement; 51 - const countEl = document.getElementById('count') as HTMLSpanElement; 52 - const connectionStatusEl = document.getElementById('connection-status') as HTMLSpanElement; 53 - 54 - function updateStatus(status: string) { 55 - statusText.textContent = status; 56 - connectionStatusEl.textContent = status; 57 - console.log('๐Ÿ”„ Status updated:', status); 58 - 59 - if (status === 'connected') { 60 - connectBtn.disabled = true; 61 - disconnectBtn.disabled = false; 62 - } else { 63 - connectBtn.disabled = false; 64 - disconnectBtn.disabled = true; 65 - } 66 - } 67 - 68 - connectBtn.addEventListener('click', async () => { 69 - console.log('๐Ÿš€ Starting Turbostream connection...'); 70 - updateStatus('connecting'); 71 - 72 - try { 73 - client = new TurbostreamClient(); 74 - 75 - client.onConnect(() => { 76 - console.log('๐ŸŽ‰ Turbostream connected successfully!'); 77 - updateStatus('connected'); 78 - }); 79 - 80 - client.onDisconnect(() => { 81 - console.log('๐Ÿ‘‹ Turbostream disconnected'); 82 - updateStatus('disconnected'); 83 - }); 84 - 85 - client.onError((error) => { 86 - console.error('๐Ÿ’ฅ Turbostream error:', error); 87 - updateStatus('error'); 88 - }); 89 - 90 - client.onRecord((record) => { 91 - recordsCount++; 92 - countEl.textContent = recordsCount.toString(); 93 - console.log('๐Ÿ“ Record received:', record); 94 - }); 95 - 96 - await client.connect(); 97 - 98 - } catch (error) { 99 - console.error('๐Ÿ’ฅ Failed to connect:', error); 100 - updateStatus('error'); 101 - } 102 - }); 103 - 104 - disconnectBtn.addEventListener('click', () => { 105 - if (client) { 106 - console.log('๐Ÿ”Œ Disconnecting...'); 107 - client.disconnect(); 108 - client = null; 109 - } 110 - }); 111 - 112 - // Log when page loads 113 - console.log('๐Ÿ“„ Simple Turbostream test page loaded'); 114 - </script>
-158
src/pages/turbostream-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="Turbostream Test"> 9 - <div class="container mx-auto px-4 py-8"> 10 - <h1 class="text-4xl font-bold mb-8">Turbostream 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">Filtering for records from your handle only. Only posts from @{config.atproto.handle} will be displayed.</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">Connection</h2> 22 - <button id="connect-btn" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"> 23 - Connect to Turbostream 24 - </button> 25 - <button id="disconnect-btn" class="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 ml-2" disabled> 26 - Disconnect 27 - </button> 28 - <div id="status" class="mt-4 p-2 rounded bg-gray-100"> 29 - Status: <span id="status-text">Disconnected</span> 30 - </div> 31 - </div> 32 - 33 - <div class="bg-white border border-gray-200 rounded-lg p-6"> 34 - <h2 class="text-2xl font-semibold mb-4">Statistics</h2> 35 - <div class="space-y-2"> 36 - <div>Records received: <span id="records-count" class="font-bold">0</span></div> 37 - <div>Connection status: <span id="connection-status" class="font-bold">Disconnected</span></div> 38 - </div> 39 - </div> 40 - </div> 41 - 42 - <div class="mt-8"> 43 - <h2 class="text-2xl font-semibold mb-4">Live Records</h2> 44 - <div id="records-container" class="space-y-4 max-h-96 overflow-y-auto"> 45 - <p class="text-gray-500 text-center py-8">No records received yet...</p> 46 - </div> 47 - </div> 48 - </div> 49 - </Layout> 50 - 51 - <script> 52 - import { TurbostreamClient } from '../lib/atproto/turbostream'; 53 - 54 - let client: TurbostreamClient | null = null; 55 - let recordsCount = 0; 56 - 57 - // DOM elements 58 - const connectBtn = document.getElementById('connect-btn') as HTMLButtonElement; 59 - const disconnectBtn = document.getElementById('disconnect-btn') as HTMLButtonElement; 60 - const statusText = document.getElementById('status-text') as HTMLSpanElement; 61 - const recordsCountEl = document.getElementById('records-count') as HTMLSpanElement; 62 - const connectionStatusEl = document.getElementById('connection-status') as HTMLSpanElement; 63 - const recordsContainer = document.getElementById('records-container') as HTMLDivElement; 64 - 65 - function updateStatus(status: string) { 66 - statusText.textContent = status; 67 - connectionStatusEl.textContent = status; 68 - 69 - if (status === 'connected') { 70 - connectBtn.disabled = true; 71 - disconnectBtn.disabled = false; 72 - } else { 73 - connectBtn.disabled = false; 74 - disconnectBtn.disabled = true; 75 - } 76 - } 77 - 78 - function addRecord(record: any) { 79 - recordsCount++; 80 - recordsCountEl.textContent = recordsCount.toString(); 81 - 82 - const recordEl = document.createElement('div'); 83 - recordEl.className = 'bg-gray-50 border border-gray-200 rounded-lg p-4'; 84 - 85 - const time = new Date(record.time_us / 1000).toLocaleString(); 86 - const did = record.did; 87 - const uri = record.at_uri; 88 - const user = record.hydrated_metadata?.user; 89 - const text = record.message?.text || 'No text content'; 90 - 91 - recordEl.innerHTML = ` 92 - <div class="flex items-start space-x-3"> 93 - <div class="flex-shrink-0"> 94 - <img src="${user?.avatar || '/favicon.svg'}" alt="Avatar" class="w-10 h-10 rounded-full"> 95 - </div> 96 - <div class="flex-1 min-w-0"> 97 - <div class="flex items-center space-x-2 mb-1"> 98 - <span class="font-semibold">${user?.displayName || 'Unknown'}</span> 99 - <span class="text-gray-500">@${user?.handle || did}</span> 100 - </div> 101 - <p class="text-sm text-gray-600 mb-2">${text}</p> 102 - <p class="text-xs text-gray-500">${time}</p> 103 - <p class="text-xs font-mono text-gray-400 mt-1 break-all">${uri}</p> 104 - </div> 105 - </div> 106 - `; 107 - 108 - recordsContainer.insertBefore(recordEl, recordsContainer.firstChild); 109 - 110 - // Keep only the last 20 records 111 - const records = recordsContainer.querySelectorAll('div'); 112 - if (records.length > 20) { 113 - records[records.length - 1].remove(); 114 - } 115 - } 116 - 117 - connectBtn.addEventListener('click', async () => { 118 - try { 119 - updateStatus('connecting'); 120 - 121 - client = new TurbostreamClient(); 122 - 123 - client.onConnect(() => { 124 - updateStatus('connected'); 125 - console.log('Connected to Turbostream'); 126 - }); 127 - 128 - client.onDisconnect(() => { 129 - updateStatus('disconnected'); 130 - console.log('Disconnected from Turbostream'); 131 - }); 132 - 133 - client.onError((error) => { 134 - console.error('Turbostream error:', error); 135 - updateStatus('error'); 136 - }); 137 - 138 - client.onRecord((record) => { 139 - console.log('Received record:', record); 140 - addRecord(record); 141 - }); 142 - 143 - await client.connect(); 144 - 145 - } catch (error) { 146 - console.error('Failed to connect:', error); 147 - updateStatus('error'); 148 - alert('Failed to connect to Turbostream. Check the console for details.'); 149 - } 150 - }); 151 - 152 - disconnectBtn.addEventListener('click', () => { 153 - if (client) { 154 - client.disconnect(); 155 - client = null; 156 - } 157 - }); 158 - </script>