+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/types/atproto';
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.
+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();
+49
src/components/content/ContentDisplay.astro
+49
src/components/content/ContentDisplay.astro
···
1
+
---
2
+
import type { AtprotoRecord } from '../../lib/types/atproto';
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
+
)}
+2
-11
src/components/content/WhitewindBlogPost.astro
+2
-11
src/components/content/WhitewindBlogPost.astro
···
1
1
---
2
2
import type { ComWhtwndBlogEntryRecord } from '../../lib/generated/com-whtwnd-blog-entry';
3
+
import { marked } from 'marked';
3
4
4
5
interface Props {
5
6
record: ComWhtwndBlogEntryRecord;
···
32
33
Published on {formatDate(published!)}
33
34
</div>
34
35
)}
35
-
36
-
{showTags && record.tags && record.tags.length > 0 && (
37
-
<div class="flex flex-wrap gap-2 mb-4">
38
-
{record.tags.map((tag) => (
39
-
<span class="px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 text-xs rounded-full">
40
-
#{tag}
41
-
</span>
42
-
))}
43
-
</div>
44
-
)}
45
36
</header>
46
37
47
38
<div class="prose prose-gray dark:prose-invert max-w-none">
48
39
<div class="text-gray-700 dark:text-gray-300 leading-relaxed">
49
-
{record.content}
40
+
<Fragment set:html={await marked(record.content || '')} />
50
41
</div>
51
42
</div>
52
43
</article>
+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
+
}