prototypey.org - atproto lexicon typescript toolkit - mirror https://github.com/tylersayshi/prototypey

first pass cli

Tyler aef63639 8303c7e0

+665
+407
aislop/plan-emit.md
··· 1 + # Plan: Lexicon Emission for Prototypey 2 + 3 + ## Current State 4 + 5 + - **Project**: Type-safe lexicon inference library (similar to Arktype's approach) 6 + - **Structure**: TypeScript library with `src/`, `lib/` (compiled output), `samples/` (example JSON lexicons) 7 + - **Build**: Uses `tsdown` for bundling, pnpm for package management 8 + 9 + ## Emission Strategy 10 + 11 + ### 1. Two-Track Approach 12 + 13 + Since prototypey is about **type inference** from lexicons (not traditional codegen), we should support both: 14 + 15 + #### Track A: Traditional Code Generation (compatibility) 16 + 17 + - Install `@atproto/lex-cli` as a dev dependency 18 + - Emit standard TypeScript files like other atproto projects 19 + - Useful for projects that want traditional generated types 20 + 21 + #### Track B: Type Inference (prototypey's core value) 22 + 23 + - Leverage your existing inference engine (`src/infer.ts`) 24 + - Generate minimal runtime code with inferred types 25 + - This is your differentiator from standard atproto tooling 26 + 27 + ### 2. Directory Structure 28 + 29 + ``` 30 + prototypey/ 31 + ├── lexicons/ # NEW: Input lexicon schemas 32 + │ └── (empty initially, users add their schemas here) 33 + ├── samples/ # Keep existing samples 34 + │ └── *.json 35 + ├── src/ 36 + │ ├── cli/ # NEW: CLI tool for codegen 37 + │ │ ├── index.ts # Main CLI entry 38 + │ │ ├── commands/ 39 + │ │ │ ├── gen-types.ts # Track A: Standard codegen 40 + │ │ │ └── gen-inferred.ts # Track B: Inference-based 41 + │ │ └── templates/ 42 + │ └── ...existing code 43 + ├── generated/ # NEW: Default output directory 44 + │ ├── types/ # Track A output 45 + │ └── inferred/ # Track B output 46 + └── package.json 47 + ``` 48 + 49 + ### 3. CLI Commands 50 + 51 + Add to `package.json`: 52 + 53 + ```json 54 + { 55 + "bin": { 56 + "prototypey": "./lib/cli/index.js" 57 + }, 58 + "scripts": { 59 + "codegen": "prototypey gen-inferred ./generated/inferred ./lexicons/**/*.json" 60 + } 61 + } 62 + ``` 63 + 64 + Provide these commands: 65 + 66 + - `prototypey gen-inferred <outdir> <schemas...>` - Generate type-inferred code (your unique approach) 67 + - `prototypey gen-types <outdir> <schemas...>` - Generate standard TypeScript (delegates to @atproto/lex-cli) 68 + - `prototypey init` - Initialize a new lexicon project with sample configs 69 + 70 + ### 4. Track B: Inferred Code Generation (Your Secret Sauce) 71 + 72 + Generate minimal runtime code that leverages your inference: 73 + 74 + ```typescript 75 + // Example output: generated/inferred/app/bsky/feed/post.ts 76 + import type { Infer } from 'prototypey' 77 + import schema from '../../../../lexicons/app/bsky/feed/post.json' with { type: 'json' } 78 + 79 + export type Post = Infer<typeof schema> 80 + 81 + // Minimal runtime helpers 82 + export const PostSchema = schema 83 + export const isPost = (v: unknown): v is Post => { 84 + return typeof v === 'object' && v !== null && '$type' in v && 85 + v.$type === 'app.bsky.feed.post' 86 + } 87 + ``` 88 + 89 + Benefits: 90 + 91 + - **No validation code duplication** - reuse @atproto/lexicon at runtime 92 + - **Type inference magic** - your core competency 93 + - **Smaller bundle size** - minimal generated code 94 + - **Simpler output** - easier to understand 95 + 96 + ### 5. Dependencies to Add 97 + 98 + ```json 99 + { 100 + "dependencies": { 101 + "@atproto/lexicon": "^0.3.0" 102 + }, 103 + "devDependencies": { 104 + "@atproto/lex-cli": "^0.9.1", 105 + "commander": "^12.0.0", 106 + "glob": "^10.0.0" 107 + }, 108 + "peerDependencies": { 109 + "typescript": ">=5.0.0" 110 + } 111 + } 112 + ``` 113 + 114 + ### 6. Build Pipeline Integration 115 + 116 + Update `package.json` scripts: 117 + 118 + ```json 119 + { 120 + "scripts": { 121 + "build": "tsdown", 122 + "build:cli": "tsdown --entry src/cli/index.ts --format esm --dts false", 123 + "codegen:samples": "prototypey gen-inferred ./generated/samples ./samples/*.json", 124 + "prepack": "pnpm build && pnpm build:cli" 125 + } 126 + } 127 + ``` 128 + 129 + ### 7. Configuration File (optional) 130 + 131 + `prototypey.config.json`: 132 + 133 + ```json 134 + { 135 + "lexicons": "./lexicons", 136 + "output": { 137 + "inferred": "./generated/inferred", 138 + "types": "./generated/types" 139 + }, 140 + "include": ["**/*.json"], 141 + "exclude": ["**/node_modules/**"] 142 + } 143 + ``` 144 + 145 + ### 8. Documentation Updates 146 + 147 + Create docs for: 148 + 149 + 1. **Quick Start**: How to run codegen on your lexicons 150 + 2. **Track Comparison**: When to use inferred vs. standard generation 151 + 3. **Migration Guide**: Moving from @atproto/lex-cli to prototypey 152 + 4. **Type Inference Deep Dive**: How your inference works (marketing!) 153 + 154 + ## Key Differentiators 155 + 156 + ### Prototypey's Unique Value 157 + 158 + 1. **Compile-time type inference** - No runtime validation code needed 159 + 2. **Smaller bundles** - Minimal generated code 160 + 3. **Better DX** - Types are inferred, not generated boilerplate 161 + 4. **Same safety guarantees** - Full TypeScript type checking 162 + 163 + ### vs. Standard @atproto/lex-cli 164 + 165 + - **Standard**: Generates verbose validation code 166 + - **Prototypey**: Generates minimal code + type inference 167 + - **Both**: Same type safety, but prototypey is leaner 168 + 169 + ## Implementation Priority 170 + 171 + 1. ✅ **Phase 1**: Basic CLI structure + Track B (inferred generation) - COMPLETE 172 + 2. ✅ **Phase 2**: File organization + output directory structure - COMPLETE 173 + 3. **Phase 3**: Track A (standard generation, delegate to lex-cli) 174 + 4. **Phase 4**: Configuration file support 175 + 5. **Phase 5**: Documentation + examples 176 + 177 + ## Phase 1 & 2 Implementation Notes 178 + 179 + ### ✅ Completed (2025-10-16) 180 + 181 + **Tech Stack Choices:** 182 + - Used `sade` instead of `commander` (modern, minimal CLI framework from awesome-e18e) 183 + - Used `tinyglobby` instead of `glob` (faster, modern alternative) 184 + - Built with `tsdown` for CLI bundling 185 + 186 + **Structure Created:** 187 + ``` 188 + prototypey/ 189 + ├── src/cli/ 190 + │ ├── index.ts # CLI entry with sade 191 + │ ├── commands/ 192 + │ │ └── gen-inferred.ts # Track B implementation 193 + │ └── templates/ 194 + │ └── inferred.ts # Code generation template 195 + ├── generated/ 196 + │ └── inferred/ # Generated type files 197 + ├── lexicons/ # Input directory (empty, ready for user schemas) 198 + └── lib/cli/ # Built CLI output 199 + ``` 200 + 201 + **Generated Code Pattern:** 202 + ```typescript 203 + // generated/inferred/app/bsky/actor/profile.ts 204 + import type { Infer } from "prototypey"; 205 + import schema from "../../../../../samples/demo.json" with { type: "json" }; 206 + 207 + export type Profile = Infer<typeof schema>; 208 + export const ProfileSchema = schema; 209 + export function isProfile(v: unknown): v is Profile { ... } 210 + ``` 211 + 212 + **CLI Usage:** 213 + ```bash 214 + # Build CLI 215 + pnpm build:cli 216 + 217 + # Generate from samples 218 + pnpm codegen:samples 219 + 220 + # Direct usage 221 + node lib/cli/index.js gen-inferred ./generated/inferred './samples/*.json' 222 + ``` 223 + 224 + **Key Features:** 225 + - Converts NSID to file paths: `app.bsky.feed.post` → `app/bsky/feed/post.ts` 226 + - Generates minimal runtime code with type inference 227 + - Auto-creates directory structure 228 + - Skips invalid schemas gracefully 229 + - Type guard functions for runtime checks 230 + 231 + **Testing:** 232 + - Successfully generated types from sample lexicons 233 + - Runtime validation works (tested with node) 234 + - Schema imports work correctly with JSON modules 235 + 236 + ## ATProto Lexicon Background Research 237 + 238 + ### Official Tooling: @atproto/lex-cli 239 + 240 + ATProto projects use **lexicon schemas** (JSON files) to define data structures, API endpoints, and event streams. These schemas are then automatically transformed into type-safe TypeScript code using the **@atproto/lex-cli** code generation tool. 241 + 242 + #### Installation 243 + 244 + ```bash 245 + npm install @atproto/lex-cli 246 + ``` 247 + 248 + #### Available Commands 249 + 250 + - **`lex gen-api <outdir> <schemas...>`** - Generate TypeScript client API 251 + - **`lex gen-server <outdir> <schemas...>`** - Generate TypeScript server API 252 + - **`lex gen-ts-obj <schemas...>`** - Generate a TS file that exports an array of schemas 253 + - **`lex gen-md <schemas...>`** - Generate markdown documentation 254 + - **`lex new [options] <nsid> [outfile]`** - Create a new schema JSON file 255 + 256 + #### Common Options 257 + 258 + - **`--yes`** - Auto-confirm overwrites during generation 259 + 260 + ### Typical Project Structure 261 + 262 + ``` 263 + project-root/ 264 + ├── lexicons/ # Input: JSON schema definitions 265 + │ ├── com/ 266 + │ │ └── atproto/ 267 + │ │ ├── repo/ 268 + │ │ │ ├── getRecord.json 269 + │ │ │ └── createRecord.json 270 + │ │ └── server/ 271 + │ │ └── defs.json 272 + │ └── app/ 273 + │ └── bsky/ 274 + │ ├── feed/ 275 + │ │ └── post.json 276 + │ └── richtext/ 277 + │ └── facet.json 278 + ├── src/ 279 + │ ├── client/ # Output: Generated client code 280 + │ │ └── types/ 281 + │ │ ├── com/ 282 + │ │ │ └── atproto/ 283 + │ │ │ └── repo/ 284 + │ │ │ └── getRecord.ts 285 + │ │ └── app/ 286 + │ │ └── bsky/ 287 + │ │ └── richtext/ 288 + │ │ └── facet.ts 289 + │ └── lexicon/ # Output: Generated server code 290 + └── package.json 291 + ``` 292 + 293 + ### Naming Conventions 294 + 295 + **NSIDs (Namespaced Identifiers)**: 296 + 297 + - Format: Reverse-DNS + name (e.g., `com.atproto.repo.getRecord`) 298 + - Domain authority: `com.atproto` (reverse DNS of `atproto.com`) 299 + - Name segment: `getRecord` 300 + - File path mirrors NSID: `lexicons/com/atproto/repo/getRecord.json` 301 + 302 + **Definition Naming**: 303 + 304 + - Records: Single nouns, not pluralized (e.g., `post`, `profile`) 305 + - XRPC methods: verbNoun format (e.g., `getProfile`, `createRecord`) 306 + - Shared definitions: Use `*.defs` lexicons (e.g., `com.atproto.server.defs`) 307 + 308 + ### Generated TypeScript Code Structure 309 + 310 + The generated TypeScript file includes: 311 + 312 + 1. **TypeScript Interfaces** with explicit `$type` properties 313 + 2. **Type Guard Functions** (`is*`) for runtime type checking 314 + 3. **Validation Functions** (`validate*`) for schema validation 315 + 316 + Example: 317 + 318 + ```typescript 319 + /** 320 + * GENERATED CODE - DO NOT MODIFY 321 + */ 322 + import { ValidationResult, BlobRef } from '@atproto/lexicon' 323 + import { lexicons } from '../../../../lexicons' 324 + import { isObj, hasProp } from '../../../../util' 325 + import { CID } from 'multiformats/cid' 326 + 327 + export interface Main { 328 + $type?: 'app.bsky.richtext.facet' 329 + index: ByteSlice 330 + features: (Mention | Link | Tag | { $type: string; [k: string]: unknown })[] 331 + [k: string]: unknown 332 + } 333 + 334 + export function isMain(v: unknown): v is Main { 335 + return ( 336 + isObj(v) && 337 + hasProp(v, '$type') && 338 + (v.$type === 'app.bsky.richtext.facet#main' || 339 + v.$type === 'app.bsky.richtext.facet') 340 + ) 341 + } 342 + 343 + export function validateMain(v: unknown): ValidationResult { 344 + return lexicons.validate('app.bsky.richtext.facet#main', v) 345 + } 346 + ``` 347 + 348 + ### Build Scripts & Integration 349 + 350 + Example `package.json` scripts: 351 + 352 + ```json 353 + { 354 + "scripts": { 355 + "codegen": "lex gen-api --yes ./src/client ../../lexicons/com/atproto/*/* ../../lexicons/app/bsky/*/*", 356 + "build": "tsc --build tsconfig.build.json" 357 + }, 358 + "devDependencies": { 359 + "@atproto/lex-cli": "^0.9.1" 360 + } 361 + } 362 + ``` 363 + 364 + ### Best Practices 365 + 366 + 1. **Use reverse-DNS NSIDs** for your domain (e.g., `com.example.*`) 367 + 2. **Group related schemas** by namespace hierarchy 368 + 3. **Create `*.defs` lexicons** for shared definitions used across multiple schemas 369 + 4. **Store lexicons in `/lexicons` directory** at repository root 370 + 5. **Mirror NSID structure in filesystem** (e.g., `lexicons/com/example/thing.json`) 371 + 6. **Run codegen before build** in your npm scripts 372 + 7. **Generate to predictable directories** (e.g., `./src/client`, `./src/lexicon`) 373 + 374 + ### Schema Evolution Rules 375 + 376 + 1. **New fields must be optional** to maintain backward compatibility 377 + 2. **Cannot remove non-optional fields** without breaking changes 378 + 3. **Cannot change field types** without creating new lexicon 379 + 4. **Cannot rename fields** - must deprecate and add new field 380 + 5. **Breaking changes require new NSID** (e.g., `v2` suffix) 381 + 382 + ### Type Categories in Lexicons 383 + 384 + #### Primary Types (one per file) 385 + 386 + - **record** - Repository-stored objects 387 + - **query** - XRPC HTTP GET endpoints 388 + - **procedure** - XRPC HTTP POST endpoints 389 + - **subscription** - WebSocket event streams 390 + 391 + #### Field Types 392 + 393 + - **Primitives**: null, boolean, integer, string, bytes 394 + - **Special**: cid-link, blob, unknown 395 + - **Structures**: array, object, params 396 + - **References**: ref, union, token 397 + 398 + ### Real-World Examples 399 + 400 + - **Official ATProto Repository**: https://github.com/bluesky-social/atproto 401 + - Lexicons: `/lexicons/com/atproto/*`, `/lexicons/app/bsky/*` 402 + - Generated Client: `/packages/api/src/client/` 403 + - Generated Server: `/packages/pds/src/lexicon/` 404 + 405 + ## Next Steps 406 + 407 + Start with **Phase 1** - building the CLI and the inferred code generation, since that's prototypey's core differentiator.
+26
generated/inferred/app/bsky/actor/defs.ts
··· 1 + // Generated by prototypey - DO NOT EDIT 2 + // Source: app.bsky.actor.defs 3 + import type { Infer } from "prototypey"; 4 + import schema from "../../../../../samples/actor-namespace.json" with { type: "json" }; 5 + 6 + /** 7 + * Type-inferred from lexicon schema: app.bsky.actor.defs 8 + */ 9 + export type Defs = Infer<typeof schema>; 10 + 11 + /** 12 + * The lexicon schema object 13 + */ 14 + export const DefsSchema = schema; 15 + 16 + /** 17 + * Type guard to check if a value is a Defs 18 + */ 19 + export function isDefs(v: unknown): v is Defs { 20 + return ( 21 + typeof v === "object" && 22 + v !== null && 23 + "$type" in v && 24 + v.$type === "app.bsky.actor.defs" 25 + ); 26 + }
+26
generated/inferred/app/bsky/actor/profile.ts
··· 1 + // Generated by prototypey - DO NOT EDIT 2 + // Source: app.bsky.actor.profile 3 + import type { Infer } from "prototypey"; 4 + import schema from "../../../../../samples/profile-namespace.json" with { type: "json" }; 5 + 6 + /** 7 + * Type-inferred from lexicon schema: app.bsky.actor.profile 8 + */ 9 + export type Profile = Infer<typeof schema>; 10 + 11 + /** 12 + * The lexicon schema object 13 + */ 14 + export const ProfileSchema = schema; 15 + 16 + /** 17 + * Type guard to check if a value is a Profile 18 + */ 19 + export function isProfile(v: unknown): v is Profile { 20 + return ( 21 + typeof v === "object" && 22 + v !== null && 23 + "$type" in v && 24 + v.$type === "app.bsky.actor.profile" 25 + ); 26 + }
+23
generated/test-generated-types.ts
··· 1 + // Test file to verify generated types work correctly 2 + import type { Profile } from "./inferred/app/bsky/actor/profile.js"; 3 + import type { Defs } from "./inferred/app/bsky/actor/defs.js"; 4 + import { isProfile } from "./inferred/app/bsky/actor/profile.js"; 5 + import { isDefs } from "./inferred/app/bsky/actor/defs.js"; 6 + 7 + // Test that the types are inferred correctly 8 + const profile: Profile = { 9 + $type: "app.bsky.actor.profile", 10 + displayName: "Tyler", 11 + description: "Building cool stuff", 12 + }; 13 + 14 + // Test type guard 15 + const unknownValue: unknown = { $type: "app.bsky.actor.defs" }; 16 + if (isDefs(unknownValue)) { 17 + // Type should be narrowed to Defs 18 + const defs: Defs = unknownValue; 19 + console.log("Is Defs:", defs.$type); 20 + } 21 + 22 + console.log("Types work! ✓"); 23 + console.log("Profile:", profile);
+9
package.json
··· 13 13 }, 14 14 "type": "module", 15 15 "main": "lib/index.js", 16 + "bin": { 17 + "prototypey": "./lib/cli/index.js" 18 + }, 16 19 "files": [ 17 20 "LICENSE.md", 18 21 "README.md", ··· 21 24 ], 22 25 "scripts": { 23 26 "build": "tsdown", 27 + "build:cli": "tsdown --entry src/cli/index.ts --format esm --dts false --outDir lib/cli", 28 + "codegen:samples": "node lib/cli/index.js gen-inferred ./generated/inferred './samples/*.json'", 24 29 "format": "prettier . --list-different", 25 30 "format:fix": "prettier . --write", 26 31 "lint": "eslint . --max-warnings 0", ··· 43 48 "packageManager": "pnpm@10.4.0", 44 49 "engines": { 45 50 "node": ">=20.19.0" 51 + }, 52 + "dependencies": { 53 + "sade": "^1.8.1", 54 + "tinyglobby": "^0.2.15" 46 55 } 47 56 }
+21
pnpm-lock.yaml
··· 7 7 importers: 8 8 9 9 .: 10 + dependencies: 11 + sade: 12 + specifier: ^1.8.1 13 + version: 1.8.1 14 + tinyglobby: 15 + specifier: ^0.2.15 16 + version: 0.2.15 10 17 devDependencies: 11 18 '@ark/attest': 12 19 specifier: ^0.49.0 ··· 1019 1026 minimatch@9.0.5: 1020 1027 resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} 1021 1028 engines: {node: '>=16 || 14 >=14.17'} 1029 + 1030 + mri@1.2.0: 1031 + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} 1032 + engines: {node: '>=4'} 1022 1033 1023 1034 ms@2.1.3: 1024 1035 resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} ··· 1152 1163 run-parallel@1.2.0: 1153 1164 resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} 1154 1165 1166 + sade@1.8.1: 1167 + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} 1168 + engines: {node: '>=6'} 1169 + 1155 1170 safe-buffer@5.2.1: 1156 1171 resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} 1157 1172 ··· 2306 2321 dependencies: 2307 2322 brace-expansion: 2.0.2 2308 2323 2324 + mri@1.2.0: {} 2325 + 2309 2326 ms@2.1.3: {} 2310 2327 2311 2328 nanoid@3.3.11: {} ··· 2449 2466 run-parallel@1.2.0: 2450 2467 dependencies: 2451 2468 queue-microtask: 1.2.3 2469 + 2470 + sade@1.8.1: 2471 + dependencies: 2472 + mri: 1.2.0 2452 2473 2453 2474 safe-buffer@5.2.1: {} 2454 2475
+71
src/cli/commands/gen-inferred.ts
··· 1 + import { glob } from "tinyglobby"; 2 + import { readFile, mkdir, writeFile } from "node:fs/promises"; 3 + import { join, dirname, relative, parse } from "node:path"; 4 + import { generateInferredCode } from "../templates/inferred.ts"; 5 + 6 + interface LexiconSchema { 7 + lexicon: number; 8 + id: string; 9 + defs: Record<string, unknown>; 10 + } 11 + 12 + export async function genInferred( 13 + outdir: string, 14 + schemas: string | string[], 15 + ): Promise<void> { 16 + try { 17 + const schemaPatterns = Array.isArray(schemas) ? schemas : [schemas]; 18 + 19 + // Find all schema files matching the patterns 20 + const schemaFiles = await glob(schemaPatterns, { 21 + absolute: true, 22 + onlyFiles: true, 23 + }); 24 + 25 + if (schemaFiles.length === 0) { 26 + console.log("No schema files found matching patterns:", schemaPatterns); 27 + return; 28 + } 29 + 30 + console.log(`Found ${schemaFiles.length} schema file(s)`); 31 + 32 + // Process each schema file 33 + for (const schemaPath of schemaFiles) { 34 + await processSchema(schemaPath, outdir); 35 + } 36 + 37 + console.log(`\nGenerated inferred types in ${outdir}`); 38 + } catch (error) { 39 + console.error("Error generating inferred types:", error); 40 + process.exit(1); 41 + } 42 + } 43 + 44 + async function processSchema( 45 + schemaPath: string, 46 + outdir: string, 47 + ): Promise<void> { 48 + const content = await readFile(schemaPath, "utf-8"); 49 + const schema: LexiconSchema = JSON.parse(content); 50 + 51 + if (!schema.id || !schema.defs) { 52 + console.warn(`Skipping ${schemaPath}: Missing id or defs`); 53 + return; 54 + } 55 + 56 + // Convert NSID to file path: app.bsky.feed.post -> app/bsky/feed/post.ts 57 + const nsidParts = schema.id.split("."); 58 + const relativePath = join(...nsidParts) + ".ts"; 59 + const outputPath = join(outdir, relativePath); 60 + 61 + // Create directory structure 62 + await mkdir(dirname(outputPath), { recursive: true }); 63 + 64 + // Generate the TypeScript code 65 + const code = generateInferredCode(schema, schemaPath, outdir); 66 + 67 + // Write the file 68 + await writeFile(outputPath, code, "utf-8"); 69 + 70 + console.log(` ✓ ${schema.id} -> ${relativePath}`); 71 + }
+17
src/cli/index.ts
··· 1 + #!/usr/bin/env node 2 + import sade from "sade"; 3 + import { genInferred } from "./commands/gen-inferred.ts"; 4 + 5 + const prog = sade("prototypey"); 6 + 7 + prog 8 + .version("0.0.0") 9 + .describe("Type-safe lexicon inference and code generation"); 10 + 11 + prog 12 + .command("gen-inferred <outdir> <schemas...>") 13 + .describe("Generate type-inferred code from lexicon schemas") 14 + .example("gen-inferred ./generated/inferred ./lexicons/**/*.json") 15 + .action(genInferred); 16 + 17 + prog.parse(process.argv);
+65
src/cli/templates/inferred.ts
··· 1 + import { relative, dirname } from "node:path"; 2 + 3 + interface LexiconSchema { 4 + lexicon: number; 5 + id: string; 6 + defs: Record<string, unknown>; 7 + } 8 + 9 + export function generateInferredCode( 10 + schema: LexiconSchema, 11 + schemaPath: string, 12 + outdir: string, 13 + ): string { 14 + const { id } = schema; 15 + 16 + // Calculate relative import path from output file to schema file 17 + // We need to go from generated/{nsid}.ts to the original schema 18 + const nsidParts = id.split("."); 19 + const outputDir = dirname([outdir, ...nsidParts].join("/")); 20 + const relativeSchemaPath = relative(outputDir, schemaPath); 21 + 22 + // Generate a clean type name from the NSID 23 + const typeName = generateTypeName(id); 24 + 25 + return `// Generated by prototypey - DO NOT EDIT 26 + // Source: ${id} 27 + import type { Infer } from "prototypey"; 28 + import schema from "${relativeSchemaPath}" with { type: "json" }; 29 + 30 + /** 31 + * Type-inferred from lexicon schema: ${id} 32 + */ 33 + export type ${typeName} = Infer<typeof schema>; 34 + 35 + /** 36 + * The lexicon schema object 37 + */ 38 + export const ${typeName}Schema = schema; 39 + 40 + /** 41 + * Type guard to check if a value is a ${typeName} 42 + */ 43 + export function is${typeName}(v: unknown): v is ${typeName} { 44 + return ( 45 + typeof v === "object" && 46 + v !== null && 47 + "$type" in v && 48 + v.$type === "${id}" 49 + ); 50 + } 51 + `; 52 + } 53 + 54 + function generateTypeName(nsid: string): string { 55 + // Convert app.bsky.feed.post -> Post 56 + // Convert com.atproto.repo.createRecord -> CreateRecord 57 + const parts = nsid.split("."); 58 + const lastPart = parts[parts.length - 1]; 59 + 60 + // Convert kebab-case or camelCase to PascalCase 61 + return lastPart 62 + .split(/[-_]/) 63 + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) 64 + .join(""); 65 + }