@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 +443 -0
- package/dist/index.cjs +528 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +738 -0
- package/dist/index.d.ts +738 -0
- package/dist/index.js +473 -0
- package/dist/index.js.map +1 -0
- package/package.json +56 -0
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
|