+1
-1
LEXICON_INTEGRATION.md
+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
+1
src/components/content/BlueskyFeed.astro
+14
-10
src/components/content/BlueskyPost.astro
+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
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
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
-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
-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
+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
-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
+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
+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
+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
-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
-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();