An experimental TypeSpec syntax for Lexicon
56
fork

Configure Feed

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

feat: add @lexiconMain decorator and minimum constraint support

- Add @lexiconMain decorator to explicitly mark standalone lexicons
- Models without @lexiconMain go into defs.json
- Add support for @minValue decorator (maps to lexicon minimum)
- Port app.bsky.embed.defs with AspectRatio

This allows namespaces to have both defs.json and standalone lexicons.

Tests passing (20/20)

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

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

+62 -5
+6
typelex-emitter/lib/decorators.tsp
··· 31 31 * Maps to lexicon's "knownValues" field. 32 32 */ 33 33 extern dec lexKnownValues(target: unknown, values: valueof string[]); 34 + 35 + /** 36 + * Marks a model as a standalone lexicon with a main definition. 37 + * Without this decorator, models go into the namespace's defs.json file. 38 + */ 39 + extern dec lexiconMain(target: unknown);
+12
typelex-emitter/src/decorators.ts
··· 4 4 const maxGraphemesKey = Symbol("maxGraphemes"); 5 5 const minGraphemesKey = Symbol("minGraphemes"); 6 6 const knownValuesKey = Symbol("knownValues"); 7 + const lexiconMainKey = Symbol("lexiconMain"); 7 8 8 9 /** 9 10 * @lexFormat decorator for lexicon-specific string formats ··· 69 70 export function getLexKnownValues(program: Program, target: Type): string[] | undefined { 70 71 return program.stateMap(knownValuesKey).get(target); 71 72 } 73 + 74 + /** 75 + * @lexiconMain decorator to mark a model as a standalone lexicon 76 + */ 77 + export function $lexiconMain(context: DecoratorContext, target: Type) { 78 + context.program.stateMap(lexiconMainKey).set(target, true); 79 + } 80 + 81 + export function isLexiconMain(program: Program, target: Type): boolean { 82 + return program.stateMap(lexiconMainKey).get(target) === true; 83 + }
+13 -4
typelex-emitter/src/emitter.ts
··· 10 10 isTemplateInstance, 11 11 isType, 12 12 getMaxLength, 13 + getMinValue, 13 14 } from "@typespec/compiler"; 14 15 import { join, dirname } from "path"; 15 16 import type { ··· 21 22 LexiconRef, 22 23 LexiconUnion, 23 24 } from "./types.js"; 24 - import { getLexFormat, getMaxGraphemes, getMinGraphemes, getLexKnownValues } from "./decorators.js"; 25 + import { getLexFormat, getMaxGraphemes, getMinGraphemes, getLexKnownValues, isLexiconMain } from "./decorators.js"; 25 26 26 27 export interface EmitterOptions { 27 28 outputDir: string; ··· 64 65 65 66 if (shouldEmitLexicon) { 66 67 // Check if we should create standalone lexicons or a defs collection 67 - // Standalone: single model with @doc on the model itself 68 + // Standalone: models marked with @lexiconMain 68 69 // Defs: everything else 69 70 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)); 71 + const standaloneModels = models.filter(m => isLexiconMain(this.program, m)); 72 + const defsModels = models.filter(m => !isLexiconMain(this.program, m)); 72 73 73 74 // Create standalone lexicons for models with @doc 74 75 for (const model of standaloneModels) { ··· 401 402 const knownValues = getLexKnownValues(this.program, prop); 402 403 if (knownValues) { 403 404 primitive.knownValues = knownValues; 405 + } 406 + } 407 + 408 + // Add minimum constraint for integer/number types 409 + if (prop && (primitive.type === "integer" || primitive.type === "number")) { 410 + const minValue = getMinValue(this.program, prop); 411 + if (minValue !== undefined) { 412 + primitive.minimum = minValue; 404 413 } 405 414 } 406 415
+1 -1
typelex-emitter/src/index.ts
··· 14 14 } 15 15 16 16 // Export decorators 17 - export { $lexFormat, $maxGraphemes, $minGraphemes, $lexKnownValues } from "./decorators.js"; 17 + export { $lexFormat, $maxGraphemes, $minGraphemes, $lexKnownValues, $lexiconMain } from "./decorators.js";
+13
typelex-emitter/test/scenarios/app-bsky-embed-defs/input/main.tsp
··· 1 + import "@typelex/emitter"; 2 + 3 + namespace app.bsky.embed { 4 + // Description goes on the model for defs, unlike standalone lexicons where it goes at lexicon level 5 + @doc("width:height represents an aspect ratio. It may be approximate, and may not correspond to absolute dimensions in any given unit.") 6 + model AspectRatio { 7 + @minValue(1) 8 + width: int32; 9 + 10 + @minValue(1) 11 + height: int32; 12 + } 13 + }
+15
typelex-emitter/test/scenarios/app-bsky-embed-defs/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 + }
+1
typelex-emitter/test/scenarios/atproto-repo-strongref/input/main.tsp
··· 1 1 import "@typelex/emitter"; 2 2 3 3 namespace com.atproto.repo { 4 + @lexiconMain 4 5 @doc("A URI with a content-hash fingerprint.") 5 6 model StrongRef { 6 7 @lexFormat("at-uri")
+1
typelex-emitter/test/scenarios/com-atproto-repo-strongRef/input/main.tsp
··· 1 1 import "@typelex/emitter"; 2 2 3 3 namespace com.atproto.repo { 4 + @lexiconMain 4 5 @doc("A URI with a content-hash fingerprint.") 5 6 model StrongRef { 6 7 @lexFormat("at-uri")