An experimental TypeSpec syntax for Lexicon
56
fork

Configure Feed

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

feat: add @lexConst and @record decorators for advanced lexicon patterns

Implemented support for two new lexicon patterns:

1. @lexConst decorator for boolean constant values
- Maps to lexicon's "const" field
- Used for discriminated unions (e.g., notFound: true, blocked: true)

2. @record decorator for repository record types
- Wraps models in lexicon "record" type structure
- Supports key types (tid, literal:self, any)
- Example: app.bsky.graph.follow (social follow relationship)

Key fixes:
- Prevent duplicate processing when model named "Main" has @lexiconMain
- Export new decorators from index.ts
- Handle description placement for record-wrapped definitions
- Use namespace name directly for Main models with @lexiconMain

Tests: 23/23 passing

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

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

+509 -9
+14
packages/emitter/lib/decorators.tsp
··· 47 47 * Specifies maximum size in bytes for a blob field. 48 48 */ 49 49 extern dec blobMaxSize(target: unknown, size: valueof int32); 50 + 51 + /** 52 + * Marks a boolean field as having a constant value. 53 + * Maps to lexicon's "const" field. 54 + */ 55 + extern dec lexConst(target: unknown, value: valueof boolean); 56 + 57 + /** 58 + * Marks a model as a record type with a specific key type. 59 + * Maps to lexicon's "record" type. 60 + * 61 + * @param key - The key type for the record (e.g., "tid", "literal:self", "any") 62 + */ 63 + extern dec record(target: unknown, key: valueof string);
+26
packages/emitter/src/decorators.ts
··· 7 7 const lexiconMainKey = Symbol("lexiconMain"); 8 8 const blobAcceptKey = Symbol("blobAccept"); 9 9 const blobMaxSizeKey = Symbol("blobMaxSize"); 10 + const constKey = Symbol("const"); 11 + const recordKey = Symbol("record"); 10 12 11 13 /** 12 14 * @lexFormat decorator for lexicon-specific string formats ··· 120 122 export function getBlobMaxSize(program: Program, target: Type): number | undefined { 121 123 return program.stateMap(blobMaxSizeKey).get(target); 122 124 } 125 + 126 + /** 127 + * @lexConst decorator for constant boolean values 128 + */ 129 + export function $lexConst(context: DecoratorContext, target: Type, value: Type) { 130 + const boolValue = (value as any).kind === "Boolean" ? (value as any).value : value; 131 + context.program.stateMap(constKey).set(target, Boolean(boolValue)); 132 + } 133 + 134 + export function getLexConst(program: Program, target: Type): boolean | undefined { 135 + return program.stateMap(constKey).get(target); 136 + } 137 + 138 + /** 139 + * @record decorator for record type lexicons 140 + */ 141 + export function $record(context: DecoratorContext, target: Type, key: Type) { 142 + const keyValue = (key as any).kind === "String" ? (key as any).value : key; 143 + context.program.stateMap(recordKey).set(target, keyValue); 144 + } 145 + 146 + export function getRecordKey(program: Program, target: Type): string | undefined { 147 + return program.stateMap(recordKey).get(target); 148 + }
+39 -8
packages/emitter/src/emitter.ts
··· 32 32 isLexiconMain, 33 33 getBlobAccept, 34 34 getBlobMaxSize, 35 + getLexConst, 36 + getRecordKey, 35 37 } from "./decorators.js"; 36 38 37 39 export interface EmitterOptions { ··· 79 81 const standaloneModels = models.filter((m) => 80 82 isLexiconMain(this.program, m), 81 83 ); 82 - const mainModel = models.find((m) => m.name === "Main"); 84 + const mainModel = models.find( 85 + (m) => m.name === "Main" && !isLexiconMain(this.program, m), 86 + ); 83 87 const otherModels = models.filter( 84 88 (m) => !isLexiconMain(this.program, m) && m.name !== "Main", 85 89 ); 86 90 87 91 // Case 1: Models marked with @lexiconMain -> standalone lexicon with ONLY main 88 92 for (const model of standaloneModels) { 89 - const lexiconId = 90 - fullName + 91 - "." + 92 - model.name.charAt(0).toLowerCase() + 93 - model.name.slice(1); 93 + // If model is named "Main", use namespace directly, otherwise append model name 94 + const lexiconId = model.name === "Main" 95 + ? fullName 96 + : fullName + 97 + "." + 98 + model.name.charAt(0).toLowerCase() + 99 + model.name.slice(1); 94 100 this.currentLexiconId = lexiconId; 95 101 96 102 const modelDef = this.modelToLexiconObject(model, false); 97 103 const description = 98 104 getDoc(this.program, ns) || getDoc(this.program, model); 99 105 106 + // Check if this is a record type 107 + const recordKey = getRecordKey(this.program, model); 108 + let mainDef: any = modelDef; 109 + 110 + if (recordKey) { 111 + // Wrap the object in a record structure 112 + mainDef = { 113 + type: "record", 114 + key: recordKey, 115 + record: modelDef, 116 + }; 117 + 118 + // Add description to record def if it exists 119 + if (description) { 120 + mainDef.description = description; 121 + } 122 + } 123 + 100 124 const lexicon: LexiconDocument = { 101 125 lexicon: 1, 102 126 id: lexiconId, 103 127 defs: { 104 - main: modelDef, 128 + main: mainDef, 105 129 }, 106 130 }; 107 131 108 - if (description) { 132 + // Only add description to lexicon if not a record type (records have it on main def) 133 + if (description && !recordKey) { 109 134 lexicon.description = description; 110 135 } 111 136 ··· 494 519 const knownValues = getLexKnownValues(this.program, prop); 495 520 if (knownValues) { 496 521 primitive.knownValues = knownValues; 522 + } 523 + 524 + // Check for const value on boolean properties 525 + const constValue = getLexConst(this.program, prop); 526 + if (constValue !== undefined && primitive.type === "boolean") { 527 + (primitive as any).const = constValue; 497 528 } 498 529 } 499 530
+2
packages/emitter/src/index.ts
··· 22 22 $lexiconMain, 23 23 $blobAccept, 24 24 $blobMaxSize, 25 + $lexConst, 26 + $record, 25 27 } from "./decorators.js";
+14
packages/emitter/test/scenarios/app-bsky-graph-follow/input/main.tsp
··· 1 + import "@typlex/emitter"; 2 + 3 + namespace app.bsky.graph.follow { 4 + @lexiconMain 5 + @record("tid") 6 + @doc("Record declaring a social 'follow' relationship of another account. Duplicate follows will be ignored by the AppView.") 7 + model Main { 8 + @lexFormat("did") 9 + subject: string; 10 + 11 + @lexFormat("datetime") 12 + createdAt: string; 13 + } 14 + }
+19
packages/emitter/test/scenarios/app-bsky-graph-follow/output/app/bsky/graph/follow.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.graph.follow", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "Record declaring a social 'follow' relationship of another account. Duplicate follows will be ignored by the AppView.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["subject", "createdAt"], 12 + "properties": { 13 + "subject": { "type": "string", "format": "did" }, 14 + "createdAt": { "type": "string", "format": "datetime" } 15 + } 16 + } 17 + } 18 + } 19 + }
+1 -1
packages/example/.atprotorc.yaml
··· 1 1 lexicons: 2 - - lexicons/**/*.json 2 + - lexicon/**/*.json
+34
packages/example/lexicon/app/example/entity.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.example.entity", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "start": { 12 + "type": "integer", 13 + "description": "Start index in text" 14 + }, 15 + "end": { 16 + "type": "integer", 17 + "description": "End index in text" 18 + }, 19 + "value": { 20 + "type": "string", 21 + "description": "Entity value (handle, URL, or tag)" 22 + } 23 + }, 24 + "required": [ 25 + "start", 26 + "end", 27 + "type", 28 + "value" 29 + ] 30 + }, 31 + "description": "Entity mentioned or linked in a post" 32 + } 33 + } 34 + }
+29
packages/example/lexicon/app/example/follow.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.example.follow", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "subject": { 12 + "type": "string", 13 + "description": "DID of the account being followed" 14 + }, 15 + "createdAt": { 16 + "type": "string", 17 + "format": "datetime", 18 + "description": "When the follow was created" 19 + } 20 + }, 21 + "required": [ 22 + "subject", 23 + "createdAt" 24 + ] 25 + }, 26 + "description": "A follow relationship" 27 + } 28 + } 29 + }
+43
packages/example/lexicon/app/example/like.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.example.like", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "subject": { 12 + "type": "object", 13 + "properties": { 14 + "uri": { 15 + "type": "string", 16 + "description": "AT URI of the post" 17 + }, 18 + "cid": { 19 + "type": "string", 20 + "description": "CID of the post" 21 + } 22 + }, 23 + "description": "The post being liked", 24 + "required": [ 25 + "uri", 26 + "cid" 27 + ] 28 + }, 29 + "createdAt": { 30 + "type": "string", 31 + "format": "datetime", 32 + "description": "When the like was created" 33 + } 34 + }, 35 + "required": [ 36 + "subject", 37 + "createdAt" 38 + ] 39 + }, 40 + "description": "A like on a post" 41 + } 42 + } 43 + }
+110
packages/example/lexicon/app/example/post.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.example.post", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "text": { 12 + "type": "string", 13 + "description": "Post text content" 14 + }, 15 + "createdAt": { 16 + "type": "string", 17 + "format": "datetime", 18 + "description": "Creation timestamp" 19 + }, 20 + "langs": { 21 + "type": "array", 22 + "items": { 23 + "type": "string" 24 + }, 25 + "description": "Languages the post is written in" 26 + }, 27 + "entities": { 28 + "type": "array", 29 + "items": { 30 + "type": "object", 31 + "properties": { 32 + "start": { 33 + "type": "integer", 34 + "description": "Start index in text" 35 + }, 36 + "end": { 37 + "type": "integer", 38 + "description": "End index in text" 39 + }, 40 + "value": { 41 + "type": "string", 42 + "description": "Entity value (handle, URL, or tag)" 43 + } 44 + }, 45 + "description": "Entity mentioned or linked in a post", 46 + "required": [ 47 + "start", 48 + "end", 49 + "type", 50 + "value" 51 + ] 52 + }, 53 + "description": "Referenced entities in the post" 54 + }, 55 + "reply": { 56 + "type": "object", 57 + "properties": { 58 + "root": { 59 + "type": "object", 60 + "properties": { 61 + "uri": { 62 + "type": "string", 63 + "description": "AT URI of the post" 64 + }, 65 + "cid": { 66 + "type": "string", 67 + "description": "CID of the post" 68 + } 69 + }, 70 + "description": "Root post in thread", 71 + "required": [ 72 + "uri", 73 + "cid" 74 + ] 75 + }, 76 + "parent": { 77 + "type": "object", 78 + "properties": { 79 + "uri": { 80 + "type": "string", 81 + "description": "AT URI of the post" 82 + }, 83 + "cid": { 84 + "type": "string", 85 + "description": "CID of the post" 86 + } 87 + }, 88 + "description": "Direct parent being replied to", 89 + "required": [ 90 + "uri", 91 + "cid" 92 + ] 93 + } 94 + }, 95 + "description": "Post the user is replying to", 96 + "required": [ 97 + "root", 98 + "parent" 99 + ] 100 + } 101 + }, 102 + "required": [ 103 + "text", 104 + "createdAt" 105 + ] 106 + }, 107 + "description": "A post in the feed" 108 + } 109 + } 110 + }
+28
packages/example/lexicon/app/example/postRef.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.example.postRef", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "uri": { 12 + "type": "string", 13 + "description": "AT URI of the post" 14 + }, 15 + "cid": { 16 + "type": "string", 17 + "description": "CID of the post" 18 + } 19 + }, 20 + "required": [ 21 + "uri", 22 + "cid" 23 + ] 24 + }, 25 + "description": "Reference to a post" 26 + } 27 + } 28 + }
+50
packages/example/lexicon/app/example/profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.example.profile", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "did": { 12 + "type": "string", 13 + "description": "Decentralized identifier" 14 + }, 15 + "handle": { 16 + "type": "string", 17 + "description": "Handle identifier" 18 + }, 19 + "displayName": { 20 + "type": "string", 21 + "description": "Display name shown in UI" 22 + }, 23 + "description": { 24 + "type": "string", 25 + "description": "User biography" 26 + }, 27 + "avatar": { 28 + "type": "string", 29 + "description": "Avatar image URL" 30 + }, 31 + "banner": { 32 + "type": "string", 33 + "description": "Banner image URL" 34 + }, 35 + "createdAt": { 36 + "type": "string", 37 + "format": "datetime", 38 + "description": "When the profile was created" 39 + } 40 + }, 41 + "required": [ 42 + "did", 43 + "handle", 44 + "createdAt" 45 + ] 46 + }, 47 + "description": "User profile information" 48 + } 49 + } 50 + }
+56
packages/example/lexicon/app/example/replyRef.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.example.replyRef", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "root": { 12 + "type": "object", 13 + "properties": { 14 + "uri": { 15 + "type": "string", 16 + "description": "AT URI of the post" 17 + }, 18 + "cid": { 19 + "type": "string", 20 + "description": "CID of the post" 21 + } 22 + }, 23 + "description": "Root post in thread", 24 + "required": [ 25 + "uri", 26 + "cid" 27 + ] 28 + }, 29 + "parent": { 30 + "type": "object", 31 + "properties": { 32 + "uri": { 33 + "type": "string", 34 + "description": "AT URI of the post" 35 + }, 36 + "cid": { 37 + "type": "string", 38 + "description": "CID of the post" 39 + } 40 + }, 41 + "description": "Direct parent being replied to", 42 + "required": [ 43 + "uri", 44 + "cid" 45 + ] 46 + } 47 + }, 48 + "required": [ 49 + "root", 50 + "parent" 51 + ] 52 + }, 53 + "description": "Reference to the post being replied to" 54 + } 55 + } 56 + }
+43
packages/example/lexicon/app/example/repost.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.example.repost", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "tid", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "subject": { 12 + "type": "object", 13 + "properties": { 14 + "uri": { 15 + "type": "string", 16 + "description": "AT URI of the post" 17 + }, 18 + "cid": { 19 + "type": "string", 20 + "description": "CID of the post" 21 + } 22 + }, 23 + "description": "The post being reposted", 24 + "required": [ 25 + "uri", 26 + "cid" 27 + ] 28 + }, 29 + "createdAt": { 30 + "type": "string", 31 + "format": "datetime", 32 + "description": "When the repost was created" 33 + } 34 + }, 35 + "required": [ 36 + "subject", 37 + "createdAt" 38 + ] 39 + }, 40 + "description": "A repost of another post" 41 + } 42 + } 43 + }
+1
packages/example/package.json
··· 7 7 "build": "typlex typlex/main.tsp" 8 8 }, 9 9 "dependencies": { 10 + "@atproto/lex-cli": "^0.0.6", 10 11 "@typespec/compiler": "^0.64.0", 11 12 "@typlex/emitter": "workspace:*", 12 13 "@typlex/cli": "workspace:*"