A TypeScript toolchain that compiles a concise Lexicon DSL (.lexd) into AT Protocol lexicon JSON compatible with goat, @atproto/lex, pdsls, and the rest of the Atmosphere tooling.
Documentation: harveyrandall.github.io/lexd (Docsify site in docs/)
import { StrongRef } from "com.atproto.repo.strongRef"
namespace app.bsky.feed {
@record("tid")
type like {
subject: StrongRef
@format("datetime") createdAt: string
}
}
pnpm build
pnpm lexd compile "examples/**/*.lexd" -o lexicons| Package | Role |
|---|---|
@lexd/core |
Parser, AST, import resolution, JSON emitter, decompiler |
@lexd/cli |
lexd compile / lexd decompile CLI |
@lexd/vite-plugin |
Vite plugin (disk emit + optional .lexd imports) |
@lexd/stdlib-atproto |
Curated com.atproto.* .lexd sources |
@lexd/stdlib-standard |
Curated site.standard.* .lexd sources |
@lexd/language-server |
LSP for .lexd (diagnostics, hover, completion, go-to-definition) |
vscode-lexd |
VS Code / Cursor extension client |
pnpm install
pnpm build
pnpm testnamespace app.bsky.actor {
@record("self") // record key: self | tid | literal:…
@description("A profile")
type profile {
@maxGraphemes(64) displayName?: string // optional
@maxGraphemes(256) description?: string
}
}
namespace a.b.cis the NSID prefix; a@recordtypeprofileemits lexicon ida.b.c.profile.- Fields without
?are listed in JSONrequired. - Trailing non-primary
types after a@recordbecome siblingdefsuntil the next primary.
Use @object when main should be an object (e.g. com.atproto.repo.strongRef):
namespace com.atproto.repo {
@object
@description("A URI with a content-hash fingerprint.")
type strongRef {
@format("at-uri") uri: string
@format("cid") cid: string
}
}
If a namespace contains only non-primary types (no @record / @object), the namespace is the lexicon id and each type becomes a named def (no main). Shown below is the *.defs pattern:
namespace com.atproto.label.defs {
type selfLabel {
@maxGraphemes(128) val: string
}
type selfLabels {
values: selfLabel[]
}
}
// Named import: aliases `main` of that lexicon (or a named def if the name matches)
import { StrongRef } from "com.atproto.repo.strongRef"
import { selfLabels } from "com.atproto.label.defs"
// Rename
import { StrongRef as Ref } from "com.atproto.repo.strongRef"
// Whole-module import: local name is the last NSID segment
import com.atproto.repo.strongRef
Imported symbols lower to external refs ("com.atproto.repo.strongRef" or "com.atproto.label.defs#selfLabels"). Foreign defs are never copied into the emitting lexicon.
Resolution order for a bare type name: import alias → local def → error.
namespace com.atproto.repo {
@query
@description("Get a single record from a repository.")
type getRecord {
params {
@format("at-identifier") repo: string
@format("nsid") collection: string
@format("record-key") rkey: string
@format("cid") cid?: string
}
output {
encoding: "application/json"
schema {
@format("at-uri") uri: string
value: unknown
}
}
errors {
RecordNotFound
}
}
@procedure
type createRecord {
input {
encoding: "application/json"
schema {
repo: string
collection: string
record: unknown
}
}
output {
encoding: "application/json"
schema {
@format("at-uri") uri: string
@format("cid") cid: string
}
}
}
}
namespace com.example.sync {
@subscription
type subscribeDemo {
params { cursor?: integer }
message {
schema: union(#commit, #identity)
}
errors {
FutureCursor
ConsumerTooSlow: "Backpressure disconnect."
}
}
type commit { seq: integer }
type identity { seq: integer }
}
schema: TypeExprorschema { fields… }insideinput/output/messageclosed union(A, B)sets"closed": true@nullableon a field adds it to the objectnullablelist@accept([...])/@maxSize(n)onblob@token type Name {}emits a token def
namespace app.bsky {
@permissionSet
@title("Create Bluesky Posts")
@detail("Create only; no update/delete.")
type authCreatePosts {
permissions {
rpc {
inheritAud: true
lxm: ["app.bsky.video.uploadVideo"]
}
repo {
collection: ["app.bsky.feed.post"]
action: ["create"]
}
}
}
}
| DSL | Lexicon |
|---|---|
string, integer, boolean, bytes, cid-link, blob, unknown |
same |
T[] |
array + items |
TypeName / #frag / ns.id / ns.id#frag |
ref |
union(A, B) / closed union(A, B) |
union (+ closed) |
@maxGraphemes @minGraphemes @maxLength @minLength @format @default @const @enum @knownValues @description @minimum @maximum @accept @maxSize @nullable @title @detail
On array fields, @maxLength / @minLength apply to the array length.
Reserved section words (cannot be bare field names):
params,input,output,message,errors,permissions,encoding,schema,closed,union.
# From the repo (after pnpm build)
pnpm lexd compile "examples/**/*.lexd" -o lexicons
# Watch
pnpm lexd compile "examples/**/*.lexd" -o lexicons -w
# Nested paths: lexicons/app/bsky/actor/profile.json
pnpm lexd compile "examples/**/*.lexd" -o lexicons --layout nested
# Decompile lexicon JSON back to .lexd (flat NSID filenames)
pnpm lexd decompile lexicons -o recovered
pnpm lexd validate "examples/**/*.lexd"
pnpm lexd publish "examples/**/*.lexd" -o lexicons --layout nested
pnpm lexd publish "examples/**/*.lexd" -o lexicons --dry-run
pnpm lexd decompile lexicons/app.bsky.actor.profile.json -o recovered| Flag | Default | Meaning |
|---|---|---|
-o, --out <dir> |
lexicons (compile) / lexd-out (decompile) |
Output directory |
--layout flat|nested |
flat |
id.json vs id/path.json (compile and decompile) |
-w, --watch |
off | Recompile on change |
compile also loads @lexd/stdlib-atproto and @lexd/stdlib-standard from the workspace/node_modules so import { … } from "com.atproto…" resolves without listing those files on the command line. Stdlib lexicons are emitted when you include their sources in the glob (or compile the stdlib package paths explicitly).
decompile writes one .lexd file per lexicon JSON. Use --layout nested for com/atproto/repo/getRecord.lexd-style paths. Known stdlib refs such as com.atproto.repo.strongRef become named imports (StrongRef) instead of inlined full NSIDs. Inline JSON object fields are hoisted to named secondary types.
Sync stdlib from bluesky-social/atproto lexicons:
pnpm stdlib:bootstrapimport { defineConfig } from 'vite'
import lexd from '@lexd/vite-plugin'
export default defineConfig({
plugins: [
lexd({
include: 'src/**/*.lexd',
outDir: 'lexicons', // disk emit for goat / @atproto/lex
layout: 'flat',
virtual: true, // import doc from './profile.lexd'
}),
],
})- Disk emit: writes JSON under
outDironbuildStartand when.lexdfiles change in dev. - Virtual modules:
import schema from './profile.lexd'yields the compiledLexiconDoc(or an array if the file produces multiple docs).
| Package | Namespace examples |
|---|---|
@lexd/stdlib-atproto |
com.atproto.repo.strongRef, … |
@lexd/stdlib-standard |
site.standard.document (starter shape) |
import { StrongRef } from "com.atproto.repo.strongRef"
Add more curated .lexd files under packages/stdlib-*/src as the surface grows.
lexd only emits lexicon JSON. Publishing to the Atmosphere is unchanged:
- Put the JSON in collection
com.atproto.lexicon.schemawith record key = lexicon NSID. - Prove namespace authority with a
_lexiconDNS TXT record.
See atproto made simple: publishing lexicons for the mental model.
@lexd/language-server speaks the Language Server Protocol over stdio (lexd-lsp / packages/language-server/dist/server.js). The vscode-lexd extension contributes language id lexd for .lexd files and starts that server.
Features (MVP):
- Diagnostics from
parseLexd/ compile (syntax spans; compile errors when available) - Completion for
@constraints, primitives, section keywords, and in-scope symbols - Hover for fields / types / imports
- Go to Definition for local secondary types and imported stdlib modules when indexed
pnpm install
pnpm buildThen either:
- F5: use the “Lexd Extension” config in
.vscode/launch.json, or - Install the folder:
code --install-extension ./packages/vscode-lexd(orcursor --install-extension …), or - Package a VSIX: see
packages/vscode-lexd/README.md.
Open examples/feed-post.lexd to try diagnostics, @ completion, and go-to-definition on Reply.
Publishable workspace packages (@lexd/*) include MIT license metadata, publishConfig.access: public, and a prepack build step. From the repo root after bumping versions:
pnpm pack:packages # dry-run tarballs in dependency order
pnpm publish:packages # core → stdlib → cli / lsp / vite-pluginvscode-lexd is private and not published to npm; ship it as a VSIX instead.
pnpm package:vsixCI runs the same command on every push/PR to verify the bundle builds.
| Milestone | Status |
|---|---|
| Records, objects, defs modules, imports, stdlib seeds | Done (M0/M1) |
@query / @procedure / @subscription / @permissionSet, tokens, blob/nullable/closed unions |
Done (M2) |
JSON → .lexd decompiler |
Done (M3) |
| LSP / VS Code extension | Done (M4) |
| VSIX bundling + npm publish metadata | Done (M9) |
- Language: richer imports,
@atproto/lexcodegen hooks - Polish LSP (semantic tokens, compile-error spans, richer import navigation)