-
Notifications
You must be signed in to change notification settings - Fork 3
/
Copy pathgen-widget-nodes.js
executable file
·247 lines (214 loc) · 9.23 KB
/
gen-widget-nodes.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
// Generate Node-RED node code for a widget
// Copyright ©2022 by Thorsten von Eicken, see LICENSE
const fs = require('fs')
const path = require('path')
const parseVueSFC = require('./parse-vue-sfc.js')
const propTMPL = `
<div class="form-row">
<label for="node-input-##name##">##name_text##</label>
<input type="text" id="node-input-##name##" class="fd-typed-input" placeholder="##default_html##" />
<input type="hidden" id="node-input-##name##-type" />
<br><small class="fd-indent">##tip##Change using <tt>msg.##msg_name##</tt>.</small>
</div>
`.trim()
// map from types coming out of the props to what the NR typedInput understands
const typeMap = {
'number': 'num', 'string': 'str', 'boolean': 'bool', 'object': 'json', 'array': 'json',
}
function camel2text(camel) {
camel = camel.replace(/([a-z])([A-Z])/g, m => m[0] + ' ' + m[1])
return camel.charAt(0).toLocaleUpperCase() + camel.slice(1)
}
function snake2text(camel) {
camel = camel.replace(/_([a-z])/g, m => ' ' + m[1].toLocaleUpperCase())
return camel.charAt(0).toLocaleUpperCase() + camel.slice(1)
}
function camel2kebab(camel) {
return camel.replace(/([a-z])([A-Z])/g, m => m[0] + '-' + m[1]).toLocaleLowerCase()
}
function generate(text, info) {
return text.replace(/##([a-zA-Z0-9_]+)##/g, (m, p) => {
if (!(p in info)) return m
if (typeof info[p] === 'object') return JSON.stringify(info[p], null, 2)
else return info[p]
})
}
class FDWidgetCodeGen {
constructor(vue_file, module_dir, custom_dir, module_name) {
this.info = {
vue_file: vue_file,
base_filename: path.basename(vue_file, '.vue'),
module_dir: module_dir,
module_name: module_name,
resources_dir: path.join(module_dir, 'resources'), // filesystem directory
resources_path: `resources/${module_name}`, // URL path
}
this.custom_dir = custom_dir
}
async parseSource() {
try {
const source = await fs.promises.readFile(this.info.vue_file, 'utf8')
this.widget = parseVueSFC(source)
} catch (e) {
throw new Error(`Error parsing ${this.info.vue_file}: ${e.message}`)
}
}
parseWidget() {
if (!this.widget) return
const props = this.widget.props
Object.assign(this.info, {
name: this.widget.name,
name_text: camel2text(this.widget.name),
type_kebab: "fd-" + camel2kebab(this.widget.name),
help: this.widget.help,
})
// parse help into title and body and produce html version
const help = this.widget.help || 'FlexDash widget\n'
const m = help.match(/^([^\n.!]+)(.*)$/s)
this.info.help_title = m && m[1].trim() || 'FlexDash widget'
let body = m && m[2].trim() || ""
if (body.startsWith('.') || body.startsWith('!')) body = body.slice(1).trim()
if (!body) body = "<i>(There is no help text in the widget's help property :-( .)</i>"
// turn \n\n into paragraph boundary and `...` into fixed-width font
this.info.help_body = body
// parse output
this.info.output = !!this.widget.output // boolean whether there's an output or not
// parse props
this.info.props = {
title: {
name: 'title', name_text: 'Title', name_kebab: 'title', msg_name: 'title',
type: 'string', input_type: 'str',
tip: 'Text to display in the widget header. ',
default: this.info.name_text, default_html: `'${this.info.name_text}'`,
},
popup_info: {
name: 'popup_info', name_text: 'Popup Info', name_kebab: 'popup-info',
msg_name: 'popup_info', type: 'string', input_type: 'str',
tip: 'Info text to display in (i) pop-up. ',
default: null, default_html: null,
}
}
for (const prop in props) {
if (prop === 'title') continue
const p = {}
p.name = p.msg_name = prop
p.name_text = snake2text(camel2text(prop)) // could be either...
p.name_kebab = camel2kebab(prop).replace(/_/g, '-')
// handle 'msg.payload': FlexDash doesn't use payload, but we map msg.payload into one
// of a couple of hard-coded props. This should really be configurable...
if (['value', 'data', 'text'].includes(prop) && !this.info.payload_prop) {
p.name_text = 'Payload'
p.msg_name = 'payload' // name expected in incoming msg
this.info.payload_prop = prop
}
// handle `props: { min: 100 }` and `props: { min: null }` cases
if (props[prop] === null || typeof props[prop] !== 'object') {
props[prop] = { default: props[prop] }
}
// tip
let tip = props[prop].tip?.trim() || ''
if (tip) {
if (!tip.match(/[.!?]$/)) tip += '.'
tip = tip.charAt(0).toLocaleUpperCase() + tip.slice(1) + ' '
}
p.tip = tip
// default
let def = props[prop].default
if (typeof def === 'function') def = def() // FIXME: security risk
p.default = (def !== undefined) ? def : null
if (def === undefined || def === null)
p.default_html = null
else if (typeof def === 'object')
p.default_html = JSON.stringify(def).replace(/"/g, "'")
else
p.default_html = def.toString()
// type
let type = props[prop].type
if (type && 'name' in type) type = type['name'].toLowerCase()
//if (!type && props[prop].default) type = typeof props[prop].default
p.type = type
p.input_type = type && typeMap[type] || "any" // for typedInput field
this.info.props[p.name] = p
}
}
async doit() {
console.log(`Generating code for ${this.info.vue_file}`)
await this.parseSource()
this.parseWidget()
// custom handlers
const custom_file = path.join(this.custom_dir, this.info.base_filename + '.js')
if (fs.existsSync(custom_file)) {
this.info.custom_handlers = await fs.promises.readFile(custom_file, 'utf8')
} else {
this.info.custom_handlers = ""
}
// create resources subdir
const resources_dir = this.info.resources_dir
try { await fs.promises.mkdir(resources_dir) } catch (e) {}
// generate -props.html
const base_name = this.info.base_filename
const props_file = path.join(resources_dir, base_name + '-props.html')
const custom_props_file = path.join(this.custom_dir, base_name + '-props.html')
let props_html = Object.keys(this.info.props).map(p =>
generate(propTMPL, this.info.props[p])
).join('\n')
if (fs.existsSync(custom_props_file)) {
const html = await fs.promises.readFile(custom_props_file, 'utf8')
props_html += "\n" + html
}
//console.log(`\n\n***** Generating ${props_file} *****\n${props_html}`)
await fs.promises.writeFile(props_file, props_html)
// generate -info.js
const info_file = path.join(resources_dir, base_name + '-info.js')
const custom_info_file = path.join(path.resolve(this.custom_dir), base_name + '-info.js')
if (fs.existsSync(custom_info_file)) {
// merge the custom info, also into the props, i.e. don't just replace them...
const custom_info = Object.assign({}, require(custom_info_file)) // clone due to symlink!
const props = custom_info.props || {}
delete custom_info.props
Object.assign(this.info, custom_info)
Object.assign(this.info.props, props)
}
const info_obj = { ...this.info, custom_handlers: undefined }
const info_js = `export default ${JSON.stringify(info_obj, null, 2)}`
//console.log(`\n\n***** Generating ${info_file} *****\n${info_js}`)
await fs.promises.writeFile(info_file, info_js)
// generate node html if not present
const node_html_file = path.join(this.info.module_dir, base_name + '.html')
if (!fs.existsSync(node_html_file)) {
const node_tmpl_file = path.join(__dirname, 'templates', 'widget-node.html')
const node_tmpl = await fs.promises.readFile(node_tmpl_file, 'utf8')
const node_html = generate(node_tmpl, this.info)
//console.log(`\n\n***** Generating ${node_html_file} *****\n${node_html}`)
await fs.promises.writeFile(node_html_file, node_html)
}
// generate node js if not present
const node_js_file = path.join(this.info.module_dir, base_name + '.js')
if (!fs.existsSync(node_js_file)) {
const node_tmpl_file = path.join(__dirname, 'templates', 'widget-node.js')
const node_tmpl = await fs.promises.readFile(node_tmpl_file, 'utf8')
const node_js = generate(node_tmpl, this.info)
//console.log(`\n\n***** Generating ${node_js_file} *****\n${node_js}`)
await fs.promises.writeFile(node_js_file, node_js)
}
}
}
if (require.main === module) {
const module_dir = "."
const custom_dir = "custom"
const package = JSON.parse(fs.readFileSync(path.join(module_dir, 'package.json')))
const pkg_json = []
for (vue of fs.readdirSync(path.join(module_dir, 'widgets'))) {
if (vue.endsWith('.vue')) {
const widget_path = path.join(module_dir, 'widgets', vue)
const cg = new FDWidgetCodeGen(widget_path, module_dir, custom_dir, package.name)
cg.doit().then(() => { }).catch(e => { console.log(e.stack); process.exit(1) })
const bn = path.basename(vue, '.vue')
pkg_json.push(` "flexdash ${bn}": "${bn}.js"`)
}
}
// generate package.json fragment
const pkg_file = path.join(module_dir, 'package-nodes.json')
fs.writeFileSync(pkg_file, '{\n' + pkg_json.join(',\n') + '\n}\n')
}
module.exports = FDWidgetCodeGen