A personal website powered by Astro and ATProto

type cleanup

+1 -1
LEXICON_INTEGRATION.md
··· 140 140 ```astro 141 141 --- 142 142 import ContentDisplay from '../../components/content/ContentDisplay.astro'; 143 - import type { AtprotoRecord } from '../../lib/types/atproto'; 143 + import type { AtprotoRecord } from '../../lib/atproto/atproto-browser'; 144 144 145 145 const records: AtprotoRecord[] = await fetchRecords(); 146 146 ---
+1
src/components/content/BlueskyFeed.astro
··· 34 34 blueskyPosts.map((record) => ( 35 35 <BlueskyPost 36 36 post={record.value} 37 + author={record.author} 37 38 showAuthor={showAuthor} 38 39 showTimestamp={showTimestamp} 39 40 />
+14 -10
src/components/content/BlueskyPost.astro
··· 1 1 --- 2 - import type { BlueskyPost } from '../../lib/types/atproto'; 2 + import type { AppBskyFeedPost } from '@atproto/api'; 3 3 import { extractCidFromBlobRef, blobCdnUrl } from '../../lib/atproto/blob-url'; 4 4 import { loadConfig } from '../../lib/config/site'; 5 5 6 6 interface Props { 7 - post: BlueskyPost; 7 + post: AppBskyFeedPost.Record; 8 + author?: { 9 + displayName?: string; 10 + handle?: string; 11 + }; 8 12 showAuthor?: boolean; 9 13 showTimestamp?: boolean; 10 14 } 11 15 12 - const { post, showAuthor = false, showTimestamp = true } = Astro.props; 16 + const { post, author, showAuthor = false, showTimestamp = true } = Astro.props; 13 17 14 18 // Validate post data 15 19 if (!post || !post.text) { ··· 57 61 --- 58 62 59 63 <article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 mb-4"> 60 - {showAuthor && post.author && ( 64 + {showAuthor && author && ( 61 65 <div class="flex items-center mb-3"> 62 66 <div class="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm font-medium"> 63 - {post.author.displayName?.[0] || 'U'} 67 + {author.displayName?.[0] || 'U'} 64 68 </div> 65 69 <div class="ml-3"> 66 70 <div class="text-sm font-medium text-gray-900 dark:text-white"> 67 - {post.author.displayName || 'Unknown'} 71 + {author.displayName || 'Unknown'} 68 72 </div> 69 73 <div class="text-xs text-gray-500 dark:text-gray-400"> 70 - @{post.author.handle || 'unknown'} 74 + @{author.handle || 'unknown'} 71 75 </div> 72 76 </div> 73 77 </div> ··· 80 84 {post.embed && ( 81 85 <div class="mb-3"> 82 86 {/* Handle image embeds */} 83 - {post.embed.$type === 'app.bsky.embed.images' && post.embed.images && ( 87 + {post.embed.$type === 'app.bsky.embed.images' && 'images' in post.embed && post.embed.images && ( 84 88 renderImages(post.embed.images) 85 89 )} 86 90 87 91 {/* Handle external link embeds */} 88 - {post.embed.$type === 'app.bsky.embed.external' && post.embed.external && ( 92 + {post.embed.$type === 'app.bsky.embed.external' && 'external' in post.embed && post.embed.external && ( 89 93 <div class="border border-gray-200 dark:border-gray-700 rounded-lg p-3"> 90 94 <div class="text-sm text-gray-600 dark:text-gray-400 mb-1"> 91 95 {post.embed.external.uri} ··· 102 106 )} 103 107 104 108 {/* Handle record embeds (quotes/reposts) */} 105 - {post.embed.$type === 'app.bsky.embed.record' && post.embed.record && ( 109 + {post.embed.$type === 'app.bsky.embed.record' && 'record' in post.embed && post.embed.record && ( 106 110 <div class="border border-gray-200 dark:border-gray-700 rounded-lg p-3 bg-gray-50 dark:bg-gray-700"> 107 111 <div class="text-sm text-gray-600 dark:text-gray-400"> 108 112 Quoted post
+1 -1
src/components/content/ContentDisplay.astro
··· 1 1 --- 2 - import type { AtprotoRecord } from '../../lib/types/atproto'; 2 + import type { AtprotoRecord } from '../../lib/atproto/atproto-browser'; 3 3 import type { GeneratedLexiconUnion } from '../../lib/generated/lexicon-types'; 4 4 import { getComponentInfo, autoAssignComponent } from '../../lib/components/registry'; 5 5 import { loadConfig } from '../../lib/config/site';
+1 -1
src/components/content/ContentFeed.astro
··· 1 1 --- 2 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 5 import { extractCidFromBlobRef, blobCdnUrl } from '../../lib/atproto/blob-url'; 6 6 7 7
-69
src/components/content/GrainImageGallery.astro
··· 1 - --- 2 - import type { GrainImageGallery } from '../../lib/types/atproto'; 3 - import type { SocialGrainGallery } from '../../lib/generated/social-grain-gallery'; 4 - 5 - interface Props { 6 - gallery: GrainImageGallery; 7 - showDescription?: boolean; 8 - showTimestamp?: boolean; 9 - columns?: number; 10 - } 11 - 12 - const { gallery, showDescription = true, showTimestamp = true, columns = 3 } = Astro.props; 13 - 14 - const formatDate = (dateString: string) => { 15 - return new Date(dateString).toLocaleDateString('en-US', { 16 - year: 'numeric', 17 - month: 'long', 18 - day: 'numeric', 19 - }); 20 - }; 21 - 22 - const gridCols = { 23 - 1: 'grid-cols-1', 24 - 2: 'grid-cols-2', 25 - 3: 'grid-cols-3', 26 - 4: 'grid-cols-4', 27 - 5: 'grid-cols-5', 28 - 6: 'grid-cols-6', 29 - }[columns] || 'grid-cols-3'; 30 - --- 31 - 32 - <article class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-6"> 33 - <header class="mb-4"> 34 - <h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2"> 35 - {gallery.title} 36 - </h2> 37 - 38 - {showDescription && gallery.description && ( 39 - <div class="text-gray-600 dark:text-gray-400 mb-3"> 40 - {gallery.description} 41 - </div> 42 - )} 43 - 44 - {showTimestamp && ( 45 - <div class="text-sm text-gray-500 dark:text-gray-400 mb-4"> 46 - Created on {formatDate(gallery.createdAt)} 47 - </div> 48 - )} 49 - </header> 50 - 51 - {gallery.images && gallery.images.length > 0 && ( 52 - <div class={`grid ${gridCols} gap-4`}> 53 - {gallery.images.map((image) => ( 54 - <div class="relative group"> 55 - <img 56 - src={image.url} 57 - alt={image.alt || 'Gallery image'} 58 - class="w-full h-48 object-cover rounded-lg transition-transform duration-200 group-hover:scale-105" 59 - /> 60 - {image.alt && ( 61 - <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"> 62 - {image.alt} 63 - </div> 64 - )} 65 - </div> 66 - ))} 67 - </div> 68 - )} 69 - </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>
+8
src/lib/atproto/atproto-browser.ts
··· 9 9 indexedAt: string; 10 10 collection: string; 11 11 $type: string; 12 + author?: { 13 + displayName?: string; 14 + handle?: string; 15 + }; 12 16 } 13 17 14 18 export interface RepoInfo { ··· 254 258 indexedAt: item.post.indexedAt, 255 259 collection: item.post.uri.split('/')[2] || 'unknown', 256 260 $type: (item.post.record?.$type as string) || 'unknown', 261 + author: item.post.author ? { 262 + displayName: item.post.author.displayName, 263 + handle: item.post.author.handle, 264 + } : undefined, 257 265 })); 258 266 259 267 return records;
-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();
+6 -5
src/lib/components/registry.ts
··· 22 22 component: 'StatusUpdate', 23 23 props: {} 24 24 }, 25 - // Add more mappings as you create components 26 - // 'ComExampleRecord': { 27 - // component: 'ExampleComponent', 28 - // props: {} 29 - // } 25 + // Bluesky posts (not in generated types, but used by components) 26 + 'app.bsky.feed.post': { 27 + component: 'BlueskyPost', 28 + props: {} 29 + }, 30 + 30 31 }; 31 32 32 33 // Type-safe component lookup
+2 -2
src/lib/services/content-renderer.ts
··· 1 1 import { AtprotoBrowser } from '../atproto/atproto-browser'; 2 2 import { loadConfig } from '../config/site'; 3 - import type { AtprotoRecord } from '../types/atproto'; 3 + import type { AtprotoRecord } from '../atproto/atproto-browser'; 4 4 5 5 export interface ContentRendererOptions { 6 6 showAuthor?: boolean; ··· 39 39 'app.bsky.feed.post': 'BlueskyPost', 40 40 'app.bsky.actor.profile#whitewindBlogPost': 'WhitewindBlogPost', 41 41 'app.bsky.actor.profile#leafletPublication': 'LeafletPublication', 42 - 'app.bsky.actor.profile#grainImageGallery': 'GrainImageGallery', 42 + 43 43 'gallery.display': 'GalleryDisplay', 44 44 }; 45 45
+1 -1
src/lib/services/content-system.ts
··· 2 2 import { JetstreamClient } from '../atproto/jetstream-client'; 3 3 import { GrainGalleryService } from './grain-gallery-service'; 4 4 import { loadConfig } from '../config/site'; 5 - import type { AtprotoRecord } from '../types/atproto'; 5 + import type { AtprotoRecord } from '../atproto/atproto-browser'; 6 6 7 7 export interface ContentItem { 8 8 uri: string;
-144
src/lib/types/atproto.ts
··· 1 - // Base ATproto record types 2 - import type { AtprotoRecord } from '../atproto/atproto-browser'; 3 - export type { AtprotoRecord }; 4 - 5 - // Bluesky post types with proper embed handling 6 - export interface BlueskyPost { 7 - text: string; 8 - createdAt: string; 9 - embed?: { 10 - $type: 'app.bsky.embed.images' | 'app.bsky.embed.external' | 'app.bsky.embed.record'; 11 - images?: Array<{ 12 - alt?: string; 13 - image: { 14 - $type: 'blob'; 15 - ref: { 16 - $link: string; 17 - }; 18 - mimeType: string; 19 - size: number; 20 - }; 21 - aspectRatio?: { 22 - width: number; 23 - height: number; 24 - }; 25 - }>; 26 - external?: { 27 - uri: string; 28 - title: string; 29 - description?: string; 30 - }; 31 - record?: { 32 - uri: string; 33 - cid: string; 34 - }; 35 - }; 36 - author?: { 37 - displayName?: string; 38 - handle?: string; 39 - }; 40 - reply?: { 41 - root: { 42 - uri: string; 43 - cid: string; 44 - }; 45 - parent: { 46 - uri: string; 47 - cid: string; 48 - }; 49 - }; 50 - facets?: Array<{ 51 - index: { 52 - byteStart: number; 53 - byteEnd: number; 54 - }; 55 - features: Array<{ 56 - $type: string; 57 - [key: string]: any; 58 - }>; 59 - }>; 60 - langs?: string[]; 61 - uri?: string; 62 - cid?: string; 63 - } 64 - 65 - // Custom lexicon types (to be extended) 66 - export interface CustomLexiconRecord { 67 - $type: string; 68 - [key: string]: any; 69 - } 70 - 71 - // Whitewind blog post type 72 - export interface WhitewindBlogPost extends CustomLexiconRecord { 73 - $type: 'app.bsky.actor.profile#whitewindBlogPost'; 74 - title: string; 75 - content: string; 76 - publishedAt: string; 77 - tags?: string[]; 78 - } 79 - 80 - // Leaflet publication type 81 - export interface LeafletPublication extends CustomLexiconRecord { 82 - $type: 'app.bsky.actor.profile#leafletPublication'; 83 - title: string; 84 - content: string; 85 - publishedAt: string; 86 - category?: string; 87 - } 88 - 89 - // Grain social image gallery type 90 - export interface GrainImageGallery extends CustomLexiconRecord { 91 - $type: 'app.bsky.actor.profile#grainImageGallery'; 92 - title: string; 93 - description?: string; 94 - images: Array<{ 95 - alt: string; 96 - url: string; 97 - }>; 98 - createdAt: string; 99 - } 100 - 101 - // Generic grain gallery post type (for posts that contain galleries) 102 - export interface GrainGalleryPost extends CustomLexiconRecord { 103 - $type: 'app.bsky.feed.post#grainGallery' | 'app.bsky.feed.post#grainImageGallery'; 104 - text?: string; 105 - createdAt: string; 106 - embed?: { 107 - $type: 'app.bsky.embed.images'; 108 - images?: Array<{ 109 - alt?: string; 110 - image: { 111 - $type: 'blob'; 112 - ref: string; 113 - mimeType: string; 114 - size: number; 115 - }; 116 - aspectRatio?: { 117 - width: number; 118 - height: number; 119 - }; 120 - }>; 121 - }; 122 - } 123 - 124 - // Union type for all supported content types 125 - export type SupportedContentType = 126 - | BlueskyPost 127 - | WhitewindBlogPost 128 - | LeafletPublication 129 - | GrainImageGallery 130 - | GrainGalleryPost; 131 - 132 - // Component registry type 133 - export interface ContentComponent { 134 - type: string; 135 - component: any; 136 - props?: Record<string, any>; 137 - } 138 - 139 - // Feed configuration type 140 - export interface FeedConfig { 141 - uri: string; 142 - limit?: number; 143 - filter?: (record: AtprotoRecord) => boolean; 144 - }
-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();