bare-tui-form 0.0.1 → 0.0.2
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/fields/file.js +129 -0
- package/fields/list.js +158 -0
- package/form.js +14 -1
- package/index.js +6 -0
- package/package.json +2 -2
- package/schema.js +39 -5
package/fields/file.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// file — a path field you can either type or browse.
|
|
2
|
+
//
|
|
3
|
+
// It wraps bare-tui's textinput (the editable path, and the field's string
|
|
4
|
+
// value) and lazily opens bare-tui's filepicker as an overlay — the same
|
|
5
|
+
// host-the-control-and-expose-an-overlay shape as fields/select.js. The picker
|
|
6
|
+
// is created the first time you open it, so a form full of file fields doesn't
|
|
7
|
+
// pull in bare-fs until one is actually browsed.
|
|
8
|
+
//
|
|
9
|
+
// Interaction (mirrors select's "space opens, never steals enter"):
|
|
10
|
+
// closed — type a path; SPACE on an empty input opens the picker
|
|
11
|
+
// open — ↑/↓ move, →/l descend, ↵ open/select, ⌫ up a dir, esc closes
|
|
12
|
+
// While open the field claims enter (wantsEnter) so the form's enter reaches the
|
|
13
|
+
// picker instead of advancing; on a pick the chosen path fills the input.
|
|
14
|
+
//
|
|
15
|
+
// `pick: 'file' | 'dir'` chooses the picker mode. `fs`/`path`/`cwd`/`height`/
|
|
16
|
+
// `showHidden` are forwarded to filepicker.create — `fs`/`path` let tests inject
|
|
17
|
+
// filepicker.mock with no real I/O.
|
|
18
|
+
const { textinput, filepicker, key } = require('bare-tui')
|
|
19
|
+
const { Field } = require('./base')
|
|
20
|
+
|
|
21
|
+
const openKey = key.binding({ keys: ['space'] })
|
|
22
|
+
const closeKey = key.binding({ keys: ['esc'] })
|
|
23
|
+
|
|
24
|
+
class FileField extends Field {
|
|
25
|
+
constructor(opts = {}) {
|
|
26
|
+
super(opts)
|
|
27
|
+
this.pick = opts.pick === 'dir' ? 'dir' : 'file'
|
|
28
|
+
this.input = textinput.create({
|
|
29
|
+
value: opts.value || '',
|
|
30
|
+
placeholder:
|
|
31
|
+
opts.placeholder ||
|
|
32
|
+
(this.pick === 'dir' ? 'choose a directory…' : 'type a path or browse…'),
|
|
33
|
+
prompt: opts.prompt ?? '> ',
|
|
34
|
+
charLimit: opts.charLimit || 0
|
|
35
|
+
})
|
|
36
|
+
// The base delegates focus()/blur()/`focused` to this.control — point it at
|
|
37
|
+
// the text input (the picker is a transient overlay, not the resting state).
|
|
38
|
+
this.control = this.input
|
|
39
|
+
this._pickerOpts = {
|
|
40
|
+
pick: this.pick,
|
|
41
|
+
cwd: opts.cwd,
|
|
42
|
+
height: opts.height,
|
|
43
|
+
showHidden: opts.showHidden,
|
|
44
|
+
fs: opts.fs,
|
|
45
|
+
path: opts.path
|
|
46
|
+
}
|
|
47
|
+
this.picker = null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
value() {
|
|
51
|
+
return this.input.value
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
setValue(v) {
|
|
55
|
+
this.input.setValue(v === null || v === undefined ? '' : v)
|
|
56
|
+
return this
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// While the picker is open, enter belongs to it (descend/select), so the form
|
|
60
|
+
// must not treat enter as confirm/advance.
|
|
61
|
+
get isOpen() {
|
|
62
|
+
return this.picker !== null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
wantsEnter() {
|
|
66
|
+
return this.isOpen
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
update(msg) {
|
|
70
|
+
if (this.picker) return this._updateOpen(msg)
|
|
71
|
+
|
|
72
|
+
// Closed: SPACE on an empty input opens the browser; otherwise the text
|
|
73
|
+
// input handles the key (so you can type, and type spaces in a real path).
|
|
74
|
+
if (msg && msg.type === 'key' && key.matches(msg, openKey) && this.input.value === '') {
|
|
75
|
+
return this._open()
|
|
76
|
+
}
|
|
77
|
+
const [c, cmd] = this.input.update(msg)
|
|
78
|
+
this.input = c
|
|
79
|
+
if (this.error) this.error = null
|
|
80
|
+
return [this, cmd]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
_updateOpen(msg) {
|
|
84
|
+
if (msg && msg.type === 'filepicker.select') {
|
|
85
|
+
this.setValue(msg.path)
|
|
86
|
+
this.picker = null
|
|
87
|
+
this.error = null
|
|
88
|
+
return [this, null]
|
|
89
|
+
}
|
|
90
|
+
if (msg && msg.type === 'key' && key.matches(msg, closeKey)) {
|
|
91
|
+
this.picker = null
|
|
92
|
+
return [this, null]
|
|
93
|
+
}
|
|
94
|
+
const [p, cmd] = this.picker.update(msg)
|
|
95
|
+
this.picker = p
|
|
96
|
+
return [this, cmd]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
_open() {
|
|
100
|
+
this.picker = filepicker.create(this._pickerOpts)
|
|
101
|
+
// The first directory read is a Cmd; thread it up so the form/program runs it.
|
|
102
|
+
return [this, this.picker.init()]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Tab-away (or any focus change) closes the overlay so it can't linger.
|
|
106
|
+
blur() {
|
|
107
|
+
this.picker = null
|
|
108
|
+
return super.blur()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
menuView() {
|
|
112
|
+
return this.picker ? this.picker.view() : ''
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
controlView() {
|
|
116
|
+
const view = this.input.view()
|
|
117
|
+
if (this.isOpen) return view
|
|
118
|
+
const hint = this.theme.help(
|
|
119
|
+
this.pick === 'dir' ? ' space to browse folders' : ' space to browse'
|
|
120
|
+
)
|
|
121
|
+
return view + '\n' + hint
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function file(opts) {
|
|
126
|
+
return new FileField(opts)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { file, FileField }
|
package/fields/list.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// list — an editable list of scalar text values (add / remove rows).
|
|
2
|
+
//
|
|
3
|
+
// For a repeatable value with no fixed option set — tags, waypoints, a flag the
|
|
4
|
+
// CLI accepts many times — where multiselect (a fixed enum) and array (a
|
|
5
|
+
// repeatable OBJECT subform) don't fit. Each row is a bare-tui textinput; the
|
|
6
|
+
// field is one focus stop in the form and manages its own row cursor:
|
|
7
|
+
//
|
|
8
|
+
// ↑/↓ move between rows
|
|
9
|
+
// ↵ add a new row below and focus it
|
|
10
|
+
// ⌫ on an EMPTY row, remove it (otherwise deletes a character)
|
|
11
|
+
// tab leave the field (the form's focus ring)
|
|
12
|
+
//
|
|
13
|
+
// value() is a plain array of the non-empty rows, in order — so a multi-valued
|
|
14
|
+
// CLI flag round-trips as ["a","b"], not a single space-joined string.
|
|
15
|
+
const { key, textinput } = require('bare-tui')
|
|
16
|
+
const { Field } = require('./base')
|
|
17
|
+
|
|
18
|
+
const keys = {
|
|
19
|
+
up: key.binding({ keys: ['up'] }),
|
|
20
|
+
down: key.binding({ keys: ['down'] }),
|
|
21
|
+
add: key.binding({ keys: ['enter'] }),
|
|
22
|
+
back: key.binding({ keys: ['backspace'] })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class ListField extends Field {
|
|
26
|
+
constructor(opts = {}) {
|
|
27
|
+
super(opts)
|
|
28
|
+
this.itemPlaceholder = opts.itemPlaceholder || 'type a value…'
|
|
29
|
+
this.min = Math.max(0, opts.minItems || 0)
|
|
30
|
+
this.max = opts.maxItems > 0 ? opts.maxItems : 0 // 0 = unbounded
|
|
31
|
+
this.cursor = 0
|
|
32
|
+
this._focused = false
|
|
33
|
+
const seed = Array.isArray(opts.value) ? opts.value.map(String) : []
|
|
34
|
+
// Always keep at least one (editable) row, even when minItems is 0; empty
|
|
35
|
+
// rows are dropped from value().
|
|
36
|
+
this.rows = (seed.length ? seed : ['']).map((v) => this._mkRow(v))
|
|
37
|
+
// No single wrapped control — the delegating methods are overridden below.
|
|
38
|
+
this.control = null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
_mkRow(value) {
|
|
42
|
+
return textinput.create({ value, placeholder: this.itemPlaceholder, prompt: '' })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
_floor() {
|
|
46
|
+
return Math.max(this.min, 1) // never collapse below one editable row
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
focus() {
|
|
50
|
+
this._focused = true
|
|
51
|
+
this._syncFocus()
|
|
52
|
+
return this
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
blur() {
|
|
56
|
+
this._focused = false
|
|
57
|
+
for (const r of this.rows) r.blur()
|
|
58
|
+
return this
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get focused() {
|
|
62
|
+
return this._focused
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Enter belongs to the field (it adds a row) whenever it's focused, so the form
|
|
66
|
+
// doesn't treat enter as confirm/advance. Tab still moves to the next field.
|
|
67
|
+
wantsEnter() {
|
|
68
|
+
return this._focused
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
value() {
|
|
72
|
+
return this.rows.map((r) => r.value).filter((v) => v.trim() !== '')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
setValue(values) {
|
|
76
|
+
const seed = Array.isArray(values) ? values.map(String) : []
|
|
77
|
+
this.rows = (seed.length ? seed : ['']).map((v) => this._mkRow(v))
|
|
78
|
+
this.cursor = 0
|
|
79
|
+
this._syncFocus()
|
|
80
|
+
return this
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
isEmpty(v) {
|
|
84
|
+
return !Array.isArray(v) || v.length === 0
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
update(msg) {
|
|
88
|
+
if (!this._focused || !msg || msg.type !== 'key') return [this, null]
|
|
89
|
+
const row = this.rows[this.cursor]
|
|
90
|
+
|
|
91
|
+
if (key.matches(msg, keys.up)) {
|
|
92
|
+
this._move(-1)
|
|
93
|
+
return [this, null]
|
|
94
|
+
}
|
|
95
|
+
if (key.matches(msg, keys.down)) {
|
|
96
|
+
this._move(1)
|
|
97
|
+
return [this, null]
|
|
98
|
+
}
|
|
99
|
+
if (key.matches(msg, keys.add)) {
|
|
100
|
+
this._addRow()
|
|
101
|
+
return [this, null]
|
|
102
|
+
}
|
|
103
|
+
// Backspace on an EMPTY row removes it; otherwise it edits (deletes a char).
|
|
104
|
+
if (
|
|
105
|
+
key.matches(msg, keys.back) &&
|
|
106
|
+
row &&
|
|
107
|
+
row.value === '' &&
|
|
108
|
+
this.rows.length > this._floor()
|
|
109
|
+
) {
|
|
110
|
+
this._removeRow()
|
|
111
|
+
return [this, null]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const [c] = row.update(msg)
|
|
115
|
+
this.rows[this.cursor] = c
|
|
116
|
+
if (this.error) this.error = null
|
|
117
|
+
return [this, null]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
_move(delta) {
|
|
121
|
+
this.cursor = Math.max(0, Math.min(this.cursor + delta, this.rows.length - 1))
|
|
122
|
+
this._syncFocus()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
_addRow() {
|
|
126
|
+
if (this.max && this.rows.length >= this.max) return
|
|
127
|
+
this.rows.splice(this.cursor + 1, 0, this._mkRow(''))
|
|
128
|
+
this.cursor++
|
|
129
|
+
this._syncFocus()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
_removeRow() {
|
|
133
|
+
this.rows.splice(this.cursor, 1)
|
|
134
|
+
this.cursor = Math.max(0, Math.min(this.cursor, this.rows.length - 1))
|
|
135
|
+
this._syncFocus()
|
|
136
|
+
if (this.error) this.error = null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
_syncFocus() {
|
|
140
|
+
this.rows.forEach((r, i) => (this._focused && i === this.cursor ? r.focus() : r.blur()))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
controlView() {
|
|
144
|
+
const t = this.theme
|
|
145
|
+
const lines = this.rows.map((r, i) => {
|
|
146
|
+
const pointer = this._focused && i === this.cursor ? '› ' : ' '
|
|
147
|
+
return pointer + r.view()
|
|
148
|
+
})
|
|
149
|
+
if (this._focused) lines.push(t.help(' ↵ add · ⌫ on an empty row removes'))
|
|
150
|
+
return lines.join('\n')
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function list(opts) {
|
|
155
|
+
return new ListField(opts)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = { list, ListField, keys }
|
package/form.js
CHANGED
|
@@ -47,6 +47,8 @@ const { radio } = require('./fields/radio')
|
|
|
47
47
|
const { confirm } = require('./fields/confirm')
|
|
48
48
|
const { multiselect } = require('./fields/multiselect')
|
|
49
49
|
const { constant } = require('./fields/constant')
|
|
50
|
+
const { file } = require('./fields/file')
|
|
51
|
+
const { list } = require('./fields/list')
|
|
50
52
|
const { action } = require('./fields/action')
|
|
51
53
|
const { SectionToggleField } = require('./fields/section')
|
|
52
54
|
const { Field } = require('./fields/base')
|
|
@@ -83,7 +85,18 @@ function mergeKeys(user) {
|
|
|
83
85
|
}
|
|
84
86
|
const SEP = '\u0000' // path/cache-key segment separator: a NUL, which never appears in a JSON key
|
|
85
87
|
|
|
86
|
-
const builders = {
|
|
88
|
+
const builders = {
|
|
89
|
+
text,
|
|
90
|
+
textarea,
|
|
91
|
+
number,
|
|
92
|
+
select,
|
|
93
|
+
radio,
|
|
94
|
+
confirm,
|
|
95
|
+
multiselect,
|
|
96
|
+
constant,
|
|
97
|
+
file,
|
|
98
|
+
list
|
|
99
|
+
}
|
|
87
100
|
|
|
88
101
|
// Turn a leaf field definition object into a field instance; pass instances
|
|
89
102
|
// through untouched. Object/variant/conditional/array groups are handled by
|
package/index.js
CHANGED
|
@@ -32,6 +32,8 @@ const { select, SelectField } = require('./fields/select')
|
|
|
32
32
|
const { radio, RadioField } = require('./fields/radio')
|
|
33
33
|
const { confirm, ConfirmField } = require('./fields/confirm')
|
|
34
34
|
const { multiselect, MultiSelectField } = require('./fields/multiselect')
|
|
35
|
+
const { file, FileField } = require('./fields/file')
|
|
36
|
+
const { list, ListField } = require('./fields/list')
|
|
35
37
|
const { section, SectionToggleField } = require('./fields/section')
|
|
36
38
|
const { constant, ConstField } = require('./fields/constant')
|
|
37
39
|
const { action, ActionField } = require('./fields/action')
|
|
@@ -57,6 +59,8 @@ module.exports = {
|
|
|
57
59
|
radio,
|
|
58
60
|
confirm,
|
|
59
61
|
multiselect,
|
|
62
|
+
file, // a type-or-browse path field (filepicker overlay); ui:widget 'file' / 'directory'
|
|
63
|
+
list, // an editable list of scalar text rows (array of string items)
|
|
60
64
|
section, // the optional-section gate (usually produced by fromSchema, not hand-written)
|
|
61
65
|
constant, // a fixed value (JSON Schema const); usually produced by fromSchema
|
|
62
66
|
action, // a focusable button (array add/remove); usually produced by fromSchema
|
|
@@ -74,6 +78,8 @@ module.exports = {
|
|
|
74
78
|
RadioField,
|
|
75
79
|
ConfirmField,
|
|
76
80
|
MultiSelectField,
|
|
81
|
+
FileField,
|
|
82
|
+
ListField,
|
|
77
83
|
SectionToggleField,
|
|
78
84
|
ConstField,
|
|
79
85
|
ActionField
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bare-tui-form",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"description": "A declarative form builder for bare-tui",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"exports": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"README.md"
|
|
24
24
|
],
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"bare-tui": "^0.0.
|
|
26
|
+
"bare-tui": "^0.0.3"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"bare-fs": "^4.7.1",
|
package/schema.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// integer / boolean
|
|
19
19
|
// enum → select (single choice)
|
|
20
20
|
// array + items.enum → multiselect (choose many)
|
|
21
|
+
// array + items: string → an editable list of scalar text rows (add/remove)
|
|
21
22
|
// array + items: object → a repeatable subform the user grows/shrinks
|
|
22
23
|
// (minItems / maxItems) (each entry is the item object's fields)
|
|
23
24
|
// required[] → required fields
|
|
@@ -28,9 +29,9 @@
|
|
|
28
29
|
// pattern / format
|
|
29
30
|
// minimum / maximum → number bounds
|
|
30
31
|
//
|
|
31
|
-
// Not yet: arrays of primitives
|
|
32
|
-
// merge), $ref. Unsupported shapes throw a clear error rather than
|
|
33
|
-
// producing the wrong form.
|
|
32
|
+
// Not yet: arrays of non-string primitives (number/boolean items), nested arrays,
|
|
33
|
+
// allOf (schema merge), $ref. Unsupported shapes throw a clear error rather than
|
|
34
|
+
// silently producing the wrong form.
|
|
34
35
|
//
|
|
35
36
|
// SECURITY: a JSON Schema is frequently untrusted input — produced by an LLM or
|
|
36
37
|
// received from a peer — and this function is the boundary where it becomes
|
|
@@ -201,6 +202,18 @@ function applyWidget(def, ui, ctx, name, prop) {
|
|
|
201
202
|
if (def.type !== 'select') return widgetMismatch(def, ctx, name, widget, 'enum')
|
|
202
203
|
def.type = 'radio' // radio takes the same options/selected as select
|
|
203
204
|
return def
|
|
205
|
+
case 'file':
|
|
206
|
+
case 'directory': {
|
|
207
|
+
if (def.type !== 'text') return widgetMismatch(def, ctx, name, widget, 'string')
|
|
208
|
+
def.type = 'file' // a type-or-browse path field; opens a filepicker overlay
|
|
209
|
+
def.pick = widget === 'directory' ? 'dir' : 'file'
|
|
210
|
+
const cwd = uiGet(ui, 'cwd')
|
|
211
|
+
if (typeof cwd === 'string') def.cwd = harden.cleanText(cwd, ctx.limits.maxTextLength)
|
|
212
|
+
const height = harden.safeNumber(uiGet(ui, 'height'))
|
|
213
|
+
if (height) def.height = Math.min(height, 100)
|
|
214
|
+
if (uiGet(ui, 'showHidden')) def.showHidden = true
|
|
215
|
+
return def
|
|
216
|
+
}
|
|
204
217
|
case 'select':
|
|
205
218
|
case 'checkbox':
|
|
206
219
|
case 'checkboxes':
|
|
@@ -445,8 +458,8 @@ function withoutNested(sub) {
|
|
|
445
458
|
return { properties: sub.properties || {}, required: sub.required || [] }
|
|
446
459
|
}
|
|
447
460
|
|
|
448
|
-
// array → multiselect (items.enum)
|
|
449
|
-
// object
|
|
461
|
+
// array → multiselect (items.enum), an editable scalar list (string items), or a
|
|
462
|
+
// repeatable object subform (object items). Nested arrays are refused.
|
|
450
463
|
// `ui` supplies item presentation (ui.items) and add/remove gating.
|
|
451
464
|
function arrayProperty(name, base, prop, ctx, depth, ui = {}) {
|
|
452
465
|
const items = prop.items || {}
|
|
@@ -462,6 +475,27 @@ function arrayProperty(name, base, prop, ctx, depth, ui = {}) {
|
|
|
462
475
|
}
|
|
463
476
|
}
|
|
464
477
|
|
|
478
|
+
// string (or untyped) primitive items → an editable list of scalar text rows.
|
|
479
|
+
if (!items.properties && (typeOf(items) === 'string' || typeOf(items) === undefined)) {
|
|
480
|
+
countLeaf(ctx)
|
|
481
|
+
const cap = ctx.limits.maxArrayItems
|
|
482
|
+
const maxItems = Math.min(harden.safeNumber(prop.maxItems) ?? cap, cap)
|
|
483
|
+
const minItems = Math.min(Math.max(harden.safeNumber(prop.minItems) ?? 0, 0), maxItems)
|
|
484
|
+
const def = Array.isArray(prop.default) ? prop.default.slice(0, cap) : []
|
|
485
|
+
const placeholder = uiGet(ui, 'placeholder')
|
|
486
|
+
return {
|
|
487
|
+
...base,
|
|
488
|
+
type: 'list',
|
|
489
|
+
value: def.map((v) => harden.cleanText(String(v), ctx.limits.maxTextLength)),
|
|
490
|
+
minItems,
|
|
491
|
+
maxItems,
|
|
492
|
+
itemPlaceholder:
|
|
493
|
+
typeof placeholder === 'string'
|
|
494
|
+
? harden.cleanText(placeholder, ctx.limits.maxTextLength)
|
|
495
|
+
: undefined
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
465
499
|
if (items && typeof items === 'object' && (items.properties || typeOf(items) === 'object')) {
|
|
466
500
|
if (ctx.inArray) {
|
|
467
501
|
throw new Error(`fromSchema: nested arrays ("${name}") are not supported yet`)
|