@boba-cli/dsl 0.1.0-alpha.1

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.
package/README.md ADDED
@@ -0,0 +1,443 @@
1
+ # @boba-cli/dsl
2
+
3
+ Declarative DSL for building CLI applications with minimal ceremony. Build terminal UIs using a fluent builder API and view primitives inspired by SwiftUI.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pnpm add @boba-cli/dsl
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```typescript
14
+ import { createApp, spinner, vstack, hstack, text, Style } from '@boba-cli/dsl'
15
+
16
+ const app = createApp()
17
+ .state({ message: 'Loading something amazing...' })
18
+ .component('loading', spinner({ style: new Style().foreground('#50fa7b') }))
19
+ .onKey(['q', 'ctrl+c'], ({ quit }) => quit())
20
+ .view(({ state, components }) =>
21
+ vstack(
22
+ text('🧼 My App').bold().foreground('#ff79c6'),
23
+ spacer(),
24
+ hstack(components.loading, text(' ' + state.message)),
25
+ spacer(),
26
+ text('Press [q] to quit').dim()
27
+ )
28
+ )
29
+ .build()
30
+
31
+ await app.run()
32
+ ```
33
+
34
+ ## Why DSL?
35
+
36
+ Compare the DSL approach to raw TEA (The Elm Architecture):
37
+
38
+ | Aspect | Raw TEA | DSL |
39
+ |--------|---------|-----|
40
+ | Lines of code | ~147 lines | ~35 lines |
41
+ | Boilerplate | Manual class, state management, `instanceof` checks | Declarative builder, automatic state handling |
42
+ | Type safety | Manual type guards, verbose generics | Phantom types provide compile-time safety |
43
+ | View composition | String concatenation | Composable view primitives |
44
+ | Component integration | Manual model wrapping and message routing | Automatic component lifecycle management |
45
+
46
+ See [examples/spinner-demo.ts](../../examples/spinner-demo.ts) (raw TEA) vs [examples/spinner-demo-dsl.ts](../../examples/spinner-demo-dsl.ts) (DSL) for a real comparison.
47
+
48
+ ## API Reference
49
+
50
+ ### App Builder
51
+
52
+ #### `createApp()`
53
+
54
+ Creates a new application builder. Start here to build your CLI app.
55
+
56
+ ```typescript
57
+ const app = createApp()
58
+ .state({ count: 0 })
59
+ .view(({ state }) => text(`Count: ${state.count}`))
60
+ .build()
61
+ ```
62
+
63
+ #### `AppBuilder.state<S>(initial: S)`
64
+
65
+ Sets the initial application state. The state type is inferred from the provided object.
66
+
67
+ ```typescript
68
+ .state({ count: 0, name: 'World' })
69
+ ```
70
+
71
+ #### `AppBuilder.component<K, M>(key: K, builder: ComponentBuilder<M>)`
72
+
73
+ Registers a component with a unique key. The component's rendered view is available in the view function via `components[key]`.
74
+
75
+ ```typescript
76
+ .component('spinner', spinner())
77
+ .component('input', textInput())
78
+ ```
79
+
80
+ #### `AppBuilder.onKey(keys: string | string[], handler: KeyHandler)`
81
+
82
+ Registers a key handler. Supports single keys, key arrays, and modifiers.
83
+
84
+ ```typescript
85
+ .onKey('q', ({ quit }) => quit())
86
+ .onKey(['up', 'k'], ({ state, update }) => update({ index: state.index - 1 }))
87
+ .onKey('ctrl+c', ({ quit }) => quit())
88
+ ```
89
+
90
+ **Key handler context:**
91
+ - `state` - Current application state
92
+ - `components` - Current component views
93
+ - `update(patch)` - Merge partial state (shallow merge)
94
+ - `setState(newState)` - Replace entire state
95
+ - `quit()` - Gracefully quit the application
96
+
97
+ #### `AppBuilder.view(fn: ViewFunction)`
98
+
99
+ Sets the view function. Called on every render cycle. Returns a view node tree describing the UI.
100
+
101
+ ```typescript
102
+ .view(({ state, components }) =>
103
+ vstack(
104
+ text('Hello ' + state.name),
105
+ components.spinner
106
+ )
107
+ )
108
+ ```
109
+
110
+ #### `AppBuilder.build()`
111
+
112
+ Finalizes the builder chain and creates an `App` ready to run.
113
+
114
+ ```typescript
115
+ const app = builder.build()
116
+ await app.run()
117
+ ```
118
+
119
+ ### View Primitives
120
+
121
+ #### `text(content: string): TextNode`
122
+
123
+ Creates a text node with chainable style methods.
124
+
125
+ ```typescript
126
+ text('Hello').bold().foreground('#ff79c6')
127
+ text('Warning').dim().italic()
128
+ text('Success').background('#282a36')
129
+ ```
130
+
131
+ **Style methods:**
132
+ - `bold()` - Apply bold styling
133
+ - `dim()` - Apply dim styling
134
+ - `italic()` - Apply italic styling
135
+ - `foreground(color)` - Set foreground color (hex or named)
136
+ - `background(color)` - Set background color (hex or named)
137
+
138
+ #### `vstack(...children: ViewNode[]): LayoutNode`
139
+
140
+ Arranges child views vertically with newlines between them.
141
+
142
+ ```typescript
143
+ vstack(
144
+ text('Line 1'),
145
+ text('Line 2'),
146
+ text('Line 3')
147
+ )
148
+ ```
149
+
150
+ #### `hstack(...children: ViewNode[]): LayoutNode`
151
+
152
+ Arranges child views horizontally on the same line.
153
+
154
+ ```typescript
155
+ hstack(
156
+ text('Left'),
157
+ text(' | '),
158
+ text('Right')
159
+ )
160
+ ```
161
+
162
+ #### `spacer(height?: number): string`
163
+
164
+ Creates empty vertical space. Default height is 1 line.
165
+
166
+ ```typescript
167
+ vstack(
168
+ text('Header'),
169
+ spacer(2),
170
+ text('Content')
171
+ )
172
+ ```
173
+
174
+ #### `divider(char?: string, width?: number): string`
175
+
176
+ Creates a horizontal divider line. Default is 40 '─' characters.
177
+
178
+ ```typescript
179
+ vstack(
180
+ text('Section 1'),
181
+ divider(),
182
+ text('Section 2'),
183
+ divider('=', 50)
184
+ )
185
+ ```
186
+
187
+ ### Conditional Helpers
188
+
189
+ #### `when(condition: boolean, node: ViewNode): ViewNode`
190
+
191
+ Conditionally renders a node. Returns empty string if condition is false.
192
+
193
+ ```typescript
194
+ vstack(
195
+ text('Always visible'),
196
+ when(state.showHelp, text('Help text'))
197
+ )
198
+ ```
199
+
200
+ #### `choose(condition: boolean, ifTrue: ViewNode, ifFalse: ViewNode): ViewNode`
201
+
202
+ Chooses between two nodes based on a condition.
203
+
204
+ ```typescript
205
+ choose(
206
+ state.isLoading,
207
+ text('Loading...').dim(),
208
+ text('Ready!').bold()
209
+ )
210
+ ```
211
+
212
+ #### `map<T>(items: T[], render: (item: T, index: number) => ViewNode): ViewNode[]`
213
+
214
+ Maps an array of items to view nodes. Spread the result into a layout.
215
+
216
+ ```typescript
217
+ vstack(
218
+ ...map(state.items, (item, index) =>
219
+ text(`${index + 1}. ${item.name}`)
220
+ )
221
+ )
222
+ ```
223
+
224
+ ### Component Builders
225
+
226
+ #### `spinner(options?: SpinnerBuilderOptions): ComponentBuilder<SpinnerModel>`
227
+
228
+ Creates an animated spinner component.
229
+
230
+ ```typescript
231
+ .component('loading', spinner())
232
+ .component('loading', spinner({
233
+ spinner: dot,
234
+ style: new Style().foreground('#50fa7b')
235
+ }))
236
+ ```
237
+
238
+ **Options:**
239
+ - `spinner` - Animation to use (default: `line`). Available: `line`, `dot`, `miniDot`, `pulse`, `points`, `moon`, `meter`, `ellipsis`
240
+ - `style` - Style for rendering (default: unstyled)
241
+
242
+ **Re-exported spinners:**
243
+ ```typescript
244
+ import { line, dot, miniDot, pulse, points, moon, meter, ellipsis } from '@boba-cli/dsl'
245
+ ```
246
+
247
+ ### Re-exported Types
248
+
249
+ For convenience, the DSL re-exports commonly used types:
250
+
251
+ ```typescript
252
+ // From @boba-cli/chapstick
253
+ import { Style } from '@boba-cli/dsl'
254
+
255
+ // From @boba-cli/spinner
256
+ import { type Spinner, line, dot, miniDot, pulse, points, moon, meter, ellipsis } from '@boba-cli/dsl'
257
+ ```
258
+
259
+ ## Type Safety
260
+
261
+ The DSL uses phantom types to provide compile-time guarantees about application structure:
262
+
263
+ ### State Type Safety
264
+
265
+ ```typescript
266
+ const app = createApp()
267
+ .state({ count: 0 })
268
+ .view(({ state }) => text(`Count: ${state.count}`))
269
+ // ^^^^^ TypeScript knows this is number
270
+ ```
271
+
272
+ ### Component Type Safety
273
+
274
+ ```typescript
275
+ const app = createApp()
276
+ .component('spinner', spinner())
277
+ .view(({ components }) => components.spinner)
278
+ // ^^^^^^^^^^^^^^^^^^ ComponentView
279
+ ```
280
+
281
+ If you try to access a component that doesn't exist, TypeScript will error:
282
+
283
+ ```typescript
284
+ .view(({ components }) => components.doesNotExist)
285
+ // ^^^^^^^^^^^^ Error: Property 'doesNotExist' does not exist
286
+ ```
287
+
288
+ ### Builder Chain Validation
289
+
290
+ The builder enforces that `view()` is called before `build()`:
291
+
292
+ ```typescript
293
+ createApp()
294
+ .state({ count: 0 })
295
+ .build()
296
+ // Error: AppBuilder: view() must be called before build()
297
+ ```
298
+
299
+ ## Advanced Usage
300
+
301
+ ### Accessing the Underlying TEA Model
302
+
303
+ For advanced use cases, you can access the generated TEA model:
304
+
305
+ ```typescript
306
+ const app = createApp()
307
+ .state({ count: 0 })
308
+ .view(({ state }) => text(`Count: ${state.count}`))
309
+ .build()
310
+
311
+ const model = app.getModel()
312
+ // model is a TEA Model<Msg> instance
313
+ ```
314
+
315
+ ### Custom Component Builders
316
+
317
+ You can create custom component builders by implementing the `ComponentBuilder` interface:
318
+
319
+ ```typescript
320
+ import type { ComponentBuilder } from '@boba-cli/dsl'
321
+ import type { Cmd, Msg } from '@boba-cli/tea'
322
+
323
+ interface MyComponentModel {
324
+ value: number
325
+ }
326
+
327
+ const myComponent = (): ComponentBuilder<MyComponentModel> => ({
328
+ init() {
329
+ return [{ value: 0 }, null]
330
+ },
331
+ update(model, msg) {
332
+ // Handle messages
333
+ return [model, null]
334
+ },
335
+ view(model) {
336
+ return `Value: ${model.value}`
337
+ }
338
+ })
339
+
340
+ // Use it
341
+ createApp()
342
+ .component('custom', myComponent())
343
+ .view(({ components }) => components.custom)
344
+ ```
345
+
346
+ ## Examples
347
+
348
+ ### Counter with State Updates
349
+
350
+ ```typescript
351
+ import { createApp, vstack, hstack, text } from '@boba-cli/dsl'
352
+
353
+ const app = createApp()
354
+ .state({ count: 0 })
355
+ .onKey('up', ({ state, update }) => update({ count: state.count + 1 }))
356
+ .onKey('down', ({ state, update }) => update({ count: state.count - 1 }))
357
+ .onKey('q', ({ quit }) => quit())
358
+ .view(({ state }) =>
359
+ vstack(
360
+ text('Counter').bold(),
361
+ spacer(),
362
+ text(`Count: ${state.count}`),
363
+ spacer(),
364
+ text('[↑/↓] adjust • [q] quit').dim()
365
+ )
366
+ )
367
+ .build()
368
+
369
+ await app.run()
370
+ ```
371
+
372
+ ### Todo List with Conditional Rendering
373
+
374
+ ```typescript
375
+ const app = createApp()
376
+ .state({
377
+ items: ['Buy milk', 'Write docs', 'Build CLI'],
378
+ selected: 0
379
+ })
380
+ .onKey('up', ({ state, update }) =>
381
+ update({ selected: Math.max(0, state.selected - 1) })
382
+ )
383
+ .onKey('down', ({ state, update }) =>
384
+ update({ selected: Math.min(state.items.length - 1, state.selected + 1) })
385
+ )
386
+ .onKey('q', ({ quit }) => quit())
387
+ .view(({ state }) =>
388
+ vstack(
389
+ text('Todo List').bold(),
390
+ divider(),
391
+ ...map(state.items, (item, index) =>
392
+ choose(
393
+ index === state.selected,
394
+ text(`> ${item}`).foreground('#50fa7b'),
395
+ text(` ${item}`)
396
+ )
397
+ ),
398
+ divider(),
399
+ text('[↑/↓] navigate • [q] quit').dim()
400
+ )
401
+ )
402
+ .build()
403
+
404
+ await app.run()
405
+ ```
406
+
407
+ ### Multiple Components
408
+
409
+ ```typescript
410
+ import { createApp, spinner, vstack, hstack, text, Style, dot, pulse } from '@boba-cli/dsl'
411
+
412
+ const app = createApp()
413
+ .state({ status: 'Initializing...' })
414
+ .component('spinner1', spinner({ spinner: dot, style: new Style().foreground('#50fa7b') }))
415
+ .component('spinner2', spinner({ spinner: pulse, style: new Style().foreground('#ff79c6') }))
416
+ .onKey('q', ({ quit }) => quit())
417
+ .view(({ state, components }) =>
418
+ vstack(
419
+ text('Multi-Spinner Demo').bold(),
420
+ spacer(),
421
+ hstack(components.spinner1, text(' Loading data...')),
422
+ hstack(components.spinner2, text(' Processing...')),
423
+ spacer(),
424
+ text(`Status: ${state.status}`).dim(),
425
+ spacer(),
426
+ text('[q] quit').dim()
427
+ )
428
+ )
429
+ .build()
430
+
431
+ await app.run()
432
+ ```
433
+
434
+ ## Scripts
435
+
436
+ - `pnpm -C packages/dsl build`
437
+ - `pnpm -C packages/dsl test`
438
+ - `pnpm -C packages/dsl lint`
439
+ - `pnpm -C packages/dsl generate:api-report`
440
+
441
+ ## License
442
+
443
+ MIT