An experimental TypeSpec syntax for Lexicon
56
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add Main model support and app.bsky.embed.images

Major features:
- Support for Main model in namespaces (creates main + other defs)
- Namespace-level @doc becomes lexicon description
- Blob type support (bytes -> blob)
- Array maxLength support (@maxItems decorator)
- Fixed same-namespace refs in Main-based lexicons
- Parent namespace with child namespaces creates defs.json

Port app.bsky.embed.images with:
- Main model for primary definition
- Cross-namespace refs (app.bsky.embed.defs#aspectRatio)
- Same-namespace refs (#image, #viewImage)
- Arrays with maxLength constraints
- Blob type for image data

Tests passing (21/21)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

+223 -26
+88 -26
typelex-emitter/src/emitter.ts
··· 11 11 isType, 12 12 getMaxLength, 13 13 getMinValue, 14 + getMaxItems, 14 15 } from "@typespec/compiler"; 15 16 import { join, dirname } from "path"; 16 17 import type { ··· 21 22 LexiconArray, 22 23 LexiconRef, 23 24 LexiconUnion, 25 + LexiconBlob, 24 26 } from "./types.js"; 25 27 import { getLexFormat, getMaxGraphemes, getMinGraphemes, getLexKnownValues, isLexiconMain } from "./decorators.js"; 26 28 ··· 64 66 const shouldEmitLexicon = hasModels && !hasOperations && !hasChildNamespaces; 65 67 66 68 if (shouldEmitLexicon) { 67 - // Check if we should create standalone lexicons or a defs collection 68 - // Standalone: models marked with @lexiconMain 69 - // Defs: everything else 70 69 const models = [...ns.models.values()]; 71 70 const standaloneModels = models.filter(m => isLexiconMain(this.program, m)); 72 - const defsModels = models.filter(m => !isLexiconMain(this.program, m)); 71 + const mainModel = models.find(m => m.name === "Main"); 72 + const otherModels = models.filter(m => !isLexiconMain(this.program, m) && m.name !== "Main"); 73 73 74 - // Create standalone lexicons for models with @doc 74 + // Case 1: Models marked with @lexiconMain -> standalone lexicon with ONLY main 75 75 for (const model of standaloneModels) { 76 76 const lexiconId = fullName + "." + model.name.charAt(0).toLowerCase() + model.name.slice(1); 77 77 this.currentLexiconId = lexiconId; 78 78 79 - // Don't include model description in the object itself for standalone lexicons 80 79 const modelDef = this.modelToLexiconObject(model, false); 81 80 const description = getDoc(this.program, ns) || getDoc(this.program, model); 82 81 ··· 96 95 this.currentLexiconId = null; 97 96 } 98 97 99 - // Create defs collection for models without @doc 100 - if (defsModels.length > 0) { 98 + // Case 2: Model named "Main" -> lexicon with main + other defs 99 + if (mainModel) { 100 + const lexiconId = fullName; 101 + this.currentLexiconId = lexiconId; 102 + 103 + const mainDef = this.modelToLexiconObject(mainModel, false); 104 + const description = getDoc(this.program, ns); 105 + 106 + const lexicon: LexiconDocument = { 107 + lexicon: 1, 108 + id: lexiconId, 109 + defs: { 110 + main: mainDef 111 + }, 112 + }; 113 + 114 + if (description) { 115 + lexicon.description = description; 116 + } 117 + 118 + // Add other models as defs 119 + for (const model of otherModels) { 120 + const defName = model.name.charAt(0).toLowerCase() + model.name.slice(1); 121 + const modelDef = this.modelToLexiconObject(model); 122 + lexicon.defs[defName] = modelDef; 123 + } 124 + 125 + this.lexicons.set(lexiconId, lexicon); 126 + this.currentLexiconId = null; 127 + } 128 + // Case 3: No Main model -> defs collection 129 + else if (otherModels.length > 0) { 101 130 const lexiconId = fullName + ".defs"; 102 131 this.currentLexiconId = lexiconId; 103 132 ··· 107 136 defs: {}, 108 137 }; 109 138 110 - // Add all models as definitions 111 - for (const model of defsModels) { 139 + for (const model of otherModels) { 112 140 const defName = model.name.charAt(0).toLowerCase() + model.name.slice(1); 113 141 const modelDef = this.modelToLexiconObject(model); 114 142 lexicon.defs[defName] = modelDef; ··· 117 145 this.lexicons.set(lexiconId, lexicon); 118 146 this.currentLexiconId = null; 119 147 } 120 - } else if (hasModels && !hasOperations) { 121 - // Process models individually for non-defs files 148 + } else if (hasModels && !hasOperations && hasChildNamespaces) { 149 + // Namespace has both models and child namespaces -> create defs.json for parent models 150 + const lexiconId = fullName + ".defs"; 151 + this.currentLexiconId = lexiconId; 152 + 153 + const lexicon: LexiconDocument = { 154 + lexicon: 1, 155 + id: lexiconId, 156 + defs: {}, 157 + }; 158 + 122 159 for (const [_, model] of ns.models) { 123 - this.visitModel(model); 160 + const defName = model.name.charAt(0).toLowerCase() + model.name.slice(1); 161 + const modelDef = this.modelToLexiconObject(model); 162 + lexicon.defs[defName] = modelDef; 124 163 } 164 + 165 + this.lexicons.set(lexiconId, lexicon); 166 + this.currentLexiconId = null; 125 167 } else if (hasOperations) { 126 168 // TODO: Process operations for queries and procedures 127 169 for (const [_, operation] of ns.operations) { ··· 264 306 return primitive; 265 307 case "Model": 266 308 if (this.isArrayType(type as Model)) { 267 - const array = this.modelToLexiconArray(type as Model); 309 + const array = this.modelToLexiconArray(type as Model, prop); 268 310 if (array && prop) { 269 311 const propDesc = getDoc(this.program, prop); 270 312 if (propDesc) { ··· 342 384 } 343 385 } 344 386 345 - private scalarToLexiconPrimitive(scalar: Scalar, prop?: ModelProperty): LexiconPrimitive { 387 + private scalarToLexiconPrimitive(scalar: Scalar, prop?: ModelProperty): LexiconDefinition | null { 346 388 const primitive: LexiconPrimitive = { 347 389 type: "string", // default 348 390 }; ··· 372 414 primitive.format = "datetime"; 373 415 break; 374 416 case "bytes": 375 - return { 376 - type: "string", 417 + // bytes maps to blob type in lexicon 418 + // TODO: add support for accept and maxSize decorators 419 + const blobDef: LexiconBlob = { 420 + type: "blob", 377 421 }; 422 + return blobDef; 378 423 } 379 424 380 425 // Check for decorators on the property ··· 433 478 } 434 479 435 480 const defName = model.name.charAt(0).toLowerCase() + model.name.slice(1); 436 - const modelLexiconId = `${namespaceName}.defs`; 437 481 438 482 // Check if it's in the current namespace being processed 439 - if (this.currentLexiconId && modelLexiconId === this.currentLexiconId) { 440 - // Same namespace - use local ref 441 - return `#${defName}`; 483 + if (this.currentLexiconId) { 484 + // Check for exact match (Main model case: lexicon ID = namespace) 485 + if (this.currentLexiconId === namespaceName) { 486 + return `#${defName}`; 487 + } 488 + // Check for defs file match 489 + if (this.currentLexiconId === `${namespaceName}.defs`) { 490 + return `#${defName}`; 491 + } 442 492 } 443 493 444 494 // Different namespace - use fully qualified ref 495 + // Determine if the target is a defs file or a Main-based lexicon 496 + // For now, assume defs (we can enhance this later) 445 497 return `${namespaceName}.defs#${defName}`; 446 498 } 447 499 448 - private modelToLexiconArray(model: Model): LexiconArray | null { 500 + private modelToLexiconArray(model: Model, prop?: ModelProperty): LexiconArray | null { 449 501 // Handle TypeSpec array types 450 502 if (model.templateMapper?.args && model.templateMapper.args.length > 0) { 451 503 const itemType = model.templateMapper.args[0]; 452 - 504 + 453 505 if (isType(itemType)) { 454 506 const itemDef = this.typeToLexiconDefinition(itemType); 455 - 507 + 456 508 if (itemDef) { 457 - return { 509 + const arrayDef: LexiconArray = { 458 510 type: "array", 459 511 items: itemDef, 460 512 }; 513 + 514 + // Add array constraints from property decorators 515 + if (prop) { 516 + const maxItems = getMaxItems(this.program, prop); 517 + if (maxItems !== undefined) { 518 + arrayDef.maxLength = maxItems; 519 + } 520 + } 521 + 522 + return arrayDef; 461 523 } 462 524 } 463 525 } 464 - 526 + 465 527 return null; 466 528 } 467 529
+50
typelex-emitter/test/scenarios/app-bsky-embed-images/input/main.tsp
··· 1 + import "@typelex/emitter"; 2 + 3 + namespace app.bsky.embed { 4 + @doc("width:height represents an aspect ratio. It may be approximate, and may not correspond to absolute dimensions in any given unit.") 5 + model AspectRatio { 6 + @minValue(1) 7 + width: int32; 8 + 9 + @minValue(1) 10 + height: int32; 11 + } 12 + } 13 + 14 + @doc("A set of images embedded in a Bluesky record (eg, a post).") 15 + namespace app.bsky.embed.images { 16 + model Main { 17 + @maxItems(4) 18 + images: Image[]; 19 + } 20 + 21 + model Image { 22 + // TODO: blob type 23 + image: bytes; 24 + 25 + @doc("Alt text description of the image, for accessibility.") 26 + alt: string; 27 + 28 + aspectRatio?: app.bsky.embed.AspectRatio; 29 + } 30 + 31 + model View { 32 + @maxItems(4) 33 + images: ViewImage[]; 34 + } 35 + 36 + model ViewImage { 37 + @doc("Fully-qualified URL where a thumbnail of the image can be fetched. For example, CDN location provided by the App View.") 38 + @lexFormat("uri") 39 + thumb: string; 40 + 41 + @doc("Fully-qualified URL where a large version of the image can be fetched. May or may not be the exact original blob. For example, CDN location provided by the App View.") 42 + @lexFormat("uri") 43 + fullsize: string; 44 + 45 + @doc("Alt text description of the image, for accessibility.") 46 + alt: string; 47 + 48 + aspectRatio?: app.bsky.embed.AspectRatio; 49 + } 50 + }
+15
typelex-emitter/test/scenarios/app-bsky-embed-images/output/app/bsky/embed/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.embed.defs", 4 + "defs": { 5 + "aspectRatio": { 6 + "type": "object", 7 + "description": "width:height represents an aspect ratio. It may be approximate, and may not correspond to absolute dimensions in any given unit.", 8 + "required": ["width", "height"], 9 + "properties": { 10 + "width": { "type": "integer", "minimum": 1 }, 11 + "height": { "type": "integer", "minimum": 1 } 12 + } 13 + } 14 + } 15 + }
+70
typelex-emitter/test/scenarios/app-bsky-embed-images/output/app/bsky/embed/images.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.embed.images", 4 + "description": "A set of images embedded in a Bluesky record (eg, a post).", 5 + "defs": { 6 + "main": { 7 + "type": "object", 8 + "required": ["images"], 9 + "properties": { 10 + "images": { 11 + "type": "array", 12 + "items": { "type": "ref", "ref": "#image" }, 13 + "maxLength": 4 14 + } 15 + } 16 + }, 17 + "image": { 18 + "type": "object", 19 + "required": ["image", "alt"], 20 + "properties": { 21 + "image": { 22 + "type": "blob" 23 + }, 24 + "alt": { 25 + "type": "string", 26 + "description": "Alt text description of the image, for accessibility." 27 + }, 28 + "aspectRatio": { 29 + "type": "ref", 30 + "ref": "app.bsky.embed.defs#aspectRatio" 31 + } 32 + } 33 + }, 34 + "view": { 35 + "type": "object", 36 + "required": ["images"], 37 + "properties": { 38 + "images": { 39 + "type": "array", 40 + "items": { "type": "ref", "ref": "#viewImage" }, 41 + "maxLength": 4 42 + } 43 + } 44 + }, 45 + "viewImage": { 46 + "type": "object", 47 + "required": ["thumb", "fullsize", "alt"], 48 + "properties": { 49 + "thumb": { 50 + "type": "string", 51 + "format": "uri", 52 + "description": "Fully-qualified URL where a thumbnail of the image can be fetched. For example, CDN location provided by the App View." 53 + }, 54 + "fullsize": { 55 + "type": "string", 56 + "format": "uri", 57 + "description": "Fully-qualified URL where a large version of the image can be fetched. May or may not be the exact original blob. For example, CDN location provided by the App View." 58 + }, 59 + "alt": { 60 + "type": "string", 61 + "description": "Alt text description of the image, for accessibility." 62 + }, 63 + "aspectRatio": { 64 + "type": "ref", 65 + "ref": "app.bsky.embed.defs#aspectRatio" 66 + } 67 + } 68 + } 69 + } 70 + }