forked from
slices.network/slices
fork
Configure Feed
Select the types of activity you want to include in your feed.
Highly ambitious ATProtocol AppView service and sdks
fork
Configure Feed
Select the types of activity you want to include in your feed.
1// @ts-ignore: ts-morph npm import
2import { Project } from "ts-morph";
3
4export interface LexiconProperty {
5 type: string;
6 description?: string;
7 format?: string;
8 knownValues?: string[];
9 items?: LexiconProperty;
10 ref?: string;
11 refs?: string[];
12 closed?: boolean;
13 [key: string]: unknown;
14}
15
16export interface LexiconRecord {
17 type: string;
18 properties?: Record<string, LexiconProperty>;
19 required?: string[];
20}
21
22export interface LexiconParameters {
23 type: "params";
24 properties?: Record<string, LexiconProperty>;
25 required?: string[];
26}
27
28export interface LexiconIO {
29 encoding: string;
30 schema?: LexiconProperty;
31}
32
33export interface LexiconDefinition {
34 type: string;
35 description?: string;
36 // Record type fields
37 record?: LexiconRecord;
38 key?: string;
39 // Query/Procedure fields
40 parameters?: LexiconParameters;
41 input?: LexiconIO;
42 output?: LexiconIO;
43 // Generic schema fields
44 properties?: Record<string, LexiconProperty>;
45 required?: string[];
46 refs?: string[];
47 closed?: boolean;
48 items?: LexiconProperty;
49 knownValues?: string[];
50 ref?: string;
51 [key: string]: unknown;
52}
53
54export interface Lexicon {
55 id: string;
56 definitions?: Record<string, LexiconDefinition>;
57}
58
59export interface GenerateOptions {
60 sliceUri: string;
61}
62
63// Normalize lexicons to use consistent property names
64export function normalizeLexicons(lexicons: unknown[]): Lexicon[] {
65 return lexicons.map((lex: unknown) => {
66 const lexObj = lex as Record<string, unknown>;
67 return {
68 ...lexObj,
69 // TODO: Fix upstream to use 'id' and 'defs' consistently
70 id: (lexObj.id as string) || (lexObj.nsid as string), // Normalize nsid to id
71 definitions:
72 (lexObj.defs as Record<string, LexiconDefinition>) ||
73 (lexObj.definitions as Record<string, LexiconDefinition>), // Normalize defs to definitions
74 };
75 });
76}
77
78// Convert NSID to PascalCase (for record types, no "Record" suffix)
79export function nsidToPascalCase(nsid: string): string {
80 return nsid
81 .split(".")
82 .map((part) =>
83 part
84 .split(/[-_]/)
85 .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
86 .join("")
87 )
88 .join("");
89}
90
91// Convert NSID to PascalCase for namespaces (without "Record" suffix)
92export function nsidToNamespace(nsid: string): string {
93 return nsid
94 .split(".")
95 .map((part) =>
96 part
97 .split(/[-_]/)
98 .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
99 .join("")
100 )
101 .join("");
102}
103
104// Convert definition name to PascalCase
105export function defNameToPascalCase(defName: string): string {
106 return defName
107 .split(/[-_]/)
108 .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
109 .join("");
110}
111
112// Capitalize first letter
113export function capitalizeFirst(str: string): string {
114 return str.charAt(0).toUpperCase() + str.slice(1);
115}
116
117// Sanitize a string to be a valid JavaScript identifier
118export function sanitizeIdentifier(str: string): string {
119 // Replace hyphens and other non-alphanumeric characters with camelCase
120 return str
121 .split(/[-_]/)
122 .map((word, index) =>
123 index === 0 ? word : word.charAt(0).toUpperCase() + word.slice(1)
124 )
125 .join("");
126}
127
128// Check if property is required
129export function isPropertyRequired(
130 recordObj: LexiconRecord,
131 propName: string
132): boolean {
133 return Boolean(recordObj.required && recordObj.required.includes(propName));
134}
135
136// Helper function to check if a field type is sortable
137export function isFieldSortable(propDef: LexiconProperty): boolean {
138 // Check for direct types
139 if (propDef.type) {
140 const sortableTypes = ["string", "integer", "number", "datetime"];
141 if (sortableTypes.includes(propDef.type)) {
142 return true;
143 }
144 }
145
146 // Check for format-based types (datetime strings, etc.)
147 if (propDef.format) {
148 const sortableFormats = ["datetime", "at-identifier", "at-uri"];
149 if (sortableFormats.includes(propDef.format)) {
150 return true;
151 }
152 }
153
154 // Arrays, objects, blobs, and complex types are not sortable
155 return false;
156}
157
158export function createProject(): Project {
159 return new Project({ useInMemoryFileSystem: true });
160}
161
162export function generateUsageExample(
163 lexicons: Lexicon[],
164 sliceUri: string
165): string {
166 // Find the first non-network.slices lexicon that has a record type
167 const nonSlicesLexicon = lexicons.find(
168 (lex) =>
169 lex.id &&
170 !lex.id.startsWith("network.slices.") &&
171 lex.definitions &&
172 Object.values(lex.definitions).some((def) => def.type === "record")
173 );
174
175 if (nonSlicesLexicon) {
176 // Use the first non-slices lexicon
177 const parts = nonSlicesLexicon.id.split(".");
178 // Sanitize parts for JavaScript property access (skip first part "network")
179 const accessPath = parts.map((part, index) =>
180 index === 0 ? part : sanitizeIdentifier(part)
181 ).join(".");
182
183 return `/**
184 * @example Usage
185 * \`\`\`ts
186 * import { AtProtoClient } from "./generated_client.ts";
187 *
188 * const client = new AtProtoClient(
189 * 'https://api.slices.network',
190 * '${sliceUri}'
191 * );
192 *
193 * // Get records from the ${nonSlicesLexicon.id} collection
194 * const records = await client.${accessPath}.getRecords();
195 *
196 * // Get a specific record
197 * const record = await client.${accessPath}.getRecord({
198 * uri: 'at://did:plc:example/${nonSlicesLexicon.id}/3abc123'
199 * });
200 *
201 * // Get records with filtering and search
202 * const filteredRecords = await client.${accessPath}.getRecords({
203 * where: {
204 * text: { contains: "example search term" }
205 * }
206 * });
207 *
208 * // Use slice-level methods for cross-collection queries with type safety
209 * const sliceRecords = await client.network.slices.slice.getSliceRecords<${nsidToPascalCase(
210 nonSlicesLexicon.id
211 )}>({
212 * where: {
213 * collection: { eq: '${nonSlicesLexicon.id}' }
214 * }
215 * });
216 *
217 * // Search across multiple collections using union types
218 * const multiCollectionRecords = await client.network.slices.slice.getSliceRecords<${nsidToPascalCase(
219 nonSlicesLexicon.id
220 )} | AppBskyActorProfile>({
221 * where: {
222 * collection: { in: ['${nonSlicesLexicon.id}', 'app.bsky.actor.profile'] },
223 * text: { contains: 'example search term' },
224 * did: { in: ['did:plc:user1', 'did:plc:user2'] }
225 * },
226 * limit: 20
227 * });
228 *
229 * // Serve the records as JSON
230 * Deno.serve(async () => new Response(JSON.stringify(records.records.map(r => r.value))));
231 * \`\`\`
232 */`;
233 } else {
234 // Fallback: find any lexicon with a record type (including network.slices)
235 const anyRecordLexicon = lexicons.find(
236 (lex) =>
237 lex.definitions &&
238 Object.values(lex.definitions).some((def) => def.type === "record")
239 );
240
241 if (anyRecordLexicon) {
242 const parts = anyRecordLexicon.id.split(".");
243 // Sanitize parts for JavaScript property access (skip first part "network")
244 const accessPath = parts.map((part, index) =>
245 index === 0 ? part : sanitizeIdentifier(part)
246 ).join(".");
247
248 return `/**
249 * @example Usage
250 * \`\`\`ts
251 * import { AtProtoClient } from "./generated_client.ts";
252 *
253 * const client = new AtProtoClient(
254 * 'https://api.slices.network',
255 * '${sliceUri}'
256 * );
257 *
258 * // Get records from the ${anyRecordLexicon.id} collection
259 * const records = await client.${accessPath}.getRecords();
260 *
261 * // Get a specific record
262 * const record = await client.${accessPath}.getRecord({
263 * uri: 'at://did:plc:example/${anyRecordLexicon.id}/3abc123'
264 * });
265 *
266 * // Get records with search and filtering
267 * const filteredRecords = await client.${accessPath}.getRecords({
268 * where: {
269 * text: { contains: "example search term" }
270 * }
271 * });
272 *
273 * // Cross-collection operations using base client with type safety
274 * const multiCollectionResults = await client.getSliceRecords<${nsidToPascalCase(
275 anyRecordLexicon.id
276 )}>({
277 * where: {
278 * collection: { eq: '${anyRecordLexicon.id}' }
279 * },
280 * limit: 50
281 * });
282 *
283 * // Serve the records as JSON
284 * Deno.serve(async () => new Response(JSON.stringify(records.records.map(r => r.value))));
285 * \`\`\`
286 */`;
287 }
288
289 // Final fallback if no record types exist
290 return `/**
291 * @example Usage
292 * \`\`\`ts
293 * import { AtProtoClient } from "./generated_client.ts";
294 *
295 * const client = new AtProtoClient(
296 * 'https://api.slices.network',
297 * '${sliceUri}'
298 * );
299 *
300 * // Client is ready to use with available collections
301 * // Use client methods based on your lexicon definitions
302 * \`\`\`
303 */`;
304 }
305}
306
307export function generateHeaderComment(
308 lexicons: Lexicon[],
309 usageExample: string
310): string {
311 return `// Generated TypeScript client for AT Protocol records
312// Generated at: ${new Date().toISOString().slice(0, 19).replace("T", " ")} UTC
313// Lexicons: ${lexicons.length}
314
315${usageExample}
316
317import { SlicesClient, type RecordResponse, type GetRecordsResponse, type CountRecordsResponse, type GetRecordParams, type WhereCondition, type IndexedRecordFields, type SortField, type BlobRef, type AuthProvider } from "@slices/client";
318import type { OAuthClient } from "@slices/oauth";
319
320`;
321}
322
323// Format the code using deno fmt via temp file
324export async function formatCode(code: string): Promise<string> {
325 try {
326 const tempFile = await Deno.makeTempFile({ suffix: ".ts" });
327
328 // Write unformatted code to temp file
329 await Deno.writeTextFile(tempFile, code);
330
331 // Format the temp file
332 const process = new Deno.Command("deno", {
333 args: ["fmt", tempFile],
334 stdout: "piped",
335 stderr: "piped",
336 });
337
338 const output = await process.output();
339
340 if (output.success) {
341 // Read the formatted code back
342 const formattedCode = await Deno.readTextFile(tempFile);
343 await Deno.remove(tempFile);
344 return formattedCode;
345 } else {
346 const error = new TextDecoder().decode(output.stderr);
347 console.warn("deno fmt failed, using unformatted code:", error);
348 await Deno.remove(tempFile);
349 return code;
350 }
351 } catch (error) {
352 console.warn("deno fmt not available, using unformatted code:", error);
353 return code;
354 }
355}
356
357// Convenience function that combines everything
358export async function generateTypeScript(
359 lexicons: unknown[],
360 options: GenerateOptions
361): Promise<string> {
362 const { generateInterfaces } = await import("./interfaces.ts");
363 const { generateClient } = await import("./client.ts");
364
365 const normalizedLexicons = normalizeLexicons(lexicons);
366 const project = createProject();
367 const sourceFile = project.createSourceFile("generated-client.ts", "");
368
369 const usageExample = generateUsageExample(
370 normalizedLexicons,
371 options.sliceUri
372 );
373 const headerComment = generateHeaderComment(
374 normalizedLexicons,
375 usageExample
376 );
377
378 // Add header comment and imports to the source file first
379 sourceFile.insertText(0, headerComment);
380
381 // Generate interfaces and client
382 generateInterfaces(
383 sourceFile,
384 normalizedLexicons
385 );
386 generateClient(
387 sourceFile,
388 normalizedLexicons
389 );
390
391 // Get the generated code
392 const generatedCode = sourceFile.getFullText();
393
394 return await formatCode(generatedCode);
395}