An experimental TypeSpec syntax for Lexicon
56
fork

Configure Feed

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

fix: properly handle standalone vs defs lexicons

- Standalone lexicons: models with @doc go to namespace/modelName.json with main
- Defs lexicons: models without @doc go to namespace/defs.json
- Fix description placement: only on lexicon doc, not on main object
- All tests passing (15/15)

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

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

+129 -24
+1
typelex-emitter/package.json
··· 7 7 "scripts": { 8 8 "build": "tsc", 9 9 "test": "npm run build && vitest run", 10 + "test:ci": "npm run build && vitest run", 10 11 "test:watch": "npm run build && vitest", 11 12 "clean": "rm -rf dist", 12 13 "watch": "tsc --watch"
+57 -24
typelex-emitter/src/emitter.ts
··· 51 51 52 52 private processNamespace(ns: any) { 53 53 const fullName = getNamespaceFullName(ns); 54 - 54 + 55 55 // Skip built-in TypeSpec namespaces but still process their children 56 56 if (fullName && !fullName.startsWith("TypeSpec")) { 57 57 // Check if this namespace should be a lexicon file 58 58 const hasModels = ns.models.size > 0; 59 59 const hasOperations = ns.operations?.size > 0; 60 60 const hasChildNamespaces = ns.namespaces.size > 0; 61 - 62 - // Heuristic: if namespace has models but no operations and no child namespaces, 63 - // it's likely a defs file 64 - const isLikelyDefsFile = hasModels && !hasOperations && !hasChildNamespaces; 65 - 66 - if (isLikelyDefsFile) { 67 - // Create a single lexicon for all models in this namespace 68 - const lexiconId = fullName + ".defs"; 69 - this.currentLexiconId = lexiconId; 61 + 62 + // Heuristic: if namespace has models but no operations and no child namespaces 63 + const shouldEmitLexicon = hasModels && !hasOperations && !hasChildNamespaces; 64 + 65 + if (shouldEmitLexicon) { 66 + // Check if we should create standalone lexicons or a defs collection 67 + // Standalone: single model with @doc on the model itself 68 + // Defs: everything else 69 + const models = [...ns.models.values()]; 70 + const standaloneModels = models.filter(m => getDoc(this.program, m)); 71 + const defsModels = models.filter(m => !getDoc(this.program, m)); 72 + 73 + // Create standalone lexicons for models with @doc 74 + for (const model of standaloneModels) { 75 + const lexiconId = fullName + "." + model.name.charAt(0).toLowerCase() + model.name.slice(1); 76 + this.currentLexiconId = lexiconId; 77 + 78 + // Don't include model description in the object itself for standalone lexicons 79 + const modelDef = this.modelToLexiconObject(model, false); 80 + const description = getDoc(this.program, ns) || getDoc(this.program, model); 81 + 82 + const lexicon: LexiconDocument = { 83 + lexicon: 1, 84 + id: lexiconId, 85 + defs: { 86 + main: modelDef 87 + }, 88 + }; 70 89 71 - const lexicon: LexiconDocument = { 72 - lexicon: 1, 73 - id: lexiconId, 74 - defs: {}, 75 - }; 90 + if (description) { 91 + lexicon.description = description; 92 + } 76 93 77 - // Add all models as definitions 78 - for (const [_, model] of ns.models) { 79 - const defName = model.name.charAt(0).toLowerCase() + model.name.slice(1); 80 - const modelDef = this.modelToLexiconObject(model); 81 - lexicon.defs[defName] = modelDef; 94 + this.lexicons.set(lexiconId, lexicon); 95 + this.currentLexiconId = null; 82 96 } 83 97 84 - this.lexicons.set(lexiconId, lexicon); 85 - this.currentLexiconId = null; 98 + // Create defs collection for models without @doc 99 + if (defsModels.length > 0) { 100 + const lexiconId = fullName + ".defs"; 101 + this.currentLexiconId = lexiconId; 102 + 103 + const lexicon: LexiconDocument = { 104 + lexicon: 1, 105 + id: lexiconId, 106 + defs: {}, 107 + }; 108 + 109 + // Add all models as definitions 110 + for (const model of defsModels) { 111 + const defName = model.name.charAt(0).toLowerCase() + model.name.slice(1); 112 + const modelDef = this.modelToLexiconObject(model); 113 + lexicon.defs[defName] = modelDef; 114 + } 115 + 116 + this.lexicons.set(lexiconId, lexicon); 117 + this.currentLexiconId = null; 118 + } 86 119 } else if (hasModels && !hasOperations) { 87 120 // Process models individually for non-defs files 88 121 for (const [_, model] of ns.models) { ··· 168 201 } 169 202 } 170 203 171 - private modelToLexiconObject(model: Model): LexiconObject { 172 - const description = getDoc(this.program, model); 204 + private modelToLexiconObject(model: Model, includeModelDescription: boolean = true): LexiconObject { 205 + const description = includeModelDescription ? getDoc(this.program, model) : undefined; 173 206 const required: string[] = []; 174 207 const properties: any = {}; 175 208
+12
typelex-emitter/test/scenarios/atproto-repo-strongref/input/main.tsp
··· 1 + import "@typelex/emitter"; 2 + 3 + namespace com.atproto.repo { 4 + @doc("A URI with a content-hash fingerprint.") 5 + model StrongRef { 6 + @lexFormat("at-uri") 7 + uri: string; 8 + 9 + @lexFormat("cid") 10 + cid: string; 11 + } 12 + }
+15
typelex-emitter/test/scenarios/atproto-repo-strongref/output/com/atproto/repo/strongRef.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.repo.strongRef", 4 + "description": "A URI with a content-hash fingerprint.", 5 + "defs": { 6 + "main": { 7 + "type": "object", 8 + "required": ["uri", "cid"], 9 + "properties": { 10 + "uri": { "type": "string", "format": "at-uri" }, 11 + "cid": { "type": "string", "format": "cid" } 12 + } 13 + } 14 + } 15 + }
+17
typelex-emitter/test/scenarios/constraints/input/main.tsp
··· 1 + import "@typelex/emitter"; 2 + 3 + namespace test.ns { 4 + model Post { 5 + @maxLength(3000) 6 + @maxGraphemes(300) 7 + @doc("Text with both length and grapheme limits") 8 + text: string; 9 + 10 + @lexKnownValues(#["all", "none", "following"]) 11 + @doc("String with known values") 12 + allowIncoming: string; 13 + 14 + @doc("Simple count") 15 + count: int32; 16 + } 17 + }
+27
typelex-emitter/test/scenarios/constraints/output/test/ns/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "test.ns.defs", 4 + "defs": { 5 + "post": { 6 + "type": "object", 7 + "required": ["text", "allowIncoming", "count"], 8 + "properties": { 9 + "text": { 10 + "type": "string", 11 + "maxLength": 3000, 12 + "maxGraphemes": 300, 13 + "description": "Text with both length and grapheme limits" 14 + }, 15 + "allowIncoming": { 16 + "type": "string", 17 + "knownValues": ["all", "none", "following"], 18 + "description": "String with known values" 19 + }, 20 + "count": { 21 + "type": "integer", 22 + "description": "Simple count" 23 + } 24 + } 25 + } 26 + } 27 + }