+343
CONTENT_SYSTEM.md
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
-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
-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
+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
+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
+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
+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
+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
+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
+
}
+64
-4
src/lib/atproto/atproto-browser.ts
+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
+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
-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
-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
-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
+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
-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
-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
-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
+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
-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
+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
+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
+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
+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
+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
+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": "\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\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\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\n\nStaff generated dozens of sketch thumbnails.\n\n\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\n\nPage plan with proportional grid fields and measured type\n\n\n\nPredefined text styles designed to fit in the grid and integrate with InDesignโs TOC feature\n\n\n\nStandard page layouts for collaborative consistency\n\n# The Handoff\n\n\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
+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
+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
+
};
+232
src/lib/services/content-renderer.ts
+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
+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
+
}
+197
src/lib/services/grain-gallery-service.ts
+197
src/lib/services/grain-gallery-service.ts
···
1
+
import { AtprotoBrowser } from '../atproto/atproto-browser';
2
+
import { loadConfig } from '../config/site';
3
+
import { extractCidFromBlobRef, blobCdnUrl } from '../atproto/blob-url';
4
+
import type { SocialGrainGalleryRecord } from '../generated/social-grain-gallery';
5
+
import type { SocialGrainGalleryItemRecord } from '../generated/social-grain-gallery-item';
6
+
import type { SocialGrainPhotoRecord } from '../generated/social-grain-photo';
7
+
import type { SocialGrainPhotoExifRecord } from '../generated/social-grain-photo-exif';
8
+
9
+
export interface ProcessedGrainGallery {
10
+
id: string;
11
+
title: string;
12
+
description?: string;
13
+
createdAt: string;
14
+
images: Array<{
15
+
alt?: string;
16
+
url: string;
17
+
caption?: string;
18
+
exif?: {
19
+
make?: string;
20
+
model?: string;
21
+
lensMake?: string;
22
+
lensModel?: string;
23
+
iSO?: number;
24
+
fNumber?: number;
25
+
exposureTime?: number;
26
+
focalLengthIn35mmFormat?: number;
27
+
dateTimeOriginal?: string;
28
+
};
29
+
}>;
30
+
itemCount: number;
31
+
collections: string[];
32
+
}
33
+
34
+
export class GrainGalleryService {
35
+
private browser: AtprotoBrowser;
36
+
private config: any;
37
+
38
+
constructor() {
39
+
this.config = loadConfig();
40
+
this.browser = new AtprotoBrowser();
41
+
}
42
+
43
+
// Resolve gallery URI directly from the item record if present
44
+
private extractGalleryUriFromItem(item: { value: SocialGrainGalleryItemRecord } | SocialGrainGalleryItemRecord): string | null {
45
+
const value: SocialGrainGalleryItemRecord = (item as any)?.value ?? (item as SocialGrainGalleryItemRecord);
46
+
if (typeof value?.gallery === 'string') return value.gallery;
47
+
return null;
48
+
}
49
+
50
+
// Build processed galleries using the authoritative gallery records and item mappings
51
+
private buildProcessedGalleries(
52
+
galleries: Array<{ uri: string; value: SocialGrainGalleryRecord; indexedAt: string; collection: string }>,
53
+
items: Array<{ uri: string; value: SocialGrainGalleryItemRecord }>,
54
+
photosByUri: Map<string, { uri: string; value: SocialGrainPhotoRecord }>,
55
+
exifByPhotoUri: Map<string, SocialGrainPhotoExifRecord>
56
+
): ProcessedGrainGallery[] {
57
+
// Index items by gallery URI
58
+
const itemsByGallery = new Map<string, Array<{ uri: string; value: SocialGrainGalleryItemRecord }>>();
59
+
for (const item of items) {
60
+
const galleryUri = this.extractGalleryUriFromItem(item);
61
+
if (!galleryUri) continue;
62
+
const arr = itemsByGallery.get(galleryUri) ?? [];
63
+
arr.push(item);
64
+
itemsByGallery.set(galleryUri, arr);
65
+
}
66
+
67
+
const processed: ProcessedGrainGallery[] = [];
68
+
const did = this.config.atproto.did;
69
+
70
+
for (const gallery of galleries) {
71
+
const galleryUri = gallery.uri;
72
+
const galleryItems = itemsByGallery.get(galleryUri) ?? [];
73
+
// Sort by position if available
74
+
galleryItems.sort((a, b) => {
75
+
const pa = Number(a.value.position ?? 0);
76
+
const pb = Number(b.value.position ?? 0);
77
+
return pa - pb;
78
+
});
79
+
80
+
const images: Array<{ alt?: string; url: string; caption?: string; exif?: any }> = [];
81
+
for (const item of galleryItems) {
82
+
const photoUri = item.value.item;
83
+
if (!photoUri) continue;
84
+
const photo = photosByUri.get(photoUri);
85
+
if (!photo) continue;
86
+
87
+
// Extract blob CID
88
+
const cid = extractCidFromBlobRef((photo.value as any)?.photo?.ref ?? (photo.value as any)?.photo);
89
+
if (!cid || !did) continue;
90
+
const url = blobCdnUrl(did, cid);
91
+
92
+
const exif = exifByPhotoUri.get(photoUri);
93
+
images.push({
94
+
url,
95
+
alt: photo.value.alt,
96
+
caption: (photo.value as any).caption,
97
+
exif: exif
98
+
? {
99
+
make: exif.make,
100
+
model: exif.model,
101
+
lensMake: exif.lensMake,
102
+
lensModel: exif.lensModel,
103
+
iSO: exif.iSO,
104
+
fNumber: exif.fNumber,
105
+
exposureTime: exif.exposureTime,
106
+
focalLengthIn35mmFormat: exif.focalLengthIn35mmFormat,
107
+
dateTimeOriginal: exif.dateTimeOriginal,
108
+
}
109
+
: undefined,
110
+
});
111
+
}
112
+
113
+
processed.push({
114
+
id: galleryUri,
115
+
title: gallery.value.title || 'Untitled Gallery',
116
+
description: gallery.value.description,
117
+
createdAt: gallery.value.createdAt || gallery.indexedAt,
118
+
images,
119
+
itemCount: galleryItems.length,
120
+
collections: [gallery.collection],
121
+
});
122
+
}
123
+
124
+
// Sort galleries by createdAt desc
125
+
processed.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
126
+
return processed;
127
+
}
128
+
129
+
// Fetch galleries with items, photos, and exif metadata
130
+
async getGalleries(identifier: string): Promise<ProcessedGrainGallery[]> {
131
+
try {
132
+
const repoInfo = await this.browser.getRepoInfo(identifier);
133
+
if (!repoInfo) {
134
+
throw new Error(`Could not get repository info for: ${identifier}`);
135
+
}
136
+
137
+
// Fetch the four relevant collections
138
+
const [galleryRecords, itemRecords, photoRecords, exifRecords] = await Promise.all([
139
+
this.browser.getAllCollectionRecords(identifier, 'social.grain.gallery', 500),
140
+
this.browser.getAllCollectionRecords(identifier, 'social.grain.gallery.item', 5000),
141
+
this.browser.getAllCollectionRecords(identifier, 'social.grain.photo', 5000),
142
+
this.browser.getAllCollectionRecords(identifier, 'social.grain.photo.exif', 5000),
143
+
]);
144
+
145
+
// Type and build maps for fast lookup
146
+
const typedGalleries = galleryRecords.map(r => ({
147
+
uri: r.uri,
148
+
value: r.value as SocialGrainGalleryRecord,
149
+
indexedAt: r.indexedAt,
150
+
collection: r.collection,
151
+
}));
152
+
153
+
const typedItems = itemRecords.map(r => ({
154
+
uri: r.uri,
155
+
value: r.value as SocialGrainGalleryItemRecord,
156
+
}));
157
+
158
+
const photosByUri = new Map<string, { uri: string; value: SocialGrainPhotoRecord }>();
159
+
for (const p of photoRecords) {
160
+
photosByUri.set(p.uri, { uri: p.uri, value: p.value as SocialGrainPhotoRecord });
161
+
}
162
+
163
+
const exifByPhotoUri = new Map<string, SocialGrainPhotoExifRecord>();
164
+
for (const e of exifRecords) {
165
+
const ev = e.value as SocialGrainPhotoExifRecord;
166
+
const photoUri = ev.photo;
167
+
if (photoUri) exifByPhotoUri.set(photoUri, ev);
168
+
}
169
+
170
+
const processed = this.buildProcessedGalleries(
171
+
typedGalleries,
172
+
typedItems,
173
+
photosByUri,
174
+
exifByPhotoUri,
175
+
);
176
+
177
+
return processed;
178
+
} catch (error) {
179
+
console.error('Error getting galleries:', error);
180
+
return [];
181
+
}
182
+
}
183
+
184
+
// Deprecated older flow kept for compatibility; prefer getGalleries()
185
+
186
+
// Get a specific gallery by ID
187
+
async getGallery(identifier: string, galleryId: string): Promise<ProcessedGrainGallery | null> {
188
+
try {
189
+
const galleries = await this.getGalleries(identifier);
190
+
return galleries.find(gallery => gallery.id === galleryId) || null;
191
+
} catch (error) {
192
+
console.error('Error getting gallery:', error);
193
+
return null;
194
+
}
195
+
}
196
+
197
+
}
-148
src/lib/types/atproto.ts
-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
-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
-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
+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
+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
+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
+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
-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
+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
+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
-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
+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
-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
-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
-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>