+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.
+85
-48
README.md
+85
-48
README.md
···
1
-
# ATProto Personal Website
1
+
# ATproto Personal Website
2
2
3
-
A personal website powered by ATProto, featuring real-time streaming, repository browsing, and type generation.
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
-
### ๐ Real-time Streaming
8
-
- **Jetstream Test** (`/jetstream-test`): Real-time ATProto streaming with DID filtering
9
-
- Uses the same jetstream endpoint as atptools for low-latency updates
10
-
- Filters by your configured DID for personalized streaming
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
11
14
12
-
### ๐ Repository Browsing
13
-
- **ATProto Browser Test** (`/atproto-browser-test`): Browse any ATProto account's collections and records
14
-
- Discover all collections in a repository
15
-
- View records from specific collections
16
-
- Similar functionality to atptools
15
+
## Quick Start
16
+
17
+
1. **Configure Environment**:
18
+
```bash
19
+
cp env.example .env
20
+
# Edit .env with your ATproto handle and DID
21
+
```
22
+
23
+
2. **Install Dependencies**:
24
+
```bash
25
+
npm install
26
+
```
27
+
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
17
32
18
-
### ๐ Type Generation
19
-
- **Lexicon Generator Test** (`/lexicon-generator-test`): Generate TypeScript types for all lexicons in your repository
20
-
- Automatically discovers all lexicon types from your configured account
21
-
- Generates proper TypeScript interfaces and helper functions
22
-
- Copy to clipboard or download as `.ts` file
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
23
37
24
-
### ๐ผ๏ธ Image Galleries
25
-
- **Image Galleries** (`/galleries`): View grain.social image galleries and photo collections
38
+
5. **Start Development**:
39
+
```bash
40
+
npm run dev
41
+
```
26
42
27
-
## Configuration
43
+
## Lexicon Integration
28
44
29
-
The site is configured to use your ATProto account:
45
+
The system provides full type safety for ATproto lexicons:
30
46
31
-
- **Handle**: `tynanpurdy.com`
32
-
- **DID**: `did:plc:6ayddqghxhciedbaofoxkcbs`
33
-
- **PDS**: `https://bsky.social`
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
34
51
35
-
## Development
52
+
See [LEXICON_INTEGRATION.md](./LEXICON_INTEGRATION.md) for detailed instructions.
36
53
37
-
```bash
38
-
npm install
39
-
npm run dev
40
-
```
54
+
## Available Scripts
41
55
42
-
Visit `http://localhost:4324` to see the site.
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
43
61
44
62
## Project Structure
45
63
46
64
```
47
65
src/
48
-
โโโ lib/atproto/
49
-
โ โโโ atproto-browser.ts # Repository browsing functionality
50
-
โ โโโ jetstream-client.ts # Real-time streaming client
51
-
โ โโโ client.ts # Basic ATProto client
52
-
โโโ pages/
53
-
โ โโโ index.astro # Homepage with navigation
54
-
โ โโโ jetstream-test.astro # Real-time streaming test
55
-
โ โโโ atproto-browser-test.astro # Repository browsing test
56
-
โ โโโ lexicon-generator-test.astro # Type generation test
57
-
โ โโโ galleries.astro # Image galleries
58
-
โโโ components/
59
-
โโโ content/ # Content display components
66
+
โโโ components/content/ # Content display components
67
+
โโโ lib/
68
+
โ โโโ atproto/ # ATproto client and utilities
69
+
โ โโโ components/ # Component registry
70
+
โ โโโ config/ # Site configuration
71
+
โ โโโ generated/ # Generated TypeScript types
72
+
โ โโโ services/ # Content services
73
+
โ โโโ types/ # Type definitions
74
+
โโโ lexicons/ # Lexicon schema files
75
+
โโโ pages/ # Astro pages
60
76
```
61
77
62
-
## Technologies
78
+
## Configuration
63
79
64
-
- **Astro**: Web framework
65
-
- **ATProto API**: For repository access and streaming
66
-
- **TypeScript**: For type safety
67
-
- **Tailwind CSS**: For styling
80
+
The system is configured via environment variables and `src/lib/config/site.ts`:
81
+
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
87
+
88
+
## Adding New Content Types
89
+
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`
95
+
96
+
The system will automatically route records to your components with full type safety.
68
97
69
-
## Inspired By
98
+
## Development
70
99
71
-
This project takes inspiration from [atptools](https://github.com/espeon/atptools) for repository browsing and jetstream streaming approaches.
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
105
+
106
+
## License
107
+
108
+
MIT
+440
-2
package-lock.json
+440
-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",
···
20
22
"tailwindcss": "^4.1.11",
21
23
"tsx": "^4.19.2",
22
24
"typescript": "^5.9.2"
25
+
},
26
+
"devDependencies": {
27
+
"@atproto/lex-cli": "^0.9.1"
23
28
}
24
29
},
25
30
"node_modules/@ampproject/remapping": {
···
174
179
"yaml": "^2.5.0"
175
180
}
176
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
+
},
177
226
"node_modules/@atproto/api": {
178
227
"version": "0.16.2",
179
228
"resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.16.2.tgz",
···
202
251
"zod": "^3.23.8"
203
252
}
204
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
+
},
205
310
"node_modules/@atproto/lexicon": {
206
311
"version": "0.4.12",
207
312
"resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.4.12.tgz",
···
277
382
"node": ">=6.9.0"
278
383
}
279
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"
392
+
}
393
+
},
280
394
"node_modules/@capsizecss/unpack": {
281
395
"version": "2.4.0",
282
396
"resolved": "https://registry.npmjs.org/@capsizecss/unpack/-/unpack-2.4.0.tgz",
···
1177
1291
"@jridgewell/sourcemap-codec": "^1.4.14"
1178
1292
}
1179
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
+
},
1180
1309
"node_modules/@nodelib/fs.scandir": {
1181
1310
"version": "2.1.5",
1182
1311
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
···
1212
1341
"node": ">= 8"
1213
1342
}
1214
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"
1355
+
}
1356
+
},
1215
1357
"node_modules/@oslojs/encoding": {
1216
1358
"version": "1.1.0",
1217
1359
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
···
1859
2001
"vite": "^5.2.0 || ^6 || ^7"
1860
2002
}
1861
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
+
},
1862
2016
"node_modules/@types/debug": {
1863
2017
"version": "4.1.12",
1864
2018
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
···
2289
2443
"url": "https://github.com/sponsors/wooorm"
2290
2444
}
2291
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
+
},
2292
2453
"node_modules/base-64": {
2293
2454
"version": "1.0.0",
2294
2455
"resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz",
···
2357
2518
"url": "https://github.com/sponsors/sindresorhus"
2358
2519
}
2359
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"
2529
+
}
2530
+
},
2360
2531
"node_modules/braces": {
2361
2532
"version": "3.0.3",
2362
2533
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
···
2598
2769
"node": ">=6"
2599
2770
}
2600
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
+
},
2601
2779
"node_modules/color": {
2602
2780
"version": "4.2.3",
2603
2781
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
···
2649
2827
"funding": {
2650
2828
"type": "github",
2651
2829
"url": "https://github.com/sponsors/wooorm"
2830
+
}
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"
2652
2840
}
2653
2841
},
2654
2842
"node_modules/common-ancestor-path": {
···
2745
2933
"url": "https://github.com/sponsors/wooorm"
2746
2934
}
2747
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
+
},
2748
2945
"node_modules/defu": {
2749
2946
"version": "6.1.4",
2750
2947
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
···
2827
3024
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
2828
3025
"license": "MIT"
2829
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
+
},
2830
3094
"node_modules/dotenv": {
2831
3095
"version": "17.2.1",
2832
3096
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz",
···
2963
3227
"url": "https://github.com/sponsors/sindresorhus"
2964
3228
}
2965
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"
3235
+
},
2966
3236
"node_modules/estree-walker": {
2967
3237
"version": "3.0.3",
2968
3238
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
···
2971
3241
"dependencies": {
2972
3242
"@types/estree": "^1.0.0"
2973
3243
}
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"
2974
3250
},
2975
3251
"node_modules/eventemitter3": {
2976
3252
"version": "5.0.1",
···
3187
3463
"uncrypto": "^0.1.3"
3188
3464
}
3189
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"
3474
+
}
3475
+
},
3190
3476
"node_modules/hast-util-from-html": {
3191
3477
"version": "2.0.3",
3192
3478
"resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz",
···
3390
3676
"url": "https://github.com/sponsors/wooorm"
3391
3677
}
3392
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
+
},
3393
3710
"node_modules/http-cache-semantics": {
3394
3711
"version": "4.2.0",
3395
3712
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
···
3506
3823
"url": "https://github.com/sponsors/sindresorhus"
3507
3824
}
3508
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
+
},
3509
3835
"node_modules/is-wsl": {
3510
3836
"version": "3.1.0",
3511
3837
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz",
···
3560
3886
"integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==",
3561
3887
"license": "MIT"
3562
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
+
}
3913
+
},
3563
3914
"node_modules/kleur": {
3564
3915
"version": "4.1.5",
3565
3916
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
···
4707
5058
"url": "https://github.com/sponsors/jonschlinkert"
4708
5059
}
4709
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
+
},
4710
5077
"node_modules/minipass": {
4711
5078
"version": "7.1.2",
4712
5079
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
···
4958
5325
"url": "https://github.com/sponsors/wooorm"
4959
5326
}
4960
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
+
},
4961
5334
"node_modules/parse5": {
4962
5335
"version": "7.3.0",
4963
5336
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
···
4968
5341
},
4969
5342
"funding": {
4970
5343
"url": "https://github.com/inikulin/parse5?sponsor=1"
5344
+
}
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"
4971
5353
}
4972
5354
},
4973
5355
"node_modules/path-browserify": {
···
5039
5421
"version": "3.6.2",
5040
5422
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
5041
5423
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
5424
+
"devOptional": true,
5042
5425
"license": "MIT",
5043
-
"optional": true,
5044
-
"peer": true,
5045
5426
"bin": {
5046
5427
"prettier": "bin/prettier.cjs"
5047
5428
},
···
5470
5851
"queue-microtask": "^1.2.2"
5471
5852
}
5472
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
+
},
5473
5880
"node_modules/semver": {
5474
5881
"version": "7.7.2",
5475
5882
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
···
5631
6038
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
5632
6039
}
5633
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
+
},
5634
6054
"node_modules/tailwindcss": {
5635
6055
"version": "4.1.11",
5636
6056
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz",
···
5736
6156
"funding": {
5737
6157
"type": "github",
5738
6158
"url": "https://github.com/sponsors/wooorm"
6159
+
}
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"
5739
6170
}
5740
6171
},
5741
6172
"node_modules/tsconfck": {
···
6761
7192
"engines": {
6762
7193
"node": ">=8"
6763
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"
6764
7202
},
6765
7203
"node_modules/yocto-queue": {
6766
7204
"version": "1.2.1",
+8
-1
package.json
+8
-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",
···
21
25
"tailwindcss": "^4.1.11",
22
26
"tsx": "^4.19.2",
23
27
"typescript": "^5.9.2"
28
+
},
29
+
"devDependencies": {
30
+
"@atproto/lex-cli": "^0.9.1"
24
31
}
25
32
}
+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();
+1
-3
src/components/content/BlueskyFeed.astro
+1
-3
src/components/content/BlueskyFeed.astro
···
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
15
const browser = new AtprotoBrowser();
···
34
33
blueskyPosts.map((record) => (
35
34
<BlueskyPost
36
35
post={record.value}
37
-
showAuthor={showAuthor}
38
36
showTimestamp={showTimestamp}
39
37
/>
40
38
))
+7
-22
src/components/content/BlueskyPost.astro
+7
-22
src/components/content/BlueskyPost.astro
···
1
1
---
2
-
import type { BlueskyPost } from '../../lib/types/atproto';
2
+
import type { AppBskyFeedPost } from '@atproto/api';
3
3
import { extractCidFromBlobRef, blobCdnUrl } from '../../lib/atproto/blob-url';
4
4
import { loadConfig } from '../../lib/config/site';
5
5
6
6
interface Props {
7
-
post: BlueskyPost;
8
-
showAuthor?: boolean;
7
+
post: AppBskyFeedPost.Record;
9
8
showTimestamp?: boolean;
10
9
}
11
10
12
-
const { post, showAuthor = false, showTimestamp = true } = Astro.props;
11
+
const { post, showTimestamp = true } = Astro.props;
13
12
14
13
// Validate post data
15
14
if (!post || !post.text) {
···
57
56
---
58
57
59
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">
60
-
{showAuthor && post.author && (
61
-
<div class="flex items-center mb-3">
62
-
<div class="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm font-medium">
63
-
{post.author.displayName?.[0] || 'U'}
64
-
</div>
65
-
<div class="ml-3">
66
-
<div class="text-sm font-medium text-gray-900 dark:text-white">
67
-
{post.author.displayName || 'Unknown'}
68
-
</div>
69
-
<div class="text-xs text-gray-500 dark:text-gray-400">
70
-
@{post.author.handle || 'unknown'}
71
-
</div>
72
-
</div>
73
-
</div>
74
-
)}
59
+
75
60
76
61
<div class="text-gray-900 dark:text-white mb-3">
77
62
{post.text}
···
80
65
{post.embed && (
81
66
<div class="mb-3">
82
67
{/* Handle image embeds */}
83
-
{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 && (
84
69
renderImages(post.embed.images)
85
70
)}
86
71
87
72
{/* Handle external link embeds */}
88
-
{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 && (
89
74
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-3">
90
75
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
91
76
{post.embed.external.uri}
···
102
87
)}
103
88
104
89
{/* Handle record embeds (quotes/reposts) */}
105
-
{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 && (
106
91
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-3 bg-gray-50 dark:bg-gray-700">
107
92
<div class="text-sm text-gray-600 dark:text-gray-400">
108
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
+
)}
+18
-23
src/components/content/ContentFeed.astro
+18
-23
src/components/content/ContentFeed.astro
···
1
1
---
2
2
import { AtprotoBrowser } from '../../lib/atproto/atproto-browser';
3
3
import { loadConfig } from '../../lib/config/site';
4
-
import type { AtprotoRecord } from '../../lib/types/atproto';
5
-
import { extractCidFromBlobRef, blobCdnUrl } from '../../lib/atproto/blob';
4
+
import type { AtprotoRecord } from '../../lib/atproto/atproto-browser';
5
+
import { extractCidFromBlobRef, blobCdnUrl } from '../../lib/atproto/blob-url';
6
6
7
7
8
8
interface Props {
9
9
collection?: string;
10
10
limit?: number;
11
11
feedUri?: string;
12
-
showAuthor?: boolean;
13
12
showTimestamp?: boolean;
14
13
live?: boolean;
15
14
}
···
18
17
collection = 'app.bsky.feed.post',
19
18
limit = 10,
20
19
feedUri,
21
-
showAuthor = true,
22
20
showTimestamp = true,
23
21
live = false,
24
22
} = Astro.props;
···
213
211
}
214
212
215
213
try {
216
-
const endpoint = 'wss://jetstream1.us-east.bsky.network/subscribe';
217
-
const url = new URL(endpoint);
218
-
if (DID) url.searchParams.append('wantedDids', DID);
219
-
const ws = new WebSocket(url.toString());
220
-
221
-
ws.onmessage = (event) => {
222
-
try {
223
-
const data = JSON.parse(event.data);
224
-
if (data?.kind !== 'commit') return;
225
-
const commit = data.commit;
226
-
const record = commit?.record || {};
227
-
if (commit?.operation !== 'create') return;
228
-
if (record?.$type !== 'app.bsky.feed.post') return;
229
-
const el = buildPostEl(record, data.did);
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);
230
224
// @ts-ignore
231
225
container.insertBefore(el, container.firstChild);
232
226
const posts = container.children;
233
227
if (posts.length > maxPrepend + INITIAL_LIMIT) {
234
228
if (container.lastElementChild) container.removeChild(container.lastElementChild);
235
229
}
236
-
} catch (e) {
237
-
console.error('jetstream msg error', e);
238
230
}
239
-
};
240
-
241
-
ws.onerror = (e) => console.error('jetstream ws error', e);
231
+
});
232
+
233
+
// Cleanup on page unload
234
+
window.addEventListener('beforeunload', () => {
235
+
unsubscribe();
236
+
});
242
237
} catch (e) {
243
238
console.error('jetstream start error', e);
244
239
}
-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>
+12
-21
src/components/content/WhitewindBlogPost.astro
+12
-21
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
3
import { marked } from 'marked';
4
4
5
5
interface Props {
6
-
post: WhitewindBlogPost;
6
+
record: ComWhtwndBlogEntryRecord;
7
7
showTags?: boolean;
8
8
showTimestamp?: boolean;
9
9
}
10
10
11
-
const { post, showTags = true, showTimestamp = true } = Astro.props;
11
+
const { record, showTags = true, showTimestamp = true } = Astro.props;
12
12
13
13
const formatDate = (dateString: string) => {
14
14
return new Date(dateString).toLocaleDateString('en-US', {
···
18
18
});
19
19
};
20
20
21
-
// Prefer explicit publishedAt; fall back to createdAt or indexedAt if present
22
-
const publishedRaw = (post as any).publishedAt || (post as any).createdAt || (post as any).indexedAt || null;
23
-
const publishedSafe = publishedRaw && !isNaN(new Date(publishedRaw).getTime()) ? publishedRaw : null;
21
+
const published = record.createdAt;
22
+
const isValidDate = published ? !isNaN(new Date(published).getTime()) : false;
24
23
---
25
24
26
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">
27
26
<header class="mb-4">
28
27
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">
29
-
{post.title}
28
+
{record.title}
30
29
</h2>
31
30
32
-
{showTimestamp && publishedSafe && (
31
+
{showTimestamp && isValidDate && (
33
32
<div class="text-sm text-gray-500 dark:text-gray-400 mb-3">
34
-
Published on {formatDate(publishedSafe)}
35
-
</div>
36
-
)}
37
-
38
-
{showTags && post.tags && post.tags.length > 0 && (
39
-
<div class="flex flex-wrap gap-2 mb-4">
40
-
{post.tags.map((tag) => (
41
-
<span class="px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 text-xs rounded-full">
42
-
#{tag}
43
-
</span>
44
-
))}
33
+
Published on {formatDate(published!)}
45
34
</div>
46
35
)}
47
36
</header>
48
37
49
-
<div class="prose prose-white dark:prose-invert max-w-none">
50
-
<Fragment set:html={await marked(post.content || '')} />
38
+
<div class="prose prose-gray dark:prose-invert max-w-none">
39
+
<div class="text-gray-700 dark:text-gray-300 leading-relaxed">
40
+
<Fragment set:html={await marked(record.content || '')} />
41
+
</div>
51
42
</div>
52
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 };
+3
src/layouts/Layout.astro
+3
src/layouts/Layout.astro
···
38
38
<a href="/blog" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors">
39
39
Blog
40
40
</a>
41
+
<a href="/now" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors">
42
+
Now
43
+
</a>
41
44
</div>
42
45
</div>
43
46
</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
+
}
+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
+
+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
}
-113
src/lib/components/discovered-registry.ts
-113
src/lib/components/discovered-registry.ts
···
1
-
import type { DiscoveredTypes } from '../generated/discovered-types';
2
-
import type { AnyRecordByType } from '../atproto/record-types';
3
-
4
-
export type DiscoveredComponent<T extends string = DiscoveredTypes> = {
5
-
$type: T;
6
-
component: string;
7
-
props: Record<string, unknown>;
8
-
}
9
-
10
-
export type ComponentRegistry = Record<string, { component: string; props?: Record<string, unknown> }>
11
-
12
-
export class DiscoveredComponentRegistry {
13
-
private registry: ComponentRegistry = {};
14
-
private discoveredTypes: DiscoveredTypes[] = [];
15
-
16
-
constructor() {
17
-
this.initializeRegistry();
18
-
}
19
-
20
-
// Initialize the registry with discovered types
21
-
private initializeRegistry(): void {
22
-
// This will be populated with discovered types
23
-
// For now, we'll use a basic mapping
24
-
this.registry = {
25
-
'app.bsky.feed.post': {
26
-
component: 'BlueskyPost',
27
-
props: { showAuthor: false, showTimestamp: true }
28
-
},
29
-
'app.bsky.actor.profile': {
30
-
component: 'ProfileDisplay',
31
-
props: { showHandle: true }
32
-
},
33
-
'social.grain.gallery': {
34
-
component: 'GrainGalleryDisplay',
35
-
props: { showCollections: true, columns: 3 }
36
-
},
37
-
'grain.social.feed.gallery': {
38
-
component: 'GrainGalleryDisplay',
39
-
props: { showCollections: true, columns: 3 }
40
-
}
41
-
};
42
-
}
43
-
44
-
// Register a component for a specific $type
45
-
registerComponent($type: DiscoveredTypes, component: string, props?: Record<string, unknown>): void {
46
-
this.registry[$type] = {
47
-
component,
48
-
props
49
-
};
50
-
}
51
-
52
-
// Get component info for a $type
53
-
getComponent($type: DiscoveredTypes): { component: string; props?: Record<string, unknown> } | null {
54
-
return this.registry[$type] || null;
55
-
}
56
-
57
-
// Get all registered $types
58
-
getRegisteredTypes(): DiscoveredTypes[] {
59
-
return Object.keys(this.registry) as DiscoveredTypes[];
60
-
}
61
-
62
-
// Check if a $type has a registered component
63
-
hasComponent($type: DiscoveredTypes): boolean {
64
-
return $type in this.registry;
65
-
}
66
-
67
-
// Get component mapping for rendering
68
-
getComponentMapping(): ComponentRegistry {
69
-
return this.registry;
70
-
}
71
-
72
-
// Update discovered types (called after build-time discovery)
73
-
updateDiscoveredTypes(types: DiscoveredTypes[]): void {
74
-
this.discoveredTypes = types;
75
-
76
-
// Auto-register components for discovered types that don't have explicit mappings
77
-
for (const $type of types) {
78
-
if (!this.hasComponent($type)) {
79
-
// Auto-assign based on service/collection
80
-
const component = this.autoAssignComponent($type);
81
-
if (component) {
82
-
this.registerComponent($type, component);
83
-
}
84
-
}
85
-
}
86
-
}
87
-
88
-
// Auto-assign component based on $type
89
-
private autoAssignComponent($type: DiscoveredTypes): string | null {
90
-
if ($type.includes('grain') || $type.includes('gallery')) {
91
-
return 'GrainGalleryDisplay';
92
-
}
93
-
if ($type.includes('post') || $type.includes('feed')) {
94
-
return 'BlueskyPost';
95
-
}
96
-
if ($type.includes('profile') || $type.includes('actor')) {
97
-
return 'ProfileDisplay';
98
-
}
99
-
return 'GenericContentDisplay';
100
-
}
101
-
102
-
// Get component info for rendering
103
-
getComponentInfo<T extends DiscoveredTypes>($type: T): DiscoveredComponent<T> | null {
104
-
const componentInfo = this.getComponent($type);
105
-
if (!componentInfo) return null;
106
-
107
-
return {
108
-
$type,
109
-
component: componentInfo.component,
110
-
props: componentInfo.props || {}
111
-
};
112
-
}
113
-
}
-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
}
+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
+
};
+2
-2
src/lib/services/content-renderer.ts
+2
-2
src/lib/services/content-renderer.ts
···
1
1
import { AtprotoBrowser } from '../atproto/atproto-browser';
2
2
import { loadConfig } from '../config/site';
3
-
import type { AtprotoRecord } from '../types/atproto';
3
+
import type { AtprotoRecord } from '../atproto/atproto-browser';
4
4
5
5
export interface ContentRendererOptions {
6
6
showAuthor?: boolean;
···
39
39
'app.bsky.feed.post': 'BlueskyPost',
40
40
'app.bsky.actor.profile#whitewindBlogPost': 'WhitewindBlogPost',
41
41
'app.bsky.actor.profile#leafletPublication': 'LeafletPublication',
42
-
'app.bsky.actor.profile#grainImageGallery': 'GrainImageGallery',
42
+
43
43
'gallery.display': 'GalleryDisplay',
44
44
};
45
45
+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
+
}
-271
src/lib/services/content-system.ts
-271
src/lib/services/content-system.ts
···
1
-
import { AtprotoBrowser } from '../atproto/atproto-browser';
2
-
import { JetstreamClient } from '../atproto/jetstream-client';
3
-
import { GrainGalleryService } from './grain-gallery-service';
4
-
import { loadConfig } from '../config/site';
5
-
import type { AtprotoRecord } from '../types/atproto';
6
-
7
-
export interface ContentItem {
8
-
uri: string;
9
-
cid: string;
10
-
$type: string;
11
-
collection: string;
12
-
createdAt: string;
13
-
indexedAt: string;
14
-
value: any;
15
-
service: string;
16
-
operation?: 'create' | 'update' | 'delete';
17
-
}
18
-
19
-
export interface ContentFeed {
20
-
items: ContentItem[];
21
-
lastUpdated: string;
22
-
totalItems: number;
23
-
collections: string[];
24
-
}
25
-
26
-
export interface ContentSystemConfig {
27
-
enableStreaming?: boolean;
28
-
buildTimeOnly?: boolean;
29
-
collections?: string[];
30
-
maxItems?: number;
31
-
}
32
-
33
-
export class ContentSystem {
34
-
private browser: AtprotoBrowser;
35
-
private jetstream: JetstreamClient;
36
-
private grainGalleryService: GrainGalleryService;
37
-
private config: any;
38
-
private contentFeed: ContentFeed;
39
-
private isStreaming = false;
40
-
41
-
constructor() {
42
-
this.config = loadConfig();
43
-
this.browser = new AtprotoBrowser();
44
-
this.jetstream = new JetstreamClient();
45
-
this.grainGalleryService = new GrainGalleryService();
46
-
47
-
this.contentFeed = {
48
-
items: [],
49
-
lastUpdated: new Date().toISOString(),
50
-
totalItems: 0,
51
-
collections: []
52
-
};
53
-
}
54
-
55
-
// Initialize content system (build-time)
56
-
async initialize(identifier: string, options: ContentSystemConfig = {}): Promise<ContentFeed> {
57
-
console.log('๐ Initializing content system for:', identifier);
58
-
59
-
try {
60
-
// Get repository info
61
-
const repoInfo = await this.browser.getRepoInfo(identifier);
62
-
if (!repoInfo) {
63
-
throw new Error(`Could not get repository info for: ${identifier}`);
64
-
}
65
-
66
-
console.log('๐ Repository info:', {
67
-
handle: repoInfo.handle,
68
-
did: repoInfo.did,
69
-
collections: repoInfo.collections.length,
70
-
recordCount: repoInfo.recordCount
71
-
});
72
-
73
-
// Gather all content from collections
74
-
const allItems: ContentItem[] = [];
75
-
const collections = options.collections || repoInfo.collections;
76
-
77
-
for (const collection of collections) {
78
-
console.log(`๐ฆ Fetching from collection: ${collection}`);
79
-
const records = await this.browser.getCollectionRecords(identifier, collection, options.maxItems || 100);
80
-
81
-
if (records && records.records) {
82
-
for (const record of records.records) {
83
-
const contentItem: ContentItem = {
84
-
uri: record.uri,
85
-
cid: record.cid,
86
-
$type: record.$type,
87
-
collection: record.collection,
88
-
createdAt: record.value?.createdAt || record.indexedAt,
89
-
indexedAt: record.indexedAt,
90
-
value: record.value,
91
-
service: this.inferService(record.$type, record.collection),
92
-
operation: 'create' // Build-time items are existing
93
-
};
94
-
95
-
allItems.push(contentItem);
96
-
}
97
-
}
98
-
}
99
-
100
-
// Sort by creation date (newest first)
101
-
allItems.sort((a, b) => {
102
-
const dateA = new Date(a.createdAt);
103
-
const dateB = new Date(b.createdAt);
104
-
return dateB.getTime() - dateA.getTime();
105
-
});
106
-
107
-
this.contentFeed = {
108
-
items: allItems,
109
-
lastUpdated: new Date().toISOString(),
110
-
totalItems: allItems.length,
111
-
collections: collections
112
-
};
113
-
114
-
console.log(`โ
Content system initialized with ${allItems.length} items`);
115
-
116
-
// Start streaming if enabled
117
-
if (!options.buildTimeOnly && options.enableStreaming !== false) {
118
-
await this.startStreaming(identifier);
119
-
}
120
-
121
-
return this.contentFeed;
122
-
} catch (error) {
123
-
console.error('Error initializing content system:', error);
124
-
throw error;
125
-
}
126
-
}
127
-
128
-
// Start real-time streaming
129
-
async startStreaming(identifier: string): Promise<void> {
130
-
if (this.isStreaming) {
131
-
console.log('โ ๏ธ Already streaming');
132
-
return;
133
-
}
134
-
135
-
console.log('๐ Starting real-time content streaming...');
136
-
this.isStreaming = true;
137
-
138
-
// Set up jetstream event handlers
139
-
this.jetstream.onRecord((record) => {
140
-
this.handleNewContent(record);
141
-
});
142
-
143
-
this.jetstream.onError((error) => {
144
-
console.error('โ Jetstream error:', error);
145
-
});
146
-
147
-
this.jetstream.onConnect(() => {
148
-
console.log('โ
Connected to real-time stream');
149
-
});
150
-
151
-
this.jetstream.onDisconnect(() => {
152
-
console.log('๐ Disconnected from real-time stream');
153
-
this.isStreaming = false;
154
-
});
155
-
156
-
// Start streaming
157
-
await this.jetstream.startStreaming();
158
-
}
159
-
160
-
// Handle new content from streaming
161
-
private handleNewContent(jetstreamRecord: any): void {
162
-
const contentItem: ContentItem = {
163
-
uri: jetstreamRecord.uri,
164
-
cid: jetstreamRecord.cid,
165
-
$type: jetstreamRecord.$type,
166
-
collection: jetstreamRecord.collection,
167
-
createdAt: jetstreamRecord.value?.createdAt || jetstreamRecord.indexedAt,
168
-
indexedAt: jetstreamRecord.indexedAt,
169
-
value: jetstreamRecord.value,
170
-
service: jetstreamRecord.service,
171
-
operation: jetstreamRecord.operation
172
-
};
173
-
174
-
// Add to beginning of feed (newest first)
175
-
this.contentFeed.items.unshift(contentItem);
176
-
this.contentFeed.totalItems++;
177
-
this.contentFeed.lastUpdated = new Date().toISOString();
178
-
179
-
console.log('๐ New content added:', {
180
-
$type: contentItem.$type,
181
-
collection: contentItem.collection,
182
-
operation: contentItem.operation
183
-
});
184
-
185
-
// Emit event for UI updates
186
-
this.emitContentUpdate(contentItem);
187
-
}
188
-
189
-
// Get current content feed
190
-
getContentFeed(): ContentFeed {
191
-
return this.contentFeed;
192
-
}
193
-
194
-
// Get content by type
195
-
getContentByType($type: string): ContentItem[] {
196
-
return this.contentFeed.items.filter(item => item.$type === $type);
197
-
}
198
-
199
-
// Get content by collection
200
-
getContentByCollection(collection: string): ContentItem[] {
201
-
return this.contentFeed.items.filter(item => item.collection === collection);
202
-
}
203
-
204
-
// Get galleries (using specialized service)
205
-
async getGalleries(identifier: string): Promise<any[]> {
206
-
return await this.grainGalleryService.getGalleries(identifier);
207
-
}
208
-
209
-
// Filter content by function
210
-
filterContent(filterFn: (item: ContentItem) => boolean): ContentItem[] {
211
-
return this.contentFeed.items.filter(filterFn);
212
-
}
213
-
214
-
// Search content
215
-
searchContent(query: string): ContentItem[] {
216
-
const lowerQuery = query.toLowerCase();
217
-
return this.contentFeed.items.filter(item => {
218
-
const text = JSON.stringify(item.value).toLowerCase();
219
-
return text.includes(lowerQuery);
220
-
});
221
-
}
222
-
223
-
// Stop streaming
224
-
stopStreaming(): void {
225
-
if (this.isStreaming) {
226
-
this.jetstream.stopStreaming();
227
-
this.isStreaming = false;
228
-
}
229
-
}
230
-
231
-
// Infer service from record type and collection
232
-
private inferService($type: string, collection: string): string {
233
-
if (collection.startsWith('grain.social') || $type.includes('grain')) return 'grain.social';
234
-
if (collection.startsWith('app.bsky')) return 'bsky.app';
235
-
if (collection.startsWith('sh.tangled')) return 'sh.tangled';
236
-
return 'unknown';
237
-
}
238
-
239
-
// Event system for UI updates
240
-
private listeners: {
241
-
onContentUpdate?: (item: ContentItem) => void;
242
-
onContentAdd?: (item: ContentItem) => void;
243
-
onContentRemove?: (item: ContentItem) => void;
244
-
} = {};
245
-
246
-
onContentUpdate(callback: (item: ContentItem) => void): void {
247
-
this.listeners.onContentUpdate = callback;
248
-
}
249
-
250
-
onContentAdd(callback: (item: ContentItem) => void): void {
251
-
this.listeners.onContentAdd = callback;
252
-
}
253
-
254
-
onContentRemove(callback: (item: ContentItem) => void): void {
255
-
this.listeners.onContentRemove = callback;
256
-
}
257
-
258
-
private emitContentUpdate(item: ContentItem): void {
259
-
this.listeners.onContentUpdate?.(item);
260
-
if (item.operation === 'create') {
261
-
this.listeners.onContentAdd?.(item);
262
-
} else if (item.operation === 'delete') {
263
-
this.listeners.onContentRemove?.(item);
264
-
}
265
-
}
266
-
267
-
// Get streaming status
268
-
getStreamingStatus(): 'streaming' | 'stopped' {
269
-
return this.isStreaming ? 'streaming' : 'stopped';
270
-
}
271
-
}
+55
-137
src/lib/services/grain-gallery-service.ts
+55
-137
src/lib/services/grain-gallery-service.ts
···
1
1
import { AtprotoBrowser } from '../atproto/atproto-browser';
2
2
import { loadConfig } from '../config/site';
3
-
import type { AtprotoRecord } from '../types/atproto';
4
3
import { extractCidFromBlobRef, blobCdnUrl } from '../atproto/blob-url';
5
-
6
-
export interface GrainGalleryItem {
7
-
uri: string;
8
-
cid: string;
9
-
value: {
10
-
$type: string;
11
-
galleryId?: string;
12
-
gallery_id?: string;
13
-
id?: string;
14
-
title?: string;
15
-
description?: string;
16
-
caption?: string;
17
-
image?: {
18
-
url?: string;
19
-
src?: string;
20
-
alt?: string;
21
-
caption?: string;
22
-
};
23
-
photo?: {
24
-
url?: string;
25
-
src?: string;
26
-
alt?: string;
27
-
caption?: string;
28
-
};
29
-
media?: {
30
-
url?: string;
31
-
src?: string;
32
-
alt?: string;
33
-
caption?: string;
34
-
};
35
-
createdAt: string;
36
-
};
37
-
indexedAt: string;
38
-
collection: string;
39
-
}
40
-
41
-
export interface GrainGallery {
42
-
id: string;
43
-
title: string;
44
-
description?: string;
45
-
createdAt: string;
46
-
items: GrainGalleryItem[];
47
-
imageCount: number;
48
-
collections: string[];
49
-
}
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';
50
8
51
9
export interface ProcessedGrainGallery {
52
10
id: string;
···
83
41
}
84
42
85
43
// Resolve gallery URI directly from the item record if present
86
-
private extractGalleryUriFromItem(item: any): string | null {
87
-
const value = item?.value ?? item;
44
+
private extractGalleryUriFromItem(item: { value: SocialGrainGalleryItemRecord } | SocialGrainGalleryItemRecord): string | null {
45
+
const value: SocialGrainGalleryItemRecord = (item as any)?.value ?? (item as SocialGrainGalleryItemRecord);
88
46
if (typeof value?.gallery === 'string') return value.gallery;
89
-
// Some variants might use a nested key
90
-
if (typeof value?.galleryUri === 'string') return value.galleryUri;
91
-
return null;
92
-
}
93
-
94
-
// Extract image from gallery item
95
-
private extractImageFromItem(item: GrainGalleryItem): { alt?: string; url: string; caption?: string } | null {
96
-
const value = item.value;
97
-
98
-
// Try different image fields
99
-
const imageFields = ['image', 'photo', 'media'];
100
-
101
-
for (const field of imageFields) {
102
-
const imageData = value[field];
103
-
if (imageData && (imageData.url || imageData.src)) {
104
-
return {
105
-
alt: imageData.alt || imageData.caption || value.caption,
106
-
url: imageData.url || imageData.src,
107
-
caption: imageData.caption || value.caption
108
-
};
109
-
}
110
-
}
111
-
112
47
return null;
113
48
}
114
49
115
50
// Build processed galleries using the authoritative gallery records and item mappings
116
51
private buildProcessedGalleries(
117
-
galleries: AtprotoRecord[],
118
-
items: AtprotoRecord[],
119
-
photosByUri: Map<string, AtprotoRecord>,
120
-
exifByPhotoUri: Map<string, any>
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>
121
56
): ProcessedGrainGallery[] {
122
57
// Index items by gallery URI
123
-
const itemsByGallery = new Map<string, AtprotoRecord[]>();
58
+
const itemsByGallery = new Map<string, Array<{ uri: string; value: SocialGrainGalleryItemRecord }>>();
124
59
for (const item of items) {
125
60
const galleryUri = this.extractGalleryUriFromItem(item);
126
61
if (!galleryUri) continue;
···
137
72
const galleryItems = itemsByGallery.get(galleryUri) ?? [];
138
73
// Sort by position if available
139
74
galleryItems.sort((a, b) => {
140
-
const pa = Number(a.value?.position ?? 0);
141
-
const pb = Number(b.value?.position ?? 0);
75
+
const pa = Number(a.value.position ?? 0);
76
+
const pb = Number(b.value.position ?? 0);
142
77
return pa - pb;
143
78
});
144
79
145
80
const images: Array<{ alt?: string; url: string; caption?: string; exif?: any }> = [];
146
81
for (const item of galleryItems) {
147
-
const photoUri = typeof item.value?.item === 'string' ? item.value.item : null;
82
+
const photoUri = item.value.item;
148
83
if (!photoUri) continue;
149
84
const photo = photosByUri.get(photoUri);
150
85
if (!photo) continue;
151
86
152
87
// Extract blob CID
153
-
const cid = extractCidFromBlobRef(photo.value?.photo?.ref ?? photo.value?.photo);
88
+
const cid = extractCidFromBlobRef((photo.value as any)?.photo?.ref ?? (photo.value as any)?.photo);
154
89
if (!cid || !did) continue;
155
90
const url = blobCdnUrl(did, cid);
156
91
157
92
const exif = exifByPhotoUri.get(photoUri);
158
93
images.push({
159
94
url,
160
-
alt: photo.value?.alt,
161
-
caption: photo.value?.caption,
162
-
exif: exif ? {
163
-
make: exif.make,
164
-
model: exif.model,
165
-
lensMake: exif.lensMake,
166
-
lensModel: exif.lensModel,
167
-
iSO: exif.iSO,
168
-
fNumber: exif.fNumber,
169
-
exposureTime: exif.exposureTime,
170
-
focalLengthIn35mmFormat: exif.focalLengthIn35mmFormat,
171
-
dateTimeOriginal: exif.dateTimeOriginal,
172
-
} : undefined,
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,
173
110
});
174
111
}
175
112
176
113
processed.push({
177
114
id: galleryUri,
178
-
title: gallery.value?.title || 'Untitled Gallery',
179
-
description: gallery.value?.description,
180
-
createdAt: gallery.value?.createdAt || gallery.indexedAt,
115
+
title: gallery.value.title || 'Untitled Gallery',
116
+
description: gallery.value.description,
117
+
createdAt: gallery.value.createdAt || gallery.indexedAt,
181
118
images,
182
119
itemCount: galleryItems.length,
183
120
collections: [gallery.collection],
···
189
126
return processed;
190
127
}
191
128
192
-
// Process gallery into display format
193
-
private processGalleryForDisplay(gallery: GrainGallery): ProcessedGrainGallery {
194
-
const images: Array<{ alt?: string; url: string; caption?: string }> = [];
195
-
196
-
// Extract images from all items
197
-
for (const item of gallery.items) {
198
-
const image = this.extractImageFromItem(item);
199
-
if (image) {
200
-
images.push(image);
201
-
}
202
-
}
203
-
204
-
return {
205
-
id: gallery.id,
206
-
title: gallery.title,
207
-
description: gallery.description,
208
-
createdAt: gallery.createdAt,
209
-
images,
210
-
itemCount: gallery.items.length,
211
-
collections: gallery.collections
212
-
};
213
-
}
214
-
215
129
// Fetch galleries with items, photos, and exif metadata
216
130
async getGalleries(identifier: string): Promise<ProcessedGrainGallery[]> {
217
131
try {
···
228
142
this.browser.getAllCollectionRecords(identifier, 'social.grain.photo.exif', 5000),
229
143
]);
230
144
231
-
// Build maps for fast lookup
232
-
const photosByUri = new Map<string, AtprotoRecord>();
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 }>();
233
159
for (const p of photoRecords) {
234
-
photosByUri.set(p.uri, p);
160
+
photosByUri.set(p.uri, { uri: p.uri, value: p.value as SocialGrainPhotoRecord });
235
161
}
236
-
const exifByPhotoUri = new Map<string, any>();
162
+
163
+
const exifByPhotoUri = new Map<string, SocialGrainPhotoExifRecord>();
237
164
for (const e of exifRecords) {
238
-
const photoUri = typeof e.value?.photo === 'string' ? e.value.photo : undefined;
239
-
if (photoUri) exifByPhotoUri.set(photoUri, e.value);
165
+
const ev = e.value as SocialGrainPhotoExifRecord;
166
+
const photoUri = ev.photo;
167
+
if (photoUri) exifByPhotoUri.set(photoUri, ev);
240
168
}
241
169
242
170
const processed = this.buildProcessedGalleries(
243
-
galleryRecords,
244
-
itemRecords,
171
+
typedGalleries,
172
+
typedItems,
245
173
photosByUri,
246
174
exifByPhotoUri,
247
175
);
···
266
194
}
267
195
}
268
196
269
-
// Get gallery items for a specific gallery
270
-
async getGalleryItemsForGallery(identifier: string, galleryId: string): Promise<GrainGalleryItem[]> {
271
-
try {
272
-
const items = await this.getGalleryItems(identifier);
273
-
return items.filter(item => this.extractGalleryId(item) === galleryId);
274
-
} catch (error) {
275
-
console.error('Error getting gallery items:', error);
276
-
return [];
277
-
}
278
-
}
279
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();
+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
+
+30
-58
src/pages/index.astro
+30
-58
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
-
limit={10}
30
-
showAuthor={false}
31
-
showTimestamp={true}
32
-
live={true}
33
-
/>
34
-
</section>
35
-
36
-
37
-
38
-
<section>
39
-
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">
40
-
Explore More
41
-
</h2>
42
-
<div class="grid md:grid-cols-2 gap-6">
43
-
<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">
44
-
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">
45
-
Image Galleries
46
-
</h3>
47
-
<p class="text-gray-600 dark:text-gray-400">
48
-
View my grain.social image galleries and photo collections.
49
-
</p>
50
-
</a>
51
-
</div>
52
-
</section>
53
-
</>
54
-
) : (
55
-
<section>
56
-
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">
57
-
Configuration Required
58
-
</h2>
59
-
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6">
60
-
<p class="text-yellow-800 dark:text-yellow-200 mb-4">
61
-
To display your posts, please configure your Bluesky handle in the environment variables.
62
-
</p>
63
-
<div class="text-sm text-yellow-700 dark:text-yellow-300">
64
-
<p class="mb-2">Create a <code class="bg-yellow-100 dark:bg-yellow-800 px-1 rounded">.env</code> file with:</p>
65
-
<pre class="bg-yellow-100 dark:bg-yellow-800 p-3 rounded text-xs overflow-x-auto">
66
-
ATPROTO_HANDLE=your-handle.bsky.social
67
-
SITE_TITLE=Your Site Title
68
-
SITE_AUTHOR=Your Name</pre>
69
-
</div>
70
-
</div>
71
-
</section>
72
-
)}
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>
73
45
</main>
74
46
</div>
75
47
</Layout>
+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>
+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>