@atproto/lex-builder 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/filtered-indexer.d.ts +2 -2
  3. package/dist/filtered-indexer.d.ts.map +1 -1
  4. package/dist/filtered-indexer.js.map +1 -1
  5. package/dist/formatter.d.ts +1 -1
  6. package/dist/formatter.d.ts.map +1 -1
  7. package/dist/formatter.js +1 -1
  8. package/dist/formatter.js.map +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/lex-builder.d.ts +4 -4
  14. package/dist/lex-builder.d.ts.map +1 -1
  15. package/dist/lex-builder.js +53 -2
  16. package/dist/lex-builder.js.map +1 -1
  17. package/dist/lex-def-builder.d.ts +2 -2
  18. package/dist/lex-def-builder.d.ts.map +1 -1
  19. package/dist/lex-def-builder.js +1 -1
  20. package/dist/lex-def-builder.js.map +1 -1
  21. package/dist/lexicon-directory-indexer.d.ts.map +1 -1
  22. package/dist/lexicon-directory-indexer.js.map +1 -1
  23. package/dist/ref-resolver.d.ts +1 -1
  24. package/dist/ref-resolver.d.ts.map +1 -1
  25. package/dist/ref-resolver.js +1 -0
  26. package/dist/ref-resolver.js.map +1 -1
  27. package/package.json +6 -10
  28. package/src/filter.ts +0 -96
  29. package/src/filtered-indexer.test.ts +0 -84
  30. package/src/filtered-indexer.ts +0 -60
  31. package/src/formatter.ts +0 -83
  32. package/src/index.ts +0 -56
  33. package/src/lex-builder.ts +0 -299
  34. package/src/lex-def-builder.ts +0 -1035
  35. package/src/lexicon-directory-indexer.ts +0 -65
  36. package/src/polyfill.ts +0 -7
  37. package/src/ref-resolver.test.ts +0 -75
  38. package/src/ref-resolver.ts +0 -437
  39. package/src/ts-lang.ts +0 -197
  40. package/src/util.ts +0 -72
  41. package/tsconfig.build.json +0 -13
  42. package/tsconfig.json +0 -7
  43. package/tsconfig.tests.json +0 -8
@@ -1,84 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
- import { LexiconDocument, LexiconIndexer } from '@atproto/lex-document'
3
- import { FilteredIndexer } from './filtered-indexer.js'
4
-
5
- class DummyIndexer implements LexiconIndexer, AsyncIterable<LexiconDocument> {
6
- readonly docs: Map<string, LexiconDocument>
7
-
8
- constructor(docs: LexiconDocument[]) {
9
- this.docs = new Map(docs.map((doc) => [doc.id, doc]))
10
- }
11
-
12
- async get(id: string): Promise<LexiconDocument> {
13
- const doc = this.docs.get(id)
14
- if (!doc) {
15
- throw new Error(`Document not found: ${id}`)
16
- }
17
- return doc
18
- }
19
-
20
- async *[Symbol.asyncIterator]() {
21
- for (const doc of this.docs.values()) {
22
- yield doc
23
- }
24
- }
25
- }
26
-
27
- describe('FilteredIndexer', () => {
28
- const docs: LexiconDocument[] = [
29
- {
30
- lexicon: 1,
31
- id: 'com.example.alpha',
32
- defs: {},
33
- },
34
- {
35
- lexicon: 1,
36
- id: 'com.example.beta',
37
- defs: {},
38
- },
39
- {
40
- lexicon: 1,
41
- id: 'org.sample.gamma',
42
- defs: {},
43
- },
44
- ]
45
-
46
- it('yields only filtered documents', async () => {
47
- const indexer = new DummyIndexer(docs)
48
- const filter = (id: string) => id.startsWith('com.example.')
49
- const filteredIndexer = new FilteredIndexer(indexer, filter)
50
-
51
- const yieldedDocs = []
52
- for await (const doc of filteredIndexer) {
53
- yieldedDocs.push(doc)
54
- }
55
-
56
- expect(yieldedDocs).toHaveLength(2)
57
- expect(yieldedDocs.map((d) => d.id)).toEqual([
58
- 'com.example.alpha',
59
- 'com.example.beta',
60
- ])
61
- })
62
-
63
- it('bypasses filter for requested documents', async () => {
64
- const indexer = new DummyIndexer(docs)
65
- const filter = (id: string) => id.startsWith('com.example.')
66
- const filteredIndexer = new FilteredIndexer(indexer, filter)
67
-
68
- // Request a document that would normally be filtered out
69
- const requestedDoc = await filteredIndexer.get('org.sample.gamma')
70
- expect(requestedDoc.id).toBe('org.sample.gamma')
71
-
72
- const yieldedDocs = []
73
- for await (const doc of filteredIndexer) {
74
- yieldedDocs.push(doc)
75
- }
76
-
77
- expect(yieldedDocs).toHaveLength(3)
78
- expect(yieldedDocs.map((d) => d.id)).toEqual([
79
- 'com.example.alpha',
80
- 'com.example.beta',
81
- 'org.sample.gamma',
82
- ])
83
- })
84
- })
@@ -1,60 +0,0 @@
1
- import { LexiconDocument, LexiconIndexer } from '@atproto/lex-document'
2
- import { Filter } from './filter.js'
3
-
4
- /**
5
- * A lexicon indexer that filters documents based on a provided filter.
6
- *
7
- * If a document was filtered out but later requested via `get()`, the filter
8
- * will be bypassed for that document.
9
- */
10
- export class FilteredIndexer implements LexiconIndexer, AsyncDisposable {
11
- protected readonly returned = new Set<string>()
12
-
13
- constructor(
14
- readonly indexer: LexiconIndexer & AsyncIterable<LexiconDocument>,
15
- readonly filter: Filter,
16
- ) {}
17
-
18
- async get(id: string): Promise<LexiconDocument> {
19
- this.returned.add(id)
20
- return this.indexer.get(id)
21
- }
22
-
23
- async *[Symbol.asyncIterator]() {
24
- const returned = new Set<string>()
25
-
26
- for await (const doc of this.indexer) {
27
- if (returned.has(doc.id)) {
28
- // Should never happen
29
- throw new Error(`Duplicate lexicon document id: ${doc.id}`)
30
- }
31
-
32
- if (this.returned.has(doc.id) || this.filter(doc.id)) {
33
- this.returned.add(doc.id)
34
- returned.add(doc.id)
35
- yield doc
36
- }
37
- }
38
-
39
- // When we yield control back to the caller, there may be requests (.get())
40
- // for documents that were initially ignored (filtered out). We won't be
41
- // done iterating until every document that may have been requested when the
42
- // control was yielded to the caller has been returned.
43
-
44
- let returnedAny: boolean
45
- do {
46
- returnedAny = false
47
- for (const id of this.returned) {
48
- if (!returned.has(id)) {
49
- yield await this.indexer.get(id)
50
- returned.add(id)
51
- returnedAny = true
52
- }
53
- }
54
- } while (returnedAny)
55
- }
56
-
57
- async [Symbol.asyncDispose](): Promise<void> {
58
- await this.indexer[Symbol.asyncDispose]?.()
59
- }
60
- }
package/src/formatter.ts DELETED
@@ -1,83 +0,0 @@
1
- import { Options as PrettierOptions, format as prettierFormat } from 'prettier'
2
-
3
- const DEFAULT_FORMAT_OPTIONS: PrettierOptions = {
4
- parser: 'typescript',
5
- tabWidth: 2,
6
- semi: false,
7
- singleQuote: true,
8
- trailingComma: 'all',
9
- }
10
-
11
- const DEFAULT_BANNER = `/*
12
- * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
13
- */`
14
-
15
- /**
16
- * Options for configuring the code formatter.
17
- */
18
- export type FormatterOptions = {
19
- /**
20
- * Whether to format the generated code with Prettier.
21
- *
22
- * - `false`: No formatting (default)
23
- * - `true`: Format with default Prettier options
24
- * - `PrettierOptions`: Format with custom Prettier configuration
25
- *
26
- * @default false
27
- */
28
- pretty?: boolean | PrettierOptions
29
- /**
30
- * A banner comment to prepend to each generated file.
31
- *
32
- * @default '/* THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. *\/'
33
- */
34
- banner?: string
35
- }
36
-
37
- /**
38
- * Formats generated TypeScript code with optional Prettier formatting
39
- * and banner comments.
40
- *
41
- * @example
42
- * ```ts
43
- * const formatter = new Formatter({ pretty: true })
44
- * const formatted = await formatter.format(generatedCode)
45
- * ```
46
- */
47
- export class Formatter {
48
- /** The banner comment to prepend to formatted code. */
49
- readonly banner: string
50
- /** Prettier options, or `null` if formatting is disabled. */
51
- readonly prettierOptions: PrettierOptions | null
52
-
53
- /**
54
- * Creates a new Formatter instance.
55
- *
56
- * @param options - Formatting configuration options
57
- */
58
- constructor(options: FormatterOptions = {}) {
59
- this.banner = options?.banner ?? DEFAULT_BANNER
60
-
61
- this.prettierOptions =
62
- options?.pretty === true
63
- ? DEFAULT_FORMAT_OPTIONS
64
- : options?.pretty || null
65
- }
66
-
67
- /**
68
- * Formats the given code string.
69
- *
70
- * Applies Prettier formatting if enabled, and prepends the banner comment.
71
- *
72
- * @param code - The TypeScript code to format
73
- * @returns The formatted code with banner
74
- */
75
- async format(code: string) {
76
- const bannerPadding =
77
- this.banner && !this.banner.endsWith('\n') ? '\n\n' : ''
78
- const codePretty = this.prettierOptions
79
- ? await prettierFormat(code, this.prettierOptions)
80
- : code
81
- return `${this.banner}${bannerPadding}${codePretty}`
82
- }
83
- }
package/src/index.ts DELETED
@@ -1,56 +0,0 @@
1
- // Must be first
2
- import './polyfill.js'
3
-
4
- import {
5
- LexBuilder,
6
- LexBuilderLoadOptions,
7
- LexBuilderOptions,
8
- LexBuilderSaveOptions,
9
- } from './lex-builder.js'
10
-
11
- export * from './lex-builder.js'
12
- export * from './lex-def-builder.js'
13
- export * from './lexicon-directory-indexer.js'
14
-
15
- /**
16
- * Combined options for building a TypeScript project from Lexicon documents.
17
- *
18
- * This type merges all configuration options needed for the complete build
19
- * process, including builder configuration, loading options, and save options.
20
- *
21
- * @see {@link LexBuilderOptions} for builder configuration
22
- * @see {@link LexBuilderLoadOptions} for lexicon loading options
23
- * @see {@link LexBuilderSaveOptions} for output save options
24
- */
25
- export type TsProjectBuildOptions = LexBuilderOptions &
26
- LexBuilderLoadOptions &
27
- LexBuilderSaveOptions
28
-
29
- /**
30
- * Builds TypeScript schemas from Lexicon documents.
31
- *
32
- * This is the main entry point for programmatic usage of the lex-builder
33
- * package. It creates a new {@link LexBuilder} instance, loads lexicon
34
- * documents from the specified directory, and saves the generated TypeScript
35
- * files to the output directory.
36
- *
37
- * @param options - Combined build options including source directory, output
38
- * directory, and generation settings
39
- *
40
- * @example
41
- * ```ts
42
- * import { build } from '@atproto/lex-builder'
43
- *
44
- * await build({
45
- * lexicons: './lexicons',
46
- * out: './src/generated',
47
- * pretty: true,
48
- * clear: true,
49
- * })
50
- * ```
51
- */
52
- export async function build(options: TsProjectBuildOptions) {
53
- const builder = new LexBuilder(options)
54
- await builder.load(options)
55
- await builder.save(options)
56
- }
@@ -1,299 +0,0 @@
1
- import assert from 'node:assert'
2
- import { mkdir, rm, stat, writeFile } from 'node:fs/promises'
3
- import { join, resolve } from 'node:path'
4
- import { IndentationText, Project } from 'ts-morph'
5
- import { LexiconDocument, LexiconIndexer } from '@atproto/lex-document'
6
- import { BuildFilterOptions, buildFilter } from './filter.js'
7
- import { FilteredIndexer } from './filtered-indexer.js'
8
- import { Formatter, FormatterOptions } from './formatter.js'
9
- import { LexDefBuilder, LexDefBuilderOptions } from './lex-def-builder.js'
10
- import {
11
- LexiconDirectoryIndexer,
12
- LexiconDirectoryIndexerOptions,
13
- } from './lexicon-directory-indexer.js'
14
- import { asNamespaceExport } from './ts-lang.js'
15
-
16
- /**
17
- * Configuration options for the {@link LexBuilder} class.
18
- *
19
- * Extends {@link LexDefBuilderOptions} with additional settings for
20
- * controlling the generated TypeScript project structure.
21
- *
22
- * @see {@link LexDefBuilderOptions} for definition generation options
23
- */
24
- export type LexBuilderOptions = LexDefBuilderOptions & {
25
- /**
26
- * Whether to generate an index file at the root exporting all top-level
27
- * namespaces.
28
- *
29
- * @note This could theoretically cause name conflicts if a
30
- * @default false
31
- */
32
- indexFile?: boolean
33
- /**
34
- * The file extension to use for import specifiers in the generated code.
35
- *
36
- * @default '.js'
37
- */
38
- importExt?: string
39
- /**
40
- * The file extension to use for generated TypeScript files.
41
- *
42
- * @default '.ts'
43
- */
44
- fileExt?: string
45
- /**
46
- * Whether to export the whole defs file as a namespace export (`export * as
47
- * $defs from './xyz.defs.js'`). This is useful to have an escape hatch to
48
- * access the definitions in case of name conflicts with child namespaces.
49
- *
50
- * For example if two documents with if `com.example.foo` and
51
- * `com.example.foo.bar` coexist, the `com.example.foo.bar` namespace would
52
- * shadow any `bar` definition from the `com.example.foo` namespace. In that
53
- * case, having the `$defs` namespace export allows to still access those
54
- * definitions via `com.example.foo.$defs.bar`.
55
- *
56
- * @note enabling this will negatively impact bundle size because and
57
- * additional namespace object will be generated for each lexicon document.
58
- *
59
- * @default false
60
- */
61
- defsExport?: boolean
62
- /**
63
- * Whether to generate a default export for the "main" lexicon definition
64
- * schema in the parent namespace file.
65
- *
66
- * This allows simpler access the main schema when importing directly from the
67
- * file instead of using the full namespace as in:
68
- * `com.atproto.repo.getRecord`.
69
- *
70
- * ```ts
71
- * import getRecord from './com/atproto/repo/getRecord.js'
72
- * // instead of
73
- * import { main as getRecord } from './com/atproto/repo/getRecord.js'
74
- * ```
75
- *
76
- * @default false
77
- */
78
- defaultExport?: boolean
79
- }
80
-
81
- /**
82
- * Options for loading lexicon documents into the builder.
83
- *
84
- * Combines directory indexing options with filtering options to control
85
- * which lexicon documents are processed.
86
- *
87
- * @see {@link LexiconDirectoryIndexerOptions} for directory scanning options
88
- * @see {@link BuildFilterOptions} for include/exclude filtering
89
- */
90
- export type LexBuilderLoadOptions = LexiconDirectoryIndexerOptions &
91
- BuildFilterOptions
92
-
93
- /**
94
- * Options for saving generated TypeScript files.
95
- *
96
- * Combines formatting options with output directory configuration.
97
- */
98
- export type LexBuilderSaveOptions = FormatterOptions & {
99
- /**
100
- * The output directory path where generated TypeScript files will be written.
101
- */
102
- out: string
103
- /**
104
- * Whether to clear the output directory before writing files.
105
- *
106
- * When `true`, the entire output directory is deleted before writing new files.
107
- *
108
- * @default false
109
- */
110
- clear?: boolean
111
- /**
112
- * Whether to allow overwriting existing files.
113
- *
114
- * When `false`, an error is thrown if any output file already exists.
115
- *
116
- * @default false
117
- */
118
- override?: boolean
119
- }
120
-
121
- /**
122
- * Main builder class for generating TypeScript schemas from Lexicon documents.
123
- *
124
- * The LexBuilder orchestrates the entire code generation process:
125
- * 1. Loading and indexing lexicon documents from the filesystem
126
- * 2. Generating TypeScript type definitions and runtime schemas
127
- * 3. Creating namespace export trees for convenient imports
128
- * 4. Saving formatted output files
129
- *
130
- * @example
131
- * ```ts
132
- * const builder = new LexBuilder({ indexFile: true, pretty: true })
133
- *
134
- * // Load lexicons from a directory
135
- * await builder.load({ lexicons: './lexicons' })
136
- *
137
- * // Save generated TypeScript to output directory
138
- * await builder.save({ out: './src/generated', clear: true })
139
- * ```
140
- */
141
- export class LexBuilder {
142
- readonly #imported = new Set<string>()
143
- readonly #project = new Project({
144
- useInMemoryFileSystem: true,
145
- manipulationSettings: { indentationText: IndentationText.TwoSpaces },
146
- })
147
-
148
- constructor(private readonly options: LexBuilderOptions = {}) {}
149
-
150
- get fileExt() {
151
- return this.options.fileExt ?? '.ts'
152
- }
153
-
154
- get importExt() {
155
- return this.options.importExt ?? '.js'
156
- }
157
-
158
- public async load(options: LexBuilderLoadOptions) {
159
- await using indexer = new FilteredIndexer(
160
- new LexiconDirectoryIndexer(options),
161
- buildFilter(options),
162
- )
163
-
164
- for await (const doc of indexer) {
165
- if (!this.#imported.has(doc.id)) {
166
- this.#imported.add(doc.id)
167
- } else {
168
- throw new Error(`Duplicate lexicon document id: ${doc.id}`)
169
- }
170
-
171
- await this.createDefsFile(doc, indexer)
172
- await this.createExportTree(doc)
173
- }
174
- }
175
-
176
- public async save(options: LexBuilderSaveOptions) {
177
- const files = this.#project.getSourceFiles()
178
-
179
- const destination = resolve(options.out)
180
-
181
- if (options.clear) {
182
- await rm(destination, { recursive: true, force: true })
183
- } else if (!options.override) {
184
- await Promise.all(
185
- files.map(async (f) =>
186
- assertNotFileExists(join(destination, f.getFilePath())),
187
- ),
188
- )
189
- }
190
-
191
- const formatter = new Formatter(options)
192
-
193
- await Promise.all(
194
- Array.from(files, async (file) => {
195
- const filePath = join(destination, file.getFilePath())
196
- const content = await formatter.format(file.getFullText())
197
- await mkdir(join(filePath, '..'), { recursive: true })
198
- await rm(filePath, { recursive: true, force: true })
199
- await writeFile(filePath, content, 'utf8')
200
- }),
201
- )
202
- }
203
-
204
- private createFile(path: string) {
205
- return this.#project.createSourceFile(path)
206
- }
207
-
208
- private getFile(path: string) {
209
- return this.#project.getSourceFile(path) || this.createFile(path)
210
- }
211
-
212
- private async createExportTree(doc: LexiconDocument) {
213
- const namespaces = doc.id.split('.')
214
-
215
- if (this.options.indexFile) {
216
- const indexFile = this.getFile(`/index${this.fileExt}`)
217
-
218
- const tldNs = namespaces[0]!
219
- assert(
220
- tldNs !== 'index',
221
- 'The "indexFile" options cannot be used with namespaces using a ".index" tld.',
222
- )
223
- const tldNsSpecifier = `./${tldNs}${this.importExt}`
224
- if (!indexFile.getExportDeclaration(tldNsSpecifier)) {
225
- indexFile.addExportDeclaration({
226
- moduleSpecifier: tldNsSpecifier,
227
- namespaceExport: asNamespaceExport(tldNs),
228
- })
229
- }
230
- }
231
-
232
- // First create the parent namespaces
233
- for (let i = 0; i < namespaces.length - 1; i++) {
234
- const currentNs = namespaces[i]
235
- const childNs = namespaces[i + 1]
236
-
237
- const path = join('/', ...namespaces.slice(0, i + 1))
238
- const file = this.getFile(`${path}${this.fileExt}`)
239
-
240
- const childModuleSpecifier = `./${currentNs}/${childNs}${this.importExt}`
241
- const dec = file.getExportDeclaration(childModuleSpecifier)
242
- if (!dec) {
243
- file.addExportDeclaration({
244
- moduleSpecifier: childModuleSpecifier,
245
- namespaceExport: asNamespaceExport(childNs),
246
- })
247
- }
248
- }
249
-
250
- // The child file exports the schemas (as *)
251
- const path = join('/', ...namespaces)
252
- const file = this.getFile(`${path}${this.fileExt}`)
253
-
254
- file.addExportDeclaration({
255
- moduleSpecifier: `./${namespaces.at(-1)}.defs${this.importExt}`,
256
- })
257
-
258
- if (this.options.defsExport) {
259
- // @NOTE Individual exports exports from the defs file might conflict with
260
- // child namespaces. For this reason, we also add a namespace export for the
261
- // defs (export * as $defs from './xyz.defs.js'). This is an escape hatch
262
- // allowing to still access the definitions if a hash get shadowed by a
263
- // child namespace.
264
- file.addExportDeclaration({
265
- moduleSpecifier: `./${namespaces.at(-1)}.defs${this.importExt}`,
266
- namespaceExport: '$defs',
267
- })
268
- }
269
-
270
- if (this.options.defaultExport && doc.defs.main != null) {
271
- // export { main as default } from './xyz.defs.js'
272
- file.addExportDeclaration({
273
- moduleSpecifier: `./${namespaces.at(-1)}.defs${this.importExt}`,
274
- namedExports: [{ name: 'main', alias: 'default' }],
275
- })
276
- }
277
- }
278
-
279
- private async createDefsFile(
280
- doc: LexiconDocument,
281
- indexer: LexiconIndexer,
282
- ): Promise<void> {
283
- const path = join('/', ...doc.id.split('.'))
284
- const file = this.createFile(`${path}.defs${this.fileExt}`)
285
-
286
- const fileBuilder = new LexDefBuilder(this.options, file, doc, indexer)
287
- await fileBuilder.build()
288
- }
289
- }
290
-
291
- async function assertNotFileExists(file: string): Promise<void> {
292
- try {
293
- await stat(file)
294
- throw new Error(`File already exists: ${file}`)
295
- } catch (err) {
296
- if (err instanceof Error && 'code' in err && err.code === 'ENOENT') return
297
- throw err
298
- }
299
- }