forked from
slices.network/slices
Highly ambitious ATProtocol AppView service and sdks
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}